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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.1] - 2026-09-27

### Security

- The credential cache no longer persists the Meta OAuth access token
(only `apiKey`, `accountId`, `email`); nothing ever read it back.
Existing caches are harmless but can be refreshed with `npm run login`.
- Credential-source resolution (env names, cache path) moved to a
network-free `src/config.ts`; `src/auth.ts` is transport-only with
explicit arguments.

### Fixed

- `scripts/muse-code-login.mjs` imported `../dist/auth.js` instead of
`../dist/src/auth.js` and crashed at startup; fixed and covered by
running `--help` in verification.

## [0.1.0] - 2026-09-27

### Added
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ export MUSE_CODE_SUB_TOKEN=<key>

Or run onboarding and choose **Muse Code**.

## Security & credentials

- The cache file (`~/.openclaw/muse-code-sub.json`, override with
`MUSE_CODE_SUB_CREDENTIALS`) stores exactly three fields: the
subscription `apiKey`, the `accountId`, and the account `email`.
The Meta OAuth access token from the login flow is **never written
to disk** (kept in memory only for the key exchange, then dropped).
- The cache file is created with owner-only permissions (`0600` where
supported). The login script never prints secret material.
- Network allowlist: this plugin talks only to `auth.meta.com`
(device-code login) and `api.meta.ai` (key minting + inference),
both hardcoded — no configurable endpoints, no third parties.
- Report vulnerabilities privately per [SECURITY.md](./SECURITY.md).

## Compatibility

