diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b2758..afb56dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.2] - 2026-10-26 + +### Added + +- Native credential dot on the **Muse Code (Meta subscription)** row: the route names + `apiKeyEnv: MUSE_CODE_SUB_TOKEN`; the plugin mirrors the OAuth record's minted key into that + reference (red without a credential, green with one) and re-mints it before Meta's ~24 h + expiry, because an explicit reference is DSH's highest-priority auth override. +- Documented the Meta prompt-cache behavior (`prompt_cache_key` = DSH session id) and the + meaning/consequences of the generic **Edit** and **Delete** row actions. + +### Fixed + +- The live catalog sync no longer mistakes the empty `models` array that the Settings form + projects for a user-owned catalog, so Muse Wonder keeps being filled into the route. + ## [0.2.1] - 2026-10-26 ### Added diff --git a/CITATION.cff b/CITATION.cff index f0d7b39..14a1d6a 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." title: "dsh-muse-code" -version: 0.2.1 +version: 0.2.2 date-released: "2026-10-26" url: "https://github.com/TheStreamCode/dsh-muse-code" repository-code: "https://github.com/TheStreamCode/dsh-muse-code" diff --git a/README.md b/README.md index 82ec59b..fa28157 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,11 @@ cannot reach the Host falls back to that command. direct Meta page link, backed by the routes above, plus the `/muse-login` chat command. Both start DSH's own device-code flow (`llm-pi-ai/meta`) and open the verification page in the default browser. +- Paints the native credential dot on the **Muse Code (Meta subscription)** row: the route names + `apiKeyEnv: MUSE_CODE_SUB_TOKEN`, and the plugin mirrors the OAuth record's minted key into that + reference (red without a credential, green with one) and re-mints it before Meta's ~24 h expiry. - 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 + record and the plugin keeps the route's credential-reference copy fresh, so the route keeps working after the key rotates. ## Requirements @@ -126,8 +129,9 @@ see the upgrade note under Troubleshooting. - The local `POST /muse-code/login`, `GET /muse-code/login`, and `GET /muse-code/status` routes only start the flow and report its state; they transmit no credential and are 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. +- The stored credential is the `llm-pi-ai/meta` record in the DSH credential store; the + `MUSE_CODE_SUB_TOKEN` reference the row reads is a copy of its minted key and is refreshed by + the plugin. Neither ever 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 @@ -137,6 +141,25 @@ see the upgrade note under Troubleshooting. See [SECURITY.md](SECURITY.md) for vulnerability reporting and [AUTH_CONTRACT.md](AUTH_CONTRACT.md) for the pinned login contract that DSH implements. +## Prompt caching + +The Meta Model API caches the request prefix per `prompt_cache_key`, and a request without that +key gets no reuse at all. DSH's pi-ai adapter sends the session id as the key for every call, so +turns in the same session reuse the prefix Meta already saw. Measured against the live API with +an identical 1.6k-token prefix: same key → 1521 of 1600 tokens cached on the second call; +different key or no key → 0. + +The ratio you see in the usage panel therefore depends on how the session is used: + +- the cache is per key and per model — switching model or route starts a fresh prefix; +- a short session, or turns spaced far apart, shows a low ratio, because the first call of every + cache window pays full price; +- editing the system prompt or compacting the history invalidates the prefix once. + +The plugin does not change the request shape, so this behaves like any other pi-ai route in the +same harness (for example `opencode-go`). DeepSeek's first-party routes report their own cache +counters and are not directly comparable. + ## Troubleshooting ### Nothing named Muse Code appears under Settings → Models @@ -192,6 +215,36 @@ before it expires. 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. +### The credential dot stays red after signing in + +The route's credential reference is a copy of the record's minted key; the plugin mirrors it on +boot and on every record update. If the dot stays red while the card says **Signed in**, restart +DSH and check that the `llm-pi-ai/meta` record still exists in the credential store. + +### Requests fail after about a day without the plugin running + +The route authenticates through the mirrored reference, and the plugin re-mints the key before +its ~24 h expiry. If DSH ran with the plugin disabled long enough for the key to lapse, sign in +again (Sign in card or `/muse-login`). + +### Edit and Delete on the Muse Code row + +Both are DSH's generic provider-row actions and a plugin cannot hide them. + +- **Edit** writes the route profile into your profile patch: display name, models, and the + `apiKeyEnv` reference. The plugin restores the reference on the next boot and refills an + emptied model list from the account catalog. +- **Delete** removes only that user-layer route: the provider leaves Settings → Models and the + model picker, and a hand-edited model list is lost; the credential record and its mirrored + reference stay. The plugin re-declares the route on the next boot and resyncs the catalog. To + actually remove Muse Code, uninstall the bundle and delete the credential record. + +The supported off switch is the plugin itself: + +```bash +dsh plugin --profile desktop remove @thestreamcode/dsh-muse-code +``` + ### Upgrading from 0.1.x 0.1.x inserted a second `@deepseek-ai/dsh-llm-pi-ai` instance, which DSH refuses to activate diff --git a/client/client.js b/client/client.js index 5da4acf..54541c5 100644 --- a/client/client.js +++ b/client/client.js @@ -83,9 +83,9 @@ loader?.load({ function statusView(phase) { if (phase === "authorized") return { label: "Signed in", color: COLOR.labelSuccess } if (phase === "pending" || phase === "starting") return { label: "Waiting for Meta…", color: COLOR.labelWarn } - if (phase === "failed") return { label: "Sign-in failed", color: COLOR.labelError } if (phase === "checking") return { label: "Checking…", color: COLOR.labelTertiary } - return { label: "Not signed in", color: COLOR.labelTertiary } + // Not signed in, or the last attempt failed: the row reads red. + return { label: phase === "failed" ? "Sign-in failed" : "Not signed in", color: COLOR.labelError } } function MuseCodeSignIn() { diff --git a/lib/index.d.ts b/lib/index.d.ts index ec57e93..06b4b09 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -1,6 +1,14 @@ import type { IncomingMessage, ServerResponse } from "node:http"; export declare const name = "dsh-muse-code"; export declare const inject: string[]; +/** + * Credential reference the route reads through `apiKeyEnv`. + * + * DSH paints the Models row's credential dot only for a route that names a + * reference, so the route reads the minted inference key through this reference + * and the plugin keeps it in step with the OAuth record. + */ +export declare const CREDENTIAL_REFERENCE = "MUSE_CODE_SUB_TOKEN"; /** Web route the Settings control calls to start the sign-in. */ export declare const LOGIN_ROUTE = "/muse-code/login"; /** Web route that reports whether the sign-in completed. */ @@ -84,6 +92,14 @@ type CredentialsFace = { describeRecord(key: string): Promise<{ configured: boolean; }>; + /** Stored reference state, used to avoid rewriting an unchanged key. */ + resolve?(reference: string): Promise<{ + value?: string; + source?: string; + } | undefined>; + set?(reference: string, value: string): Promise; + unset?(reference: string): Promise; + modifyRecord?(key: string, update: (current: CredentialRecord | undefined) => Promise): Promise; }; type WebRoute = { kind: "exact" | "prefix"; @@ -259,6 +275,56 @@ export declare function prettifyModelName(id: string): string; * @returns the chat model ids, or null on any failure. */ export declare function fetchLiveModelIds(accessKey: string, timeoutMs?: number): Promise; +/** Injected side effects for the credential bridge, overridable in tests. */ +export type CredentialSyncOptions = { + logger?: Logger; + fetchImpl?: typeof fetch; + now?: () => number; + refreshWindowMs?: number; +}; +/** + * Keep the route's credential reference equal to the OAuth record's minted key + * and re-mint that key before it expires. + * + * The route names `apiKeyEnv: MUSE_CODE_SUB_TOKEN` so DSH paints its credential + * dot; an explicit reference is also the highest-priority auth override, which + * means the plugin must keep it fresh itself. The OAuth record stays the source + * of truth (the shipped flow wrote it, and it carries the non-renewable identity + * token), so this only mirrors its `access` field and rotates it ahead of + * expiry through the pinned key-mint endpoint. + * + * @param credentials - the credential seam. + * @param options - injected side effects, overridable in tests. + * @returns which steps ran. + */ +export declare function syncCredentialState(credentials: CredentialsFace, options?: CredentialSyncOptions): Promise<{ + refreshed: boolean; + mirrored: boolean; +}>; +/** + * Copy the record's minted inference key into the route's credential reference, + * or clear the reference when the record is gone (the row then shows red). + * @param credentials - the credential seam. + * @param options - injected side effects. + * @returns whether the stored reference now carries a key. + */ +export declare function mirrorCredentialReference(credentials: CredentialsFace, options?: CredentialSyncOptions): Promise; +/** + * Re-mint the inference key from the record's identity token when the current + * key is close to expiry, and store it back into the record. + * @param credentials - the credential seam. + * @param options - injected side effects. + * @returns whether a fresh key was minted and stored. + */ +export declare function refreshCredentialRecord(credentials: CredentialsFace, options?: CredentialSyncOptions): Promise; +/** + * Exchange the non-renewable Meta identity token for a fresh inference key. + * @param identityToken - the record's `refresh` field. + * @param fetchImpl - injectable fetch. + * @param timeoutMs - request bound. + * @returns the minted key, or undefined on any failure. + */ +export declare function mintInferenceKey(identityToken: string, fetchImpl?: typeof fetch, timeoutMs?: number): Promise; /** * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, a link * to Meta, and a poll of {@link STATUS_ROUTE} until the sign-in completes. diff --git a/lib/index.js b/lib/index.js index e470f3a..510349f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -34,6 +34,22 @@ const AUTH_KEY = "llm-pi-ai/meta"; const AUTH_METHOD = "oauth"; /** Display name for the route this bundle declares. */ const ROUTE_LABEL = "Muse Code (Meta subscription)"; +/** + * Credential reference the route reads through `apiKeyEnv`. + * + * DSH paints the Models row's credential dot only for a route that names a + * reference, so the route reads the minted inference key through this reference + * and the plugin keeps it in step with the OAuth record. + */ +export const CREDENTIAL_REFERENCE = "MUSE_CODE_SUB_TOKEN"; +/** Key-mint endpoint of the pinned Muse contract. */ +const KEY_MINT_URL = "https://api.meta.ai/muse-code/key"; +/** Meta's minted inference keys live about a day. */ +const KEY_LIFETIME_MS = 24 * 60 * 60 * 1000; +/** Re-mint the key once the record is this close to expiry. */ +const REFRESH_WINDOW_MS = 6 * 60 * 60 * 1000; +/** How often the plugin re-checks the record while it runs. */ +const REFRESH_CHECK_MS = 15 * 60 * 1000; /** Web route the Settings control calls to start the sign-in. */ export const LOGIN_ROUTE = "/muse-code/login"; /** Web route that reports whether the sign-in completed. */ @@ -81,7 +97,28 @@ export function apply(ctx) { if (typeof ctx.inject !== "function") return; ctx.inject(["credentials"], (child) => { - credentialsFace = child.credentials; + const credentials = child.credentials; + if (credentials === undefined) + return; + credentialsFace = credentials; + child.effect(() => { + const run = () => { + void syncCredentialState(credentials, { logger: ctx.logger }).catch((error) => { + warn(ctx.logger, "dsh-muse-code: could not sync the Muse Code credentials", error); + }); + }; + run(); + const timer = setInterval(run, REFRESH_CHECK_MS); + timer.unref?.(); + const dispose = ctx.on?.("credentials/record-updated", (key) => { + if (key === AUTH_KEY) + run(); + }); + return () => { + clearInterval(timer); + dispose?.(); + }; + }, "dsh-muse-code: credential sync"); }); ctx.inject(["webServer"], (child) => { const webServer = child.webServer; @@ -458,11 +495,20 @@ export async function ensureMuseRoute(settings, logger, options = {}) { 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 (!declared) { + await settings.update(SETTINGS_NS, { providers: { [PROVIDER]: { displayName: ROUTE_LABEL, apiKeyEnv: CREDENTIAL_REFERENCE } } }, row.revision); + logger?.info?.(`dsh-muse-code: declared the ${PROVIDER} route in Settings > Models`); + return true; + } + const profile = isRecord(providers) ? providers[PROVIDER] : undefined; + if (isRecord(profile) && profile["apiKeyEnv"] === undefined) { + // The Models page paints the row's credential dot only for a route that + // names a reference; add ours without touching the rest of the profile. + await settings.update(SETTINGS_NS, { providers: { [PROVIDER]: { apiKeyEnv: CREDENTIAL_REFERENCE } } }, row.revision); + logger?.info?.(`dsh-muse-code: linked the ${PROVIDER} route to ${CREDENTIAL_REFERENCE}`); + return true; + } + return false; } if (attempt < attempts - 1 && delayMs > 0) await sleep(delayMs); @@ -498,7 +544,10 @@ export async function syncMuseModels(settings, logger, options = {}) { if (!isRecord(providers) || !isRecord(providers[PROVIDER])) return false; const profile = providers[PROVIDER]; - if (profile["models"] !== undefined) + // The Settings form projects absent arrays as `[]`, so only a non-empty + // declared list counts as a user-owned catalog. + const declaredModels = profile["models"]; + if (declaredModels !== undefined && (!Array.isArray(declaredModels) || declaredModels.length > 0)) return false; const ids = await (options.fetchModelIds ?? fetchLiveModelIds)(accessKey); if (ids === null || ids.length === 0) { @@ -600,6 +649,141 @@ async function waitForCredentials(getter, attempts = 8, delayMs = 250) { } return undefined; } +/** + * Keep the route's credential reference equal to the OAuth record's minted key + * and re-mint that key before it expires. + * + * The route names `apiKeyEnv: MUSE_CODE_SUB_TOKEN` so DSH paints its credential + * dot; an explicit reference is also the highest-priority auth override, which + * means the plugin must keep it fresh itself. The OAuth record stays the source + * of truth (the shipped flow wrote it, and it carries the non-renewable identity + * token), so this only mirrors its `access` field and rotates it ahead of + * expiry through the pinned key-mint endpoint. + * + * @param credentials - the credential seam. + * @param options - injected side effects, overridable in tests. + * @returns which steps ran. + */ +export async function syncCredentialState(credentials, options = {}) { + const refreshed = await refreshCredentialRecord(credentials, options); + const mirrored = await mirrorCredentialReference(credentials, options); + return { refreshed, mirrored }; +} +/** + * Copy the record's minted inference key into the route's credential reference, + * or clear the reference when the record is gone (the row then shows red). + * @param credentials - the credential seam. + * @param options - injected side effects. + * @returns whether the stored reference now carries a key. + */ +export async function mirrorCredentialReference(credentials, options = {}) { + let access; + try { + const record = await credentials.readRecord(AUTH_KEY); + const payload = isRecord(record?.payload) ? record.payload : undefined; + access = typeof payload?.["access"] === "string" && payload["access"].length > 0 ? payload["access"] : undefined; + } + catch (error) { + warn(options.logger, "dsh-muse-code: could not read the Muse Code record", error); + return false; + } + try { + if (access === undefined) { + await credentials.unset?.(CREDENTIAL_REFERENCE); + return false; + } + const current = credentials.resolve === undefined ? undefined : await credentials.resolve(CREDENTIAL_REFERENCE); + if (current?.value !== access) + await credentials.set?.(CREDENTIAL_REFERENCE, access); + return true; + } + catch (error) { + warn(options.logger, "dsh-muse-code: could not mirror the Muse Code credential", error); + return false; + } +} +/** + * Re-mint the inference key from the record's identity token when the current + * key is close to expiry, and store it back into the record. + * @param credentials - the credential seam. + * @param options - injected side effects. + * @returns whether a fresh key was minted and stored. + */ +export async function refreshCredentialRecord(credentials, options = {}) { + if (credentials.modifyRecord === undefined) + return false; + const now = options.now?.() ?? Date.now(); + let identity; + let expires; + try { + const record = await credentials.readRecord(AUTH_KEY); + const payload = isRecord(record?.payload) ? record.payload : undefined; + identity = typeof payload?.["refresh"] === "string" && payload["refresh"].length > 0 ? payload["refresh"] : undefined; + expires = typeof payload?.["expires"] === "number" ? payload["expires"] : undefined; + } + catch { + return false; + } + if (identity === undefined) + return false; + if (expires !== undefined && expires - now > (options.refreshWindowMs ?? REFRESH_WINDOW_MS)) + return false; + let minted; + try { + minted = await mintInferenceKey(identity, options.fetchImpl ?? fetch); + } + catch (error) { + warn(options.logger, "dsh-muse-code: could not refresh the Muse Code key", error); + return false; + } + if (minted === undefined) + return false; + try { + await credentials.modifyRecord(AUTH_KEY, async (current) => { + const payload = isRecord(current?.payload) ? current.payload : undefined; + // Another writer (a fresh sign-in) replaced the identity token meanwhile: + // keep its record instead of stamping a key minted from the old session. + if (payload === undefined || payload["refresh"] !== identity) + return current; + return { + ...current, + payload: { ...payload, access: minted, expires: now + KEY_LIFETIME_MS }, + }; + }); + return true; + } + catch (error) { + warn(options.logger, "dsh-muse-code: could not store the refreshed Muse Code key", error); + return false; + } +} +/** + * Exchange the non-renewable Meta identity token for a fresh inference key. + * @param identityToken - the record's `refresh` field. + * @param fetchImpl - injectable fetch. + * @param timeoutMs - request bound. + * @returns the minted key, or undefined on any failure. + */ +export async function mintInferenceKey(identityToken, fetchImpl = fetch, timeoutMs = MODELS_TIMEOUT_MS) { + const response = await fetchImpl(KEY_MINT_URL, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${identityToken}`, + "Content-Type": "application/json", + "x-api-version": "1.0.0", + }, + body: "{}", + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) + return undefined; + const body = await response.json(); + if (!isRecord(body)) + return undefined; + const apiKey = body["api_key"]; + return typeof apiKey === "string" && apiKey.length > 0 ? apiKey : undefined; +} function setCorsHeaders(response) { response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); diff --git a/package-lock.json b/package-lock.json index 8cb0cc4..ea0b5ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "devDependencies": { "@types/node": "^26.6.5", diff --git a/package.json b/package.json index f871472..c35aadf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.1", + "version": "0.2.2", "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": { diff --git a/src/index.ts b/src/index.ts index 72f7088..fec778c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,22 @@ const AUTH_KEY = "llm-pi-ai/meta" const AUTH_METHOD = "oauth" /** Display name for the route this bundle declares. */ const ROUTE_LABEL = "Muse Code (Meta subscription)" +/** + * Credential reference the route reads through `apiKeyEnv`. + * + * DSH paints the Models row's credential dot only for a route that names a + * reference, so the route reads the minted inference key through this reference + * and the plugin keeps it in step with the OAuth record. + */ +export const CREDENTIAL_REFERENCE = "MUSE_CODE_SUB_TOKEN" +/** Key-mint endpoint of the pinned Muse contract. */ +const KEY_MINT_URL = "https://api.meta.ai/muse-code/key" +/** Meta's minted inference keys live about a day. */ +const KEY_LIFETIME_MS = 24 * 60 * 60 * 1000 +/** Re-mint the key once the record is this close to expiry. */ +const REFRESH_WINDOW_MS = 6 * 60 * 60 * 1000 +/** How often the plugin re-checks the record while it runs. */ +const REFRESH_CHECK_MS = 15 * 60 * 1000 /** Web route the Settings control calls to start the sign-in. */ export const LOGIN_ROUTE = "/muse-code/login" /** Web route that reports whether the sign-in completed. */ @@ -140,6 +156,14 @@ type CredentialRecord = { type CredentialsFace = { readRecord(key: string): Promise describeRecord(key: string): Promise<{ configured: boolean }> + /** Stored reference state, used to avoid rewriting an unchanged key. */ + resolve?(reference: string): Promise<{ value?: string; source?: string } | undefined> + set?(reference: string, value: string): Promise + unset?(reference: string): Promise + modifyRecord?( + key: string, + update: (current: CredentialRecord | undefined) => Promise, + ): Promise } type WebRoute = { @@ -234,7 +258,26 @@ export function apply(ctx: HostContext): void { if (typeof ctx.inject !== "function") return ctx.inject(["credentials"], (child) => { - credentialsFace = child.credentials + const credentials = child.credentials + if (credentials === undefined) return + credentialsFace = credentials + child.effect(() => { + const run = () => { + void syncCredentialState(credentials, { logger: ctx.logger }).catch((error: unknown) => { + warn(ctx.logger, "dsh-muse-code: could not sync the Muse Code credentials", error) + }) + } + run() + const timer = setInterval(run, REFRESH_CHECK_MS) + timer.unref?.() + const dispose = ctx.on?.("credentials/record-updated", (key: string) => { + if (key === AUTH_KEY) run() + }) + return () => { + clearInterval(timer) + dispose?.() + } + }, "dsh-muse-code: credential sync") }) ctx.inject(["webServer"], (child) => { @@ -644,10 +687,28 @@ export async function ensureMuseRoute( 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 (!declared) { + await settings.update( + SETTINGS_NS, + { providers: { [PROVIDER]: { displayName: ROUTE_LABEL, apiKeyEnv: CREDENTIAL_REFERENCE } } }, + row.revision, + ) + logger?.info?.(`dsh-muse-code: declared the ${PROVIDER} route in Settings > Models`) + return true + } + const profile = isRecord(providers) ? providers[PROVIDER] : undefined + if (isRecord(profile) && profile["apiKeyEnv"] === undefined) { + // The Models page paints the row's credential dot only for a route that + // names a reference; add ours without touching the rest of the profile. + await settings.update( + SETTINGS_NS, + { providers: { [PROVIDER]: { apiKeyEnv: CREDENTIAL_REFERENCE } } }, + row.revision, + ) + logger?.info?.(`dsh-muse-code: linked the ${PROVIDER} route to ${CREDENTIAL_REFERENCE}`) + return true + } + return false } if (attempt < attempts - 1 && delayMs > 0) await sleep(delayMs) } @@ -683,7 +744,10 @@ export async function syncMuseModels( const providers = row.value["providers"] if (!isRecord(providers) || !isRecord(providers[PROVIDER])) return false const profile = providers[PROVIDER] - if (profile["models"] !== undefined) return false + // The Settings form projects absent arrays as `[]`, so only a non-empty + // declared list counts as a user-owned catalog. + const declaredModels = profile["models"] + if (declaredModels !== undefined && (!Array.isArray(declaredModels) || declaredModels.length > 0)) return false const ids = await (options.fetchModelIds ?? fetchLiveModelIds)(accessKey) if (ids === null || ids.length === 0) { logger?.debug?.("dsh-muse-code: live catalog unavailable; keeping the shipped pi-ai catalog") @@ -791,6 +855,153 @@ async function waitForCredentials( return undefined } +/** Injected side effects for the credential bridge, overridable in tests. */ +export type CredentialSyncOptions = { + logger?: Logger + fetchImpl?: typeof fetch + now?: () => number + refreshWindowMs?: number +} + +/** + * Keep the route's credential reference equal to the OAuth record's minted key + * and re-mint that key before it expires. + * + * The route names `apiKeyEnv: MUSE_CODE_SUB_TOKEN` so DSH paints its credential + * dot; an explicit reference is also the highest-priority auth override, which + * means the plugin must keep it fresh itself. The OAuth record stays the source + * of truth (the shipped flow wrote it, and it carries the non-renewable identity + * token), so this only mirrors its `access` field and rotates it ahead of + * expiry through the pinned key-mint endpoint. + * + * @param credentials - the credential seam. + * @param options - injected side effects, overridable in tests. + * @returns which steps ran. + */ +export async function syncCredentialState( + credentials: CredentialsFace, + options: CredentialSyncOptions = {}, +): Promise<{ refreshed: boolean; mirrored: boolean }> { + const refreshed = await refreshCredentialRecord(credentials, options) + const mirrored = await mirrorCredentialReference(credentials, options) + return { refreshed, mirrored } +} + +/** + * Copy the record's minted inference key into the route's credential reference, + * or clear the reference when the record is gone (the row then shows red). + * @param credentials - the credential seam. + * @param options - injected side effects. + * @returns whether the stored reference now carries a key. + */ +export async function mirrorCredentialReference( + credentials: CredentialsFace, + options: CredentialSyncOptions = {}, +): Promise { + let access: string | undefined + try { + const record = await credentials.readRecord(AUTH_KEY) + const payload = isRecord(record?.payload) ? record.payload : undefined + access = typeof payload?.["access"] === "string" && payload["access"].length > 0 ? payload["access"] : undefined + } catch (error) { + warn(options.logger, "dsh-muse-code: could not read the Muse Code record", error) + return false + } + try { + if (access === undefined) { + await credentials.unset?.(CREDENTIAL_REFERENCE) + return false + } + const current = credentials.resolve === undefined ? undefined : await credentials.resolve(CREDENTIAL_REFERENCE) + if (current?.value !== access) await credentials.set?.(CREDENTIAL_REFERENCE, access) + return true + } catch (error) { + warn(options.logger, "dsh-muse-code: could not mirror the Muse Code credential", error) + return false + } +} + +/** + * Re-mint the inference key from the record's identity token when the current + * key is close to expiry, and store it back into the record. + * @param credentials - the credential seam. + * @param options - injected side effects. + * @returns whether a fresh key was minted and stored. + */ +export async function refreshCredentialRecord( + credentials: CredentialsFace, + options: CredentialSyncOptions = {}, +): Promise { + if (credentials.modifyRecord === undefined) return false + const now = options.now?.() ?? Date.now() + let identity: string | undefined + let expires: number | undefined + try { + const record = await credentials.readRecord(AUTH_KEY) + const payload = isRecord(record?.payload) ? record.payload : undefined + identity = typeof payload?.["refresh"] === "string" && payload["refresh"].length > 0 ? payload["refresh"] : undefined + expires = typeof payload?.["expires"] === "number" ? payload["expires"] : undefined + } catch { + return false + } + if (identity === undefined) return false + if (expires !== undefined && expires - now > (options.refreshWindowMs ?? REFRESH_WINDOW_MS)) return false + let minted: string | undefined + try { + minted = await mintInferenceKey(identity, options.fetchImpl ?? fetch) + } catch (error) { + warn(options.logger, "dsh-muse-code: could not refresh the Muse Code key", error) + return false + } + if (minted === undefined) return false + try { + await credentials.modifyRecord(AUTH_KEY, async (current) => { + const payload = isRecord(current?.payload) ? current.payload : undefined + // Another writer (a fresh sign-in) replaced the identity token meanwhile: + // keep its record instead of stamping a key minted from the old session. + if (payload === undefined || payload["refresh"] !== identity) return current + return { + ...current, + payload: { ...payload, access: minted, expires: now + KEY_LIFETIME_MS }, + } + }) + return true + } catch (error) { + warn(options.logger, "dsh-muse-code: could not store the refreshed Muse Code key", error) + return false + } +} + +/** + * Exchange the non-renewable Meta identity token for a fresh inference key. + * @param identityToken - the record's `refresh` field. + * @param fetchImpl - injectable fetch. + * @param timeoutMs - request bound. + * @returns the minted key, or undefined on any failure. + */ +export async function mintInferenceKey( + identityToken: string, + fetchImpl: typeof fetch = fetch, + timeoutMs: number = MODELS_TIMEOUT_MS, +): Promise { + const response = await fetchImpl(KEY_MINT_URL, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${identityToken}`, + "Content-Type": "application/json", + "x-api-version": "1.0.0", + }, + body: "{}", + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) return undefined + const body: unknown = await response.json() + if (!isRecord(body)) return undefined + const apiKey = body["api_key"] + return typeof apiKey === "string" && apiKey.length > 0 ? apiKey : undefined +} + function setCorsHeaders(response: ServerResponse): void { response.setHeader("Access-Control-Allow-Origin", "*") response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 80da4a2..d8001f9 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import { apply, + CREDENTIAL_REFERENCE, createLoginRouteHandler, createSignInState, createStatusRouteHandler, @@ -12,12 +13,16 @@ import { isChatModelId, inject, LOGIN_ROUTE, + mintInferenceKey, + mirrorCredentialReference, museLoginCommand, museModelEntry, name, prettifyModelName, readSignInNotice, + refreshCredentialRecord, STATUS_ROUTE, + syncCredentialState, syncMuseModels, } from "../lib/index.js" @@ -158,17 +163,31 @@ test("plugin registers the command, both routes, the settings write, and credent ) }) -test("route declaration merges the meta provider only when absent", async () => { +test("route declaration wires the credential-dot reference", 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.deepEqual(absent.updates[0].patch, { - providers: { meta: { displayName: "Muse Code (Meta subscription)" } }, + providers: { + meta: { displayName: "Muse Code (Meta subscription)", apiKeyEnv: CREDENTIAL_REFERENCE }, + }, }) - 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) + const declared = makeSettings([ + { ns: "llm-pi-ai", revision: 1, value: { providers: { meta: { displayName: "kept" } } } }, + ]) + assert.equal(await ensureMuseRoute(declared.settings, undefined, { attempts: 1 }), true) + assert.deepEqual(declared.updates[0].patch, { providers: { meta: { apiKeyEnv: CREDENTIAL_REFERENCE } } }) + + const complete = makeSettings([ + { + ns: "llm-pi-ai", + revision: 2, + value: { providers: { meta: { displayName: "kept", apiKeyEnv: CREDENTIAL_REFERENCE } } }, + }, + ]) + assert.equal(await ensureMuseRoute(complete.settings, undefined, { attempts: 1 }), false) + assert.equal(complete.updates.length, 0) }) test("seam notices and raw adapter notices both become instructions", () => { @@ -388,6 +407,20 @@ test("the model sync leaves an existing models list alone and skips without a ke ) assert.equal(withModels.updates.length, 0) + // The Settings form projects an absent array as []; that must not look like a + // user-owned catalog. + const projectedEmpty = makeContext({ + settingsRows: [{ ns: "llm-pi-ai", revision: 1, value: { providers: { meta: { models: [] } } } }], + }) + assert.equal( + await syncMuseModels(projectedEmpty.settings, undefined, { + credentials: () => makeCredentials(true, { access: "key" }), + fetchModelIds: async () => ["muse-wonder-1.0"], + }), + true, + ) + assert.equal(projectedEmpty.updates.length, 1) + const unsigned = makeContext() assert.equal( await syncMuseModels(unsigned.settings, undefined, { @@ -412,3 +445,111 @@ test("fetchLiveModelIds parses the account catalog", async () => { globalThis.fetch = original } }) + +function makeCredentialStore({ record, probe } = {}) { + const calls = { set: [], unset: [], modify: [] } + const store = { + record, + calls, + async describeRecord() { + return { configured: store.record !== undefined } + }, + async readRecord() { + return store.record + }, + async resolve() { + return probe + }, + async set(ref, value) { + calls.set.push({ ref, value }) + }, + async unset(ref) { + calls.unset.push(ref) + }, + async modifyRecord(key, update) { + calls.modify.push(key) + const next = await update(store.record) + if (next !== undefined) store.record = next + return next + }, + } + return store +} + +const OAUTH_RECORD = { + kind: "grant", + payload: { type: "oauth", access: "minted-key", refresh: "identity-token", expires: 0 }, +} + +test("the credential reference mirrors the record and clears when signed out", async () => { + const signedIn = makeCredentialStore({ record: { ...OAUTH_RECORD, payload: { ...OAUTH_RECORD.payload } } }) + assert.equal(await mirrorCredentialReference(signedIn), true) + assert.deepEqual(signedIn.calls.set, [{ ref: CREDENTIAL_REFERENCE, value: "minted-key" }]) + + const unchanged = makeCredentialStore({ record: OAUTH_RECORD, probe: { value: "minted-key", source: "store" } }) + assert.equal(await mirrorCredentialReference(unchanged), true) + assert.equal(unchanged.calls.set.length, 0) + + const signedOut = makeCredentialStore({}) + assert.equal(await mirrorCredentialReference(signedOut), false) + assert.deepEqual(signedOut.calls.unset, [CREDENTIAL_REFERENCE]) +}) + +test("a near-expiry record is re-minted and stored back without touching the identity token", async () => { + const now = 1_000_000 + const store = makeCredentialStore({ + record: { kind: "grant", payload: { type: "oauth", access: "old-key", refresh: "identity-token", expires: now + 60_000 } }, + }) + const requests = [] + const fetchImpl = async (url, init) => { + requests.push({ url, authorization: init.headers.Authorization, version: init.headers["x-api-version"] }) + return new Response(JSON.stringify({ api_key: "fresh-key" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + assert.equal(await refreshCredentialRecord(store, { now: () => now, fetchImpl }), true) + assert.equal(requests.length, 1) + assert.equal(requests[0].url, "https://api.meta.ai/muse-code/key") + assert.equal(requests[0].authorization, "Bearer identity-token") + assert.equal(requests[0].version, "1.0.0") + assert.equal(store.record.payload.access, "fresh-key") + assert.equal(store.record.payload.refresh, "identity-token") + assert.equal(store.record.payload.expires, now + 24 * 60 * 60 * 1000) +}) + +test("a record far from expiry is left alone", async () => { + const now = 1_000_000 + const store = makeCredentialStore({ + record: { kind: "grant", payload: { type: "oauth", access: "key", refresh: "identity", expires: now + 7 * 60 * 60 * 1000 } }, + }) + let fetched = 0 + const fetchImpl = async () => { + fetched += 1 + return new Response("{}", { status: 200 }) + } + assert.equal(await refreshCredentialRecord(store, { now: () => now, fetchImpl }), false) + assert.equal(fetched, 0) +}) + +test("syncCredentialState refreshes and mirrors in one pass", async () => { + const now = 1_000_000 + const store = makeCredentialStore({ + record: { kind: "grant", payload: { type: "oauth", access: "old", refresh: "identity", expires: now - 1 } }, + }) + const fetchImpl = async () => + new Response(JSON.stringify({ api_key: "fresh" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + const result = await syncCredentialState(store, { now: () => now, fetchImpl }) + assert.deepEqual(result, { refreshed: true, mirrored: true }) + assert.equal(store.calls.modify.length, 1) + assert.deepEqual(store.calls.set, [{ ref: CREDENTIAL_REFERENCE, value: "fresh" }]) +}) + +test("mintInferenceKey rejects a failed mint", async () => { + const fetchImpl = async () => new Response("{}", { status: 401 }) + assert.equal(await mintInferenceKey("identity", fetchImpl), undefined) +}) +