From 42e88f2672859e0e96bfb2053fd7e85401a7f7e2 Mon Sep 17 00:00:00 2001 From: Michael Gasperini Date: Mon, 26 Oct 2026 10:41:40 +0100 Subject: [PATCH] feat(meta): integrate Muse Code through the shipped Meta provider DeepSeek Harness already ships the `meta` pi-ai route and its device-code flow, so the bundle now integrates them instead of inserting a second `@deepseek-ai/dsh-llm-pi-ai` instance, which cannot activate because every instance declares the whole provider catalog. - declare the `meta` route in the `llm-pi-ai` settings section so the provider appears under Settings > Models and in the model picker - add the `/muse-login` command and a Sign in control under Settings > Models, backed by an exact local `POST/GET /muse-code/login` route - open the Meta verification page in the default browser and show the code - read the authorization seam's neutral notice shape (`message`/`url`/`code`) - ship the Client half as a browser classic script; the ESM `export {}` that tsc emitted aborted the Web boot - drop the hand-written route, `MUSE_CODE_SUB_TOKEN` handling, and the `muse-wonder` ids that DSH's catalog does not serve BREAKING CHANGE: 0.1.x embeddings and their credentials are replaced; see CHANGELOG and README for the 0.2.0 upgrade note. --- AGENTS.md | 12 +- AUTH_CONTRACT.md | 17 +- CHANGELOG.md | 43 ++++ CITATION.cff | 11 +- README.md | 189 +++++++++----- client/client.js | 151 +++++++++++ cordis.patch.yml | 75 +----- lib/auth.d.ts | 22 -- lib/auth.js | 157 ------------ lib/index.d.ts | 191 ++++++++++++-- lib/index.js | 415 ++++++++++++++++++++++++++++--- lib/open-browser.d.ts | 10 + lib/open-browser.js | 38 +++ locale/en.json | 6 + package-lock.json | 4 +- package.json | 17 +- src/auth.ts | 210 ---------------- src/index.ts | 564 ++++++++++++++++++++++++++++++++++++++---- src/open-browser.ts | 39 +++ test/auth.test.mjs | 115 --------- test/client.test.mjs | 164 ++++++++++++ test/plugin.test.mjs | 381 ++++++++++++++++++++++------ 22 files changed, 1994 insertions(+), 837 deletions(-) create mode 100644 client/client.js delete mode 100644 lib/auth.d.ts delete mode 100644 lib/auth.js create mode 100644 lib/open-browser.d.ts create mode 100644 lib/open-browser.js create mode 100644 locale/en.json delete mode 100644 src/auth.ts create mode 100644 src/open-browser.ts delete mode 100644 test/auth.test.mjs create mode 100644 test/client.test.mjs diff --git a/AGENTS.md b/AGENTS.md index dd965af..952078c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,12 +10,14 @@ sibling Muse plugins (`hermes-muse-code`, `openclaw-muse-code`, `opencode-muse-a ## Layout -- `src/` — bundle source: provider route wiring, device-code login, credential handling. +- `src/` — bundle source: route declaration in the `llm-pi-ai` settings section, the + `/muse-login` command, and the Client half's Settings → Models hint. - `lib/` — compiled output, committed because DSH bundle installs from Git may not run a build. Regenerate with `npm run build`; never edit by hand. - `test/` — `node:test` suite; new behavior needs a test. -- `cordis.patch.yml` — bundle patch that inserts the model route and the auth plugin. -- `AUTH_CONTRACT.md` — pinned device-flow and key-mint contract shared with the sibling plugins. +- `cordis.patch.yml` — bundle patch that inserts the single integration row. +- `AUTH_CONTRACT.md` — pinned device-flow and key-mint contract shared with the sibling plugins; + DeepSeek Harness implements it, the bundle does not. - `.github/workflows/` — CI; do not weaken checks, pins, or permissions. ## Commands @@ -35,6 +37,10 @@ any local credential file. - Keep changes focused; no drive-by refactors, no new runtime dependencies without a stated reason. +- The provider route and the device-code flow are DeepSeek Harness features: declare the shipped + `meta` route and run the shipped `llm-pi-ai/meta` flow. Never insert a second + `@deepseek-ai/dsh-llm-pi-ai` instance — every instance declares the whole provider catalog and + the extra row cannot activate. - Public documentation, comments, and commit messages are in English. - Do not change `LICENSE`, `DISCLAIMER.md`, `AUTH_CONTRACT.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, or release metadata unless the task is about them. diff --git a/AUTH_CONTRACT.md b/AUTH_CONTRACT.md index da26c1e..d95bebb 100644 --- a/AUTH_CONTRACT.md +++ b/AUTH_CONTRACT.md @@ -1,7 +1,9 @@ # Muse auth contract (v1) -This bundle implements the same Meta device-code flow as the other Muse Code -plugins. Change this file and the pinned constants/tests together: +DeepSeek Harness implements this Meta device-code contract in its shipped pi-ai Meta +provider (`llm-pi-ai/meta`); this bundle relies on that implementation. The sibling Muse Code +plugins implement the same flow themselves, so change this file and the pinned values together +with: - `opencode-muse-auth` - `hermes-muse-code` @@ -20,6 +22,10 @@ plugins. Change this file and the pinned constants/tests together: | Credential reference | `MUSE_CODE_SUB_TOKEN` | | Inference base URL | `https://api.meta.ai/v1` | +DeepSeek Harness stores the same sign-in as the OAuth credential record `llm-pi-ai/meta` and +re-mints its short-lived key, because the pi-ai adapter owns that flow. The pinned +`MUSE_CODE_SUB_TOKEN` reference describes the sibling plugins' credential, not DSH's. + ## Behaviour - Device flow: request a device code, poll the token endpoint, accept @@ -27,7 +33,8 @@ plugins. Change this file and the pinned constants/tests together: terminal error or at `expires_in`. - Key mint: exchange the OAuth access token for the account-bound inference key; reject an inactive subscription or a pending payment. -- Persistence: the key is stored through the DSH credential seam under - `MUSE_CODE_SUB_TOKEN`; the OAuth access token stays in memory. -- Any change to this contract bumps `AUTH_CONTRACT_VERSION` in the code and +- Persistence: the sibling plugins store the key through the DSH credential seam under + `MUSE_CODE_SUB_TOKEN` and keep the OAuth access token in memory. DeepSeek Harness stores the + sign-in as the `llm-pi-ai/meta` credential record and renews the key from it. +- Any change to this contract bumps the version constant in the implementations' code and requires updating the pinned tests in all four repositories. diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0d977..53592a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-10-26 + +### Changed + +- Integrate Muse Code through the `meta` provider that DeepSeek Harness ships, instead of + inserting a second `@deepseek-ai/dsh-llm-pi-ai` instance. DSH declares every provider catalog + per instance, so the second instance could not activate + (`configurable provider "amazon-bedrock" is already declared`) and the `muse-code` route never + existed. + +### Added + +- `/muse-login` command that runs the shipped `llm-pi-ai/meta` device-code flow and prints the + verification URL and user code. +- **Sign in** control under **Settings → Models**, backed by an exact local + `POST /muse-code/login` route: DSH exposes no Remote method for provider OAuth flows, so the + Host starts the flow and answers the page with the code and instructions. +- The Host opens the verification page in the default browser for both entry points. +- Automatic declaration of the `meta` route in the `llm-pi-ai` settings section, so the provider + appears under **Settings → Models** and in the model picker labeled + **Muse Code (Meta subscription)**. +- Client half that adds the sign-in control and its footer hint under **Settings → Models**. + +### Removed + +- The hand-written `muse-code` route, its `MUSE_CODE_SUB_TOKEN` credential handling, and the + bundle's own device-code client. DSH's Meta provider owns the sign-in, the credential record, + and key renewal. +- The `muse-wonder` model ids: models now follow the catalog DeepSeek Harness advertises for the + Meta provider (`muse-spark-1.1`, `muse-spark-1.2`, `muse-spark-1.3`, and the `-contributor` + variants). + +### Fixed + +- Read the authorization seam's neutral notice (`{ message, url, code }`), not the raw pi-ai + event shape. The previous reader never recognized the device-code notice, so `/muse-login` + reported "did not answer in time" while the flow was waiting normally. +- Ship the Client half as a browser classic script (`client/client.js`, outside the TypeScript + output). The Web UI concatenates client artifacts into plain script bundles, so the `export {}` + that `tsc` emitted for `lib/client.js` broke the whole bundle with + `Uncaught SyntaxError: Unexpected token 'export'` and aborted the Web boot. + ## [0.1.0] - 2026-10-21 ### Added @@ -21,3 +63,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unofficial-use risk notice for Meta subscription credentials (`DISCLAIMER.md`). [0.1.0]: https://github.com/TheStreamCode/dsh-muse-code/releases/tag/v0.1.0 +[0.2.0]: https://github.com/TheStreamCode/dsh-muse-code/releases/tag/v0.2.0 diff --git a/CITATION.cff b/CITATION.cff index b1ed243..17d1759 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,15 +1,16 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." title: "dsh-muse-code" -version: 0.1.0 -date-released: "2026-10-21" +version: 0.2.0 +date-released: "2026-10-26" url: "https://github.com/TheStreamCode/dsh-muse-code" repository-code: "https://github.com/TheStreamCode/dsh-muse-code" license: MIT abstract: >- - Unofficial DeepSeek Harness bundle that serves Meta Muse Code models through - a Muse Code subscription credential, with a native Meta device-code login - flow and the minted key stored in the DSH credential store. + Unofficial DeepSeek Harness bundle that integrates Meta Muse Code models through + the subscription: it declares the shipped Meta provider route for Settings > Models + and the model picker, and adds the /muse-login command for the built-in device-code + sign-in. keywords: - deepseek-harness - dsh-plugin diff --git a/README.md b/README.md index b6a2fe5..9fc39c5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # dsh-muse-code -Muse Code subscription models and device-code sign-in for DeepSeek Harness +Muse Code (Meta subscription) integration for DeepSeek Harness [![CI](https://github.com/TheStreamCode/dsh-muse-code/actions/workflows/ci.yml/badge.svg)](https://github.com/TheStreamCode/dsh-muse-code/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Release](https://img.shields.io/github/v/release/TheStreamCode/dsh-muse-code)](https://github.com/TheStreamCode/dsh-muse-code/releases/latest) -DeepSeek Harness (DSH) bundle that serves Meta Muse Code models through a Muse Code -subscription and adds a native device-code login flow. No API key is copied by hand: the -plugin runs Meta's device login, mints the account-bound inference key, and stores it in the -DSH credential store. +DeepSeek Harness (DSH) already ships Meta's Muse Code subscription as the pi-ai `meta` +provider (`https://api.meta.ai/v1`, OpenAI Responses) together with its device-code sign-in +flow (`llm-pi-ai/meta`). This bundle connects that provider to the surfaces a user works in: +it declares the route so the provider appears under **Settings → Models** and in the model +picker, and it adds the `/muse-login` command that runs the shipped sign-in. No key is copied +by hand and no provider code is duplicated. > **Unofficial and experimental.** This plugin uses a Muse Code subscription credential from > DeepSeek Harness. Meta's terms limit that credential to Muse Code, so this use may not be @@ -36,90 +38,143 @@ From a local checkout: dsh plugin --profile desktop add link:C:\path\to\dsh-muse-code ``` -The bundle inserts its route and auth plugin through `cordis.patch.yml`; no manual profile -edit is required. Restart DSH if the profile does not pick up the bundle immediately. +The bundle inserts one integration row through `cordis.patch.yml`; no manual profile edit is +required. On the first boot it declares the `meta` provider route in the `llm-pi-ai` settings +section — the same write the Models page performs when a provider is added by hand — and adds the +Sign in control and the `/muse-login` command. Restart DSH after installing or upgrading the +bundle. ## Quick start -1. Install the bundle with the command above. -2. Open **Settings**, select **Authorization** (the credential and sign-in surface), then - **Muse Code (subscription)**. -3. Open the printed Meta URL, enter the printed code, and approve the login. -4. Select the `muse-code` provider and a Muse model in the DSH model picker, for example - `muse-code/muse-spark-1.3`. - -The plugin commits the minted key as the `MUSE_CODE_SUB_TOKEN` credential, and the route -reads it per request, so the key never appears in a configuration file. +1. Install the bundle and restart DSH. +2. Open **Settings → Models** and find **Muse Code (Meta subscription)**. Its models are the + ones DSH's Meta catalog advertises: Muse Spark 1.1, 1.2, and 1.3, plus their `-contributor` + variants. +3. Press **Sign in** in the Muse Code area of that page. The Host starts DSH's Meta device-code + flow, opens the verification page in your default browser, and shows the code to confirm. + Without the page (CLI or a page that cannot reach the Host), run `/muse-login` in any chat and + follow the printed URL and code instead. +4. Confirm the code on Meta's page and approve the login. DSH stores the credential and keeps it + fresh; the Muse Code models are selectable as soon as Meta confirms. +5. Pick a Muse model in the model picker, for example `Muse Spark 1.3`. + +The credential lives in the DSH credential store as the `llm-pi-ai/meta` record. It is never +written to `cordis.patch.yml`, which carries only the route's display name. + +## Where the sign-in lives + +DSH 0.2.1 exposes no generic Remote method for starting a provider OAuth flow, so the Settings +page cannot call the authorization seam directly. The bundle therefore registers an exact local +Web route (`POST /muse-code/login`) on the Host's own HTTP server; the **Sign in** control under +Settings → Models calls it, and the Host both opens the verification page and answers with the +code and the instructions. The same flow is available as the `/muse-login` command for the +composer, CLI surfaces, or a page whose origin cannot reach the Host. ## Features -- Adds a `muse-code` model route through a second `@deepseek-ai/dsh-llm-pi-ai` instance - (`api: openai-responses`, `baseURL: https://api.meta.ai/v1`), so the existing `llm-pi-ai` - configuration is not replaced. -- Registers the **Muse Code (subscription)** authorization flow: Meta device login, key - mint, and credential commit. -- Stores the key in the DSH credential store under `MUSE_CODE_SUB_TOKEN` and reads it through - `apiKeyEnv`. -- Serves `muse-spark-1.3`, `muse-spark-1.2`, `muse-wonder-1.0`, and `muse-wonder-full-1.0` - with text and image input, a 1,048,576-token context window, and reasoning-effort mapping - from `minimal` to `max`. -- Accepts an explicit `MUSE_CODE_SUB_TOKEN` environment variable instead of the stored - credential. +- Declares the shipped `meta` provider route in the `llm-pi-ai` settings section, labeled + **Muse Code (Meta subscription)**, so Settings → Models and the model picker show it. An + existing `meta` profile is left untouched. +- Adds a **Sign in** control under Settings → Models, backed by the local + `POST /muse-code/login` route, and the `/muse-login` chat command. Both start DSH's own + device-code flow (`llm-pi-ai/meta`), open the verification page in the default browser, and + return the code so the surface can show it. +- Leaves credential handling to DSH: the shipped flow stores the sign-in as an OAuth credential + record and re-mints Meta's short-lived inference key automatically, so the route keeps + working after the key rotates. +- Serves the models DSH advertises for the Meta provider, with text and image input, a + 1,048,576-token context window, and the reasoning levels each model supports. ## Requirements -- DeepSeek Harness `0.2.1-rc.2` or newer. +- DeepSeek Harness `0.2.1-rc.3` (verified) or newer, with the pi-ai `meta` provider. A DSH build + without it cannot serve the subscription. - Node.js 20 or newer on the DSH host; CI runs Node.js 20, 22, and 24. - A Meta account with an active Muse Code subscription. -- The plugin needs no separate service and no third-party CLI. +- No separate service, CLI, or key file. ## Configuration | Setting | Purpose | Default | | --- | --- | --- | -| `MUSE_CODE_SUB_TOKEN` | Inference key read by the `muse-code` route through `apiKeyEnv`. An explicit value wins over the stored credential. | from the authorization flow | -| `muse-code` provider | Provider entry added by `cordis.patch.yml`; select it in the DSH model picker. | enabled after install | -| Model id | Model served by the route; availability depends on the subscription plan. | `muse-spark-1.3` | -| Reasoning effort | Maps DSH effort levels to Meta's wire vocabulary. | provider default | +| `meta` provider route | Declared by the bundle in the `llm-pi-ai` settings section; shown under Settings → Models and in the model picker. | declared on first boot | +| **Sign in** control | Starts the shipped Meta device-code flow from Settings → Models, opens the browser, and shows the code. Backed by `POST /muse-code/login` on the local Host. | available after install | +| `/muse-login` | The same sign-in from a chat or CLI surface, printing the URL and code. | available after install | +| `META_API_KEY` | Optional Meta Model API key that DSH's Meta provider accepts instead of the subscription sign-in. | unset | +| Model | Any model the Meta provider advertises; availability depends on the subscription plan. | `muse-spark-1.3` | -## Privacy and security +The `MUSE_CODE_SUB_TOKEN` reference from the 0.1.x releases is no longer used by this bundle; +see the upgrade note under Troubleshooting. -The subscription key is stored only in the DSH credential store under `MUSE_CODE_SUB_TOKEN`; -it is never written to a configuration file, and the OAuth access token stays in memory. The -plugin talks only to `auth.meta.com` for the device login and `api.meta.ai` for key minting and -inference. Do not share credential caches or exported keys. +## Privacy and security -To remove access, delete the `MUSE_CODE_SUB_TOKEN` credential from the DSH credential store or -sign out of the authorization record, then remove the bundle: - -```bash -dsh plugin --profile desktop remove @thestreamcode/dsh-muse-code -``` +- The bundle itself calls no endpoint and stores no secret. The sign-in, the credential record, + and the inference requests belong to DSH: requests go to `https://api.meta.ai/v1`, and the + verification page is opened on `https://auth.meta.com`. +- The local `POST /muse-code/login` route only starts that flow and answers with the verification + URL and the one-time code; it transmits no credential and is reachable only on the Host's own + loopback listener. +- The stored credential is the `llm-pi-ai/meta` record in the DSH credential store. It never + appears in configuration or logs. +- To remove access, remove the bundle, remove the declared provider from **Settings → Models** + (or from the profile's `cordis.patch.yml`), and delete the `llm-pi-ai/meta` record while DSH + is stopped (the file is `$DSH_HOME/.credentials.yaml`), or run the sign-in again to replace + it. Removing the bundle alone stops the route but leaves the record and the declaration in + place. See [SECURITY.md](SECURITY.md) for vulnerability reporting and -[AUTH_CONTRACT.md](AUTH_CONTRACT.md) for the pinned login contract. +[AUTH_CONTRACT.md](AUTH_CONTRACT.md) for the pinned login contract that DSH implements. ## Troubleshooting -### The provider does not appear in the model picker +### Nothing named Muse Code appears under Settings → Models + +The bundle was not loaded by the active profile, or the profile lacks the `llm-pi-ai` adapter. +Re-run the install command for the same profile, restart DSH, and check the startup output for +`did not activate` warnings. A DSH build without the pi-ai Meta provider cannot serve the +subscription; update DSH. + +### `/muse-login` is not offered in the composer + +The command needs the DSH command and authorization services, which UI-less compositions do not +mount, or the bundle is inactive. Restart DSH after installing; in a headless profile the +command surface does not exist by design. + +### The sign-in reports that no Meta sign-in flow is registered + +This DSH build does not ship the Meta provider. Update to DeepSeek Harness `0.2.1-rc.3` or newer, +restart, and retry. + +### The Sign in control reports that it cannot reach the Host + +The Settings page's origin cannot reach the local Web route — some Desktop and non-loopback +deployments carry HTTP through a bridge. Use `/muse-login` in a chat instead; it starts the same +flow. + +### The browser did not open -The bundle was not loaded by the active profile. Re-run the install command with the same -profile, restart DSH, and check that `cordis.patch.yml` is part of the installed bundle. +The Host opens it best-effort through the platform's URI handler. Both the control and the +command print the verification URL and the code, so open the URL manually and enter the code. -### Muse Code subscription is inactive for this account +### Meta denies the login, or the subscription is inactive -The signed-in account has no active Muse Code subscription, or the subscription lapsed. -Renew it, or sign in with the account that holds the subscription. +The account has no active Muse Code subscription, the code expired, or the approval was +declined. Start the sign-in again (Sign in control or `/muse-login`) and approve the new code +before it expires. -### The device login expires or is denied +### Requests fail with 401 after a while -Start the authorization flow again and approve the printed code before the expiry printed by -the flow. The login completes in your own browser on Meta's authorization page. +DSH re-mints the inference key from the stored session. When Meta ends that session, run the +sign-in again (Sign in control or `/muse-login`) to sign in and replace the record. -### Requests fail with 401 after a subscription change +### Upgrading from 0.1.x -The stored key was revoked or rotated. Remove the `MUSE_CODE_SUB_TOKEN` credential, run the -authorization flow again, and restart DSH if a running process cached the old value. +0.1.x inserted a second `@deepseek-ai/dsh-llm-pi-ai` instance, which DSH refuses to activate +(each instance declares the whole provider catalog), so its route never appeared and its +device-code flow had no surface. Reinstall the bundle and restart DSH: the new version declares +the shipped `meta` route instead. The stale `MUSE_CODE_SUB_TOKEN` credential and its +`dsh-muse-code/subscription` record can be deleted from the DSH credential store; nothing reads +them any more. ## Development @@ -133,16 +188,20 @@ npm run typecheck npm test ``` -`npm test` builds `lib/` and runs the `node:test` suite. `lib/` is committed because DSH -bundle installs from Git may not run a build; regenerate it with `npm run build` after any -source change. The offline tests cover the device flow, the credential wiring, and the plugin -manifest; no Meta account is needed. +`npm test` builds `lib/` and runs the `node:test` suite. `lib/` is committed because DSH bundle +installs from Git may not run a build; regenerate it with `npm run build` after any source +change. The Client half is `client/client.js`, a hand-written browser classic script kept outside +the TypeScript output on purpose: DSH concatenates client artifacts into plain script bundles, so +an `export`/`import` statement there breaks the Web boot. The suite parses that file with classic +script grammar and registers its Models footer contribution. The offline tests cover the sign-in +command, the route declaration, the Client half, and the plugin manifest; no Meta account is +needed. ## Support -Usage questions and workflows belong in GitHub Discussions; reproducible defects belong in -GitHub Issues using the bug report form. See [SUPPORT.md](SUPPORT.md). The plugin cannot -obtain, restore, or protect a Meta account, subscription, or credential. +Usage questions and workflows belong to GitHub Discussions; reproducible defects belong to +GitHub Issues using the bug report form. See [SUPPORT.md](SUPPORT.md). The plugin cannot obtain, +restore, or protect a Meta account, subscription, or credential. ## Contributing diff --git a/client/client.js b/client/client.js new file mode 100644 index 0000000..d43cdf1 --- /dev/null +++ b/client/client.js @@ -0,0 +1,151 @@ +// Web client half of the Muse Code integration. +// +// The Settings > Models page renders the plugins footer seat after the provider +// rows. This module adds the Sign in control there: it calls the Host's exact +// `/muse-code/login` route, which starts the shipped Meta device-code flow, +// opens the verification page in the default browser, and answers with the code +// and the instructions to show next to the button. +// +// This file is a browser CLASSIC SCRIPT, not an ES module: DSH concatenates +// client artifacts into plain script bundles, so an `export` statement here +// breaks the whole bundle ("Unexpected token 'export'"). It therefore lives +// outside the TypeScript source tree: tsc would emit `export {}` for an +// ES-module file. The loader registers a lazy factory whose id is the package +// name; React comes from the browser module table. + +const PACKAGE_ID = "@thestreamcode/dsh-muse-code" +const HINT_ID = "dsh-muse-code-sign-in" +const LOGIN_ROUTE = "/muse-code/login" +const REQUEST_TIMEOUT_MS = 25_000 + +const loader = globalThis.__ModuleLoader__ + +/** Loopback origin of the Host that owns this page, when it is discoverable. */ +function hostOrigin() { + const transport = globalThis.__DSH_TRANSPORT__ + const base = transport && typeof transport.streamBaseUrl === "string" ? transport.streamBaseUrl : "" + if (/^https?:\/\//i.test(base)) return base.replace(/\/+$/, "") + const origin = globalThis.location && globalThis.location.origin + if (typeof origin === "string" && /^https?:\/\//i.test(origin)) return origin.replace(/\/+$/, "") + return "" +} + +loader?.load({ + id: PACKAGE_ID, + factory(require) { + const React = require("react") + const h = React.createElement + + function MuseCodeSignIn() { + const [state, setState] = React.useState({ status: "idle", text: "" }) + const origin = hostOrigin() + + async function signIn() { + setState({ status: "running", text: "" }) + const controller = typeof AbortController === "function" ? new AbortController() : undefined + const timer = controller === undefined ? undefined : setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const response = await fetch(`${origin}${LOGIN_ROUTE}`, { + method: "POST", + headers: { accept: "application/json" }, + ...(controller === undefined ? {} : { signal: controller.signal }), + }) + const payload = await response.json().catch(() => null) + if (payload && typeof payload.text === "string" && payload.text.length > 0) { + setState({ status: payload.ok === true ? "success" : "error", text: payload.text }) + return + } + setState({ + status: "error", + text: `Sign-in failed (HTTP ${response.status}). Run /muse-login in a chat instead.`, + }) + } catch { + setState({ + status: "error", + text: "Could not reach the Host. Run /muse-login in a chat instead.", + }) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } + + const style = { + display: "flex", + flexDirection: "column", + gap: "0.5rem", + margin: 0, + fontSize: "0.875rem", + lineHeight: 1.5, + color: "var(--dsw-alias-text-secondary)", + } + const children = [ + h( + "p", + { key: "title", style: { margin: 0 } }, + "Muse Code (Meta subscription): ", + origin.length > 0 + ? "sign in here once; the provider then appears in the model picker." + : "run /muse-login in any chat to sign in.", + ), + ] + if (origin.length > 0) { + children.push( + h( + "div", + { key: "actions", style: { display: "flex", alignItems: "center", gap: "0.75rem" } }, + h( + "button", + { + type: "button", + onClick: signIn, + disabled: state.status === "running", + style: { + padding: "0.375rem 0.875rem", + borderRadius: "0.5rem", + border: "1px solid var(--dsw-alias-border-secondary)", + background: "var(--dsw-alias-bg-interactive)", + color: "var(--dsw-alias-text-primary)", + cursor: state.status === "running" ? "progress" : "pointer", + fontSize: "0.875rem", + }, + }, + state.status === "running" ? "Starting sign-in…" : "Sign in", + ), + h( + "span", + { "aria-live": "polite" }, + state.status === "idle" ? "" : state.text, + ), + h( + "a", + { + href: `${origin}${LOGIN_ROUTE}`, + target: "_blank", + rel: "noreferrer", + style: { fontSize: "0.8rem" }, + }, + "Open the sign-in page", + ), + ), + ) + } + return h("div", { style }, children) + } + + return { + inject: ["slots"], + apply(ctx) { + ctx.slots.inject("settings.models.footer", () => + ctx.slots.register( + { + name: "settings.models.footer", + id: HINT_ID, + order: 10, + }, + MuseCodeSignIn, + ), + ) + }, + } + }, +}) diff --git a/cordis.patch.yml b/cordis.patch.yml index 72c87ce..82fd859 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,68 +1,15 @@ -# Muse Code subscription provider for DeepSeek Harness. +# Muse Code (Meta subscription) integration for DeepSeek Harness. # -# A second pi-ai adapter instance serves the `muse-code` route, so the -# existing `llm-pi-ai` provider configuration is not replaced. The auth -# plugin registers the device-code flow and stores the minted key under the -# MUSE_CODE_SUB_TOKEN credential reference used by apiKeyEnv. +# DeepSeek Harness already ships the provider and its sign-in: the pi-ai catalog +# serves the `meta` route (https://api.meta.ai/v1) and registers the +# `llm-pi-ai/meta` authorization flow. This bundle inserts only its integration +# plugin, which declares that route in the `llm-pi-ai` settings section and adds +# the `/muse-login` command. +# +# Do not insert a second `@deepseek-ai/dsh-llm-pi-ai` row here: every instance +# declares the whole installed provider catalog, so the extra instance cannot +# activate ("configurable provider ... is already declared"). - insert: - - id: muse-code-llm - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - muse-code: - displayName: Muse Code (subscription) - apiKeyEnv: MUSE_CODE_SUB_TOKEN - api: openai-responses - baseURL: https://api.meta.ai/v1 - models: - - id: muse-spark-1.3 - name: Muse Spark 1.3 - contextWindow: 1048576 - maxTokens: 131072 - input: [text, image] - reasoningEfforts: - minimal: minimal - low: low - medium: medium - high: high - xhigh: xhigh - max: max - - id: muse-spark-1.2 - name: Muse Spark 1.2 - contextWindow: 1048576 - maxTokens: 131072 - input: [text, image] - reasoningEfforts: - minimal: minimal - low: low - medium: medium - high: high - xhigh: xhigh - max: max - - id: muse-wonder-1.0 - name: Muse Wonder 1.0 - contextWindow: 1048576 - maxTokens: 131072 - input: [text, image] - reasoningEfforts: - minimal: minimal - low: low - medium: medium - high: high - xhigh: xhigh - max: max - - id: muse-wonder-full-1.0 - name: Muse Wonder Full 1.0 - contextWindow: 1048576 - maxTokens: 131072 - input: [text, image] - reasoningEfforts: - minimal: minimal - low: low - medium: medium - high: high - xhigh: xhigh - max: max - - id: muse-code-auth + - id: muse-code name: '@thestreamcode/dsh-muse-code' config: {} diff --git a/lib/auth.d.ts b/lib/auth.d.ts deleted file mode 100644 index 82359fc..0000000 --- a/lib/auth.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -export declare const CLIENT_ID = "1031625952748946"; -export declare const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/"; -export declare const TOKEN_URL = "https://auth.meta.com/oidc/device/token/"; -export declare const KEY_URL = "https://api.meta.ai/muse-code/key"; -export declare const API_VERSION = "1.0.0"; -export declare const AUTH_CONTRACT_VERSION = 1; -export interface DeviceAuthorization { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete?: string; - interval: number; - expires_in: number; -} -export interface MintedCredential { - apiKey: string; - accountId: string; - email?: string; -} -export declare function deviceAuthorize(signal?: AbortSignal): Promise; -export declare function pollToken(deviceCode: string, intervalS: number, expiresInS: number, signal?: AbortSignal): Promise; -export declare function mintKey(accessToken: string, signal?: AbortSignal): Promise; diff --git a/lib/auth.js b/lib/auth.js deleted file mode 100644 index 8299fab..0000000 --- a/lib/auth.js +++ /dev/null @@ -1,157 +0,0 @@ -// Meta device-code login and subscription-key minting for Muse Code. -// The wire contract is shared with the other Muse Code plugins; see -// AUTH_CONTRACT.md before changing any constant here. -export const CLIENT_ID = "1031625952748946"; -export const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/"; -export const TOKEN_URL = "https://auth.meta.com/oidc/device/token/"; -export const KEY_URL = "https://api.meta.ai/muse-code/key"; -export const API_VERSION = "1.0.0"; -export const AUTH_CONTRACT_VERSION = 1; -const HEADERS = { - Accept: "application/json", - "x-api-version": API_VERSION, -}; -const REQUEST_TIMEOUT_MS = 25_000; -const MIN_INTERVAL_S = 1; -const SLOW_DOWN_STEP_S = 5; -function combinedSignal(signal) { - const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); - return signal ? AbortSignal.any([timeout, signal]) : timeout; -} -async function postForm(url, params, signal) { - const res = await fetch(url, { - method: "POST", - headers: { ...HEADERS, "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(params).toString(), - signal: combinedSignal(signal), - }); - const text = await res.text(); - if (!res.ok) - throw new Error(`HTTP ${res.status} ${text.slice(0, 200)}`.trim()); - return JSON.parse(text); -} -function delay(ms, signal) { - return new Promise((resolve, reject) => { - const timer = setTimeout(resolve, ms); - if (!signal) - return; - if (signal.aborted) { - clearTimeout(timer); - reject(new Error("authorization cancelled")); - return; - } - signal.addEventListener("abort", () => { - clearTimeout(timer); - reject(new Error("authorization cancelled")); - }, { once: true }); - }); -} -export async function deviceAuthorize(signal) { - const body = (await postForm(DEVICE_URL, { client_id: CLIENT_ID }, signal)); - const { device_code, user_code, verification_uri, interval, expires_in } = body; - if (typeof device_code !== "string" || - !device_code || - typeof user_code !== "string" || - !user_code || - typeof verification_uri !== "string" || - !verification_uri || - typeof interval !== "number" || - typeof expires_in !== "number") { - throw new Error("device authorization response is missing fields"); - } - const complete = body.verification_uri_complete; - return { - device_code, - user_code, - verification_uri, - ...(typeof complete === "string" && complete ? { verification_uri_complete: complete } : {}), - interval, - expires_in, - }; -} -export async function pollToken(deviceCode, intervalS, expiresInS, signal) { - let interval = Math.max(MIN_INTERVAL_S, Number(intervalS) || 5); - const deadline = Date.now() + Number(expiresInS || 600) * 1000; - let slowDowns = 0; - while (Date.now() < deadline) { - if (signal?.aborted) - throw new Error("authorization cancelled"); - // The token endpoint answers pending/slow_down polls with HTTP errors as - // part of the normal flow: classify the body instead of throwing. - let body = null; - try { - const res = await fetch(TOKEN_URL, { - method: "POST", - headers: { ...HEADERS, "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: CLIENT_ID, - device_code: deviceCode, - }).toString(), - signal: combinedSignal(signal), - }); - try { - body = (await res.json()); - } - catch { - body = null; - } - } - catch (error) { - if (signal?.aborted) - throw new Error("authorization cancelled"); - if (error instanceof Error && error.name === "AbortError") - throw error; - body = null; - } - if (body && !body.error) { - if (typeof body.access_token !== "string" || !body.access_token) { - throw new Error("token response has no access_token"); - } - return body.access_token; - } - if (body?.error === "slow_down") { - slowDowns += 1; - interval = Math.max(MIN_INTERVAL_S, interval + SLOW_DOWN_STEP_S); - } - else if (body?.error !== "authorization_pending") { - throw new Error(`login failed: ${String(body?.error || "unreadable token response")}`); - } - const remaining = deadline - Date.now(); - if (remaining <= 0) - break; - await delay(Math.min(interval, remaining) * 1000, signal); - } - throw new Error(slowDowns ? "login timed out after slow_down responses" : "login timed out waiting for approval"); -} -export async function mintKey(accessToken, signal) { - const res = await fetch(KEY_URL, { - method: "POST", - headers: { - ...HEADERS, - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ onboard: true }), - signal: combinedSignal(signal), - }); - const text = await res.text(); - if (!res.ok) - throw new Error(`key exchange failed: HTTP ${res.status} ${text.slice(0, 200)}`.trim()); - const body = JSON.parse(text); - if (body.is_subs_active === false) { - throw new Error("Muse Code subscription is inactive for this account"); - } - if (typeof body.api_key !== "string" || !body.api_key.trim()) { - const action = String(body.action_url || body.require_payment_action_url || "").trim(); - if (body.require_payment === true || action) { - throw new Error(`Muse Code subscription is required${action ? ": " + action : ""}`); - } - throw new Error("key response is missing api_key"); - } - const email = String(body.user_email || "").trim().toLowerCase() || undefined; - const accountId = String(body.user_id || "").trim() || email; - if (!accountId) - throw new Error("key response is missing account identity"); - return { apiKey: body.api_key.trim(), accountId, ...(email ? { email } : {}) }; -} diff --git a/lib/index.d.ts b/lib/index.d.ts index 4aa5ff8..b398b5b 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1,31 +1,176 @@ -type CredentialStore = { - set(reference: string, value: string): Promise; - modifyRecord(key: string, update: () => Promise): Promise; -}; -type AuthorizationSession = { - signal?: AbortSignal; - notify(input: { - message: string; - url?: string; - }): void; -}; -type AuthorizationFlow = { +import type { IncomingMessage, ServerResponse } from "node:http"; +export declare const name = "dsh-muse-code"; +export declare const inject: string[]; +/** Web route the Settings control calls to start the sign-in. */ +export declare const LOGIN_ROUTE = "/muse-code/login"; +type Logger = { + info?(message: string): void; + warn?(message: string, ...rest: unknown[]): void; + debug?(message: string): void; +}; +type CommandResult = { + kind: "success"; + text?: string; +} | { + kind: "error"; + text: string; +}; +type AuthorizationMethods = Array<{ + id: string; + label: string; +}>; +type AuthorizationEntry = { key: string; label: string; - methods: Array<{ - id: string; - label: string; + methods: AuthorizationMethods; + inFlight: boolean; +}; +/** + * One notice from the authorization seam. The seam's vocabulary is + * `{ message, url?, code? }`; the raw pi-ai fields are accepted too so the + * reader also covers a direct adapter notice. + */ +type AuthorizationNotice = { + type?: string; + message?: string; + instructions?: string; + url?: string; + code?: string; + userCode?: string; + verificationUri?: string; + expiresInSeconds?: number; +}; +type AuthorizationInteraction = { + notify(notice: AuthorizationNotice): void; + prompt(prompt: unknown): Promise; +}; +type AuthorizationFace = { + describe(key: string): AuthorizationEntry | undefined; + begin(request: { + key: string; + method?: string; + interaction: AuthorizationInteraction; + }): Promise<{ + status: string; }>; - run(session: AuthorizationSession): Promise; + cancel?(key: string): void; +}; +type CommandFace = { + register(definition: { + name: string; + description: string; + handler(context: { + rawInput?: string; + }): Promise; + }): () => void; +}; +type SettingsRow = { + ns: string; + revision: number; + value: Record; +}; +type SettingsFace = { + describe(): SettingsRow[]; + update(ns: string, patch: Record, expectedRevision: number): Promise; +}; +type WebRoute = { + kind: "exact" | "prefix"; + path: string; + handler(request: IncomingMessage, response: ServerResponse): Promise; +}; +type WebServerFace = { + register(route: WebRoute): () => void; +}; +type ChildContext = HostContext & { + settings?: SettingsFace; + webServer?: WebServerFace; }; type HostContext = { - credentials: CredentialStore; - authorization: { - registerFlow(flow: AuthorizationFlow): () => void; - }; - effect?(callback: () => (() => void) | void): () => void; + authorization: AuthorizationFace; + commands: CommandFace; + logger?: Logger; + effect(callback: () => () => void, label?: string): () => void; + inject?(dependencies: string[], callback: (child: ChildContext) => void): void; }; -export declare const name = "dsh-muse-code"; -export declare const inject: string[]; +/** Injected side effects, overridable in tests. */ +export type SignInOptions = { + openBrowser?: (url: string) => boolean; + timeoutMs?: number; +}; +/** Actionable instructions extracted from one authorization notice. */ +export type SignInInstruction = { + /** Verification page as the flow reported it. */ + url: string; + /** Verification page with the user code attached when the URL accepts it. */ + completeUrl: string; + code?: string; + message?: string; +}; +type SignInOutcome = { + ok: true; + text: string; + url: string; + code?: string; +} | { + ok: false; + reason: "unavailable" | "busy" | "timeout" | "failed" | "ended"; + text: string; +}; +/** + * Register the sign-in command, the Settings > Models sign-in route, and the + * settings write that makes the Muse Code provider visible. + * @param ctx - Host plugin context. + */ export declare function apply(ctx: HostContext): void; +/** Command definition for the sign-in; exported for tests. */ +export declare function museLoginCommand(ctx: HostContext, options?: SignInOptions): Parameters[0]; +/** + * Build the exact Web route that starts the same sign-in from the Settings + * page. The route answers JSON so the Client control can render the code and + * the failure reason next to its button. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns an HTTP handler for {@link LOGIN_ROUTE}. + */ +export declare function createLoginRouteHandler(ctx: HostContext, options?: SignInOptions): (request: IncomingMessage, response: ServerResponse) => Promise; +/** + * Start one sign-in attempt and wait for the actionable device-code notice. + * The attempt itself keeps running after the notice, so Meta's approval is + * picked up and the credential is stored by the seam. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns the user-facing outcome. + */ +export declare function startSignIn(ctx: HostContext, options?: SignInOptions): Promise; +/** + * Read the actionable instruction out of one authorization notice. + * @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one. + * @returns the instruction, or undefined for progress notices with no page. + */ +export declare function readSignInNotice(notice: AuthorizationNotice): SignInInstruction | undefined; +/** + * Render the instruction text shared by the command result and the Settings + * control. + * @param instruction - actionable notice. + * @param opened - whether the browser hand-off was started. + * @returns one line the user can act on. + */ +export declare function formatSignInText(instruction: SignInInstruction, opened: boolean): string; +/** + * Declare the shipped `meta` route in the `llm-pi-ai` settings section so the + * provider appears in Settings > Models and its models in the picker. + * + * The write is a merge into the entry's editable config, exactly what the + * Models page does when a user adds this provider by hand; an already declared + * `meta` profile is never touched. + * + * @param settings - the Settings form service. + * @param logger - optional plugin logger. + * @param options - retry window for late-activating profile entries. + * @returns whether this call added the route. + */ +export declare function ensureMuseRoute(settings: SettingsFace, logger?: Logger, options?: { + attempts?: number; + delayMs?: number; +}): Promise; export {}; diff --git a/lib/index.js b/lib/index.js index 6415c96..44e68ac 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,40 +1,387 @@ -// DeepSeek Harness bundle: Muse Code subscription route + login flow. +// Muse Code (Meta subscription) integration for DeepSeek Harness. // -// The route is declared by cordis.patch.yml through a second pi-ai adapter -// instance. This plugin owns the human sign-in: it runs Meta's device flow, -// mints the account-bound inference key, stores it under the -// MUSE_CODE_SUB_TOKEN credential reference, and commits the authorization -// record the seam requires. -import { deviceAuthorize, mintKey, pollToken } from "./auth.js"; +// DeepSeek Harness already serves this subscription: the shipped pi-ai catalog +// exposes the `meta` route (api.meta.ai/v1) and registers the `llm-pi-ai/meta` +// authorization flow, which runs Meta's device-code login and stores the +// renewable credential record. The Web/Desktop Settings surface has no generic +// button for provider OAuth flows, so this bundle adds the pieces a user needs: +// +// - it declares the `meta` route in the `llm-pi-ai` settings section, so the +// provider appears under Settings > Models and its models in the model picker; +// - it exposes the shipped sign-in as the `/muse-login` command; +// - it registers an exact Web route (`/muse-code/login`) and a Sign in control +// under Settings > Models that calls it, because the Settings page cannot +// reach the Host authorization seam through the Remote API. +// +// The Host opens the verification page in the user's default browser itself; the +// route and the command also return the code and the URL, so a blocked browser +// never strands the sign-in. +// +// A second `@deepseek-ai/dsh-llm-pi-ai` instance is not an option: every +// instance declares the whole installed provider catalog, so the extra instance +// fails to activate with a duplicate configurable-provider error. +import { openExternal } from "./open-browser.js"; export const name = "dsh-muse-code"; -export const inject = ["credentials", "authorization"]; -const FLOW_KEY = "dsh-muse-code/subscription"; -const CREDENTIAL_REF = "MUSE_CODE_SUB_TOKEN"; +export const inject = ["authorization", "commands"]; +/** Settings namespace whose provider profiles configure the pi-ai routes. */ +const SETTINGS_NS = "llm-pi-ai"; +/** Route served by the shipped pi-ai Meta provider. */ +const PROVIDER = "meta"; +/** Credential record / authorization flow the shipped pi-ai adapter registers. */ +const AUTH_KEY = "llm-pi-ai/meta"; +/** Preferred sign-in method; the shipped flow offers `oauth` first. */ +const AUTH_METHOD = "oauth"; +/** Display name for the route this bundle declares. */ +const ROUTE_LABEL = "Muse Code (Meta subscription)"; +/** Web route the Settings control calls to start the sign-in. */ +export const LOGIN_ROUTE = "/muse-code/login"; +/** How long the sign-in waits for the device-code notice before giving up. */ +const NOTICE_TIMEOUT_MS = 20_000; +/** + * Register the sign-in command, the Settings > Models sign-in route, and the + * settings write that makes the Muse Code provider visible. + * @param ctx - Host plugin context. + */ export function apply(ctx) { - const flow = { - key: FLOW_KEY, - label: "Muse Code (subscription)", - methods: [{ id: "device", label: "Sign in with Meta device code" }], - async run(session) { - const device = await deviceAuthorize(session.signal); - const url = device.verification_uri_complete || device.verification_uri; - session.notify({ message: `Open ${url} and enter code ${device.user_code}`, url }); - const accessToken = await pollToken(device.device_code, device.interval, device.expires_in, session.signal); - const minted = await mintKey(accessToken, session.signal); - await ctx.credentials.set(CREDENTIAL_REF, minted.apiKey); - await ctx.credentials.modifyRecord(FLOW_KEY, async () => ({ - kind: "grant", - payload: { - accountId: minted.accountId, - ...(minted.email ? { email: minted.email } : {}), - }, - })); - session.notify({ message: `Signed in as ${minted.email || minted.accountId}` }); + ctx.effect(() => ctx.commands.register(museLoginCommand(ctx)), "dsh-muse-code: /muse-login"); + if (typeof ctx.inject !== "function") + return; + ctx.inject(["webServer"], (child) => { + const webServer = child.webServer; + if (webServer === undefined) + return; + child.effect(() => webServer.register({ + kind: "exact", + path: LOGIN_ROUTE, + handler: createLoginRouteHandler(ctx), + }), "dsh-muse-code: login route"); + }); + ctx.inject(["settings"], (child) => { + child.effect(() => { + const settings = child.settings; + if (settings === undefined) + return () => { }; + void ensureMuseRoute(settings, ctx.logger).catch((error) => { + warn(ctx.logger, "dsh-muse-code: could not declare the Muse Code route", error); + }); + return () => { }; + }, "dsh-muse-code: declare route"); + }); +} +/** Command definition for the sign-in; exported for tests. */ +export function museLoginCommand(ctx, options = {}) { + return { + name: "muse-login", + description: "Sign in to the Muse Code (Meta) subscription and enable its models", + async handler() { + const outcome = await startSignIn(ctx, options); + if (outcome.ok) + return { kind: "success", text: outcome.text }; + return { kind: "error", text: outcome.text }; + }, + }; +} +/** + * Build the exact Web route that starts the same sign-in from the Settings + * page. The route answers JSON so the Client control can render the code and + * the failure reason next to its button. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns an HTTP handler for {@link LOGIN_ROUTE}. + */ +export function createLoginRouteHandler(ctx, options = {}) { + return async (request, response) => { + setCorsHeaders(response); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + if (request.method === "GET") { + // A plain navigation: this tab is the browser, so the flow need not open + // another one. The page shows the code and moves on to Meta's page. + const outcome = await startSignIn(ctx, { ...options, openBrowser: () => false }); + sendHtml(response, signInPage(outcome)); + return; + } + if (request.method !== "POST") { + sendJson(response, 405, { ok: false, reason: "method", text: "Use GET or POST to start the sign-in." }); + return; + } + const outcome = await startSignIn(ctx, options); + if (outcome.ok) { + sendJson(response, 200, { ok: true, text: outcome.text, url: outcome.url, code: outcome.code }); + return; + } + const status = outcome.reason === "busy" ? 409 : outcome.reason === "timeout" ? 504 : 500; + sendJson(response, status, { ok: false, reason: outcome.reason, text: outcome.text }); + }; +} +/** + * Start one sign-in attempt and wait for the actionable device-code notice. + * The attempt itself keeps running after the notice, so Meta's approval is + * picked up and the credential is stored by the seam. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns the user-facing outcome. + */ +export async function startSignIn(ctx, options = {}) { + const entry = ctx.authorization.describe(AUTH_KEY); + if (entry === undefined) { + return { + ok: false, + reason: "unavailable", + text: "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " + + `(${AUTH_KEY}). Update DeepSeek Harness or add the Meta provider under Settings > Models first.`, + }; + } + if (entry.inFlight) { + return { + ok: false, + reason: "busy", + text: "A Muse Code sign-in is already running. Approve it in the browser, then retry.", + }; + } + const method = entry.methods.find((candidate) => candidate.id === AUTH_METHOD)?.id ?? entry.methods[0]?.id; + if (method === undefined) { + return { + ok: false, + reason: "unavailable", + text: `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`, + }; + } + const openBrowser = options.openBrowser ?? openExternal; + const timeoutMs = options.timeoutMs ?? NOTICE_TIMEOUT_MS; + let announced; + let announce = () => { }; + const notice = new Promise((resolve) => { + announce = resolve; + }); + const attempt = ctx.authorization.begin({ + key: AUTH_KEY, + method, + interaction: { + notify(value) { + const instruction = readSignInNotice(value); + if (instruction === undefined) + return; + announced = instruction; + announce(instruction); + }, + prompt() { + return Promise.reject(new Error("Muse Code sign-in does not accept interactive prompts")); + }, }, + }); + // The attempt outlives the request that started it: Meta approval arrives + // later and the seam stores the credential then. + attempt.then((outcome) => ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`), (failure) => warn(ctx.logger, "dsh-muse-code: sign-in failed", failure)); + const announcedNotice = notice.then((instruction) => ({ kind: "notice", instruction })); + const settled = attempt.then((outcome) => ({ kind: "settled", outcome }), (failure) => ({ kind: "failed", failure })); + const timer = {}; + const timeout = new Promise((resolve) => { + timer.handle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); + timer.handle.unref?.(); + }); + try { + const outcome = await Promise.race([announcedNotice, settled, timeout]); + if (outcome.kind === "notice") { + return successOutcome(outcome.instruction, openBrowser); + } + if (outcome.kind === "timeout") { + ctx.authorization.cancel?.(AUTH_KEY); + return { + ok: false, + reason: "timeout", + text: "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again.", + }; + } + if (outcome.kind === "failed") { + const text = messageOf(outcome.failure); + if (/already running/i.test(text)) { + return { ok: false, reason: "busy", text: "A Muse Code sign-in is already running. Approve it in the browser, then retry." }; + } + return { ok: false, reason: "failed", text: `Muse Code sign-in failed: ${text}` }; + } + if (announced !== undefined) + return successOutcome(announced, openBrowser); + if (outcome.outcome.status === "authorized") { + return { + ok: false, + reason: "ended", + text: "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable.", + }; + } + return { ok: false, reason: "ended", text: `Muse Code sign-in ended (${outcome.outcome.status}).` }; + } + finally { + if (timer.handle !== undefined) + clearTimeout(timer.handle); + } +} +function successOutcome(instruction, openBrowser) { + // The browser hand-off is a courtesy for a purely local effect: the user must + // still confirm the code on Meta's page, and both surfaces expose the URL. + const opened = openBrowser(instruction.completeUrl); + return { + ok: true, + url: instruction.completeUrl, + ...(instruction.code === undefined ? {} : { code: instruction.code }), + text: formatSignInText(instruction, opened), + }; +} +/** + * Read the actionable instruction out of one authorization notice. + * @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one. + * @returns the instruction, or undefined for progress notices with no page. + */ +export function readSignInNotice(notice) { + const rawUrl = firstNonEmptyString(notice.url, notice.verificationUri); + if (rawUrl === undefined) + return undefined; + let url; + try { + url = new URL(rawUrl); + } + catch { + return undefined; + } + if (url.protocol !== "http:" && url.protocol !== "https:") + return undefined; + const code = firstNonEmptyString(notice.code, notice.userCode); + const message = firstNonEmptyString(notice.message, notice.instructions); + if (code !== undefined && !url.searchParams.has("code")) + url.searchParams.set("code", code); + return { + url: rawUrl, + completeUrl: url.toString(), + ...(code === undefined ? {} : { code }), + ...(message === undefined ? {} : { message }), }; - const register = () => ctx.authorization.registerFlow(flow); - if (ctx.effect) - ctx.effect(register); - else - register(); +} +/** + * Render the instruction text shared by the command result and the Settings + * control. + * @param instruction - actionable notice. + * @param opened - whether the browser hand-off was started. + * @returns one line the user can act on. + */ +export function formatSignInText(instruction, opened) { + const openPart = opened + ? "A browser tab should have opened; if not, open it yourself." + : "Open this page in your browser."; + const codePart = instruction.code === undefined + ? "Confirm the sign-in on the Meta page." + : `Confirm the code ${instruction.code} there.`; + return `Muse Code sign-in started. ${openPart} ${codePart} Page: ${instruction.completeUrl} Muse Code models become available as soon as Meta confirms.`; +} +/** + * Declare the shipped `meta` route in the `llm-pi-ai` settings section so the + * provider appears in Settings > Models and its models in the picker. + * + * The write is a merge into the entry's editable config, exactly what the + * Models page does when a user adds this provider by hand; an already declared + * `meta` profile is never touched. + * + * @param settings - the Settings form service. + * @param logger - optional plugin logger. + * @param options - retry window for late-activating profile entries. + * @returns whether this call added the route. + */ +export async function ensureMuseRoute(settings, logger, options = {}) { + const attempts = options.attempts ?? 12; + const delayMs = options.delayMs ?? 250; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS); + if (row !== undefined) { + const providers = row.value["providers"]; + const declared = isRecord(providers) && Object.hasOwn(providers, PROVIDER); + if (declared) + return false; + await settings.update(SETTINGS_NS, { providers: { [PROVIDER]: { displayName: ROUTE_LABEL } } }, row.revision); + logger?.info?.(`dsh-muse-code: declared the ${PROVIDER} route in Settings > Models`); + return true; + } + if (attempt < attempts - 1 && delayMs > 0) + await sleep(delayMs); + } + logger?.debug?.(`dsh-muse-code: no configurable "${SETTINGS_NS}" entry; leaving the route to Settings > Models`); + return false; +} +function setCorsHeaders(response) { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "content-type"); + response.setHeader("Cache-Control", "no-store"); +} +function sendJson(response, status, body) { + const text = JSON.stringify(body); + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(text); +} +function sendHtml(response, html) { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(html); +} +/** + * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, the + * fallback link, and an immediate move to Meta's verification page. + * @param outcome - result of the sign-in attempt. + * @returns a self-contained HTML document. + */ +function signInPage(outcome) { + const title = "Muse Code sign-in"; + if (!outcome.ok) { + return `${title}` + + `` + + `

${escapeHtml(title)}

` + + `

${escapeHtml(outcome.text)}

` + + `

Try again

`; + } + const codePart = outcome.code === undefined + ? "" + : `

${escapeHtml(outcome.code)}

`; + return `${title}` + + `` + + `` + + `

${escapeHtml(title)}

` + + `

Confirm this code on Meta's page:

${codePart}` + + `

If the page does not open, continue on Meta.

` + + ``; +} +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} +function firstNonEmptyString(...values) { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) + return value.trim(); + } + return undefined; +} +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function messageOf(failure) { + if (failure instanceof Error && failure.message.length > 0) + return failure.message; + const text = String(failure); + return text.length > 0 ? text : "unknown error"; +} +function warn(logger, message, error) { + const text = `${message}: ${messageOf(error)}`; + if (logger?.warn !== undefined) + logger.warn(text); + else if (logger?.debug !== undefined) + logger.debug(text); +} +function sleep(ms) { + return new Promise((resolve) => { + const handle = setTimeout(resolve, ms); + handle.unref?.(); + }); } diff --git a/lib/open-browser.d.ts b/lib/open-browser.d.ts new file mode 100644 index 0000000..2998aee --- /dev/null +++ b/lib/open-browser.d.ts @@ -0,0 +1,10 @@ +import { spawn } from "node:child_process"; +type SpawnLike = typeof spawn; +/** + * Hand one http(s) URL to the platform's default browser. + * @param url - absolute URL to open. + * @param spawnImpl - injectable process spawner, for tests. + * @returns whether the hand-off was started; it does not prove a page opened. + */ +export declare function openExternal(url: string, spawnImpl?: SpawnLike): boolean; +export {}; diff --git a/lib/open-browser.js b/lib/open-browser.js new file mode 100644 index 0000000..f84fd4a --- /dev/null +++ b/lib/open-browser.js @@ -0,0 +1,38 @@ +// Best-effort "open this URL in the user's default browser" for the host side. +// +// DeepSeek Harness has no provider-neutral host service for opening an external +// page; its account flow delegates that to the Desktop shell. The sign-in needs +// the Meta verification page in a browser, so this module hands the URL to the +// platform's own URI handler without a shell: `rundll32 url.dll,FileProtocolHandler` +// on Windows, `open` on macOS, `xdg-open` elsewhere. +// +// Failure is never fatal: the command and the login route both return the URL +// as text so the user can open it manually. +import { spawn } from "node:child_process"; +/** + * Hand one http(s) URL to the platform's default browser. + * @param url - absolute URL to open. + * @param spawnImpl - injectable process spawner, for tests. + * @returns whether the hand-off was started; it does not prove a page opened. + */ +export function openExternal(url, spawnImpl = spawn) { + if (!/^https?:\/\//i.test(url)) + return false; + const [command, args] = openerCommand(url); + try { + const child = spawnImpl(command, args, { detached: true, stdio: "ignore", windowsHide: true }); + child.on("error", () => { }); + child.unref(); + return true; + } + catch { + return false; + } +} +function openerCommand(url) { + if (process.platform === "win32") + return ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]; + if (process.platform === "darwin") + return ["open", [url]]; + return ["xdg-open", [url]]; +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..3ee8baf --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Muse Code", + "description": "Declares the shipped Meta provider route for the Muse Code subscription and adds the /muse-login sign-in command." + } +} diff --git a/package-lock.json b/package-lock.json index 9fb083b..e589477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thestreamcode/dsh-muse-code", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/node": "^26.6.5", diff --git a/package.json b/package.json index 772e370..a5fd445 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.1.1", - "description": "Muse Code subscription model provider and credential login for DeepSeek Harness.", + "version": "0.2.0", + "description": "Unofficial DeepSeek Harness integration for Meta's Muse Code subscription: declares the shipped Meta provider route and adds its sign-in command. Use at your own risk.", "license": "MIT", "author": { "name": "Michael Gasperini", @@ -16,10 +16,14 @@ "main": "./lib/index.js", "exports": { ".": "./lib/index.js", - "./package.json": "./package.json" + "./client": "./client/client.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" }, "files": [ "lib", + "client/client.js", + "locale/*.json", "cordis.patch.yml", "README.md", "DISCLAIMER.md", @@ -29,6 +33,13 @@ "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-client-ui-settings-models" + ] } }, "engines": { diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index a1d3275..0000000 --- a/src/auth.ts +++ /dev/null @@ -1,210 +0,0 @@ -// Meta device-code login and subscription-key minting for Muse Code. -// The wire contract is shared with the other Muse Code plugins; see -// AUTH_CONTRACT.md before changing any constant here. - -export const CLIENT_ID = "1031625952748946" -export const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/" -export const TOKEN_URL = "https://auth.meta.com/oidc/device/token/" -export const KEY_URL = "https://api.meta.ai/muse-code/key" -export const API_VERSION = "1.0.0" -export const AUTH_CONTRACT_VERSION = 1 - -const HEADERS: Record = { - Accept: "application/json", - "x-api-version": API_VERSION, -} -const REQUEST_TIMEOUT_MS = 25_000 -const MIN_INTERVAL_S = 1 -const SLOW_DOWN_STEP_S = 5 - -export interface DeviceAuthorization { - device_code: string - user_code: string - verification_uri: string - verification_uri_complete?: string - interval: number - expires_in: number -} - -export interface MintedCredential { - apiKey: string - accountId: string - email?: string -} - -type DeviceResponse = { - device_code?: unknown - user_code?: unknown - verification_uri?: unknown - verification_uri_complete?: unknown - interval?: unknown - expires_in?: unknown -} - -type TokenResponse = { - error?: unknown - access_token?: unknown -} - -type KeyResponse = { - api_key?: unknown - is_subs_active?: unknown - require_payment?: unknown - action_url?: unknown - require_payment_action_url?: unknown - user_email?: unknown - user_id?: unknown -} - -function combinedSignal(signal: AbortSignal | undefined): AbortSignal { - const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) - return signal ? AbortSignal.any([timeout, signal]) : timeout -} - -async function postForm( - url: string, - params: Record, - signal: AbortSignal | undefined, -): Promise { - const res = await fetch(url, { - method: "POST", - headers: { ...HEADERS, "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(params).toString(), - signal: combinedSignal(signal), - }) - const text = await res.text() - if (!res.ok) throw new Error(`HTTP ${res.status} ${text.slice(0, 200)}`.trim()) - return JSON.parse(text) -} - -function delay(ms: number, signal: AbortSignal | undefined): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(resolve, ms) - if (!signal) return - if (signal.aborted) { - clearTimeout(timer) - reject(new Error("authorization cancelled")) - return - } - signal.addEventListener( - "abort", - () => { - clearTimeout(timer) - reject(new Error("authorization cancelled")) - }, - { once: true }, - ) - }) -} - -export async function deviceAuthorize(signal?: AbortSignal): Promise { - const body = (await postForm(DEVICE_URL, { client_id: CLIENT_ID }, signal)) as DeviceResponse - const { device_code, user_code, verification_uri, interval, expires_in } = body - if ( - typeof device_code !== "string" || - !device_code || - typeof user_code !== "string" || - !user_code || - typeof verification_uri !== "string" || - !verification_uri || - typeof interval !== "number" || - typeof expires_in !== "number" - ) { - throw new Error("device authorization response is missing fields") - } - const complete = body.verification_uri_complete - return { - device_code, - user_code, - verification_uri, - ...(typeof complete === "string" && complete ? { verification_uri_complete: complete } : {}), - interval, - expires_in, - } -} - -export async function pollToken( - deviceCode: string, - intervalS: number, - expiresInS: number, - signal?: AbortSignal, -): Promise { - let interval = Math.max(MIN_INTERVAL_S, Number(intervalS) || 5) - const deadline = Date.now() + Number(expiresInS || 600) * 1000 - let slowDowns = 0 - while (Date.now() < deadline) { - if (signal?.aborted) throw new Error("authorization cancelled") - // The token endpoint answers pending/slow_down polls with HTTP errors as - // part of the normal flow: classify the body instead of throwing. - let body: TokenResponse | null = null - try { - const res = await fetch(TOKEN_URL, { - method: "POST", - headers: { ...HEADERS, "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: CLIENT_ID, - device_code: deviceCode, - }).toString(), - signal: combinedSignal(signal), - }) - try { - body = (await res.json()) as TokenResponse - } catch { - body = null - } - } catch (error) { - if (signal?.aborted) throw new Error("authorization cancelled") - if (error instanceof Error && error.name === "AbortError") throw error - body = null - } - if (body && !body.error) { - if (typeof body.access_token !== "string" || !body.access_token) { - throw new Error("token response has no access_token") - } - return body.access_token - } - if (body?.error === "slow_down") { - slowDowns += 1 - interval = Math.max(MIN_INTERVAL_S, interval + SLOW_DOWN_STEP_S) - } else if (body?.error !== "authorization_pending") { - throw new Error(`login failed: ${String(body?.error || "unreadable token response")}`) - } - const remaining = deadline - Date.now() - if (remaining <= 0) break - await delay(Math.min(interval, remaining) * 1000, signal) - } - throw new Error( - slowDowns ? "login timed out after slow_down responses" : "login timed out waiting for approval", - ) -} - -export async function mintKey(accessToken: string, signal?: AbortSignal): Promise { - const res = await fetch(KEY_URL, { - method: "POST", - headers: { - ...HEADERS, - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ onboard: true }), - signal: combinedSignal(signal), - }) - const text = await res.text() - if (!res.ok) throw new Error(`key exchange failed: HTTP ${res.status} ${text.slice(0, 200)}`.trim()) - const body = JSON.parse(text) as KeyResponse - if (body.is_subs_active === false) { - throw new Error("Muse Code subscription is inactive for this account") - } - if (typeof body.api_key !== "string" || !body.api_key.trim()) { - const action = String(body.action_url || body.require_payment_action_url || "").trim() - if (body.require_payment === true || action) { - throw new Error(`Muse Code subscription is required${action ? ": " + action : ""}`) - } - throw new Error("key response is missing api_key") - } - const email = String(body.user_email || "").trim().toLowerCase() || undefined - const accountId = String(body.user_id || "").trim() || email - if (!accountId) throw new Error("key response is missing account identity") - return { apiKey: body.api_key.trim(), accountId, ...(email ? { email } : {}) } -} diff --git a/src/index.ts b/src/index.ts index 3ea891c..0179738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,75 +1,529 @@ -// DeepSeek Harness bundle: Muse Code subscription route + login flow. +// Muse Code (Meta subscription) integration for DeepSeek Harness. // -// The route is declared by cordis.patch.yml through a second pi-ai adapter -// instance. This plugin owns the human sign-in: it runs Meta's device flow, -// mints the account-bound inference key, stores it under the -// MUSE_CODE_SUB_TOKEN credential reference, and commits the authorization -// record the seam requires. +// DeepSeek Harness already serves this subscription: the shipped pi-ai catalog +// exposes the `meta` route (api.meta.ai/v1) and registers the `llm-pi-ai/meta` +// authorization flow, which runs Meta's device-code login and stores the +// renewable credential record. The Web/Desktop Settings surface has no generic +// button for provider OAuth flows, so this bundle adds the pieces a user needs: +// +// - it declares the `meta` route in the `llm-pi-ai` settings section, so the +// provider appears under Settings > Models and its models in the model picker; +// - it exposes the shipped sign-in as the `/muse-login` command; +// - it registers an exact Web route (`/muse-code/login`) and a Sign in control +// under Settings > Models that calls it, because the Settings page cannot +// reach the Host authorization seam through the Remote API. +// +// The Host opens the verification page in the user's default browser itself; the +// route and the command also return the code and the URL, so a blocked browser +// never strands the sign-in. +// +// A second `@deepseek-ai/dsh-llm-pi-ai` instance is not an option: every +// instance declares the whole installed provider catalog, so the extra instance +// fails to activate with a duplicate configurable-provider error. -import { deviceAuthorize, mintKey, pollToken } from "./auth.js" +import type { IncomingMessage, ServerResponse } from "node:http" -type CredentialStore = { - set(reference: string, value: string): Promise - modifyRecord(key: string, update: () => Promise): Promise -} +import { openExternal } from "./open-browser.js" + +export const name = "dsh-muse-code" +export const inject = ["authorization", "commands"] -type AuthorizationSession = { - signal?: AbortSignal - notify(input: { message: string; url?: string }): void +/** Settings namespace whose provider profiles configure the pi-ai routes. */ +const SETTINGS_NS = "llm-pi-ai" +/** Route served by the shipped pi-ai Meta provider. */ +const PROVIDER = "meta" +/** Credential record / authorization flow the shipped pi-ai adapter registers. */ +const AUTH_KEY = "llm-pi-ai/meta" +/** Preferred sign-in method; the shipped flow offers `oauth` first. */ +const AUTH_METHOD = "oauth" +/** Display name for the route this bundle declares. */ +const ROUTE_LABEL = "Muse Code (Meta subscription)" +/** Web route the Settings control calls to start the sign-in. */ +export const LOGIN_ROUTE = "/muse-code/login" +/** How long the sign-in waits for the device-code notice before giving up. */ +const NOTICE_TIMEOUT_MS = 20_000 + +type Logger = { + info?(message: string): void + warn?(message: string, ...rest: unknown[]): void + debug?(message: string): void } -type AuthorizationFlow = { +type CommandResult = { kind: "success"; text?: string } | { kind: "error"; text: string } + +type AuthorizationMethods = Array<{ id: string; label: string }> + +type AuthorizationEntry = { key: string label: string - methods: Array<{ id: string; label: string }> - run(session: AuthorizationSession): Promise + methods: AuthorizationMethods + inFlight: boolean +} + +/** + * One notice from the authorization seam. The seam's vocabulary is + * `{ message, url?, code? }`; the raw pi-ai fields are accepted too so the + * reader also covers a direct adapter notice. + */ +type AuthorizationNotice = { + type?: string + message?: string + instructions?: string + url?: string + code?: string + userCode?: string + verificationUri?: string + expiresInSeconds?: number +} + +type AuthorizationInteraction = { + notify(notice: AuthorizationNotice): void + prompt(prompt: unknown): Promise +} + +type AuthorizationFace = { + describe(key: string): AuthorizationEntry | undefined + begin(request: { + key: string + method?: string + interaction: AuthorizationInteraction + }): Promise<{ status: string }> + cancel?(key: string): void +} + +type CommandFace = { + register(definition: { + name: string + description: string + handler(context: { rawInput?: string }): Promise + }): () => void +} + +type SettingsRow = { + ns: string + revision: number + value: Record +} + +type SettingsFace = { + describe(): SettingsRow[] + update(ns: string, patch: Record, expectedRevision: number): Promise +} + +type WebRoute = { + kind: "exact" | "prefix" + path: string + handler(request: IncomingMessage, response: ServerResponse): Promise +} + +type WebServerFace = { + register(route: WebRoute): () => void +} + +type ChildContext = HostContext & { + settings?: SettingsFace + webServer?: WebServerFace } type HostContext = { - credentials: CredentialStore - authorization: { - registerFlow(flow: AuthorizationFlow): () => void - } - effect?(callback: () => (() => void) | void): () => void + authorization: AuthorizationFace + commands: CommandFace + logger?: Logger + effect(callback: () => () => void, label?: string): () => void + inject?(dependencies: string[], callback: (child: ChildContext) => void): void } -export const name = "dsh-muse-code" -export const inject = ["credentials", "authorization"] +/** Injected side effects, overridable in tests. */ +export type SignInOptions = { + openBrowser?: (url: string) => boolean + timeoutMs?: number +} + +/** Actionable instructions extracted from one authorization notice. */ +export type SignInInstruction = { + /** Verification page as the flow reported it. */ + url: string + /** Verification page with the user code attached when the URL accepts it. */ + completeUrl: string + code?: string + message?: string +} -const FLOW_KEY = "dsh-muse-code/subscription" -const CREDENTIAL_REF = "MUSE_CODE_SUB_TOKEN" +type SignInOutcome = + | { ok: true; text: string; url: string; code?: string } + | { ok: false; reason: "unavailable" | "busy" | "timeout" | "failed" | "ended"; text: string } +/** + * Register the sign-in command, the Settings > Models sign-in route, and the + * settings write that makes the Muse Code provider visible. + * @param ctx - Host plugin context. + */ export function apply(ctx: HostContext): void { - const flow: AuthorizationFlow = { - key: FLOW_KEY, - label: "Muse Code (subscription)", - methods: [{ id: "device", label: "Sign in with Meta device code" }], - async run(session) { - const device = await deviceAuthorize(session.signal) - const url = device.verification_uri_complete || device.verification_uri - session.notify({ message: `Open ${url} and enter code ${device.user_code}`, url }) - - const accessToken = await pollToken( - device.device_code, - device.interval, - device.expires_in, - session.signal, - ) - const minted = await mintKey(accessToken, session.signal) - - await ctx.credentials.set(CREDENTIAL_REF, minted.apiKey) - await ctx.credentials.modifyRecord(FLOW_KEY, async () => ({ - kind: "grant", - payload: { - accountId: minted.accountId, - ...(minted.email ? { email: minted.email } : {}), - }, - })) - session.notify({ message: `Signed in as ${minted.email || minted.accountId}` }) + ctx.effect(() => ctx.commands.register(museLoginCommand(ctx)), "dsh-muse-code: /muse-login") + + if (typeof ctx.inject !== "function") return + + ctx.inject(["webServer"], (child) => { + const webServer = child.webServer + if (webServer === undefined) return + child.effect( + () => + webServer.register({ + kind: "exact", + path: LOGIN_ROUTE, + handler: createLoginRouteHandler(ctx), + }), + "dsh-muse-code: login route", + ) + }) + + ctx.inject(["settings"], (child) => { + child.effect(() => { + const settings = child.settings + if (settings === undefined) return () => {} + void ensureMuseRoute(settings, ctx.logger).catch((error: unknown) => { + warn(ctx.logger, "dsh-muse-code: could not declare the Muse Code route", error) + }) + return () => {} + }, "dsh-muse-code: declare route") + }) +} + +/** Command definition for the sign-in; exported for tests. */ +export function museLoginCommand( + ctx: HostContext, + options: SignInOptions = {}, +): Parameters[0] { + return { + name: "muse-login", + description: "Sign in to the Muse Code (Meta) subscription and enable its models", + async handler() { + const outcome = await startSignIn(ctx, options) + if (outcome.ok) return { kind: "success", text: outcome.text } + return { kind: "error", text: outcome.text } + }, + } +} + +/** + * Build the exact Web route that starts the same sign-in from the Settings + * page. The route answers JSON so the Client control can render the code and + * the failure reason next to its button. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns an HTTP handler for {@link LOGIN_ROUTE}. + */ +export function createLoginRouteHandler( + ctx: HostContext, + options: SignInOptions = {}, +): (request: IncomingMessage, response: ServerResponse) => Promise { + return async (request, response) => { + setCorsHeaders(response) + if (request.method === "OPTIONS") { + response.writeHead(204) + response.end() + return + } + if (request.method === "GET") { + // A plain navigation: this tab is the browser, so the flow need not open + // another one. The page shows the code and moves on to Meta's page. + const outcome = await startSignIn(ctx, { ...options, openBrowser: () => false }) + sendHtml(response, signInPage(outcome)) + return + } + if (request.method !== "POST") { + sendJson(response, 405, { ok: false, reason: "method", text: "Use GET or POST to start the sign-in." }) + return + } + const outcome = await startSignIn(ctx, options) + if (outcome.ok) { + sendJson(response, 200, { ok: true, text: outcome.text, url: outcome.url, code: outcome.code }) + return + } + const status = outcome.reason === "busy" ? 409 : outcome.reason === "timeout" ? 504 : 500 + sendJson(response, status, { ok: false, reason: outcome.reason, text: outcome.text }) + } +} + +/** + * Start one sign-in attempt and wait for the actionable device-code notice. + * The attempt itself keeps running after the notice, so Meta's approval is + * picked up and the credential is stored by the seam. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns the user-facing outcome. + */ +export async function startSignIn(ctx: HostContext, options: SignInOptions = {}): Promise { + const entry = ctx.authorization.describe(AUTH_KEY) + if (entry === undefined) { + return { + ok: false, + reason: "unavailable", + text: + "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " + + `(${AUTH_KEY}). Update DeepSeek Harness or add the Meta provider under Settings > Models first.`, + } + } + if (entry.inFlight) { + return { + ok: false, + reason: "busy", + text: "A Muse Code sign-in is already running. Approve it in the browser, then retry.", + } + } + const method = entry.methods.find((candidate) => candidate.id === AUTH_METHOD)?.id ?? entry.methods[0]?.id + if (method === undefined) { + return { + ok: false, + reason: "unavailable", + text: `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`, + } + } + + const openBrowser = options.openBrowser ?? openExternal + const timeoutMs = options.timeoutMs ?? NOTICE_TIMEOUT_MS + let announced: SignInInstruction | undefined + let announce: (instruction: SignInInstruction) => void = () => {} + const notice = new Promise((resolve) => { + announce = resolve + }) + const attempt = ctx.authorization.begin({ + key: AUTH_KEY, + method, + interaction: { + notify(value) { + const instruction = readSignInNotice(value) + if (instruction === undefined) return + announced = instruction + announce(instruction) + }, + prompt() { + return Promise.reject(new Error("Muse Code sign-in does not accept interactive prompts")) + }, }, + }) + // The attempt outlives the request that started it: Meta approval arrives + // later and the seam stores the credential then. + attempt.then( + (outcome) => ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`), + (failure: unknown) => warn(ctx.logger, "dsh-muse-code: sign-in failed", failure), + ) + + const announcedNotice = notice.then((instruction) => ({ kind: "notice" as const, instruction })) + const settled = attempt.then( + (outcome) => ({ kind: "settled" as const, outcome }), + (failure: unknown) => ({ kind: "failed" as const, failure }), + ) + const timer: { handle?: ReturnType } = {} + const timeout = new Promise<{ kind: "timeout" }>((resolve) => { + timer.handle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs) + timer.handle.unref?.() + }) + try { + const outcome = await Promise.race([announcedNotice, settled, timeout]) + if (outcome.kind === "notice") { + return successOutcome(outcome.instruction, openBrowser) + } + if (outcome.kind === "timeout") { + ctx.authorization.cancel?.(AUTH_KEY) + return { + ok: false, + reason: "timeout", + text: + "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again.", + } + } + if (outcome.kind === "failed") { + const text = messageOf(outcome.failure) + if (/already running/i.test(text)) { + return { ok: false, reason: "busy", text: "A Muse Code sign-in is already running. Approve it in the browser, then retry." } + } + return { ok: false, reason: "failed", text: `Muse Code sign-in failed: ${text}` } + } + if (announced !== undefined) return successOutcome(announced, openBrowser) + if (outcome.outcome.status === "authorized") { + return { + ok: false, + reason: "ended", + text: "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable.", + } + } + return { ok: false, reason: "ended", text: `Muse Code sign-in ended (${outcome.outcome.status}).` } + } finally { + if (timer.handle !== undefined) clearTimeout(timer.handle) + } +} + +function successOutcome(instruction: SignInInstruction, openBrowser: (url: string) => boolean): SignInOutcome { + // The browser hand-off is a courtesy for a purely local effect: the user must + // still confirm the code on Meta's page, and both surfaces expose the URL. + const opened = openBrowser(instruction.completeUrl) + return { + ok: true, + url: instruction.completeUrl, + ...(instruction.code === undefined ? {} : { code: instruction.code }), + text: formatSignInText(instruction, opened), } +} + +/** + * Read the actionable instruction out of one authorization notice. + * @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one. + * @returns the instruction, or undefined for progress notices with no page. + */ +export function readSignInNotice(notice: AuthorizationNotice): SignInInstruction | undefined { + const rawUrl = firstNonEmptyString(notice.url, notice.verificationUri) + if (rawUrl === undefined) return undefined + let url: URL + try { + url = new URL(rawUrl) + } catch { + return undefined + } + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined + const code = firstNonEmptyString(notice.code, notice.userCode) + const message = firstNonEmptyString(notice.message, notice.instructions) + if (code !== undefined && !url.searchParams.has("code")) url.searchParams.set("code", code) + return { + url: rawUrl, + completeUrl: url.toString(), + ...(code === undefined ? {} : { code }), + ...(message === undefined ? {} : { message }), + } +} + +/** + * Render the instruction text shared by the command result and the Settings + * control. + * @param instruction - actionable notice. + * @param opened - whether the browser hand-off was started. + * @returns one line the user can act on. + */ +export function formatSignInText(instruction: SignInInstruction, opened: boolean): string { + const openPart = opened + ? "A browser tab should have opened; if not, open it yourself." + : "Open this page in your browser." + const codePart = instruction.code === undefined + ? "Confirm the sign-in on the Meta page." + : `Confirm the code ${instruction.code} there.` + return `Muse Code sign-in started. ${openPart} ${codePart} Page: ${instruction.completeUrl} Muse Code models become available as soon as Meta confirms.` +} + +/** + * Declare the shipped `meta` route in the `llm-pi-ai` settings section so the + * provider appears in Settings > Models and its models in the picker. + * + * The write is a merge into the entry's editable config, exactly what the + * Models page does when a user adds this provider by hand; an already declared + * `meta` profile is never touched. + * + * @param settings - the Settings form service. + * @param logger - optional plugin logger. + * @param options - retry window for late-activating profile entries. + * @returns whether this call added the route. + */ +export async function ensureMuseRoute( + settings: SettingsFace, + logger?: Logger, + options: { attempts?: number; delayMs?: number } = {}, +): Promise { + const attempts = options.attempts ?? 12 + const delayMs = options.delayMs ?? 250 + for (let attempt = 0; attempt < attempts; attempt += 1) { + const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS) + if (row !== undefined) { + const providers = row.value["providers"] + const declared = isRecord(providers) && Object.hasOwn(providers, PROVIDER) + if (declared) return false + await settings.update(SETTINGS_NS, { providers: { [PROVIDER]: { displayName: ROUTE_LABEL } } }, row.revision) + logger?.info?.(`dsh-muse-code: declared the ${PROVIDER} route in Settings > Models`) + return true + } + if (attempt < attempts - 1 && delayMs > 0) await sleep(delayMs) + } + logger?.debug?.(`dsh-muse-code: no configurable "${SETTINGS_NS}" entry; leaving the route to Settings > Models`) + return false +} + +function setCorsHeaders(response: ServerResponse): void { + response.setHeader("Access-Control-Allow-Origin", "*") + response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS") + response.setHeader("Access-Control-Allow-Headers", "content-type") + response.setHeader("Cache-Control", "no-store") +} + +function sendJson(response: ServerResponse, status: number, body: unknown): void { + const text = JSON.stringify(body) + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }) + response.end(text) +} + +function sendHtml(response: ServerResponse, html: string): void { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }) + response.end(html) +} + +/** + * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, the + * fallback link, and an immediate move to Meta's verification page. + * @param outcome - result of the sign-in attempt. + * @returns a self-contained HTML document. + */ +function signInPage(outcome: SignInOutcome): string { + const title = "Muse Code sign-in" + if (!outcome.ok) { + return `${title}` + + `` + + `

${escapeHtml(title)}

` + + `

${escapeHtml(outcome.text)}

` + + `

Try again

` + } + const codePart = outcome.code === undefined + ? "" + : `

${escapeHtml(outcome.code)}

` + return `${title}` + + `` + + `` + + `

${escapeHtml(title)}

` + + `

Confirm this code on Meta's page:

${codePart}` + + `

If the page does not open, continue on Meta.

` + + `` +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) +} + +function firstNonEmptyString(...values: Array): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) return value.trim() + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function messageOf(failure: unknown): string { + if (failure instanceof Error && failure.message.length > 0) return failure.message + const text = String(failure) + return text.length > 0 ? text : "unknown error" +} + +function warn(logger: Logger | undefined, message: string, error: unknown): void { + const text = `${message}: ${messageOf(error)}` + if (logger?.warn !== undefined) logger.warn(text) + else if (logger?.debug !== undefined) logger.debug(text) +} - const register = () => ctx.authorization.registerFlow(flow) - if (ctx.effect) ctx.effect(register) - else register() +function sleep(ms: number): Promise { + return new Promise((resolve) => { + const handle = setTimeout(resolve, ms) + handle.unref?.() + }) } diff --git a/src/open-browser.ts b/src/open-browser.ts new file mode 100644 index 0000000..291bd8e --- /dev/null +++ b/src/open-browser.ts @@ -0,0 +1,39 @@ +// Best-effort "open this URL in the user's default browser" for the host side. +// +// DeepSeek Harness has no provider-neutral host service for opening an external +// page; its account flow delegates that to the Desktop shell. The sign-in needs +// the Meta verification page in a browser, so this module hands the URL to the +// platform's own URI handler without a shell: `rundll32 url.dll,FileProtocolHandler` +// on Windows, `open` on macOS, `xdg-open` elsewhere. +// +// Failure is never fatal: the command and the login route both return the URL +// as text so the user can open it manually. + +import { spawn } from "node:child_process" + +type SpawnLike = typeof spawn + +/** + * Hand one http(s) URL to the platform's default browser. + * @param url - absolute URL to open. + * @param spawnImpl - injectable process spawner, for tests. + * @returns whether the hand-off was started; it does not prove a page opened. + */ +export function openExternal(url: string, spawnImpl: SpawnLike = spawn): boolean { + if (!/^https?:\/\//i.test(url)) return false + const [command, args] = openerCommand(url) + try { + const child = spawnImpl(command, args, { detached: true, stdio: "ignore", windowsHide: true }) + child.on("error", () => {}) + child.unref() + return true + } catch { + return false + } +} + +function openerCommand(url: string): [string, string[]] { + if (process.platform === "win32") return ["rundll32.exe", ["url.dll,FileProtocolHandler", url]] + if (process.platform === "darwin") return ["open", [url]] + return ["xdg-open", [url]] +} diff --git a/test/auth.test.mjs b/test/auth.test.mjs deleted file mode 100644 index 6c46cf0..0000000 --- a/test/auth.test.mjs +++ /dev/null @@ -1,115 +0,0 @@ -import { test } from "node:test" -import assert from "node:assert/strict" -import { - AUTH_CONTRACT_VERSION, - CLIENT_ID, - DEVICE_URL, - KEY_URL, - TOKEN_URL, - deviceAuthorize, - mintKey, - pollToken, -} from "../lib/auth.js" - -const DEVICE_OK = { - device_code: "dc", - user_code: "ABCD-EFGH", - verification_uri: "https://auth.meta.com/device", - verification_uri_complete: "https://auth.meta.com/device?user_code=ABCD-EFGH", - interval: 0, - expires_in: 600, -} - -const KEY_OK = { - api_key: "LLM|fresh", - user_email: "User@Example.com", - user_id: "uid-1", - is_subs_active: true, -} - -function stubFetch(handler) { - const original = globalThis.fetch - globalThis.fetch = async (input, init) => { - const { status = 200, body } = handler(String(input), init) - return new Response(JSON.stringify(body), { status }) - } - return () => { - globalThis.fetch = original - } -} - -test("auth contract is pinned", () => { - assert.equal(AUTH_CONTRACT_VERSION, 1) - assert.equal(CLIENT_ID, "1031625952748946") - assert.equal(DEVICE_URL, "https://auth.meta.com/oidc/device/authorization/") - assert.equal(TOKEN_URL, "https://auth.meta.com/oidc/device/token/") - assert.equal(KEY_URL, "https://api.meta.ai/muse-code/key") -}) - -test("device authorize validates fields", async () => { - let restore = stubFetch(() => ({ body: DEVICE_OK })) - try { - const device = await deviceAuthorize() - assert.equal(device.user_code, "ABCD-EFGH") - assert.match(device.verification_uri_complete, /ABCD-EFGH/) - } finally { - restore() - } - - restore = stubFetch(() => ({ body: { user_code: "x" } })) - try { - await assert.rejects(deviceAuthorize(), /missing fields/) - } finally { - restore() - } -}) - -test("poll token returns the access token", async () => { - const restore = stubFetch((url) => { - assert.match(url, /device\/token/) - return { body: { access_token: "dca-new" } } - }) - try { - assert.equal(await pollToken("dc", 0, 600), "dca-new") - } finally { - restore() - } -}) - -test("poll token rejects terminal errors", async () => { - const restore = stubFetch(() => ({ status: 400, body: { error: "access_denied" } })) - try { - await assert.rejects(pollToken("dc", 0, 600), /access_denied/) - } finally { - restore() - } -}) - -test("mint validates the subscription", async () => { - let restore = stubFetch(() => ({ body: KEY_OK })) - try { - assert.deepEqual(await mintKey("dca-x"), { - apiKey: "LLM|fresh", - accountId: "uid-1", - email: "user@example.com", - }) - } finally { - restore() - } - - restore = stubFetch(() => ({ body: { ...KEY_OK, is_subs_active: false } })) - try { - await assert.rejects(mintKey("dca-x"), /inactive/) - } finally { - restore() - } - - restore = stubFetch(() => ({ - body: { require_payment: true, action_url: "https://example.test/pay" }, - })) - try { - await assert.rejects(mintKey("dca-x"), /required.*example\.test\/pay/) - } finally { - restore() - } -}) diff --git a/test/client.test.mjs b/test/client.test.mjs new file mode 100644 index 0000000..9574d64 --- /dev/null +++ b/test/client.test.mjs @@ -0,0 +1,164 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" + +// DSH serves Client artifacts as CLASSIC SCRIPTS (they are concatenated into +// plain script bundles), not as ES modules. These tests are the closest +// browser-equivalent checks available without a page: `new Function` rejects +// `export`/`import` exactly like a classic script, and the captured factory is +// driven with a stub React, slot surface, and fetch. + +async function loadClient() { + const source = await readFile(new URL("../client/client.js", import.meta.url), "utf8") + assert.doesNotMatch(source, /^\s*(?:export|import)\b/m) + + const modules = [] + globalThis.__ModuleLoader__ = { + load(module) { + modules.push(module) + }, + } + try { + new Function(source)() + } finally { + delete globalThis.__ModuleLoader__ + } + assert.equal(modules.length, 1) + assert.equal(modules[0].id, "@thestreamcode/dsh-muse-code") + return modules[0] +} + +function makeReact() { + const slots = [] + return { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState(initial) { + const slot = { value: initial } + slots.push(slot) + return [slot.value, (next) => { slot.value = next }] + }, + }, + slots, + } +} + +function registerFooter(module, react) { + const plugin = module.factory(() => react) + assert.deepEqual(plugin.inject, ["slots"]) + const registered = [] + const ctx = { + slots: { + inject(ownerKey, callback) { + assert.equal(ownerKey, "settings.models.footer") + callback() + }, + register(options, component) { + registered.push({ options, component }) + return () => {} + }, + }, + } + plugin.apply(ctx) + assert.equal(registered.length, 1) + assert.equal(registered[0].options.name, "settings.models.footer") + return registered[0] +} + +function findButton(node) { + if (node === null || typeof node !== "object") return undefined + if (Array.isArray(node)) { + for (const child of node) { + const hit = findButton(child) + if (hit !== undefined) return hit + } + return undefined + } + if (node.type === "button") return node + for (const child of node.children ?? []) { + const hit = findButton(child) + if (hit !== undefined) return hit + } + return undefined +} + +function findLink(node) { + if (node === null || typeof node !== "object") return undefined + if (Array.isArray(node)) { + for (const child of node) { + const hit = findLink(child) + if (hit !== undefined) return hit + } + return undefined + } + if (node.type === "a") return node + for (const child of node.children ?? []) { + const hit = findLink(child) + if (hit !== undefined) return hit + } + return undefined +} + +function textOf(node) { + if (node === null || node === undefined) return "" + if (typeof node === "string" || typeof node === "number") return String(node) + if (Array.isArray(node)) return node.map(textOf).join("") + return (node.children ?? []).map(textOf).join("") +} + +test("client artifact registers the Models footer contribution", async () => { + const module = await loadClient() + const { react } = makeReact() + const footer = registerFooter(module, react) + assert.equal(footer.options.id, "dsh-muse-code-sign-in") +}) + +test("the footer control signs in through the Host login route and shows the code", async () => { + const module = await loadClient() + const { react, slots } = makeReact() + globalThis.__DSH_TRANSPORT__ = { streamBaseUrl: "http://127.0.0.1:49806" } + const requests = [] + const originalFetch = globalThis.fetch + globalThis.fetch = async (url, init) => { + requests.push({ url: String(url), init }) + return new Response( + JSON.stringify({ + ok: true, + text: "Muse Code sign-in started. Confirm the code ABCD-EFGH. Page: https://auth.meta.com/oauth/device/?code=ABCD-EFGH", + url: "https://auth.meta.com/oauth/device/?code=ABCD-EFGH", + code: "ABCD-EFGH", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + try { + const footer = registerFooter(module, react) + const tree = footer.component() + const button = findButton(tree) + assert.ok(button, "the control renders a Sign in button") + assert.match(textOf(button), /Sign in/) + const link = findLink(tree) + assert.ok(link, "the control renders a direct sign-in link") + assert.equal(link.props.href, "http://127.0.0.1:49806/muse-code/login") + assert.equal(link.props.target, "_blank") + await button.props.onClick() + } finally { + globalThis.fetch = originalFetch + delete globalThis.__DSH_TRANSPORT__ + } + + assert.equal(requests.length, 1) + assert.equal(requests[0].url, "http://127.0.0.1:49806/muse-code/login") + assert.equal(requests[0].init.method, "POST") + assert.equal(slots[0].value.status, "success") + assert.match(slots[0].value.text, /ABCD-EFGH/) +}) + +test("without a discoverable Host origin the control points at the command", async () => { + const module = await loadClient() + const { react } = makeReact() + const footer = registerFooter(module, react) + const tree = footer.component() + assert.equal(findButton(tree), undefined) + assert.match(textOf(tree), /\/muse-login/) +}) diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index e198ec8..60d6070 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -1,99 +1,332 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { apply, inject, name } from "../lib/index.js" +import { readFile } from "node:fs/promises" +import { + apply, + createLoginRouteHandler, + ensureMuseRoute, + formatSignInText, + inject, + LOGIN_ROUTE, + museLoginCommand, + name, + readSignInNotice, +} from "../lib/index.js" -function makeContext() { - const flows = [] - const stored = [] - const records = [] +const PACKAGE = "@thestreamcode/dsh-muse-code" +const FLOW = { + key: "llm-pi-ai/meta", + label: "Meta", + methods: [ + { id: "oauth", label: "Sign in with Meta" }, + { id: "api-key", label: "Meta Model API key" }, + ], + inFlight: false, +} +const NEUTRAL_NOTICE = { + message: "Enter this code on the verification page to finish signing in.", + url: "https://auth.meta.com/oauth/device/", + code: "ABCD-EFGH", +} + +function makeSettings(rows) { + const updates = [] + const settings = { + describe() { + return rows.map((row) => ({ ...row })) + }, + async update(ns, patch, revision) { + updates.push({ ns, patch, revision }) + }, + } + return { settings, updates } +} + +function makeContext(options = {}) { + const flow = "flow" in options ? options.flow : FLOW + const settingsRows = options.settingsRows ?? [ + { ns: "llm-pi-ai", revision: 3, value: { providers: { meta: {} } } }, + ] + const commands = [] + const children = [] + const routes = [] + const cancels = [] + const { settings, updates } = makeSettings(settingsRows) const ctx = { - credentials: { - async set(reference, value) { - stored.push({ reference, value }) + authorization: { + describe(key) { + assert.equal(key, "llm-pi-ai/meta") + return flow + }, + async begin() { + throw new Error("unexpected begin") }, - async modifyRecord(key, update) { - records.push({ key, value: await update() }) + cancel(key) { + cancels.push(key) }, }, - authorization: { - registerFlow(flow) { - flows.push(flow) + commands: { + register(definition) { + commands.push(definition) return () => {} }, }, + logger: { + info() {}, + warn() {}, + debug() {}, + }, effect(callback) { return callback() }, + inject(dependencies, callback) { + children.push({ dependencies }) + callback({ + ...ctx, + settings, + webServer: { + register(route) { + routes.push(route) + return () => {} + }, + }, + }) + return () => {} + }, + } + return { ctx, commands, children, routes, cancels, updates } +} + +function makeResponse() { + const state = { status: 0, headers: {}, body: "" } + const response = { + setHeader(name, value) { + state.headers[name.toLowerCase()] = value + }, + writeHead(status, headers) { + state.status = status + for (const [name, value] of Object.entries(headers ?? {})) state.headers[name.toLowerCase()] = value + }, + end(text) { + state.body = text === undefined ? "" : String(text) + }, } - return { ctx, flows, stored, records } + return { response, state } } -test("registers one authorization flow", () => { - const { ctx, flows } = makeContext() +test("manifest: bundle patch inserts one row and the client half is exported", async () => { + const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) + assert.deepEqual(manifest.dsh.bundle, { patch: "./cordis.patch.yml" }) + assert.equal(manifest.dsh.client.platform, "web") + assert.equal(manifest.exports["./client"], "./client/client.js") + assert.ok(manifest.files.includes("client/client.js")) + assert.ok(manifest.files.includes("locale/*.json")) + + const patch = await readFile(new URL("../cordis.patch.yml", import.meta.url), "utf8") + assert.match(patch, new RegExp(`name: '${PACKAGE}'`)) + assert.equal(patch.match(/- id: muse-code/g)?.length, 1) + assert.doesNotMatch(patch, /^\s*- id: muse-code-llm/m) + assert.doesNotMatch(patch, /^\s*name: '@deepseek-ai\/dsh-llm-pi-ai'/m) +}) + +test("plugin registers the command, the login route, and the settings write", () => { + const { ctx, commands, children, routes } = makeContext() apply(ctx) assert.equal(name, "dsh-muse-code") - assert.deepEqual(inject, ["credentials", "authorization"]) - assert.equal(flows.length, 1) - assert.equal(flows[0].key, "dsh-muse-code/subscription") - assert.match(flows[0].label, /Muse Code/) - assert.equal(flows[0].methods[0].id, "device") + assert.deepEqual(inject, ["authorization", "commands"]) + assert.equal(commands.length, 1) + assert.equal(commands[0].name, "muse-login") + assert.match(commands[0].description, /Muse Code/) + + assert.deepEqual(children.map((child) => child.dependencies), [["webServer"], ["settings"]]) + assert.equal(routes.length, 1) + assert.equal(routes[0].kind, "exact") + assert.equal(routes[0].path, LOGIN_ROUTE) + assert.equal(typeof routes[0].handler, "function") }) -test("flow stores the minted key and commits its record", async () => { - const { ctx, flows, stored, records } = makeContext() - apply(ctx) - const flow = flows[0] - - const original = globalThis.fetch - globalThis.fetch = async (input) => { - const url = String(input) - if (url.includes("authorization")) { - return new Response( - JSON.stringify({ - device_code: "dc", - user_code: "ABCD", - verification_uri: "https://auth.meta.com/device", - interval: 0, - expires_in: 600, - }), - ) - } - if (url.includes("token")) { - return new Response(JSON.stringify({ access_token: "dca-token" })) - } - return new Response( - JSON.stringify({ - api_key: "LLM|inference", - user_id: "account-1", - user_email: "user@example.com", - is_subs_active: true, - }), - ) - } +test("route declaration merges the meta provider only when absent", async () => { + const absent = makeSettings([ + { ns: "llm-pi-ai", revision: 7, value: { providers: { "opencode-go": { apiKeyEnv: "X" } } } }, + ]) + assert.equal(await ensureMuseRoute(absent.settings, undefined, { attempts: 1 }), true) + assert.equal(absent.updates.length, 1) + assert.equal(absent.updates[0].ns, "llm-pi-ai") + assert.equal(absent.updates[0].revision, 7) + assert.deepEqual(absent.updates[0].patch, { + providers: { meta: { displayName: "Muse Code (Meta subscription)" } }, + }) - const notifications = [] - try { - await flow.run({ - signal: undefined, - notify(input) { - notifications.push(input) - }, - }) - } finally { - globalThis.fetch = original + const declared = makeSettings([ + { ns: "llm-pi-ai", revision: 1, value: { providers: { meta: { displayName: "kept" } } } }, + ]) + assert.equal(await ensureMuseRoute(declared.settings, undefined, { attempts: 1 }), false) + assert.equal(declared.updates.length, 0) +}) + +test("route declaration retries while the llm-pi-ai entry activates", async () => { + const rows = [] + const updates = [] + let calls = 0 + const settings = { + describe() { + calls += 1 + return rows.slice() + }, + async update(ns, patch, revision) { + updates.push({ ns, patch, revision }) + }, } + setTimeout(() => { + rows.push({ ns: "llm-pi-ai", revision: 2, value: {} }) + }, 5) + assert.equal(await ensureMuseRoute(settings, undefined, { attempts: 20, delayMs: 5 }), true) + assert.equal(updates.length, 1) + assert.ok(calls > 1) +}) + +test("route declaration gives up when the namespace never appears", async () => { + const { settings, updates } = makeSettings([]) + assert.equal(await ensureMuseRoute(settings, undefined, { attempts: 2, delayMs: 0 }), false) + assert.equal(updates.length, 0) +}) + +test("seam notices and raw adapter notices both become instructions", () => { + const neutral = readSignInNotice(NEUTRAL_NOTICE) + assert.deepEqual(neutral, { + url: NEUTRAL_NOTICE.url, + completeUrl: `${NEUTRAL_NOTICE.url}?code=ABCD-EFGH`, + code: "ABCD-EFGH", + message: NEUTRAL_NOTICE.message, + }) + + const raw = readSignInNotice({ + type: "device_code", + userCode: "WXYZ", + verificationUri: "https://auth.meta.com/oauth/device/?theme=dark", + }) + assert.equal(raw.completeUrl, "https://auth.meta.com/oauth/device/?theme=dark&code=WXYZ") + assert.equal(raw.code, "WXYZ") + + assert.equal(readSignInNotice({ message: "Signing in…" }), undefined) + assert.equal(readSignInNotice({ url: "not a url" }), undefined) + assert.equal(readSignInNotice({ url: "file:///etc/passwd" }), undefined) +}) + +test("instruction text carries the code, the page, and the browser hint", () => { + const instruction = readSignInNotice(NEUTRAL_NOTICE) + const opened = formatSignInText(instruction, true) + assert.match(opened, /ABCD-EFGH/) + assert.match(opened, /auth\.meta\.com/) + assert.match(opened, /browser tab should have opened/) + const failed = formatSignInText(instruction, false) + assert.match(failed, /Open this page in your browser/) +}) - assert.equal(stored.length, 1) - assert.deepEqual(stored[0], { - reference: "MUSE_CODE_SUB_TOKEN", - value: "LLM|inference", +test("the command opens the browser and reports the code", async () => { + const { ctx } = makeContext() + const opened = [] + ctx.authorization.begin = async (request) => { + assert.equal(request.method, "oauth") + request.interaction.notify(NEUTRAL_NOTICE) + return { status: "authorized" } + } + const command = museLoginCommand(ctx, { + openBrowser(url) { + opened.push(url) + return true + }, }) - assert.equal(records.length, 1) - assert.equal(records[0].key, "dsh-muse-code/subscription") - assert.equal(records[0].value.kind, "grant") - assert.equal(records[0].value.payload.accountId, "account-1") - assert.equal(records[0].value.payload.email, "user@example.com") - assert.equal(notifications.length, 2) - assert.match(notifications[0].message, /ABCD/) - assert.match(notifications[1].message, /Signed in as user@example.com/) + const result = await command.handler({ rawInput: "" }) + assert.equal(result.kind, "success") + assert.match(result.text, /ABCD-EFGH/) + assert.match(result.text, /auth\.meta\.com/) + assert.deepEqual(opened, [NEUTRAL_NOTICE.url + "?code=ABCD-EFGH"]) +}) + +test("the command reports a missing flow, a busy flow, and a failed attempt", async () => { + const missing = makeContext({ flow: undefined }) + const missingResult = await museLoginCommand(missing.ctx).handler({}) + assert.equal(missingResult.kind, "error") + assert.match(missingResult.text, /Muse Code is unavailable/) + + const busy = makeContext({ flow: { ...FLOW, inFlight: true } }) + const busyResult = await museLoginCommand(busy.ctx).handler({}) + assert.equal(busyResult.kind, "error") + assert.match(busyResult.text, /already running/) + + const failing = makeContext() + failing.ctx.authorization.begin = async () => { + throw new Error("Meta login was denied.") + } + const failedResult = await museLoginCommand(failing.ctx).handler({}) + assert.equal(failedResult.kind, "error") + assert.match(failedResult.text, /Meta login was denied\./) +}) + +test("the login route starts the sign-in and answers JSON with CORS", async () => { + const { ctx } = makeContext() + ctx.authorization.begin = async (request) => { + request.interaction.notify(NEUTRAL_NOTICE) + return { status: "authorized" } + } + const handler = createLoginRouteHandler(ctx, { openBrowser: () => true }) + const { response, state } = makeResponse() + await handler({ method: "POST" }, response) + assert.equal(state.status, 200) + assert.equal(state.headers["access-control-allow-origin"], "*") + const payload = JSON.parse(state.body) + assert.equal(payload.ok, true) + assert.equal(payload.code, "ABCD-EFGH") + assert.match(payload.text, /auth\.meta\.com/) +}) + +test("the login route answers a plain navigation with the code and a Meta page", async () => { + const { ctx } = makeContext() + ctx.authorization.begin = async (request) => { + request.interaction.notify(NEUTRAL_NOTICE) + return { status: "authorized" } + } + const handler = createLoginRouteHandler(ctx, { openBrowser: () => true }) + const { response, state } = makeResponse() + await handler({ method: "GET" }, response) + assert.equal(state.status, 200) + assert.match(state.headers["content-type"] ?? "", /text\/html/) + assert.match(state.body, /ABCD-EFGH/) + assert.match(state.body, /http-equiv="refresh"/) + assert.match(state.body, /auth\.meta\.com/) +}) + +test("the login route refuses unsupported methods", async () => { + const { ctx } = makeContext() + const handler = createLoginRouteHandler(ctx) + const { response, state } = makeResponse() + await handler({ method: "PUT" }, response) + assert.equal(state.status, 405) + assert.equal(JSON.parse(state.body).ok, false) +}) + +test("the login route reports a busy flow", async () => { + const { ctx } = makeContext({ flow: { ...FLOW, inFlight: true } }) + const handler = createLoginRouteHandler(ctx) + const { response, state } = makeResponse() + await handler({ method: "POST" }, response) + assert.equal(state.status, 409) + assert.equal(JSON.parse(state.body).reason, "busy") +}) + +test("the login route times out and cancels the attempt", async () => { + const { ctx, cancels } = makeContext() + // A slow flow that settles after the route's deadline: never leave an + // unresolved promise behind, the test runner treats one as a failure when the + // event loop drains. + ctx.authorization.begin = () => + new Promise((resolve) => { + setTimeout(() => resolve({ status: "cancelled" }), 50) + }) + const handler = createLoginRouteHandler(ctx, { timeoutMs: 5 }) + const { response, state } = makeResponse() + await handler({ method: "POST" }, response) + assert.equal(state.status, 504) + assert.deepEqual(cancels, ["llm-pi-ai/meta"]) })