- OpenClaw gateway `>=2026.7.1` (built and tested against `2026.9.5`).
Expand Down
7 changes: 4 additions & 3 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
* intentionally distinct from the official `meta` id so both can coexist.
*/
import { defineSingleProviderPluginEntry, } from "openclaw/plugin-sdk/provider-entry";
import { ENV_VAR, readCacheSync } from "./src/auth.js";
import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js";
import { readCacheSync } from "./src/auth.js";
import { museCodeBaselineModels } from "./src/baseline.models.js";
// Resolution order: explicit env wins, else the login cache. Silent miss.
if (!process.env[ENV_VAR]?.trim()) {
const cached = readCacheSync();
if (!explicitToken()) {
const cached = readCacheSync(defaultCachePath());
if (cached)
process.env[ENV_VAR] = cached;
}
Expand Down
31 changes: 15 additions & 16 deletions dist/src/auth.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,25 @@
// Shared Meta device-login logic (no third-party CLI).
// Meta device-login transport (no third-party CLI).
// Flow parameters are compatible with the published behavior of oh-my-pi
// (MIT-licensed; see NOTICE).
import { homedir } from "node:os";
import { join, dirname } from "node:path";
//
// This module performs network requests but never reads the environment:
// credential sources (env names, cache paths) live in config.ts and are
// passed in explicitly by callers.
import { dirname } from "node:path";
import { readFileSync } from "node:fs";
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
export const CLIENT_ID = "1031625952748946";
export const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/";
export const TOKEN_URL = "https://auth.meta.com/oidc/device/token/";
export const KEY_URL = "https://api.meta.ai/muse-code/key";
export const ENV_VAR = "MUSE_CODE_SUB_TOKEN";
export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS";
export const CREDENTIALS_FILENAME = "muse-code-sub.json";
const HEADERS = {
Accept: "application/json",
"x-api-version": "1.0.0",
};
const REQUEST_TIMEOUT_MS = 25_000;
const MIN_INTERVAL_S = 1;
const SLOW_DOWN_STEP_S = 5;
export function defaultCachePath() {
const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim();
if (override)
return override;
return join(homedir(), ".openclaw", CREDENTIALS_FILENAME);
}
export async function readCache(path = defaultCachePath()) {
export async function readCache(path) {
try {
const blob = (await readFile(path, "utf8").then(JSON.parse));
return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : "";
Expand All @@ -35,7 +29,7 @@ export async function readCache(path = defaultCachePath()) {
}
}
/** Sync cache read for module-load key resolution (silent miss on any failure). */
export function readCacheSync(path = defaultCachePath()) {
export function readCacheSync(path) {
try {
const blob = JSON.parse(readFileSync(path, "utf8"));
return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : "";
Expand All @@ -44,9 +38,14 @@ export function readCacheSync(path = defaultCachePath()) {
return "";
}
}
export async function writeCache(credentials, path = defaultCachePath()) {
export async function writeCache(credentials, path) {
// Retention minimization: persist only what inference needs. The OAuth
// access token has unknown broader scope and nothing reads it back, so it
// must never touch disk — sanitize at the sink, whatever callers pass in.
const { apiKey, accountId, email } = credentials;
const cached = { apiKey, accountId, ...(email ? { email } : {}) };
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(credentials, null, 2));
await writeFile(path, JSON.stringify(cached, null, 2));
try {
await chmod(path, 0o600);
}
Expand Down
21 changes: 21 additions & 0 deletions dist/src/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Credential-source resolution: env names and cache-path policy.
//
// Deliberately network-free: this module never performs requests and never
// handles secret material beyond reading configuration. Transport lives in
// auth.ts and receives explicit arguments, so each side is easy to audit
// in isolation.
import { homedir } from "node:os";
import { join } from "node:path";
export const ENV_VAR = "MUSE_CODE_SUB_TOKEN";
export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS";
export const CREDENTIALS_FILENAME = "muse-code-sub.json";
/** Explicitly configured token (highest priority), or "" when unset. */
export function explicitToken() {
return (process.env[ENV_VAR] || "").trim();
}
export function defaultCachePath() {
const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim();
if (override)
return override;
return join(homedir(), ".openclaw", CREDENTIALS_FILENAME);
}
7 changes: 4 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ import type {
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-types";
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import { ENV_VAR, readCacheSync } from "./src/auth.js";
import { ENV_VAR, defaultCachePath, explicitToken } from "./src/config.js";
import { readCacheSync } from "./src/auth.js";
import { museCodeBaselineModels } from "./src/baseline.models.js";

// Resolution order: explicit env wins, else the login cache. Silent miss.
if (!process.env[ENV_VAR]?.trim()) {
const cached = readCacheSync();
if (!explicitToken()) {
const cached = readCacheSync(defaultCachePath());
if (cached) process.env[ENV_VAR] = cached;
}

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thestreamcode/openclaw-muse-code",
"version": "0.1.0",
"version": "0.1.1",
"description": "Muse Spark in OpenClaw billed to the Muse Code monthly subscription (Meta device login, no API key)",
"type": "module",
"license": "MIT",
Expand Down
4 changes: 2 additions & 2 deletions scripts/muse-code-login.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
pollToken,
mintKey,
writeCache,
defaultCachePath,
} from "../dist/auth.js";
} from "../dist/src/auth.js";
import { defaultCachePath } from "../dist/src/config.js";

const args = process.argv.slice(2);
let cache = null;
Expand Down
43 changes: 25 additions & 18 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
// Shared Meta device-login logic (no third-party CLI).
// Meta device-login transport (no third-party CLI).
// Flow parameters are compatible with the published behavior of oh-my-pi
// (MIT-licensed; see NOTICE).
//
// This module performs network requests but never reads the environment:
// credential sources (env names, cache paths) live in config.ts and are
// passed in explicitly by callers.

import { homedir } from "node:os"
import { join, dirname } from "node:path"
import { dirname } from "node:path"
import { readFileSync } from "node:fs"
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises"

export const CLIENT_ID = "1031625952748946"
export const DEVICE_URL = "https://auth.meta.com/oidc/device/authorization/"
export const TOKEN_URL = "https://auth.meta.com/oidc/device/token/"
export const KEY_URL = "https://api.meta.ai/muse-code/key"
export const ENV_VAR = "MUSE_CODE_SUB_TOKEN"
export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS"
export const CREDENTIALS_FILENAME = "muse-code-sub.json"

const HEADERS: Record<string, string> = {
Accept: "application/json",
Expand All @@ -33,12 +33,20 @@ export interface DeviceAuthorization {
}

export interface MintedCredential {
// In-memory only: writeCache never persists the OAuth token (see below).
oauthAccessToken: string
apiKey: string
accountId: string
email?: string
}

/** The only fields ever written to the credential cache. */
export interface CachedCredential {
apiKey: string
accountId: string
email?: string
}

type DeviceResponse = {
device_code?: unknown
user_code?: unknown
Expand All @@ -64,34 +72,33 @@ type KeyResponse = {
user_id?: unknown
}

export function defaultCachePath(): string {
const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim()
if (override) return override
return join(homedir(), ".openclaw", CREDENTIALS_FILENAME)
}

export async function readCache(path: string = defaultCachePath()): Promise<string> {
export async function readCache(path: string): Promise<string> {
try {
const blob = (await readFile(path, "utf8").then(JSON.parse)) as Partial<MintedCredential>
const blob = (await readFile(path, "utf8").then(JSON.parse)) as Partial<CachedCredential>
return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : ""
} catch {
return ""
}
}

/** Sync cache read for module-load key resolution (silent miss on any failure). */
export function readCacheSync(path: string = defaultCachePath()): string {
export function readCacheSync(path: string): string {
try {
const blob = JSON.parse(readFileSync(path, "utf8")) as Partial<MintedCredential>
const blob = JSON.parse(readFileSync(path, "utf8")) as Partial<CachedCredential>
return typeof blob?.apiKey === "string" && blob.apiKey.trim() ? blob.apiKey.trim() : ""
} catch {
return ""
}
}

export async function writeCache(credentials: MintedCredential, path: string = defaultCachePath()): Promise<void> {
export async function writeCache(credentials: MintedCredential, path: string): Promise<void> {
// Retention minimization: persist only what inference needs. The OAuth
// access token has unknown broader scope and nothing reads it back, so it
// must never touch disk — sanitize at the sink, whatever callers pass in.
const { apiKey, accountId, email } = credentials
const cached: CachedCredential = { apiKey, accountId, ...(email ? { email } : {}) }
await mkdir(dirname(path), { recursive: true })
await writeFile(path, JSON.stringify(credentials, null, 2))
await writeFile(path, JSON.stringify(cached, null, 2))
try {
await chmod(path, 0o600)
} catch {
Expand Down
24 changes: 24 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Credential-source resolution: env names and cache-path policy.
//
// Deliberately network-free: this module never performs requests and never
// handles secret material beyond reading configuration. Transport lives in
// auth.ts and receives explicit arguments, so each side is easy to audit
// in isolation.

import { homedir } from "node:os";
import { join } from "node:path";

export const ENV_VAR = "MUSE_CODE_SUB_TOKEN";
export const CREDENTIALS_ENV_VAR = "MUSE_CODE_SUB_CREDENTIALS";
export const CREDENTIALS_FILENAME = "muse-code-sub.json";

/** Explicitly configured token (highest priority), or "" when unset. */
export function explicitToken(): string {
return (process.env[ENV_VAR] || "").trim();
}

export function defaultCachePath(): string {
const override = (process.env[CREDENTIALS_ENV_VAR] || "").trim();
if (override) return override;
return join(homedir(), ".openclaw", CREDENTIALS_FILENAME);
}
18 changes: 15 additions & 3 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { describe, expect, it, afterEach, vi } from "vitest";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { mkdtempSync, readFileSync } from "node:fs";
import { CREDENTIALS_ENV_VAR, defaultCachePath } from "../src/config.js";
import {
CREDENTIALS_ENV_VAR,
defaultCachePath,
readCache,
writeCache,
deviceAuthorize,
Expand Down Expand Up @@ -55,6 +54,19 @@ describe("credential cache", () => {
expect(await readCache(join(tmpdir(), "muse-auth-absent.json"))).toBe("");
});

it("never persists the OAuth access token", async () => {
const dir = mkdtempSync(join(tmpdir(), "muse-auth-"));
const path = join(dir, "creds.json");
await writeCache(
{ oauthAccessToken: "dca-secret", apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" },
path,
);
const raw = readFileSync(path, "utf8");
expect(raw).not.toContain("dca-secret");
expect(raw).not.toContain("oauthAccessToken");
expect(JSON.parse(raw)).toStrictEqual({ apiKey: "LLM|k", accountId: "uid-1", email: "u@e.c" });
});

it("honors the MUSE_CODE_SUB_CREDENTIALS override", () => {
const custom = join(tmpdir(), "custom-creds.json");
process.env[CREDENTIALS_ENV_VAR] = custom;
Expand Down
Loading