Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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; }
49 changes: 49 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
61 changes: 47 additions & 14 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ meshy balance --api-key-file ./keys.env # dotenv-style file; only MESHY_

Get a key at <https://www.meshy.ai/developers/keys>.

**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
Expand All @@ -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]`.
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
58 changes: 52 additions & 6 deletions src/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,7 +29,9 @@ import {
removeProfile,
saveProfile,
setActiveProfile,
storeWritable,
DEFAULT_PROFILE,
type CredentialAccount,
} from "../internal/credentials.js";
import {
deviceAuthorization,
Expand All @@ -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";
Expand All @@ -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 ??
Expand Down Expand Up @@ -125,6 +145,7 @@ async function finishLogin(
},
): Promise<void> {
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.
Expand All @@ -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);

Expand All @@ -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 }),
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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:") {
Expand All @@ -585,6 +611,7 @@ async function runLoopbackFlow(
redirectUri,
state: oauthState,
codeChallenge,
scope: IDENTITY_SCOPE,
});

await openBrowser(authorizeUrl);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -673,17 +706,21 @@ 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, {
authenticated: true,
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 } : {}),
Expand Down Expand Up @@ -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 });
Expand All @@ -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<typeof readCredentials> {
try {
return readCredentials(file);
} catch {
return null;
}
}

function safeReadProfiles(file: string): { names: string[]; active: string | null } {
try {
const state = readCredentials(file);
Expand Down
Loading
Loading