From 6b996630d930ee5b74af6d56f897afff111ab4c3 Mon Sep 17 00:00:00 2001 From: Arlieeee Date: Tue, 3 Nov 2026 14:29:51 +0800 Subject: [PATCH] feat(auth): seal stored secrets under an OS-held key, never refresh a store that cannot be written, report the account [ENG-5285] - Stored API keys and tokens are AES-256-GCM encrypted in credentials.v2.json; the key is kept by the OS (Keychain on macOS, DPAPI on Windows, Secret Service on Linux desktops), with a 0600 key file where none is usable or MESHY_KEYRING=file. The pre-0.8 credentials.json is read until the first write, which moves its profiles over and deletes it. - A refresh is skipped when the credentials directory cannot be written (a sandboxed agent shell): the API replaces the refresh token on use, so an unsaved refresh spent the login. A valid token is used as-is; an expired one exits 3 with the stored login intact. `auth login` checks this before any flow starts. - Refresh 30 minutes ahead instead of 60 seconds; a refresh keeps the profile's region (it used to drop it) and account. - A host app can hand over the session: DSH_MESHY_CREDENTIAL (used as-is), DSH_MESHY_REGION, DSH_MESHY_SUBJECT and DSH_MESHY_STATE_DIR (operation journal). `auth status --show-token` prints the credential for it. - Logins ask for `openid profile email`; `auth status` and `auth list` report the account. - CI runs the key-store tests on macOS and Windows. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 27 ++- docs/decisions.md | 49 +++++ docs/usage.md | 61 +++++-- package.json | 2 +- src/cmd/auth.ts | 58 +++++- src/internal/config.ts | 44 ++++- src/internal/credentials.ts | 182 +++++++++++++++---- src/internal/doctor.ts | 16 +- src/internal/keyring.ts | 287 ++++++++++++++++++++++++++++++ src/internal/oauth.ts | 36 ++++ src/internal/operation-store.ts | 9 +- src/internal/runtime.ts | 58 +++--- src/root.ts | 13 +- tests/auth-headless.test.ts | 11 +- tests/codex-review-round2.test.ts | 7 +- tests/credentials.test.ts | 84 ++++++++- tests/doctor.test.ts | 5 +- tests/helpers/cli.ts | 2 + tests/helpers/credentials.ts | 18 ++ tests/host-session.test.ts | 195 ++++++++++++++++++++ tests/keyring.test.ts | 89 +++++++++ tests/oauth.test.ts | 13 +- tests/runtime.test.ts | 15 +- tests/test.env | 2 + 24 files changed, 1151 insertions(+), 132 deletions(-) create mode 100644 src/internal/keyring.ts create mode 100644 tests/helpers/credentials.ts create mode 100644 tests/host-session.test.ts create mode 100644 tests/keyring.test.ts create mode 100644 tests/test.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51d6d33..b5da7e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,10 +121,33 @@ jobs: test -f "$root/skills/meshy-cli/SKILL.md" \ || { echo "::error::skills missing from the global install"; exit 1; } + # The credential store keeps its master key in each OS's own key store, so + # sealing is exercised there for real: Keychain on macOS, DPAPI on Windows. + # Linux (check) has no session bus on the runner and uses the file key. + os-key-store: + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: pnpm exec tsx --env-file=tests/test.env --test tests/keyring.test.ts tests/host-session.test.ts + env: + MESHY_TEST_OS_KEYRING: "1" + # Single required context for branch protection. ci: if: always() - needs: [check, smoke] + needs: [check, smoke, os-key-store] runs-on: ubuntu-latest steps: - name: All gates passed @@ -133,3 +156,5 @@ jobs: || { echo "::error::check failed (${{ needs.check.result }})"; exit 1; } test "${{ needs.smoke.result }}" = "success" \ || { echo "::error::smoke failed (${{ needs.smoke.result }})"; exit 1; } + test "${{ needs.os-key-store.result }}" = "success" \ + || { echo "::error::os-key-store failed (${{ needs.os-key-store.result }})"; exit 1; } diff --git a/docs/decisions.md b/docs/decisions.md index 2d95175..acedc57 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1413,3 +1413,52 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu plugin add only `DSH_*` variables, so its Meshy plugin cannot set `MESHY_CLI_CLIENT`. Without that variable, such a call is tagged `dsh` in the User-Agent. An explicit `MESHY_CLI_CLIENT` still wins. + +## D-088 Secrets are sealed under an OS-held key; a store the CLI cannot write is never refreshed; a host can hand over the session + +- Stored secrets are AES-256-GCM ciphertext under one 32-byte master key the OS + keeps: the login Keychain on macOS (through `/usr/bin/security`, so the + item's trusted application does not change when Node does), DPAPI on Windows + (through Windows PowerShell), the Secret Service on Linux when a session bus + exists. Where that store is missing, locked or refuses the write, the key is + a `0600` `master.key` file, and once that file exists it is used for every + later seal. Each sealed value names its key store, so a profile stays + readable after the store in use changes. The file keeps kind, masked hint, + expiry, region and account in the clear, so `auth list` and the region + lookup never need the key. No dependency is added. +- The sealed file is `credentials.v2.json` (`credentials.v2.dev.json`), not the + pre-0.8 `credentials.json`. Sealing that file in place would hand older CLIs — + still pinned by skills published before this release — a file with no + usable credential; they would sign in, write plaintext, and the next 0.8 + write would seal it again, so the two would take turns forcing a login. 0.8 + reads the old file until its first write, then moves the profiles over and + deletes it. It moves rather than copies: two stores holding one refresh token + would each rotate it, and the API ends a login whose rotated-out refresh token + is presented again. An older CLI signs in once more and keeps its own login. +- The API replaces the refresh token on every refresh. A sandboxed agent shell + that refreshed, then failed to save the result, spent the stored token, and + the next command ended the login — the reason agents in DeepSeek Harness had + to sign in again in nearly every session. Before refreshing, the CLI now + probes the credentials directory with a real file (a sandbox or a Windows ACL + is not always visible to `access(2)`). If it cannot write there it does not + refresh: a valid token is used as it is; an expired one is exit 3 saying where + to refresh it, with the stored login intact. `auth login` checks the same + before any flow starts, so nobody approves a login that cannot be kept. +- The refresh window grows from 60 seconds to 30 minutes, so every command + starts with at least that long left and a long `wait` does not outlive its + token. A refresh also keeps the profile's `region` (it used to drop it, + moving a China-site login to the global site an hour after sign-in) and + `account`. +- DeepSeek Harness lets a plugin add only `DSH_*` variables to a shell call, and + its sandbox cannot write the config directory. Its Meshy plugin keeps the + login on the host, refreshes it there, and gives each call + `DSH_MESHY_CREDENTIAL` (used as-is, after `--api-key-file` and before the + stored profile; never refreshed or stored), `DSH_MESHY_REGION`, + `DSH_MESHY_SUBJECT` (journal binding) and `DSH_MESHY_STATE_DIR` (operation + journal, which a create cannot proceed without). `auth status --show-token` + is how the host reads the credential, the way `gh auth token` serves other + tools. +- Logins ask for `openid profile email`, and `auth status` / `auth list` report + `account` from the API's userinfo. It is shown, never relied on: a site that + does not grant the scope, a pre-0.8 login and an API key report `null`, and + the scope is not part of the granted-scope check. diff --git a/docs/usage.md b/docs/usage.md index 2b4b13d..8ccd3a8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -39,7 +39,7 @@ meshy balance --api-key-file ./keys.env # dotenv-style file; only MESHY_ Get a key at . -**Resolution order:** `--api-key` › `MESHY_API_KEY` › `--api-key-file` › the active stored profile. +**Resolution order:** `--api-key` › `MESHY_API_KEY` › `--api-key-file` › a host app's credential (`DSH_MESHY_CREDENTIAL`, below) › the active stored profile. The environment variable stays ahead of the stored credential on purpose, so a CI runner is never overridden by whatever a developer once logged into on that machine. With none of the four, commands exit `3` and print the command that @@ -53,20 +53,51 @@ another account. There is no automatic `.env` discovery. The flag is not called `--env-file` because Node.js itself intercepts that name anywhere in argv and loads the whole file into the environment before the CLI starts. -**Where it lives:** `~/.config/meshy/credentials.json`, mode `0600`, on every -platform (`MESHY_CONFIG_DIR` or `MESHY_CREDENTIALS_PATH` move it). Writes go -through a cross-process lock and a temp-file rename, because several agents -driving this CLI at once is the normal case. The global and China API hosts -(`api.meshy.ai`, `api.meshy.cn`) share this file, since one account works on -both. A non-production `--base-url-v1` reads and writes `credentials.dev.json` -instead, so staging cannot clobber a production login. Stored profiles are only ever sent to the v1/v2 origins they -were resolved for (and to a Creative Lab base on the same origin). - -**OAuth token refresh:** when the stored OAuth access token is within 60 seconds +**Where it lives:** `~/.config/meshy/credentials.v2.json`, mode `0600`, on every +platform (`MESHY_CONFIG_DIR` or `MESHY_CREDENTIALS_PATH` move it). The API key or +tokens in it are encrypted (AES-256-GCM); the key that opens them is kept by the +OS: the login Keychain on macOS, DPAPI on Windows, the Secret Service (GNOME +Keyring, KWallet) on Linux desktops. Where none of those is usable — CI, +containers, an SSH session, a locked keychain, or `MESHY_KEYRING=file` — the key +is a `0600` `master.key` file next to the credentials instead. Writes go through +a cross-process lock and a temp-file rename, because several agents driving this +CLI at once is the normal case. The global and China API hosts (`api.meshy.ai`, +`api.meshy.cn`) share this file, since one account works on both. A +non-production `--base-url-v1` reads and writes `credentials.v2.dev.json` +instead, so staging cannot clobber a production login. Stored profiles are only +ever sent to the v1/v2 origins they were resolved for (and to a Creative Lab base +on the same origin). + +Releases before 0.8 kept a plaintext `credentials.json`. 0.8 reads it until its +first write, which moves the profiles into the encrypted file and deletes it. An +older CLI on the same machine then finds no login and signs in once on its own; +the two logins do not interfere afterwards. + +**OAuth token refresh:** when the stored OAuth access token is within 30 minutes of expiry (or already expired), the CLI silently refreshes it using the stored -refresh token before running the command. A refresh failure with an unexpired -token is swallowed (the existing token is used); a failure with an expired token -exits `3` with a hint to run `meshy auth login`. +refresh token before running the command, so a command never starts with a +token about to run out. A refresh failure with an unexpired token is swallowed +(the existing token is used); a failure with an expired token exits `3` with a +hint to run `meshy auth login`. Where the credentials directory cannot be +written — a sandboxed agent shell — the CLI does not refresh at all: the API +replaces the refresh token on every use, so a refresh it could not save would +spend the stored login. It uses a still-valid token as it is, and for an expired +one exits `3` saying where to refresh it; the stored login stays intact. +`auth login` checks the same thing before it opens the browser. + +**Who is signed in:** `auth status` and `auth list` show the `account` (name and +email) of a browser login. It is reported by the site at sign-in, so a login +made before 0.8, an API key, and the China site (which does not report it yet) +show `null`. `auth status --show-token` also prints the credential itself, for a +host app that hands it to sandboxed tools. + +**Host apps (DeepSeek Harness):** a shell the host runs in a sandbox cannot +write the credentials directory, and the host may only add `DSH_*` variables to +it. The Meshy plugin therefore keeps the login on the host side and gives every +shell call `DSH_MESHY_CREDENTIAL` (the current access token or API key, used as +is and never refreshed or stored), `DSH_MESHY_REGION` (its site), +`DSH_MESHY_SUBJECT` (the account the operation journal binds to) and +`DSH_MESHY_STATE_DIR` (a writable directory for the operation journal). **Profiles:** `auth login --profile work`, `auth list`, `auth use work`, `auth logout [--all]`. @@ -475,6 +506,8 @@ URI is accepted when its bytes are a GLB. | `MESHY_OAUTH_AUTHORIZE_URL` | `https://www.meshy.ai/oauth/authorize` (`https://meshy.cn/oauth/authorize` for `cn`) — override for staging/testing | | `MESHY_CLI_NO_BROWSER` | unset — set to `1` to suppress browser open (URL still printed to stderr) | | `MESHY_CONFIG_DIR` | `~/.config/meshy` (credentials, operation journal, update cache) | +| `MESHY_KEYRING` | unset — the OS key store; `file` keeps the credentials key in a `0600` file instead | +| `DSH_MESHY_CREDENTIAL`, `DSH_MESHY_REGION`, `DSH_MESHY_SUBJECT`, `DSH_MESHY_STATE_DIR` | set by the DeepSeek Harness plugin for its sandboxed shell calls (see Credentials) | | `MESHY_CONNECT_TIMEOUT_MS` | `10000` (read for compatibility; fetch has no separate connect timeout) | | `MESHY_READ_TIMEOUT_MS` | `120000` — covers headers *and* body | | `MESHY_POLL_INTERVAL_MS` | `3000` | diff --git a/package.json b/package.json index 38a3267..81e971d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "dev": "tsx src/index.ts", "start": "node dist/index.js", "pretest": "tsc", - "test": "tsx --test tests/**/*.test.ts", + "test": "tsx --env-file=tests/test.env --test tests/**/*.test.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "prepare": "test -f dist/index.js || npm run build", "prepublishOnly": "npm run clean && npm run build" diff --git a/src/cmd/auth.ts b/src/cmd/auth.ts index 03039bd..78a95ec 100644 --- a/src/cmd/auth.ts +++ b/src/cmd/auth.ts @@ -17,6 +17,7 @@ import * as readline from "node:readline"; import { randomUUID } from "node:crypto"; +import { dirname } from "node:path"; import { Command } from "commander"; import { MeshyClient, MeshyApiError } from "../client/index.js"; import { loadConfig, parseRegion, REGIONS, regionBaseUrls, regionOf, resolveRegion, type Region } from "../internal/config.js"; @@ -28,7 +29,9 @@ import { removeProfile, saveProfile, setActiveProfile, + storeWritable, DEFAULT_PROFILE, + type CredentialAccount, } from "../internal/credentials.js"; import { deviceAuthorization, @@ -44,14 +47,16 @@ import { loadDeviceFlowByFlowId, saveDeviceFlow, } from "../internal/device-state.js"; -import { CREDENTIAL_REJECTED_HINT, EXIT_CODES, HintedError, SESSION_REVOKED_HINT, UsageError } from "../internal/errors.js"; +import { CliError, CREDENTIAL_REJECTED_HINT, EXIT_CODES, HintedError, SESSION_REVOKED_HINT, UsageError } from "../internal/errors.js"; import { detectHeadless, resolveLoginMode } from "../internal/headless.js"; import { buildAuthorizeUrl, codeChallengeS256, exchangeCode, + fetchAccount, generateCodeVerifier, generateState, + IDENTITY_SCOPE, openBrowser, startCallbackServer, } from "../internal/oauth.js"; @@ -78,6 +83,21 @@ function resolveFile(flags: GlobalFlags): string { return credentialsPath(resolveBaseUrlV1(flags)); } +/** + * A login the store cannot keep would make the user approve it in the browser + * for nothing, so this is checked before any flow starts. + */ +function assertStoreWritable(file: string): void { + if (storeWritable(file)) return; + throw new CliError({ + code: "local_io", + message: `Cannot sign in here: ${dirname(file)} is not writable, so the login could not be stored.`, + hint: process.env["DSH_SHELL"] === "1" + ? "In DeepSeek Harness, sign in from Settings → Meshy, or with the meshy_sign_in tool." + : `Run meshy auth login where ${dirname(file)} is writable (outside the sandbox).`, + }); +} + function resolveBaseUrlV1(flags: GlobalFlags, region: Region = resolveRegion()): string { return ( flags.baseUrlV1 ?? @@ -125,6 +145,7 @@ async function finishLogin( }, ): Promise { const file = resolveFile(flags); + const account = await fetchAccount(baseUrlV1, tok.access_token); // Every login gets its own identifier: the operation journal binds a // submission to the account (user_id) when the token endpoint reports one, // and to this login otherwise — never to a token that rotates on refresh. @@ -136,6 +157,7 @@ async function finishLogin( ...(tok.user_id ? { user_id: tok.user_id } : {}), login_id: randomUUID(), ...(regionOf(baseUrlV1) ? { region: "cn" as const } : {}), + ...(account ? { account } : {}), }; const credState = saveProfile(file, opts.profile, profileData); @@ -159,6 +181,7 @@ async function finishLogin( profile: opts.profile, active_profile: credState.active_profile, credential: maskSecret(tok.access_token), + account, credentials_file: file, verified: verifyOutcome, ...(balance === undefined ? {} : { balance }), @@ -214,6 +237,7 @@ const loginCommand = new Command("login") const region = opts.region === undefined ? resolveRegion() : parseRegion(opts.region, "--region"); const baseUrlV1 = resolveBaseUrlV1(flags, region); + assertStoreWritable(resolveFile(flags)); const key = opts.withKey?.trim(); if (key) { @@ -345,7 +369,7 @@ const loginCommand = new Command("login") if (mode === "device" || mode === "no-wait") { let deviceResp; try { - deviceResp = await deviceAuthorization(baseUrlV1); + deviceResp = await deviceAuthorization(baseUrlV1, IDENTITY_SCOPE); } catch (err) { if (err instanceof DeviceFlowNotSupportedError) { // --no-wait is a contract with agent harnesses: emit the @@ -437,6 +461,7 @@ const loginCommand = new Command("login") redirectUri: OOB_REDIRECT_URI, state: oauthState, codeChallenge, + scope: IDENTITY_SCOPE, }); process.stderr.write(`Open this URL in a browser:\n${authorizeUrl}\n`); @@ -563,6 +588,7 @@ async function runLoopbackFlow( redirectUri: "http://127.0.0.1:0/callback", state: oauthState, codeChallenge, + scope: IDENTITY_SCOPE, }); const parsed = new URL(authorizeUrl); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { @@ -585,6 +611,7 @@ async function runLoopbackFlow( redirectUri, state: oauthState, codeChallenge, + scope: IDENTITY_SCOPE, }); await openBrowser(authorizeUrl); @@ -620,9 +647,10 @@ async function runLoopbackFlow( // --------------------------------------------------------------------------- const statusCommand = new Command("status") - .description("Show which credential is in effect, where it came from, and whether it works") - .option("--offline", "skip the balance call") - .action(async (opts: { offline?: boolean }, thisCmd: Command) => { + .description("Show which credential is in effect, who it belongs to, where it came from, and whether it works") + .option("--offline", "skip the balance and account calls") + .option("--show-token", "also print the credential itself, for a host app that hands it to sandboxed tools") + .action(async (opts: { offline?: boolean; showToken?: boolean }, thisCmd: Command) => { const opened = openCommand(thisCmd, "auth.status"); rejectOutputFlag(opened, undefined); const flags = opened.flags; @@ -656,10 +684,15 @@ const statusCommand = new Command("status") throw err; } + const profile = config.credentialSource === "file" && config.credentialProfile + ? safeReadCredentials(file)?.profiles[config.credentialProfile] + : undefined; + let account: CredentialAccount | null = profile?.account ?? null; let balance: unknown; let verified: boolean | undefined; let verifyHint: string | undefined; if (!opts.offline) { + const live = fetchAccount(config.baseUrlV1, config.apiKey); try { balance = await new MeshyClient(config).balance.get(); verified = true; @@ -673,6 +706,7 @@ const statusCommand = new Command("status") verifyHint = "Could not verify credential (network or server error). Check connectivity and retry."; } } + account = (await live) ?? account; } await emitAuth(flags, { @@ -680,10 +714,13 @@ const statusCommand = new Command("status") source: config.credentialSource, profile: config.credentialProfile ?? null, credential: maskSecret(config.apiKey), + account, + expires_at: profile?.kind === "oauth" ? (profile.expires_at ?? null) : null, credentials_file: file, base_url_v1: config.baseUrlV1, profiles: stored.names, active_profile: stored.active, + ...(opts.showToken ? { token: config.apiKey, subject: config.credentialSubject ?? config.credentialLoginId ?? null } : {}), ...(verified === undefined ? {} : { verified }), ...(balance === undefined ? {} : { balance }), ...(verifyHint !== undefined ? { hint: verifyHint } : {}), @@ -756,7 +793,8 @@ const listCommand = new Command("list") name, active: name === state?.active_profile, kind: profile.kind, - credential: maskSecret(profile.api_key ?? profile.access_token ?? ""), + credential: profile.hint ?? maskSecret(profile.api_key ?? profile.access_token ?? ""), + account: profile.account ?? null, created_at: profile.created_at ?? null, })); await emitAuth(flags, { credentials_file: file, active_profile: state?.active_profile ?? null, profiles }); @@ -767,6 +805,14 @@ const listCommand = new Command("list") * telling the user which file to look at — that is the one moment the path * matters most. */ +function safeReadCredentials(file: string): ReturnType { + try { + return readCredentials(file); + } catch { + return null; + } +} + function safeReadProfiles(file: string): { names: string[]; active: string | null } { try { const state = readCredentials(file); diff --git a/src/internal/config.ts b/src/internal/config.ts index 5e8606a..1a840a9 100644 --- a/src/internal/config.ts +++ b/src/internal/config.ts @@ -3,8 +3,9 @@ * 1. CLI flags (--api-key, --base-url-v1, --base-url-v2, --base-url-creative-lab, --verbose) * 2. Environment variables (MESHY_*) * 3. An explicit --api-key-file (only MESHY_API_KEY is read from it) - * 4. The active profile in the credentials file (written by `meshy auth login`) - * 5. Built-in defaults + * 4. A credential the host app hands over (DSH_MESHY_CREDENTIAL, below) + * 5. The active profile in the credentials file (written by `meshy auth login`) + * 6. Built-in defaults * * The env var keeps priority over the stored credential on purpose: CI and * containers export MESHY_API_KEY and must not be silently overridden by @@ -18,6 +19,13 @@ * * Fail-fast on a missing/placeholder credential — with the command that fixes * it attached, not just a complaint. + * + * DeepSeek Harness runs model shell calls in a sandbox that cannot write the + * credential store, and lets a plugin add only DSH_* variables to them. Its + * Meshy plugin keeps the login on the host side and hands every shell call a + * current credential: DSH_MESHY_CREDENTIAL (an access token or API key, used + * as-is — never refreshed or stored here), DSH_MESHY_REGION (the site it + * belongs to) and DSH_MESHY_SUBJECT (the account it binds journal records to). */ import { credentialsPath, readCredentials, resolveStoredCredential, type CredentialKind } from "./credentials.js"; @@ -26,6 +34,8 @@ import { authRequiredError, CliError, UsageError } from "./errors.js"; import { setLogLevel, type LogLevel } from "./logger.js"; const PLACEHOLDER_KEYS = new Set(["", "YOUR_MESHY_API_KEY_HERE"]); +/** Browser-login access tokens carry this prefix; static API keys do not. */ +export const OAUTH_ACCESS_TOKEN_PREFIX = "msy_at_"; /** Meshy's two sites share accounts; only the API and sign-in hosts differ. */ export const REGIONS = { @@ -39,13 +49,22 @@ export function parseRegion(raw: string, source: string): Region { throw new UsageError(`${source} must be global or cn, got '${raw}'`); } +/** The credential a host app hands over (see the header), or "" when there is none. */ +export function hostCredential(env: NodeJS.ProcessEnv = process.env): string { + const raw = env["DSH_MESHY_CREDENTIAL"]?.trim() ?? ""; + return PLACEHOLDER_KEYS.has(raw) ? "" : raw; +} + /** - * MESHY_REGION, else the region the active stored profile logged in to, else - * global. An unreadable credentials file is left for loadConfig to report. + * MESHY_REGION, else the region of a host-provided credential, else the + * region the active stored profile logged in to, else global. An unreadable + * credentials file is left for loadConfig to report. */ export function resolveRegion(env: NodeJS.ProcessEnv = process.env): Region { const raw = env["MESHY_REGION"]?.trim(); if (raw) return parseRegion(raw, "MESHY_REGION"); + const hostRegion = env["DSH_MESHY_REGION"]?.trim(); + if (hostCredential(env) && hostRegion) return parseRegion(hostRegion, "DSH_MESHY_REGION"); try { const state = readCredentials(credentialsPath(undefined, env)); return state?.profiles[state.active_profile]?.region === "cn" ? "cn" : "global"; @@ -65,7 +84,7 @@ export function regionOf(baseUrlV1: string): "cn" | undefined { } /** Where the credential in use came from — surfaced by `meshy auth status`. */ -export type CredentialSource = "flag" | "env" | "env-file" | "file"; +export type CredentialSource = "flag" | "env" | "env-file" | "host" | "file"; export interface MeshyConfig { apiKey: string; @@ -93,7 +112,8 @@ export interface MeshyConfig { /** * The kind of credential in use — "oauth" for browser-login tokens, "api_key" for static keys. * "api_key" when credentialSource is "flag", "env" or "env-file" (those paths only accept static keys). - * Derived from the stored profile kind when credentialSource is "file". + * Derived from the stored profile kind when credentialSource is "file", and + * from the token prefix when it is "host". */ credentialKind: CredentialKind; /** @@ -221,6 +241,11 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { apiKey = envFileKey; credentialSource = "env-file"; credentialKind = "api_key"; + } else if (hostCredential()) { + apiKey = hostCredential(); + credentialSource = "host"; + credentialKind = apiKey.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) ? "oauth" : "api_key"; + credentialSubject = process.env["DSH_MESHY_SUBJECT"]?.trim() || undefined; } else { // A corrupt credentials file throws out of resolveStoredCredential rather // than being swallowed into "not logged in" — see credentials.ts. @@ -264,11 +289,12 @@ export function loadConfig(overrides: ConfigOverrides = {}): MeshyConfig { } /** - * Stored profiles were issued for the v1 origin they were resolved against. - * A different Creative Lab origin only receives an explicitly supplied key. + * Stored profiles and host-provided credentials were issued for the v1 origin + * they were resolved against. A different Creative Lab origin only receives an + * explicitly supplied key. */ export function assertCredentialAllowedForOrigin(cfg: MeshyConfig, targetBase: string, label: string): void { - if (cfg.credentialSource !== "file") return; + if (cfg.credentialSource !== "file" && cfg.credentialSource !== "host") return; const v1Origin = originOf(cfg.baseUrlV1); const targetOrigin = originOf(targetBase); if (v1Origin && targetOrigin && v1Origin === targetOrigin) return; diff --git a/src/internal/credentials.ts b/src/internal/credentials.ts index 8b29cf2..ef35a8c 100644 --- a/src/internal/credentials.ts +++ b/src/internal/credentials.ts @@ -1,15 +1,28 @@ /** * Credential store — the on-disk half of `meshy auth`. * - * Layout: ~/.config/meshy/credentials.json, one file holding named profiles so - * a user can keep several accounts side by side. `~/.config` is used on every - * platform (including macOS, where the native spot would be Application + * Layout: ~/.config/meshy/credentials.v2.json, one file holding named profiles + * so a user can keep several accounts side by side. `~/.config` is used on + * every platform (including macOS, where the native spot would be Application * Support) because the file is meant to be scriptable and dotfile-friendly. * - * Non-production hosts get their own file — credentials.dev.json — so pointing - * MESHY_BASE_URL_V1 at staging cannot overwrite the production login, and - * logging out of one leaves the other alone. The global and China API hosts - * are both production: one Meshy account works on either. + * Secrets never reach the disk in the clear: a profile's API key or tokens are + * sealed under a master key held by the OS credential store (keyring.ts), and + * the file keeps only the ciphertext plus what can be shown without it — kind, + * masked hint, expiry, region, account. Profiles are opened on demand, so + * listing them or resolving the region never touches the key. + * + * Releases before 0.8 kept plaintext in credentials.json. That file is still + * read while the v2 file does not exist, and the first write moves its + * profiles over and deletes it — moves, never copies: two stores holding one + * refresh token would each rotate it, and the API revokes a login whose + * rotated-out refresh token is presented again. An older CLI that finds the + * old file gone signs in separately and keeps its own login there. + * + * Non-production hosts get their own file — credentials.v2.dev.json — so + * pointing MESHY_BASE_URL_V1 at staging cannot overwrite the production login, + * and logging out of one leaves the other alone. The global and China API + * hosts are both production: one Meshy account works on either. * * Every mutation runs under a cross-process lock and lands via a temp file + * rename, because several agents driving this CLI in parallel is the normal @@ -30,10 +43,12 @@ import { unlinkSync, writeFileSync, } from "node:fs"; +import { randomBytes } from "node:crypto"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { openSecret, sealSecret } from "./keyring.js"; -export const AUTH_VERSION = 1; +export const AUTH_VERSION = 2; export const DEFAULT_PROFILE = "default"; /** The global and China API hosts; they share accounts and this file. */ export const PROD_API_HOSTS = ["api.meshy.ai", "api.meshy.cn"]; @@ -44,13 +59,28 @@ const LOCK_STALE_MS = 60_000; export type CredentialKind = "api_key" | "oauth"; +/** Who a login belongs to, as the API's userinfo reports it. */ +export interface CredentialAccount { + name?: string; + email?: string; +} + export interface CredentialProfile { kind: CredentialKind; - /** Present when kind === "api_key". */ + /** + * The secrets, in memory only: `writeCredentials` seals them into `secret`, + * and `profileSecrets` opens them again. A pre-0.8 file carries them in the + * clear, which reads the same way. + */ api_key?: string; - /** Present when kind === "oauth" (CLI-4 writes these; nothing reads them yet). */ access_token?: string; refresh_token?: string; + /** On disk: the sealed secrets (keyring.ts). */ + secret?: string; + /** Masked credential, so a profile can be listed without opening it. */ + hint?: string; + /** Set at login when the API reports who signed in. */ + account?: CredentialAccount; /** Unix epoch millis. */ expires_at?: number; user_id?: string; @@ -119,10 +149,16 @@ export function credentialsPath( ): string { const explicit = env["MESHY_CREDENTIALS_PATH"]?.trim(); if (explicit) return explicit; - const name = isNonProdBaseUrl(baseUrlV1) ? "credentials.dev.json" : "credentials.json"; + const name = isNonProdBaseUrl(baseUrlV1) ? "credentials.v2.dev.json" : "credentials.v2.json"; return join(configDir(env), name); } +/** The pre-0.8 plaintext file a default v2 path replaces, or null for any other path. */ +export function legacyCredentialsPath(file: string): string | null { + const legacy = ({ "credentials.v2.json": "credentials.json", "credentials.v2.dev.json": "credentials.dev.json" } as Record)[basenameOf(file)]; + return legacy ? join(dirname(file), legacy) : null; +} + function lockPathFor(file: string): string { return join(dirname(file), "locks", `${basenameOf(file)}.lock`); } @@ -198,12 +234,18 @@ function emptyFile(): CredentialsFile { } /** - * Reads the credentials file. Returns null when it does not exist — that is - * "not logged in", not an error. A file that exists but is unreadable throws + * Reads the credentials file — or, while a default v2 file does not exist yet, + * the pre-0.8 file it replaces. Returns null when neither exists: that is "not + * logged in", not an error. A file that exists but is unreadable throws * CredentialsFileError so the user hears about it instead of silently falling - * back to "no credentials". + * back to "no credentials". Profiles come back sealed; see profileSecrets. */ export function readCredentials(file: string): CredentialsFile | null { + const legacy = legacyCredentialsPath(file); + return readStateFile(file) ?? (legacy ? readStateFile(legacy) : null); +} + +function readStateFile(file: string): CredentialsFile | null { let raw: string; try { raw = readFileSync(file, "utf8"); @@ -234,15 +276,53 @@ export function readCredentials(file: string): CredentialsFile | null { }; } +/** The on-disk form of a profile: any secret it carries in the clear is sealed. */ +function sealProfile(dir: string, profile: CredentialProfile): CredentialProfile { + const { api_key, access_token, refresh_token, ...rest } = profile; + const secrets = Object.fromEntries( + Object.entries({ api_key, access_token, refresh_token }).filter(([, v]) => v), + ); + if (Object.keys(secrets).length === 0) return profile; + return { ...rest, secret: sealSecret(dir, JSON.stringify(secrets)), hint: maskSecret(api_key ?? access_token ?? "") }; +} + +/** + * The secrets of one profile, opened from its sealed form (or read as-is from + * a pre-0.8 plaintext profile). Throws an auth error when they cannot be + * opened here — a missing or locked key, or a value that does not decrypt. + */ +export function profileSecrets( + file: string, + profile: CredentialProfile, +): Pick { + const opened: Record = + profile.secret && !profile.api_key && !profile.access_token + ? (JSON.parse(openSecret(dirname(file), profile.secret)) as Record) + : { api_key: profile.api_key, access_token: profile.access_token, refresh_token: profile.refresh_token }; + const pick = (k: string): string | undefined => (typeof opened[k] === "string" && opened[k] ? (opened[k] as string) : undefined); + return { + ...(pick("api_key") ? { api_key: pick("api_key") } : {}), + ...(pick("access_token") ? { access_token: pick("access_token") } : {}), + ...(pick("refresh_token") ? { refresh_token: pick("refresh_token") } : {}), + }; +} + /** * Writes the file with 0600 via a temp file + rename, so a crash mid-write - * cannot leave a half-written credentials file behind. Callers that mutate - * existing state must hold the lock; see updateCredentials. + * cannot leave a half-written credentials file behind. Secrets are sealed on + * the way out; `state` itself is left as the caller passed it. Callers that + * mutate existing state must hold the lock; see updateCredentials. */ export function writeCredentials(state: CredentialsFile, file: string): void { - mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const dir = dirname(file); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const onDisk: CredentialsFile = { + ...state, + auth_version: AUTH_VERSION, + profiles: Object.fromEntries(Object.entries(state.profiles).map(([name, p]) => [name, sealProfile(dir, p)])), + }; const tmp = `${file}.${process.pid}.tmp`; - writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + writeFileSync(tmp, `${JSON.stringify(onDisk, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); try { renameSync(tmp, file); } catch (err) { @@ -258,19 +338,58 @@ export function writeCredentials(state: CredentialsFile, file: string): void { chmodSync(file, 0o600); } -/** Read-modify-write under the lock. The mutator may return a new state or mutate in place. */ +/** + * Read-modify-write under the lock. The mutator may return a new state or + * mutate in place. The first write to a default v2 path starts from the + * pre-0.8 file and then deletes it, so its profiles move rather than copy. + */ export function updateCredentials( file: string, mutate: (state: CredentialsFile) => CredentialsFile | void, ): CredentialsFile { return withCredentialsLock(file, () => { - const current = readCredentials(file) ?? emptyFile(); + const own = readStateFile(file); + const legacy = own ? null : legacyCredentialsPath(file); + const seeded = legacy ? readStateFile(legacy) : null; + const current = own ?? seeded ?? emptyFile(); const next = mutate(current) ?? current; writeCredentials(next, file); + if (legacy && seeded) unlinkUnderLock(legacy); return next; }); } +function unlinkUnderLock(file: string): boolean { + return withCredentialsLock(file, () => { + try { + unlinkSync(file); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; + throw err; + } + }); +} + +/** + * Whether this process may write the store. A sandboxed agent shell often may + * not, and an OAuth refresh it cannot save is worse than none: the API rotates + * the refresh token on use, so the stored one would be spent and the next + * attempt would end the login. Probed with a real file, because a sandbox or a + * Windows ACL is not always visible to access(2). + */ +export function storeWritable(file: string): boolean { + const probe = join(dirname(file), "locks", `.probe-${process.pid}-${randomBytes(4).toString("hex")}`); + try { + mkdirSync(dirname(probe), { recursive: true, mode: 0o700 }); + closeSync(openSync(probe, "wx", 0o600)); + unlinkSync(probe); + return true; + } catch { + return false; + } +} + // --------------------------------------------------------------------------- // Profile operations // --------------------------------------------------------------------------- @@ -311,17 +430,11 @@ export function setActiveProfile(file: string, name: string): void { }); } -/** Deletes the whole file. Returns false when there was nothing to delete. */ +/** Deletes the whole file, and the pre-0.8 one it replaces. Returns false when there was nothing to delete. */ export function deleteCredentialsFile(file: string): boolean { - return withCredentialsLock(file, () => { - try { - unlinkSync(file); - return true; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return false; - throw err; - } - }); + const legacy = legacyCredentialsPath(file); + const deleted = unlinkUnderLock(file); + return (legacy ? unlinkUnderLock(legacy) : false) || deleted; } export interface ResolvedStoredCredential { @@ -346,15 +459,16 @@ export function resolveStoredCredential(file: string): ResolvedStoredCredential const name = state.active_profile; const profile = state.profiles[name]; if (!profile) return null; + const secrets = profileSecrets(file, profile); if (profile.kind === "api_key") { - if (!profile.api_key) return null; - return { profile: name, kind: "api_key", apiKey: profile.api_key }; + if (!secrets.api_key) return null; + return { profile: name, kind: "api_key", apiKey: secrets.api_key }; } - if (!profile.access_token) return null; + if (!secrets.access_token) return null; return { profile: name, kind: "oauth", - accessToken: profile.access_token, + accessToken: secrets.access_token, expiresAt: profile.expires_at, ...(profile.user_id ? { userId: profile.user_id } : {}), ...(profile.login_id ? { loginId: profile.login_id } : {}), diff --git a/src/internal/doctor.ts b/src/internal/doctor.ts index 8d0c145..0206036 100644 --- a/src/internal/doctor.ts +++ b/src/internal/doctor.ts @@ -22,12 +22,14 @@ import { MeshyClient } from "../client/index.js"; import { deriveCreativeLabBase, derivePublicWebBase, + hostCredential, loadConfig, regionBaseUrls, resolveRegion, type MeshyConfig, } from "./config.js"; -import { credentialsPath } from "./credentials.js"; +import { credentialsPath, legacyCredentialsPath } from "./credentials.js"; +import { platformKeyStore } from "./keyring.js"; import { loadEnvFile } from "./env-file.js"; import { configOverridesFrom, type GlobalFlags } from "./runtime.js"; import { detectSlicers as detectSlicersImpl, type DetectionEnv, type SlicerDetection } from "./slicers.js"; @@ -53,6 +55,8 @@ export interface DoctorReport { env: boolean; /** Absolute path of --api-key-file when given (its verdict is a check), else null. */ api_key_file: string | null; + /** DSH_MESHY_CREDENTIAL, handed over by a host app (DeepSeek Harness). */ + host: boolean; stored_profile: { path: string; exists: boolean }; }; base_urls: { v1: string; v2: string; creative_lab: string | null; public_web: string }; @@ -223,15 +227,18 @@ export async function runDoctorDetailed(opts: DoctorOptions): Promise or `meshy auth login`"), }); @@ -285,6 +292,7 @@ export async function runDoctorDetailed(opts: DoctorOptions): Promise join(dir, "master.key"); +const dpapiKeyPath = (dir: string): string => join(dir, "master.key.dpapi"); + +/** Thrown by a store that cannot be used on this machine at all (as opposed to one holding no key yet). */ +class StoreUnavailable extends Error {} + +interface Store { + /** The key, or null when the store has none yet. Throws when the store cannot be read. */ + get(dir: string, env: NodeJS.ProcessEnv): Buffer | null; + set(dir: string, key: Buffer, env: NodeJS.ProcessEnv): void; +} + +function decodeKey(text: string, where: string): Buffer { + const key = Buffer.from(text.trim(), "base64"); + if (key.length !== KEY_BYTES) throw new Error(`the master key in ${where} is corrupt`); + return key; +} + +function run(cmd: string, args: string[], input?: string): string { + return execFileSync(cmd, args, { + encoding: "utf8", + input, + timeout: STORE_TIMEOUT_MS, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); +} + +function isMissingBinary(err: unknown): boolean { + return (err as NodeJS.ErrnoException).code === "ENOENT"; +} + +const SECURITY = "/usr/bin/security"; +/** `security` exits 44 for "The specified item could not be found in the keychain". */ +const SECURITY_NOT_FOUND = 44; + +const keychain: Store = { + get(_dir, env) { + try { + return decodeKey(run(SECURITY, ["find-generic-password", "-s", serviceName(env), "-a", ACCOUNT, "-w"]), "the keychain"); + } catch (err) { + if ((err as { status?: number }).status === SECURITY_NOT_FOUND) return null; + if (isMissingBinary(err)) throw new StoreUnavailable(SECURITY); + throw err; + } + }, + set(_dir, key, env) { + // Without a default keychain (a HOME that is not the user's own: CI, + // sandboxes, test harnesses) macOS answers an add with a "A keychain + // cannot be found" dialog rather than an error, so ask first; the query + // itself never prompts. + try { + run(SECURITY, ["default-keychain"]); + } catch { + throw new StoreUnavailable("no default keychain"); + } + // `security -i` reads the command from stdin, so the key never appears in + // a process listing. It reports failures on stderr with exit 0, which is + // why callers read the key back instead of trusting the exit code. + run(SECURITY, ["-i"], `add-generic-password -U -s ${serviceName(env)} -a ${ACCOUNT} -w ${key.toString("base64")}\n`); + }, +}; + +/** Windows PowerShell 5.1 ships with every supported Windows and has ProtectedData built in. */ +function dpapi(direction: "Protect" | "Unprotect", input: string): string { + const script = [ + "$ErrorActionPreference = 'Stop'", + "Add-Type -AssemblyName System.Security", + "$bytes = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())", + `$out = [Security.Cryptography.ProtectedData]::${direction}($bytes, $null, 'CurrentUser')`, + "[Console]::Out.Write([Convert]::ToBase64String($out))", + ].join("; "); + const encoded = Buffer.from(script, "utf16le").toString("base64"); + try { + return run("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], input); + } catch (err) { + if (isMissingBinary(err)) throw new StoreUnavailable("powershell.exe"); + throw err; + } +} + +const dpapiStore: Store = { + get(dir) { + let blob: string; + try { + blob = readFileSync(dpapiKeyPath(dir), "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + return decodeKey(dpapi("Unprotect", blob), dpapiKeyPath(dir)); + }, + set(dir, key) { + writeNewFile(dpapiKeyPath(dir), dpapi("Protect", key.toString("base64"))); + }, +}; + +const secretService: Store = { + get(_dir, env) { + try { + const out = run("secret-tool", ["lookup", "service", serviceName(env), "account", ACCOUNT]); + return out.trim() ? decodeKey(out, "the Secret Service") : null; + } catch (err) { + if (isMissingBinary(err)) throw new StoreUnavailable("secret-tool"); + // secret-tool exits 1 with nothing on stderr when the item is absent. + const e = err as { status?: number; stderr?: string }; + if (e.status === 1 && !e.stderr?.trim()) return null; + throw err; + } + }, + set(_dir, key, env) { + try { + run("secret-tool", ["store", "--label=Meshy CLI", "service", serviceName(env), "account", ACCOUNT], key.toString("base64")); + } catch (err) { + if (isMissingBinary(err)) throw new StoreUnavailable("secret-tool"); + throw err; + } + }, +}; + +/** Creates `file` with 0600, failing with EEXIST rather than replacing one another process wrote first. */ +function writeNewFile(file: string, content: string): void { + const fd = openSync(file, "wx", 0o600); + try { + writeSync(fd, content); + } finally { + closeSync(fd); + } +} + +const fileStore: Store = { + get(dir) { + try { + return decodeKey(readFileSync(fileKeyPath(dir), "utf8"), fileKeyPath(dir)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + }, + set(dir, key) { + try { + writeNewFile(fileKeyPath(dir), key.toString("base64")); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + }, +}; + +const BACKENDS: Record = { + keychain, + dpapi: dpapiStore, + "secret-service": secretService, + file: fileStore, +}; + +/** The OS store for this platform, or `file` when there is none or MESHY_KEYRING=file. */ +export function platformKeyStore(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): KeyStore { + if (env["MESHY_KEYRING"]?.trim().toLowerCase() === "file") return "file"; + if (platform === "darwin") return "keychain"; + if (platform === "win32") return "dpapi"; + if (platform === "linux" && env["DBUS_SESSION_BUS_ADDRESS"]) return "secret-service"; + return "file"; +} + +const cache = new Map(); + +function readKey(store: KeyStore, dir: string, env: NodeJS.ProcessEnv): Buffer | null { + const id = store === "file" || store === "dpapi" ? `${store}:${dir}` : `${store}:${serviceName(env)}`; + const hit = cache.get(id); + if (hit) return hit; + const key = BACKENDS[store].get(dir, env); + if (key) cache.set(id, key); + return key; +} + +/** + * The key a new seal uses, created on first use. Callers hold the credentials + * lock, so two processes never mint competing keys for one directory; the key + * is read back after creation so the value used is the value stored. + */ +function sealingKey(dir: string, env: NodeJS.ProcessEnv): { store: KeyStore; key: Buffer } { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const preferred: KeyStore = existsSync(fileKeyPath(dir)) ? "file" : platformKeyStore(env); + if (preferred !== "file") { + try { + const existing = readKey(preferred, dir, env); + if (existing) return { store: preferred, key: existing }; + BACKENDS[preferred].set(dir, randomBytes(KEY_BYTES), env); + const stored = readKey(preferred, dir, env); + if (stored) return { store: preferred, key: stored }; + throw new Error("the key did not read back after it was stored"); + } catch (err) { + logger.debug(`${preferred} is unavailable for the credentials key (${(err as Error).message}); using ${fileKeyPath(dir)}`); + } + } + const existing = readKey("file", dir, env); + if (existing) return { store: "file", key: existing }; + fileStore.set(dir, randomBytes(KEY_BYTES), env); + const stored = readKey("file", dir, env); + if (!stored) throw new Error(`could not create ${fileKeyPath(dir)}`); + return { store: "file", key: stored }; +} + +/** Encrypts `plaintext` for the credentials file in `dir`: `v1..`. */ +export function sealSecret(dir: string, plaintext: string, env: NodeJS.ProcessEnv = process.env): string { + const { store, key } = sealingKey(dir, env); + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const body = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return `${SEAL_PREFIX}.${store}.${Buffer.concat([iv, body, cipher.getAuthTag()]).toString("base64")}`; +} + +function unreadable(detail: string): HintedError { + return new HintedError({ + message: `The stored login cannot be read: ${detail}.`, + code: "unauthenticated", + hint: "Unlock the OS keychain and retry, or sign in again: meshy auth login", + }); +} + +/** Decrypts a value from sealSecret; a missing key or tampered value is an auth error, not a crash. */ +export function openSecret(dir: string, sealed: string, env: NodeJS.ProcessEnv = process.env): string { + const [prefix, store, payload] = sealed.split("."); + if (prefix !== SEAL_PREFIX || !STORES.includes(store as KeyStore) || !payload) { + throw unreadable("the sealed value has an unknown format"); + } + let key: Buffer | null; + try { + key = readKey(store as KeyStore, dir, env); + } catch (err) { + throw unreadable(`the ${store} key could not be read (${(err as Error).message.split("\n")[0]})`); + } + if (!key) throw unreadable(`its ${store} key is gone`); + const raw = Buffer.from(payload, "base64"); + try { + const decipher = createDecipheriv("aes-256-gcm", key, raw.subarray(0, IV_BYTES)); + decipher.setAuthTag(raw.subarray(raw.length - TAG_BYTES)); + return Buffer.concat([decipher.update(raw.subarray(IV_BYTES, raw.length - TAG_BYTES)), decipher.final()]).toString("utf8"); + } catch { + throw unreadable(`it does not match the ${store} key`); + } +} diff --git a/src/internal/oauth.ts b/src/internal/oauth.ts index 66a7304..779e54f 100644 --- a/src/internal/oauth.ts +++ b/src/internal/oauth.ts @@ -17,6 +17,8 @@ import { createHash, randomBytes } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { spawn } from "node:child_process"; +import { OAUTH_ACCESS_TOKEN_PREFIX } from "./config.js"; +import type { CredentialAccount } from "./credentials.js"; import { HintedError } from "./errors.js"; import { USER_AGENT } from "./user-agent.js"; import { renderCallbackPage } from "./oauth-page.js"; @@ -44,11 +46,19 @@ export function codeChallengeS256(verifier: string): string { // Authorize URL // --------------------------------------------------------------------------- +/** + * Asked for at every login so `auth status` can say who is signed in. It is a + * request, not a requirement: a site that does not grant these still issues + * the token, and the account is then simply unknown. + */ +export const IDENTITY_SCOPE = "openid profile email"; + export interface BuildAuthorizeUrlParams { base: string; redirectUri: string; state: string; codeChallenge: string; + scope?: string; } /** @@ -63,9 +73,35 @@ export function buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string { url.searchParams.set("state", params.state); url.searchParams.set("code_challenge", params.codeChallenge); url.searchParams.set("code_challenge_method", "S256"); + if (params.scope) url.searchParams.set("scope", params.scope); return url.toString(); } +/** + * Who an OAuth access token belongs to, from the API's OpenID Connect + * userinfo endpoint. Null whenever it cannot say — a token issued without the + * identity scope, a site without the endpoint, an API key, a network error — + * because the account is shown, never relied on. + */ +export async function fetchAccount(baseUrlV1: string, accessToken: string): Promise { + if (!accessToken.startsWith(OAUTH_ACCESS_TOKEN_PREFIX)) return null; + try { + const res = await fetch(`${new URL(baseUrlV1).origin}/openapi/oauth/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", "User-Agent": USER_AGENT }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return null; + const body = (await res.json()) as Record; + const account: CredentialAccount = { + ...(typeof body["name"] === "string" && body["name"] ? { name: body["name"] } : {}), + ...(typeof body["email"] === "string" && body["email"] ? { email: body["email"] } : {}), + }; + return account.name || account.email ? account : null; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Loopback callback server // --------------------------------------------------------------------------- diff --git a/src/internal/operation-store.ts b/src/internal/operation-store.ts index c4cd401..f1a9384 100644 --- a/src/internal/operation-store.ts +++ b/src/internal/operation-store.ts @@ -76,8 +76,15 @@ export interface OperationIdentity { payloadFingerprint: string; } +/** + * Under the config directory, unless a host app names a writable one: a + * DeepSeek Harness shell cannot write the config directory, and a submission + * the journal cannot record is refused, so its Meshy plugin points + * DSH_MESHY_STATE_DIR at a directory in the session's workspace. + */ export function operationsRoot(env: NodeJS.ProcessEnv = process.env): string { - return join(configDir(env), "operations"); + const state = env["DSH_MESHY_STATE_DIR"]?.trim(); + return join(state || configDir(env), "operations"); } export function newOperationId(): string { diff --git a/src/internal/runtime.ts b/src/internal/runtime.ts index 944f41c..a8e3a92 100644 --- a/src/internal/runtime.ts +++ b/src/internal/runtime.ts @@ -11,12 +11,13 @@ * store, never refreshes anything. */ +import { dirname } from "node:path"; import { Command } from "commander"; import { looksLikeFile } from "./paths.js"; import { MeshyClient } from "../client/index.js"; import { loadConfig, type ConfigOverrides, type MeshyConfig } from "./config.js"; -import { credentialsPath, readCredentials, saveProfile } from "./credentials.js"; -import { authRequiredError, UsageError } from "./errors.js"; +import { credentialsPath, profileSecrets, readCredentials, saveProfile, storeWritable } from "./credentials.js"; +import { authRequiredError, HintedError, UsageError } from "./errors.js"; import { logger, setLogLevel } from "./logger.js"; import { refreshTokens } from "./oauth.js"; import type { LogLevel } from "./logger.js"; @@ -54,8 +55,13 @@ export interface LocalRuntime { readonly flags: GlobalFlags; } -/** 60-second skew window: refresh if token expires within this many ms. */ -const REFRESH_SKEW_MS = 60_000; +/** + * Refresh an OAuth credential this far ahead of expiry. Every command then + * starts with at least this long left, so a long `wait` does not outlive its + * token, and a host app that re-reads the token on a schedule shorter than + * this always hands out one with time to spare. + */ +export const REFRESH_SKEW_MS = 30 * 60_000; /** * Silently refresh an OAuth credential when it is near expiry or already @@ -66,6 +72,12 @@ const REFRESH_SKEW_MS = 60_000; * - the active profile is kind "oauth" with a refresh_token * - the access_token expires within REFRESH_SKEW_MS (or is already past) * + * When the store cannot be written here (a sandboxed agent shell), the + * refresh is skipped: the API rotates the refresh token on use, so a refresh + * whose result cannot be saved spends the stored token, and the next attempt + * ends the login. A still-valid token is used as-is; an expired one is an + * auth error that says where to refresh it instead. + * * On a successful refresh the new tokens are written back to the credentials * file and loadConfig is re-run so the returned config carries the fresh token. * @@ -90,32 +102,36 @@ export async function refreshOAuthCredentialIfNeeded( const credFile = credentialsPath(config.baseUrlV1); const stored = readCredentials(credFile); const profile = stored?.profiles[config.credentialProfile]; - - if ( - !profile || - profile.kind !== "oauth" || - !profile.refresh_token || - !profile.access_token - ) { - return config; - } + if (!profile || profile.kind !== "oauth") return config; const expiresAt = profile.expires_at ?? 0; const needsRefresh = expiresAt - Date.now() < REFRESH_SKEW_MS; if (!needsRefresh) return config; + const secrets = profileSecrets(credFile, profile); + if (!secrets.refresh_token || !secrets.access_token) return config; + const alreadyExpired = expiresAt <= Date.now(); + if (!storeWritable(credFile)) { + if (!alreadyExpired) return config; + throw new HintedError({ + message: `The OAuth token expired, and ${dirname(credFile)} is not writable here, so it was not refreshed; the stored login is kept.`, + code: "unauthenticated", + hint: `Run any meshy command where ${dirname(credFile)} is writable (outside the sandbox) to refresh it, then retry.`, + }); + } + // Snapshot the access token we started with so we can detect a // concurrent rotation after a refresh failure (B3). - const originalAccessToken = profile.access_token; + const originalAccessToken = secrets.access_token; const profileName = config.credentialProfile; try { const tok = await refreshTokens({ baseUrlV1: config.baseUrlV1, - refreshToken: profile.refresh_token, + refreshToken: secrets.refresh_token, }); - // Write back: preserve profile name and created_at, update tokens. + // Write back: preserve profile name, created_at and account, update tokens. saveProfile( credFile, profileName, @@ -126,6 +142,8 @@ export async function refreshOAuthCredentialIfNeeded( expires_at: Date.now() + tok.expires_in * 1000, user_id: tok.user_id ?? profile.user_id, ...(profile.login_id ? { login_id: profile.login_id } : {}), + ...(profile.region ? { region: profile.region } : {}), + ...(profile.account ? { account: profile.account } : {}), created_at: profile.created_at, }, { makeActive: false }, @@ -140,11 +158,11 @@ export async function refreshOAuthCredentialIfNeeded( // adopt it and proceed rather than failing the command. const afterFailure = readCredentials(credFile); const afterProfile = profileName ? afterFailure?.profiles[profileName] : undefined; + const afterToken = afterProfile?.kind === "oauth" ? profileSecrets(credFile, afterProfile).access_token : undefined; if ( - afterProfile?.kind === "oauth" && - afterProfile.access_token && - afterProfile.access_token !== originalAccessToken && - (afterProfile.expires_at ?? 0) - Date.now() > REFRESH_SKEW_MS + afterToken && + afterToken !== originalAccessToken && + (afterProfile?.expires_at ?? 0) - Date.now() > REFRESH_SKEW_MS ) { // Another process already rotated — adopt the new token. logger.debug("OAuth refresh failed but concurrent rotation detected; adopting new token"); diff --git a/src/root.ts b/src/root.ts index f55aed3..2baa376 100644 --- a/src/root.ts +++ b/src/root.ts @@ -93,15 +93,18 @@ EXAMPLES: AUTHENTICATION: auth login open the browser for OAuth login (loopback + PKCE) auth login --with-key msy_... paste an existing API key instead of the browser flow - auth status which credential is in effect, and does it work + auth status which credential is in effect, whose it is, and does it work auth list stored profiles auth use switch the active profile auth logout [--all] forget the stored credential - Resolution order: --api-key > MESHY_API_KEY > --api-key-file > stored profile. The - env var stays ahead of the stored credential so CI is never overridden by - whatever a developer logged into on that machine. Non-production --base-url-v1 - uses a separate credentials.dev.json, so staging cannot clobber a production login. + Resolution order: --api-key > MESHY_API_KEY > --api-key-file > a host app's + DSH_MESHY_CREDENTIAL > stored profile. The env var stays ahead of the stored + credential so CI is never overridden by whatever a developer logged into on + that machine. Stored secrets are encrypted under a key the OS keeps (Keychain, + DPAPI, Secret Service; MESHY_KEYRING=file for a key file). Non-production + --base-url-v1 uses a separate credentials.v2.dev.json, so staging cannot + clobber a production login. RESOURCE COMMANDS: All endpoint commands stay available and stay supported — they are indexed diff --git a/tests/auth-headless.test.ts b/tests/auth-headless.test.ts index 20e0ff3..e449031 100644 --- a/tests/auth-headless.test.ts +++ b/tests/auth-headless.test.ts @@ -26,6 +26,7 @@ import { join } from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname as pathDirname } from "node:path"; +import { openStoredProfile } from "./helpers/credentials.js"; /** The `result` of the v1 envelope every command prints on stdout. */ function envelopeResult(stdout: string): Record { @@ -176,12 +177,10 @@ test( // Credentials file should exist with oauth kind. assert.ok(existsSync(credFile), "credentials file should exist"); - const creds = JSON.parse(readFileSync(credFile, "utf8")) as { - profiles: Record; - }; - assert.equal(creds.profiles["default"]?.kind, "oauth"); - assert.equal(creds.profiles["default"]?.access_token, "e2e-device-access-token"); - assert.match(String(creds.profiles["default"]?.login_id), /^[0-9a-f-]{36}$/, "every OAuth login mints a login id (R2-F06)"); + const stored = openStoredProfile(credFile); + assert.equal(stored.kind, "oauth"); + assert.equal(stored.access_token, "e2e-device-access-token"); + assert.match(String(stored.login_id), /^[0-9a-f-]{36}$/, "every OAuth login mints a login id (R2-F06)"); } finally { await stub.close(); } diff --git a/tests/codex-review-round2.test.ts b/tests/codex-review-round2.test.ts index 22a0e12..73c5d08 100644 --- a/tests/codex-review-round2.test.ts +++ b/tests/codex-review-round2.test.ts @@ -15,6 +15,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFil import { join } from "node:path"; import { credentialBinding } from "../src/internal/operation-store.js"; import { jsonReply, parseNdjson, parseSingleJson, runCli, startMockApi, tmpDir } from "./helpers/cli.js"; +import { openStoredProfile } from "./helpers/credentials.js"; const CREATE = ["text-to-3d", "create", "--mode", "preview", "--prompt", "round two", "--async"]; @@ -379,9 +380,9 @@ test("N07/R2-F06 an OAuth profile without user_id or login_id cannot replay an e const after = await runCli(refreshOp, { env, cwd: dir }); assert.equal(after.code, 0, `${after.stderr}\n${after.stdout}`); assert.equal((parseSingleJson(after.stdout) as { warnings: Array<{ code: string }> }).warnings[0]?.code, "operation_replayed"); - const saved = JSON.parse(readFileSync(creds, "utf8")) as { profiles: { default: { access_token: string; login_id: string } } }; - assert.equal(saved.profiles.default.login_id, "login-cccc", "refresh preserved the login id"); - assert.match(saved.profiles.default.access_token, /^refreshed-/, "the token was rotated by the refresh"); + const saved = openStoredProfile(creds); + assert.equal(saved.login_id, "login-cccc", "refresh preserved the login id"); + assert.match(String(saved.access_token), /^refreshed-/, "the token was rotated by the refresh"); assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 3); // Journals hold neither tokens nor login ids in the clear. const ops = join(String(env["MESHY_CONFIG_DIR"]), "operations"); diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 33c01d8..5c89cba 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -8,6 +8,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { + existsSync, mkdirSync, mkdtempSync, readdirSync, @@ -24,6 +25,9 @@ import { fileURLToPath } from "node:url"; import { CredentialsFileError, DEFAULT_PROFILE, + legacyCredentialsPath, + profileSecrets, + storeWritable, configDir, credentialsPath, deleteCredentialsFile, @@ -38,6 +42,7 @@ import { writeCredentials, } from "../src/internal/credentials.js"; import { loadConfig } from "../src/internal/config.js"; +import { openStoredProfile } from "./helpers/credentials.js"; const execFileAsync = promisify(execFile); const HERE = dirname(fileURLToPath(import.meta.url)); @@ -92,8 +97,8 @@ test("isNonProdBaseUrl — an unparseable URL counts as non-prod", () => { test("credentialsPath — prod and non-prod resolve to separate files", () => { const env = { MESHY_CONFIG_DIR: "/cfg", MESHY_CREDENTIALS_PATH: undefined }; - assert.equal(credentialsPath("https://api.meshy.ai/openapi/v1", env), "/cfg/credentials.json"); - assert.equal(credentialsPath("https://staging.meshy.ai/v1", env), "/cfg/credentials.dev.json"); + assert.equal(credentialsPath("https://api.meshy.ai/openapi/v1", env), join("/cfg", "credentials.v2.json")); + assert.equal(credentialsPath("https://staging.meshy.ai/v1", env), join("/cfg", "credentials.v2.dev.json")); }); test("credentialsPath — MESHY_CREDENTIALS_PATH overrides even the dev split", () => { @@ -121,7 +126,8 @@ test("writeCredentials → readCredentials round-trip", () => { ); const state = readCredentials(file); assert.equal(state?.active_profile, "work"); - assert.equal(state?.profiles["work"]?.api_key, "msy_work"); + assert.equal(openStoredProfile(file, "work").api_key, "msy_work"); + assert.equal(state?.profiles["work"]?.hint, "ms…", "the masked hint lists the profile without its key"); }); test("writeCredentials — file is 0600 and leaves no temp file behind", () => { @@ -178,7 +184,7 @@ test("saveProfile — stamps created_at and preserves existing profiles", () => saveProfile(file, "two", { kind: "api_key", api_key: "msy_2" }); const state = readCredentials(file); assert.equal(typeof state?.profiles["one"]?.created_at, "number"); - assert.equal(state?.profiles["one"]?.api_key, "msy_1"); + assert.equal(openStoredProfile(file, "one").api_key, "msy_1"); }); test("removeProfile — reports whether it existed and repoints the active profile", () => { @@ -350,3 +356,73 @@ test("loadConfig — a corrupt store surfaces instead of reading as 'not logged assert.throws(() => loadConfig(), CredentialsFileError); }); }); + +// --------------------------------------------------------------------------- +// Sealing and the pre-0.8 file +// --------------------------------------------------------------------------- + +function legacyDir(profiles: Record): { dir: string; v2: string; legacy: string } { + const dir = mkdtempSync(join(tmpdir(), "meshy-creds-legacy-")); + const legacy = join(dir, "credentials.json"); + writeFileSync(legacy, JSON.stringify({ auth_version: 1, active_profile: "default", profiles }), { mode: 0o600 }); + return { dir, v2: join(dir, "credentials.v2.json"), legacy }; +} + +test("a pre-0.8 plaintext file is read as-is until the first write moves it, then it is deleted", () => { + const { v2, legacy } = legacyDir({ + default: { kind: "oauth", access_token: "msy_at_old_access", refresh_token: "msy_rt_old_refresh", expires_at: 1, login_id: "L1" }, + ci: { kind: "api_key", api_key: "msy_ci_key_1234567890" }, + }); + assert.equal(legacyCredentialsPath(v2), legacy); + const before = readCredentials(v2); + assert.equal(before?.profiles["default"]?.access_token, "msy_at_old_access", "read from the old file while v2 is absent"); + + saveProfile(v2, "new", { kind: "api_key", api_key: "msy_new_key_1234567890" }, { makeActive: false }); + + assert.ok(!existsSync(legacy), "the old file is deleted once its profiles moved"); + assert.equal(openStoredProfile(v2, "default").refresh_token, "msy_rt_old_refresh"); + assert.equal(openStoredProfile(v2, "default").login_id, "L1"); + assert.equal(openStoredProfile(v2, "ci").api_key, "msy_ci_key_1234567890"); + assert.equal(openStoredProfile(v2, "new").api_key, "msy_new_key_1234567890"); + assert.equal(readCredentials(v2)?.active_profile, "default"); +}); + +test("once the v2 file exists, an old file an older CLI writes is left alone", () => { + const { v2, legacy } = legacyDir({ default: { kind: "api_key", api_key: "msy_first_1234567890" } }); + saveProfile(v2, "default", { kind: "api_key", api_key: "msy_v2_key_1234567890" }); + writeFileSync(legacy, JSON.stringify({ active_profile: "default", profiles: { default: { kind: "api_key", api_key: "msy_older_cli_login" } } })); + + assert.equal(openStoredProfile(v2).api_key, "msy_v2_key_1234567890"); + saveProfile(v2, "other", { kind: "api_key", api_key: "msy_other_1234567890" }); + assert.ok(existsSync(legacy), "an older CLI keeps its own login"); +}); + +test("deleteCredentialsFile removes the old file too", () => { + const { v2, legacy } = legacyDir({ default: { kind: "api_key", api_key: "msy_k" } }); + assert.equal(deleteCredentialsFile(v2), true); + assert.ok(!existsSync(legacy)); + assert.equal(deleteCredentialsFile(v2), false); +}); + +test("a profile whose key is gone is an auth error, not a crash", () => { + const sealedFile = tmpFile(); + saveProfile(sealedFile, DEFAULT_PROFILE, { kind: "api_key", api_key: "msy_sealed_1234567890" }); + // The same file in a directory without its master key. + const file = tmpFile(); + writeFileSync(file, readFileSync(sealedFile)); + const profile = readCredentials(file)?.profiles[DEFAULT_PROFILE]; + assert.ok(profile); + assert.throws( + () => profileSecrets(file, profile), + (err: unknown) => (err as { code?: string }).code === "unauthenticated" && /cannot be read/.test((err as Error).message), + ); +}); + +test("storeWritable — true for a writable directory, false when it cannot be written", () => { + assert.equal(storeWritable(tmpFile()), true); + const blocker = join(mkdtempSync(join(tmpdir(), "meshy-creds-ro-")), "not-a-dir"); + writeFileSync(blocker, ""); + // The store directory would have to live under a regular file: no platform can create it. + assert.equal(storeWritable(join(blocker, "credentials.v2.json")), false); +}); + diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 50751a3..3488ee2 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -123,7 +123,8 @@ test("T-102 default run: local only — no probe, no detector, no fetch; boolean flag: true, env: true, api_key_file: null, - stored_profile: { path: join(env["MESHY_CONFIG_DIR"]!, "credentials.json"), exists: false }, + host: false, + stored_profile: { path: join(env["MESHY_CONFIG_DIR"]!, "credentials.v2.json"), exists: false }, }); assert.deepEqual(report.cwd_env_candidates, [".env"]); assert.match(check(report, "cwd_env_files").detail, /\.env found .* not read/); @@ -172,7 +173,7 @@ test("stored profile: the credentials file is stat'ed, never parsed", async () = assert.ok(!JSON.stringify(report).includes("msy_stored_secret_value_000")); // Non-production v1 base → the dev file is the one that applies. const staging = await runDoctor({ flags: flagsOf({ baseUrlV1: "https://staging.example.invalid/openapi/v1/" }), checkApi: false, checkSlicers: false, env: isolatedEnv(), cwd: dir }); - assert.match(staging.credential_sources.stored_profile.path, /credentials\.dev\.json$/); + assert.match(staging.credential_sources.stored_profile.path, /credentials\.v2\.dev\.json$/); assert.equal(staging.credential_sources.stored_profile.exists, false); }); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index cbc66c6..8e4772e 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -49,6 +49,8 @@ export function isolatedEnv(extra: Record = {}): Rec SystemRoot: process.env["SystemRoot"], MESHY_CLI_NO_UPDATE_NOTIFIER: "1", MESHY_CLI_NO_BROWSER: "1", + // tests/test.env keeps every test off the OS keychain; children inherit it. + MESHY_KEYRING: process.env["MESHY_KEYRING"], MESHY_CONFIG_DIR: configDir, MESHY_API_KEY: undefined, MESHY_BASE_URL_V1: undefined, diff --git a/tests/helpers/credentials.ts b/tests/helpers/credentials.ts new file mode 100644 index 0000000..3eca8d6 --- /dev/null +++ b/tests/helpers/credentials.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { profileSecrets, readCredentials, type CredentialProfile } from "../../src/internal/credentials.js"; + +/** + * A stored profile opened the way the CLI opens it, after checking that none + * of its secrets is on disk in the clear. + */ +export function openStoredProfile(file: string, name = "default"): CredentialProfile { + const raw = readFileSync(file, "utf8"); + const profile = readCredentials(file)?.profiles[name]; + assert.ok(profile, `no profile '${name}' in ${file}`); + const secrets = profileSecrets(file, profile); + for (const value of Object.values(secrets)) { + if (value) assert.ok(!raw.includes(value), `a secret of '${name}' is stored in the clear`); + } + return { ...profile, ...secrets }; +} diff --git a/tests/host-session.test.ts b/tests/host-session.test.ts new file mode 100644 index 0000000..e0be4ae --- /dev/null +++ b/tests/host-session.test.ts @@ -0,0 +1,195 @@ +/** + * Sign-in behaviour that sandboxed agents and host apps depend on: a store + * this process cannot write never spends its refresh token; a credential a + * host app hands over (DSH_MESHY_*) is used as-is; `auth status` names the + * account and, on request, prints the credential; a login asks for the + * identity scope and keeps the account; a refresh keeps the profile's region + * and account. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { resolveRegion } from "../src/internal/config.js"; +import { saveProfile, type CredentialProfile } from "../src/internal/credentials.js"; +import { operationsRoot } from "../src/internal/operation-store.js"; +import { jsonReply, parseSingleJson, runCli, startMockApi, tmpDir, type MockApi } from "./helpers/cli.js"; +import { openStoredProfile } from "./helpers/credentials.js"; + +interface Envelope { + ok: boolean; + result: Record | null; + error: { code: string; message: string; hint?: string } | null; +} +const envelope = (stdout: string): Envelope => parseSingleJson(stdout) as Envelope; + +function oauthStore(expiresAt: number, extra: Partial = {}): string { + const file = join(tmpDir("meshy-host-"), "credentials.v2.json"); + saveProfile(file, "default", { + kind: "oauth", + access_token: "msy_at_stored_token", + refresh_token: "msy_rt_stored_refresh", + expires_at: expiresAt, + login_id: "login-1", + ...extra, + }); + return file; +} + +/** Makes the store unwritable for the probe on every platform: `locks` cannot be created as a directory. */ +function blockWrites(file: string): void { + const locks = join(dirname(file), "locks"); + rmSync(locks, { recursive: true, force: true }); + writeFileSync(locks, ""); +} + +async function api(): Promise { + return startMockApi((req, res) => { + if (req.path.endsWith("/oauth/token")) { + return jsonReply(res, 200, { access_token: "msy_at_fresh", token_type: "Bearer", expires_in: 3600, refresh_token: "msy_rt_fresh" }); + } + if (req.path.endsWith("/oauth/device_authorization")) { + return jsonReply(res, 200, { + device_code: "dev-code", + user_code: "ABCD-EFGH", + verification_uri: "https://www.meshy.ai/activate", + verification_uri_complete: "https://www.meshy.ai/activate?user_code=ABCD-EFGH", + expires_in: 600, + interval: 0.001, + }); + } + if (req.path === "/openapi/oauth/userinfo") { + return jsonReply(res, 200, { sub: "user-9", name: "Ada Lovelace", email: "ada@example.com" }); + } + if (req.path.endsWith("/balance")) return jsonReply(res, 200, { balance: 42 }); + return jsonReply(res, 404, { message: "not found" }); + }); +} + +const tokenRequests = (m: MockApi): number => m.requests.filter((r) => r.path.endsWith("/oauth/token")).length; + +test("an expired login in a store this process cannot write is kept, not refreshed", async () => { + const m = await api(); + try { + const file = oauthStore(Date.now() - 1_000); + blockWrites(file); + const run = await runCli(["balance", "--format", "json"], { env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }) }); + const out = envelope(run.stdout); + assert.equal(run.code, 3, run.stderr); + assert.equal(out.error?.code, "auth"); + assert.match(out.error?.message ?? "", /not writable here, so it was not refreshed; the stored login is kept/); + assert.equal(tokenRequests(m), 0, "no refresh was attempted"); + assert.equal(openStoredProfile(file).refresh_token, "msy_rt_stored_refresh"); + } finally { + await m.close(); + } +}); + +test("a valid token inside the refresh window is used as-is when the store cannot be written", async () => { + const m = await api(); + try { + const file = oauthStore(Date.now() + 10 * 60_000); + blockWrites(file); + const run = await runCli(["balance", "--format", "json"], { env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }) }); + assert.equal(run.code, 0, run.stderr); + assert.equal(tokenRequests(m), 0); + const balance = m.requests.find((r) => r.path.endsWith("/balance")); + assert.equal(balance?.headers["authorization"], "Bearer msy_at_stored_token"); + } finally { + await m.close(); + } +}); + +test("a writable store refreshes 30 minutes ahead and keeps the region and the account", async () => { + const m = await api(); + try { + const file = oauthStore(Date.now() + 10 * 60_000, { region: "cn", account: { name: "Ada Lovelace" } }); + const run = await runCli(["balance", "--format", "json"], { env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }) }); + assert.equal(run.code, 0, run.stderr); + assert.equal(tokenRequests(m), 1); + const stored = openStoredProfile(file); + assert.equal(stored.access_token, "msy_at_fresh"); + assert.equal(stored.region, "cn", "a refresh must not move a China-site login to the global site"); + assert.deepEqual(stored.account, { name: "Ada Lovelace" }); + assert.equal(stored.login_id, "login-1"); + } finally { + await m.close(); + } +}); + +test("a host credential outranks the stored profile, is never refreshed, and --show-token prints it", async () => { + const m = await api(); + try { + const file = oauthStore(Date.now() - 1_000); + const env = m.env({ + MESHY_API_KEY: undefined, + MESHY_CREDENTIALS_PATH: file, + DSH_MESHY_CREDENTIAL: "msy_at_host_token", + DSH_MESHY_SUBJECT: "user-9", + }); + const run = await runCli(["auth", "status", "--show-token", "--format", "json"], { env }); + const out = envelope(run.stdout).result!; + assert.equal(run.code, 0, run.stderr); + assert.equal(out["source"], "host"); + assert.equal(out["token"], "msy_at_host_token"); + assert.equal(out["subject"], "user-9"); + assert.deepEqual(out["account"], { name: "Ada Lovelace", email: "ada@example.com" }); + assert.equal(tokenRequests(m), 0, "the stored profile is not touched while a host credential is set"); + + const plain = envelope((await runCli(["auth", "status", "--format", "json"], { env })).stdout).result!; + assert.equal("token" in plain, false, "the credential is printed only on request"); + + const envKey = envelope((await runCli(["auth", "status", "--offline", "--format", "json"], { env: { ...env, MESHY_API_KEY: "msy_env_key_1234567890" } })).stdout).result!; + assert.equal(envKey["source"], "env", "an exported MESHY_API_KEY still wins"); + } finally { + await m.close(); + } +}); + +test("DSH_MESHY_REGION names the site of a host credential, and only then", () => { + const dir = mkdtempSync(join(tmpdir(), "meshy-host-region-")); + assert.equal(resolveRegion({ MESHY_CONFIG_DIR: dir, DSH_MESHY_CREDENTIAL: "msy_at_x", DSH_MESHY_REGION: "cn" }), "cn"); + assert.equal(resolveRegion({ MESHY_CONFIG_DIR: dir, DSH_MESHY_REGION: "cn" }), "global"); + assert.equal(resolveRegion({ MESHY_CONFIG_DIR: dir, MESHY_REGION: "global", DSH_MESHY_CREDENTIAL: "msy_at_x", DSH_MESHY_REGION: "cn" }), "global"); +}); + +test("auth status --offline shows the stored account; online it asks the API", async () => { + const m = await api(); + try { + const file = oauthStore(Date.now() + 3_600_000, { account: { name: "Stored Name" } }); + const env = m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }); + const offline = envelope((await runCli(["auth", "status", "--offline", "--format", "json"], { env })).stdout).result!; + assert.deepEqual(offline["account"], { name: "Stored Name" }); + assert.equal(typeof offline["expires_at"], "number"); + assert.equal(m.requests.some((r) => r.path.endsWith("/userinfo")), false); + const online = envelope((await runCli(["auth", "status", "--format", "json"], { env })).stdout).result!; + assert.deepEqual(online["account"], { name: "Ada Lovelace", email: "ada@example.com" }); + } finally { + await m.close(); + } +}); + +test("a login asks for the identity scope and keeps the account it reports", async () => { + const m = await api(); + try { + const file = join(tmpDir("meshy-host-login-"), "credentials.v2.json"); + const run = await runCli(["auth", "login", "--device", "--no-verify", "--format", "json"], { + env: m.env({ MESHY_API_KEY: undefined, MESHY_CREDENTIALS_PATH: file }), + }); + assert.equal(run.code, 0, run.stderr); + const started = m.requests.find((r) => r.path.endsWith("/oauth/device_authorization")); + assert.equal((started?.json as { scope?: string }).scope, "openid profile email"); + assert.deepEqual(envelope(run.stdout).result!["account"], { name: "Ada Lovelace", email: "ada@example.com" }); + assert.deepEqual(openStoredProfile(file).account, { name: "Ada Lovelace", email: "ada@example.com" }); + } finally { + await m.close(); + } +}); + +test("operationsRoot follows DSH_MESHY_STATE_DIR", () => { + assert.equal(operationsRoot({ MESHY_CONFIG_DIR: "/cfg" }), join("/cfg", "operations")); + assert.equal(operationsRoot({ MESHY_CONFIG_DIR: "/cfg", DSH_MESHY_STATE_DIR: "/ws/.meshy" }), join("/ws/.meshy", "operations")); +}); diff --git a/tests/keyring.test.ts b/tests/keyring.test.ts new file mode 100644 index 0000000..1d5afed --- /dev/null +++ b/tests/keyring.test.ts @@ -0,0 +1,89 @@ +/** + * Sealing for the credential store. Everything here runs on the file key + * (tests/test.env); the last test drives the real OS store only when + * MESHY_TEST_OS_KEYRING=1, under a throwaway service name, and removes it. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; + +import { openSecret, platformKeyStore, sealSecret } from "../src/internal/keyring.js"; + +const tmp = (): string => mkdtempSync(join(tmpdir(), "meshy-keyring-")); + +test("seal → open round-trip; the ciphertext differs every time and names its key store", () => { + const dir = tmp(); + const a = sealSecret(dir, "msy_secret_value"); + const b = sealSecret(dir, "msy_secret_value"); + assert.notEqual(a, b, "a fresh IV per seal"); + assert.match(a, /^v1\.file\./); + assert.ok(!a.includes("msy_secret_value")); + assert.equal(openSecret(dir, a), "msy_secret_value"); +}); + +test("the file key is 0600 and reused by later seals", { skip: process.platform === "win32" }, () => { + const dir = tmp(); + sealSecret(dir, "x"); + const key = readFileSync(join(dir, "master.key"), "utf8"); + assert.equal(statSync(join(dir, "master.key")).mode & 0o777, 0o600); + sealSecret(dir, "y"); + assert.equal(readFileSync(join(dir, "master.key"), "utf8"), key); +}); + +test("a tampered or foreign value is an auth error", () => { + const dir = tmp(); + const sealed = sealSecret(dir, "msy_secret_value"); + const [v, store, payload] = sealed.split("."); + const raw = Buffer.from(payload!, "base64"); + raw[raw.length - 20]! ^= 0xff; + const isAuth = (err: unknown): boolean => (err as { code?: string }).code === "unauthenticated"; + assert.throws(() => openSecret(dir, `${v}.${store}.${raw.toString("base64")}`), isAuth); + assert.throws(() => openSecret(tmp(), sealed), isAuth, "another directory has another key"); + assert.throws(() => openSecret(dir, "v9.file.AAAA"), isAuth); + assert.throws(() => openSecret(dir, "v1.vault.AAAA"), isAuth); +}); + +test("platformKeyStore — the OS store per platform, the file when there is none or MESHY_KEYRING=file", () => { + assert.equal(platformKeyStore({}, "darwin"), "keychain"); + assert.equal(platformKeyStore({}, "win32"), "dpapi"); + assert.equal(platformKeyStore({ DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" }, "linux"), "secret-service"); + assert.equal(platformKeyStore({}, "linux"), "file", "no session bus, no Secret Service"); + assert.equal(platformKeyStore({}, "freebsd"), "file"); + assert.equal(platformKeyStore({ MESHY_KEYRING: "file" }, "darwin"), "file"); +}); + +test("macOS: a HOME without a keychain uses the key file and never asks the Keychain to store", { skip: process.platform !== "darwin" }, () => { + // An add without a default keychain raises a GUI dialog instead of failing, so the CLI must + // not get that far. The child sees a HOME of its own, as CI and sandboxed shells do. + const home = tmp(); + const out = execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", + `import { sealSecret } from ${JSON.stringify(new URL("../src/internal/keyring.ts", import.meta.url).href)}; process.stdout.write(sealSecret(${JSON.stringify(join(home, "cfg"))}, "x"))`], + { env: { PATH: process.env["PATH"], HOME: home, MESHY_KEYRING_SERVICE: `meshy-cli-test-${randomBytes(4).toString("hex")}` }, encoding: "utf8" }); + assert.match(out, /^v1\.file\./); +}); + +test( + "the OS key store seals and opens for real", + { skip: process.env["MESHY_TEST_OS_KEYRING"] !== "1" && "set MESHY_TEST_OS_KEYRING=1 to use this machine's key store" }, + () => { + const service = `meshy-cli-test-${randomBytes(4).toString("hex")}`; + const env = { ...process.env, MESHY_KEYRING: undefined, MESHY_KEYRING_SERVICE: service }; + const dir = tmp(); + try { + const sealed = sealSecret(dir, "msy_os_store", env); + assert.match(sealed, new RegExp(`^v1\\.${platformKeyStore(env)}\\.`)); + assert.equal(openSecret(dir, sealed, env), "msy_os_store"); + } finally { + if (process.platform === "darwin") { + execFileSync("/usr/bin/security", ["delete-generic-password", "-s", service, "-a", "master-key"], { stdio: "ignore" }); + } else if (platformKeyStore(env) === "secret-service") { + execFileSync("secret-tool", ["clear", "service", service, "account", "master-key"], { stdio: "ignore" }); + } + } + }, +); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index d432d69..7a2fedb 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -30,6 +30,7 @@ import { refreshTokens, } from "../src/internal/oauth.js"; import { USER_AGENT } from "../src/internal/user-agent.js"; +import { openStoredProfile } from "./helpers/credentials.js"; /** The `result` of the v1 envelope every command prints on stdout. */ function envelopeResult(stdout: string): Record { @@ -939,17 +940,7 @@ test( ); // Assert credentials file has kind=oauth and correct fields. - const creds = JSON.parse(readFileSync(credFile, "utf8")) as { - profiles: Record; - }; - const profile = creds.profiles["default"]; - assert.ok(profile, "default profile should exist"); + const profile = openStoredProfile(credFile); assert.equal(profile.kind, "oauth"); assert.equal(profile.access_token, "e2e-access-token"); assert.equal(profile.refresh_token, "e2e-refresh-token"); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts index 7a545bd..d36395c 100644 --- a/tests/runtime.test.ts +++ b/tests/runtime.test.ts @@ -10,12 +10,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname as pathDirname } from "node:path"; +import { openStoredProfile } from "./helpers/credentials.js"; /** The `result` of the v1 envelope every command prints on stdout. */ function envelopeResult(stdout: string): Record { @@ -385,19 +386,11 @@ test( ); // Credentials file must now hold the rotated tokens. - const creds = JSON.parse(readFileSync(credFile, "utf8")) as { - profiles: Record; - }; - const profile = creds.profiles["default"]; - assert.ok(profile, "default profile must exist"); + const profile = openStoredProfile(credFile); assert.equal(profile.access_token, "fresh-status-token", "credentials file must hold new access_token"); assert.equal(profile.refresh_token, "fresh-status-refresh", "credentials file must hold new refresh_token"); assert.ok( - profile.expires_at > Date.now(), + (profile.expires_at ?? 0) > Date.now(), `expires_at must be in the future, got ${profile.expires_at}`, ); } finally { diff --git a/tests/test.env b/tests/test.env new file mode 100644 index 0000000..8837132 --- /dev/null +++ b/tests/test.env @@ -0,0 +1,2 @@ +# Tests never touch the OS keychain; keyring.test.ts opts in explicitly. +MESHY_KEYRING=file