diff --git a/README.md b/README.md index 361d8fe..8c6e890 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,22 @@ curl -L https://registry.npmjs.org/@tloncorp/tlon-skill-linux-arm64/-/tlon-skill # Cookie-based auth (fastest - ship parsed from cookie) tlon --url https://your-ship.tlon.network --cookie "urbauth-~your-ship=0v..." contacts self +# Cookie-based auth with explicit ship and code fallback +tlon --url https://your-ship.tlon.network --ship ~your-ship \ + --cookie "urbauth-~your-ship=0v..." --code sampel-ticlyt-migfun-falmel contacts self + # Code-based auth (requires all three) tlon --url https://your-ship.tlon.network --ship ~your-ship --code sampel-ticlyt-migfun-falmel contacts self +# Use skill-dir or cached credentials for one ship +tlon --ship ~your-ship contacts self + # Or use a config file tlon --config ~/ships/my-ship.json contacts self ``` +Valid CLI credential forms are `--config `, `--url --cookie ` with optional `--ship` and fallback `--code`, `--url --ship --code `, and `--ship `. Incomplete or conflicting credential flag sets fail locally instead of merging with environment variables. + Config file format: ```json // Cookie-based (ship derived from cookie) @@ -60,34 +69,42 @@ export URBIT_SHIP="~your-ship" export URBIT_CODE="sampel-ticlyt-migfun-falmel" ``` -**Option 3: OpenClaw config** +`URBIT_*` aliases take precedence over `TLON_*` aliases for the same field. Partial ambient credentials fail locally except ship-only env, which is used for `TLON_SHIP + TLON_SKILL_DIR` or cache lookup. + +**Option 3: Skill directory** -If you have OpenClaw configured with a Tlon channel, credentials are loaded automatically. +When `TLON_SHIP` and `TLON_SKILL_DIR` are set, the CLI loads `ships/.json` from that skill directory before checking cached credentials. + +**Option 4: OpenClaw config** + +If you have OpenClaw configured with a Tlon channel, credentials are loaded automatically from JSON config. The default path is `~/.openclaw/openclaw.json`; `OPENCLAW_CONFIG` can point to an explicit JSON config path and is parsed as JSON regardless of extension. + +**Resolution order:** CLI credential flags -> `TLON_CONFIG_FILE` -> URL + cookie env -> URL + ship + code env -> `TLON_SHIP + TLON_SKILL_DIR` -> ship-only cache lookup -> OpenClaw JSON -> single cached ship. ## Cookie Caching -The skill automatically caches auth cookies to `~/.tlon/cache/.json` after successful authentication. +The skill caches fresh auth cookies from code login and code fallback to `~/.tlon/cache/.json`. Provided-cookie flows validate the cookie but do not copy that cookie into cache. ```bash # First time - auth and cache $ tlon --url https://zod.tlon.network --ship ~zod --code abcd-efgh contacts self -Note: Credentials cached for ~zod. Next time just run: tlon +Note: Credentials cached for ~zod. Next time run: tlon --ship ~zod -# After that - no flags needed! -$ tlon contacts self +# After that - select the cached ship +$ tlon --ship ~zod contacts self # Multiple cached ships? Specify which one: $ tlon --ship ~zod contacts self ``` -Clear cache: `rm ~/.tlon/cache/*.json` +Cache entries are ship- and URL-specific. Clear cache: `rm ~/.tlon/cache/*.json` ## Cookie vs Code Authentication - **Cookie-based auth**: Uses a pre-authenticated session cookie. Faster since it skips login. -- **Code-based auth**: Performs a login request to get a session cookie. +- **Code-based auth**: Performs a login request to get a fresh session cookie. -The ship name is embedded in the cookie (`urbauth-~ship=...`), so you don't need to specify it separately with cookie auth. +The ship name is embedded in the cookie (`urbauth-~ship=...`), so you don't need to specify it separately with cookie auth unless you want to override it. You can provide both cookie and code; the cookie is used first and the code is fallback if the cookie has expired. ## Multi-Ship Usage diff --git a/SKILL.md b/SKILL.md index 5cc0429..4d1e7a4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -61,13 +61,22 @@ Replace `darwin-arm64` with `darwin-x64`, `linux-x64`, or `linux-arm64` as neede # Cookie-based auth (fastest - ship parsed from cookie name) tlon --url https://your-ship.tlon.network --cookie "urbauth-~your-ship=0v..." +# Cookie-based auth with explicit ship and code fallback +tlon --url https://your-ship.tlon.network --ship ~your-ship \ + --cookie "urbauth-~your-ship=0v..." --code sampel-ticlyt-migfun-falmel + # Code-based auth (requires url + ship + code) tlon --url https://your-ship.tlon.network --ship ~your-ship --code sampel-ticlyt-migfun-falmel +# Use skill-dir or cached credentials for one ship +tlon --ship ~your-ship + # Or load from a JSON config file tlon --config ~/ships/my-ship.json ``` +Valid CLI credential forms are `--config `, `--url --cookie ` with optional `--ship` and fallback `--code`, `--url --ship --code `, and `--ship `. Incomplete or conflicting credential flag sets fail locally instead of merging with environment variables. + Config file format: ```json @@ -91,20 +100,24 @@ export URBIT_SHIP="~your-ship" export URBIT_CODE="sampel-ticlyt-migfun-falmel" ``` -**OpenClaw:** If configured with a Tlon channel, credentials load automatically. +`URBIT_*` aliases take precedence over `TLON_*` aliases for the same field. Partial ambient credentials fail locally except ship-only env, which is used for `TLON_SHIP + TLON_SKILL_DIR` or cache lookup. -**Resolution order:** CLI flags → `TLON_CONFIG_FILE` → `URL + COOKIE` → `URL + SHIP + CODE` → `--ship` with cache → OpenClaw config → cached ships (auto-select if only one) +**Skill directory:** When `TLON_SHIP` and `TLON_SKILL_DIR` are set, the CLI loads `ships/.json` from that skill directory before checking cached credentials. + +**OpenClaw:** If configured with a Tlon channel, credentials load automatically from JSON config. The default path is `~/.openclaw/openclaw.json`; `OPENCLAW_CONFIG` can point to an explicit JSON config path and is parsed as JSON regardless of extension. + +**Resolution order:** CLI credential flags → `TLON_CONFIG_FILE` → URL + cookie env → URL + ship + code env → `TLON_SHIP + TLON_SKILL_DIR` → ship-only cache lookup → OpenClaw JSON → single cached ship. **Cookie vs Code:** - **Cookie-based:** Uses pre-authenticated session cookie. Ship is parsed from the cookie name (`urbauth-~ship=...`). Fastest option. -- **Code-based:** Performs login to get session cookie. Requires URL + ship + code. +- **Code-based:** Performs login to get a fresh session cookie. Requires URL + ship + code. You can provide both cookie and code — cookie is used first, code serves as fallback if cookie expires. ## Cookie Caching -The skill automatically caches auth cookies to `~/.tlon/cache/.json` after successful authentication. This makes subsequent invocations much faster by skipping the login request. +The skill caches fresh auth cookies from code login and code fallback to `~/.tlon/cache/.json`. Provided-cookie flows validate the cookie but do not copy that cookie into cache. This makes subsequent invocations much faster by skipping the login request. **How it works:** @@ -112,10 +125,10 @@ The skill automatically caches auth cookies to `~/.tlon/cache/.json` after # First time - authenticates and caches $ tlon --url https://zod.tlon.network --ship ~zod --code abcd-efgh contacts self ~zod -Note: Credentials cached for ~zod. Next time just run: tlon +Note: Credentials cached for ~zod. Next time run: tlon --ship ~zod -# After that - no flags needed (if only one cached ship) -$ tlon contacts self +# After that - select the cached ship +$ tlon --ship ~zod contacts self ~zod # With multiple cached ships - specify which one @@ -128,7 +141,7 @@ $ tlon --ship ~bus contacts self - Cached cookies are URL-specific (won't use a cookie for the wrong host) - If only one ship is cached, it's auto-selected (no flags needed) - If multiple ships are cached, you'll be prompted to specify with `--ship` -- The skill reminds you when you pass credentials that aren't needed +- Code login and code fallback cache fresh cookies; provided-cookie flows do not copy cookies into cache **Clear cache:** `rm ~/.tlon/cache/*.json` diff --git a/scripts/api-client.test.ts b/scripts/api-client.test.ts new file mode 100644 index 0000000..b61c2ef --- /dev/null +++ b/scripts/api-client.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { + type CredentialResolution, + type EnsureClientDeps, + __resetApiClientForTests, + ensureClient, +} from "./api-client"; + +function resolution( + overrides: Omit, "config"> & { + config?: Partial; + } = {} +): CredentialResolution { + const { config: _config, ...rest } = overrides; + const cfg = { + url: "https://zod.tlon.network", + ship: "zod", + code: "", + ..._config, + } as CredentialResolution["config"]; + + return { + config: cfg, + origin: "cli", + authKind: cfg.cookie ? "cookie" : "code", + mayReadAuthCache: false, + mayWriteAuthCache: !!cfg.code || !!overrides.fallbackCode, + provenance: { selectedBy: "cli", ship: "cli" }, + ...rest, + }; +} + +function makeDeps(resolved: CredentialResolution, options: { cookieValid?: boolean; freshCookie?: string } = {}) { + const configureCalls: unknown[] = []; + const cacheWrites: Array<{ url: string; ship: string; cookie: string }> = []; + + const deps: EnsureClientDeps = { + resolve: () => resolved, + configureClient: async (params) => { + configureCalls.push(params); + }, + createCookieClient: () => ({}) as any, + validateCookie: async () => options.cookieValid ?? true, + getAuthenticatedCookie: () => options.freshCookie ?? "urbauth-~zod=0v-fresh", + cacheCookie: (url, ship, cookie) => { + cacheWrites.push({ url, ship, cookie }); + }, + setupSubscriptions: async () => {}, + }; + + return { deps, configureCalls, cacheWrites }; +} + +const originalConsoleError = console.error; + +beforeEach(() => { + console.error = () => {}; +}); + +afterEach(() => { + console.error = originalConsoleError; + __resetApiClientForTests(); +}); + +describe("ensureClient auth/cache policy", () => { + it("uses provided cookie first and does not cache it when validation succeeds", async () => { + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback-code" }, + authKind: "cookie", + fallbackCode: "fallback-code", + mayWriteAuthCache: true, + }); + const { deps, configureCalls, cacheWrites } = makeDeps(resolved, { cookieValid: true }); + + await ensureClient([], deps); + + expect(configureCalls).toHaveLength(1); + expect(cacheWrites).toEqual([]); + }); + + it("uses fallback code after a provided cookie expires and caches the fresh cookie", async () => { + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback-code" }, + authKind: "cookie", + fallbackCode: "fallback-code", + mayWriteAuthCache: true, + }); + const { deps, configureCalls, cacheWrites } = makeDeps(resolved, { + cookieValid: false, + freshCookie: "urbauth-~zod=0v-fresh", + }); + + await ensureClient([], deps); + + expect(configureCalls).toHaveLength(2); + expect(cacheWrites).toEqual([ + { url: "https://zod.tlon.network", ship: "zod", cookie: "urbauth-~zod=0v-fresh" }, + ]); + }); + + it("does not cache provided-cookie flows without fresh code auth", async () => { + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cookie" }, + authKind: "cookie", + mayWriteAuthCache: false, + }); + const { deps, cacheWrites } = makeDeps(resolved, { cookieValid: true }); + + await ensureClient([], deps); + + expect(cacheWrites).toEqual([]); + }); + + it("uses source-aware errors for expired provided cookies without fallback code", async () => { + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cookie" }, + origin: "config-file", + authKind: "cookie", + mayWriteAuthCache: false, + provenance: { selectedBy: "env", ship: "cookie", configPath: "/tmp/zod.json" }, + }); + const { deps } = makeDeps(resolved, { cookieValid: false }); + + await expect(ensureClient([], deps)).rejects.toThrow("Cookie credentials for ~zod from config file /tmp/zod.json"); + }); + + it("identifies expired cached cookies as cache sourced", async () => { + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cache" }, + origin: "ship-cache", + authKind: "cached-cookie", + mayReadAuthCache: true, + mayWriteAuthCache: false, + provenance: { selectedBy: "env", ship: "cache", cachePath: "/tmp/cache/zod.json" }, + }); + const { deps } = makeDeps(resolved, { cookieValid: false }); + + await expect(ensureClient([], deps)).rejects.toThrow("Cached cookie for ~zod has expired"); + }); + + it("caches fresh cookies after code login", async () => { + const resolved = resolution({ + config: { code: "code" }, + authKind: "code", + mayWriteAuthCache: true, + }); + const { deps, configureCalls, cacheWrites } = makeDeps(resolved, { + freshCookie: "urbauth-~zod=0v-fresh", + }); + + await ensureClient([], deps); + + expect(configureCalls).toHaveLength(1); + expect(cacheWrites).toEqual([ + { url: "https://zod.tlon.network", ship: "zod", cookie: "urbauth-~zod=0v-fresh" }, + ]); + }); + + it("keeps config-file, skill-dir, and OpenClaw cookies out of cache when they validate", async () => { + for (const origin of ["config-file", "skill-dir", "openclaw"] as const) { + __resetApiClientForTests(); + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback" }, + origin, + authKind: "cookie", + fallbackCode: "fallback", + mayWriteAuthCache: true, + }); + const { deps, cacheWrites } = makeDeps(resolved, { cookieValid: true }); + + await ensureClient([], deps); + + expect(cacheWrites).toEqual([]); + } + }); + + it("uses fallback code and caches fresh cookies for expired file-backed cookies", async () => { + for (const origin of ["config-file", "skill-dir", "openclaw"] as const) { + __resetApiClientForTests(); + const resolved = resolution({ + config: { cookie: "urbauth-~zod=0v-cookie", code: "fallback" }, + origin, + authKind: "cookie", + fallbackCode: "fallback", + mayWriteAuthCache: true, + }); + const { deps, cacheWrites } = makeDeps(resolved, { + cookieValid: false, + freshCookie: "urbauth-~zod=0v-fresh", + }); + + await ensureClient([], deps); + + expect(cacheWrites).toEqual([ + { url: "https://zod.tlon.network", ship: "zod", cookie: "urbauth-~zod=0v-fresh" }, + ]); + } + }); + + it("reset hook clears initialized state for isolated tests", async () => { + const first = makeDeps(resolution({ config: { code: "first" }, authKind: "code", mayWriteAuthCache: true })); + await ensureClient([], first.deps); + + __resetApiClientForTests(); + + const second = makeDeps(resolution({ config: { code: "second" }, authKind: "code", mayWriteAuthCache: true })); + await ensureClient([], second.deps); + + expect(first.configureCalls).toHaveLength(1); + expect(second.configureCalls).toHaveLength(1); + }); +}); diff --git a/scripts/api-client.ts b/scripts/api-client.ts index 38f4706..eafd889 100644 --- a/scripts/api-client.ts +++ b/scripts/api-client.ts @@ -1,32 +1,56 @@ /** - * API client setup for CLI usage - * Config resolution + @tloncorp/api client initialization + * API client setup for CLI usage. + * Adapts real process/env/filesystem state into the credential resolver and + * initializes @tloncorp/api according to the resolver's auth/cache policy. */ import * as fs from "fs"; -import * as path from "path"; import * as os from "os"; -import { configureClient, preSig, subscribe, Urbit, client, scry } from "@tloncorp/api"; - -export interface UrbitConfig { - url: string; - ship: string; - /** Access code (required unless cookie is provided/cached) */ - code: string; - /** Pre-authenticated cookie (optional, bypasses code-based auth) */ - cookie?: string; -} - -interface CachedAuth { - url: string; - ship: string; - cookie: string; - cachedAt: number; -} +import * as path from "path"; +import { + client, + configureClient as configureApiClient, + internalRemoveClient, + preSig, + scry, + subscribe, + Urbit, +} from "@tloncorp/api"; +import { + type CachedAuth, + type CliCredentialOverrides, + type CredentialResolution, + type ResolverFileSystem, + type UrbitConfig, + getCachePath, + normalizeShipName, + readCachedEntryForShip, + readCachedShipCandidates, + resolveCredentials, +} from "./credential-resolver"; + +export type { + AuthKind, + CachedAuth, + CliCredentialOverrides, + CredentialOrigin, + CredentialResolution, + UrbitConfig, +} from "./credential-resolver"; + +type SubscriptionApp = "groups" | "channels" | "chat" | "lanyard"; let initialized = false; let subscribed = false; let cachedConfig: UrbitConfig | null = null; +let cachedResolution: CredentialResolution | null = null; +let cliCredentialOverrides: CliCredentialOverrides | null = null; + +const nodeFs: ResolverFileSystem = { + exists: (filePath) => fs.existsSync(filePath), + readFile: (filePath) => fs.readFileSync(filePath, "utf-8"), + readDir: (dirPath) => fs.readdirSync(dirPath), +}; function isVerbose(): boolean { const value = process.env.TLON_VERBOSE?.trim().toLowerCase(); @@ -39,392 +63,244 @@ function logSubscriptionUpdate(label: string, event: unknown): void { } /** - * Get the cache directory, configurable via TLON_CACHE_DIR env var + * Get the cache directory, configurable via TLON_CACHE_DIR env var. */ function getCacheDir(): string { return process.env.TLON_CACHE_DIR || path.join(os.homedir(), ".tlon", "cache"); } -// Track if user provided explicit credentials (for helpful warnings) -let userProvidedCode = false; -let userProvidedUrl = false; +export function setCliCredentialOverrides(overrides: CliCredentialOverrides | null): void { + cliCredentialOverrides = overrides; + cachedConfig = null; + cachedResolution = null; +} + +export function getCredentialResolution(): CredentialResolution { + if (cachedResolution) return cachedResolution; + + cachedResolution = resolveCredentials({ + env: process.env, + fs: nodeFs, + cacheDir: getCacheDir(), + homeDir: os.homedir(), + cli: cliCredentialOverrides, + }); + cachedConfig = cachedResolution.config; + return cachedResolution; +} /** - * Get path to cookie cache file for a ship + * Get config from CLI overrides, environment, config files, OpenClaw, or cache. */ -function getCachePath(ship: string): string { - return path.join(getCacheDir(), `${ship.replace(/^~/, "")}.json`); +export function getConfig(): UrbitConfig { + if (cachedConfig) return cachedConfig; + return getCredentialResolution().config; } /** - * Get all cached ship entries + * Get all valid cached ship entries. + * Invalid cache files are ignored for single-cache discovery, but duplicates fail. */ -function getCachedShips(): CachedAuth[] { - try { - const cacheDir = getCacheDir(); - if (!fs.existsSync(cacheDir)) return []; - - const files = fs.readdirSync(cacheDir).filter(f => f.endsWith(".json")); - const entries: CachedAuth[] = []; - - for (const file of files) { - try { - const data = JSON.parse(fs.readFileSync(path.join(cacheDir, file), "utf-8")); - if (data.url && data.ship && data.cookie) { - entries.push(data); - } - } catch { - // Skip invalid cache files - } - } - - return entries; - } catch { - return []; - } +export function getCachedShips(): CachedAuth[] { + return readCachedShipCandidates(getCacheDir(), nodeFs, { ignoreInvalid: true }).map( + ({ cachePath: _cachePath, ...entry }) => entry + ); } /** - * Get cached cookie for a ship+url combo + * Get cached cookie for a ship+url combo. + * Existing invalid, mismatched, or wrong-URL cache files fail clearly. */ -function getCachedCookie(url: string, ship: string): string | null { - try { - const cachePath = getCachePath(ship); - if (!fs.existsSync(cachePath)) return null; - - const data: CachedAuth = JSON.parse(fs.readFileSync(cachePath, "utf-8")); - - // Verify URL matches (don't use cookie meant for different host) - if (data.url !== url) return null; - - return data.cookie || null; - } catch { - return null; - } +export function getCachedCookie(url: string, ship: string): string | null { + const entry = readCachedEntryForShip(getCacheDir(), ship, nodeFs, url); + return entry?.cookie ?? null; } /** - * Get cached entry for a ship (any url) + * Get cached entry for a ship. + * Existing invalid or mismatched cache files fail clearly. */ -function getCachedEntry(ship: string): CachedAuth | null { - try { - const cachePath = getCachePath(ship); - if (!fs.existsSync(cachePath)) return null; - - const data: CachedAuth = JSON.parse(fs.readFileSync(cachePath, "utf-8")); - if (data.url && data.ship && data.cookie) { - return data; - } - return null; - } catch { - return null; - } +export function getCachedEntry(ship: string): CachedAuth | null { + const entry = readCachedEntryForShip(getCacheDir(), ship, nodeFs); + if (!entry) return null; + const { cachePath: _cachePath, ...cached } = entry; + return cached; } /** - * Cache cookie for future use + * Cache cookie for future use. */ function cacheCookie(url: string, ship: string, cookie: string): void { try { fs.mkdirSync(getCacheDir(), { recursive: true, mode: 0o700 }); - const cachePath = getCachePath(ship); + const normalizedShip = normalizeShipName(ship); + const cachePath = getCachePath(getCacheDir(), normalizedShip); const data: CachedAuth = { url, - ship: ship.replace(/^~/, ""), + ship: normalizedShip, cookie, cachedAt: Date.now(), }; fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), { mode: 0o600 }); } catch { - // Cache write failure is non-fatal - } -} - -/** - * Parse ship name from auth cookie. - * Cookie format: urbauth-~ship=0v... - */ -function parseShipFromCookie(cookie: string): string | null { - const match = cookie.match(/urbauth-~?([a-z-]+)=/); - return match ? match[1] : null; -} - -/** - * Try to read Tlon credentials from OpenClaw config - */ -function getConfigFromOpenClaw(): UrbitConfig | null { - const configPaths = [ - process.env.OPENCLAW_CONFIG, - path.join(os.homedir(), ".openclaw", "openclaw.yaml"), - path.join(os.homedir(), ".openclaw", "openclaw.json"), - path.join(os.homedir(), ".clawdbot", "moltbot.json"), - path.join(os.homedir(), ".moltbot", "moltbot.json"), - ].filter(Boolean) as string[]; - - for (const configPath of configPaths) { - try { - if (!fs.existsSync(configPath)) continue; - - const raw = fs.readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(raw); - - const tlon = parsed?.channels?.tlon; - if (tlon?.url && (tlon?.code || tlon?.cookie)) { - if (tlon.url.includes("${")) continue; - if (tlon.code?.includes("${") || tlon.cookie?.includes("${")) continue; - if (tlon.ship?.includes("${")) continue; - - let ship = tlon.ship?.replace(/^~/, ""); - if (!ship && tlon.cookie) { - ship = parseShipFromCookie(tlon.cookie); - } - if (!ship) continue; - - return { - url: tlon.url, - ship, - code: tlon.code || "", - cookie: tlon.cookie, - }; - } - } catch { - // Continue to next path - } + // Cache write failure is non-fatal. } - - return null; } /** - * Get config from ship file or environment - * - * Priority: - * 1. TLON_CONFIG_FILE env var (direct path to config file) - * 2. Cookie-based auth (URL + COOKIE, ship derived from cookie) - * 3. Cache lookup (if SHIP provided, checks TLON_CACHE_DIR or ~/.tlon/cache) - * 4. Code-based auth (URL + SHIP + CODE) - fallback if cache miss - * 5. TLON_SHIP + TLON_SKILL_DIR (loads ships/.json) - * 6. OpenClaw config (~/.openclaw/openclaw.yaml) - * 7. Cached ships (if exactly one cached, use it) + * Set up subscriptions required for trackedPoke to receive acks. + * Only subscribes to requested apps to minimize overhead. */ -export function getConfig(): UrbitConfig { - if (cachedConfig) return cachedConfig; - - const configFile = process.env.TLON_CONFIG_FILE; - if (configFile) { - cachedConfig = loadConfigFile(configFile); - return cachedConfig; - } - - const url = process.env.URBIT_URL || process.env.TLON_URL; - const shipEnv = process.env.URBIT_SHIP || process.env.TLON_SHIP; - const cookie = process.env.URBIT_COOKIE || process.env.TLON_COOKIE; - const code = process.env.URBIT_CODE || process.env.TLON_CODE; - - // Track what user provided for later warnings - userProvidedUrl = !!url; - userProvidedCode = !!code; - - // Cookie-based auth (URL + COOKIE) - if (url && cookie) { - const ship = shipEnv?.replace(/^~/, "") || parseShipFromCookie(cookie); - if (ship) { - cachedConfig = { url, ship, code: code || "", cookie }; - return cachedConfig; - } - } - - // Check cache first if ship is provided (before code-based auth) - // This allows passing URL/SHIP/CODE as fallback while preferring cached cookies - if (shipEnv) { - const cached = getCachedEntry(shipEnv.replace(/^~/, "")); - if (cached) { - cachedConfig = { url: cached.url, ship: cached.ship, code: code || "", cookie: cached.cookie }; - return cachedConfig; - } - } +async function setupSubscriptions(subs: SubscriptionApp[]): Promise { + if (subscribed) return; - // Code-based auth (URL + SHIP + CODE) - fallback if cache miss - if (url && shipEnv && code) { - cachedConfig = { url, ship: shipEnv.replace(/^~/, ""), code }; - return cachedConfig; + if (subs.includes("groups")) { + await subscribe({ app: "groups", path: "/v1/groups" }, (e) => logSubscriptionUpdate("groups", e)); } - // Ship + skill dir (loads ships/.json) - const skillDir = process.env.TLON_SKILL_DIR; - if (shipEnv && skillDir) { - cachedConfig = loadConfigFile(path.join(skillDir, "ships", `${shipEnv.replace(/^~/, "")}.json`)); - return cachedConfig; + if (subs.includes("channels")) { + await subscribe({ app: "channels", path: "/v4" }, (e) => logSubscriptionUpdate("channels", e)); } - // OpenClaw config - const openclawConfig = getConfigFromOpenClaw(); - if (openclawConfig) { - cachedConfig = openclawConfig; - return cachedConfig; + if (subs.includes("chat")) { + await subscribe({ app: "chat", path: "/v4" }, (e) => logSubscriptionUpdate("chat", e)); } - // Cached ships fallback - const cachedShips = getCachedShips(); - if (cachedShips.length === 1) { - const entry = cachedShips[0]; - cachedConfig = { url: entry.url, ship: entry.ship, code: "", cookie: entry.cookie }; - return cachedConfig; - } - if (cachedShips.length > 1) { - const shipList = cachedShips.map(s => ` ~${s.ship}`).join("\n"); - throw new Error( - `Multiple cached ships found. Specify which with --ship:\n${shipList}` - ); + if (subs.includes("lanyard")) { + await subscribe({ app: "lanyard", path: "/v1/records" }, (e) => logSubscriptionUpdate("lanyard", e)); } - throw new Error( - "Missing Urbit config. Either:\n" + - " - Use CLI flags: --config , or --url + --cookie, or --url + --ship + --code\n" + - " - Use --ship with a previously cached ship\n" + - " - Set URBIT_URL + URBIT_COOKIE, or URBIT_URL + URBIT_SHIP + URBIT_CODE\n" + - " - Configure Tlon channel in OpenClaw (~/.openclaw/openclaw.yaml)" - ); + subscribed = true; } -function loadConfigFile(filePath: string): UrbitConfig { - if (!fs.existsSync(filePath)) { - throw new Error(`Ship config not found: ${filePath}`); - } +function createCookieClient(cfg: UrbitConfig, cookie: string): Urbit { + const urbit = new Urbit(cfg.url); + urbit.cookie = cookie; + urbit.nodeId = preSig(cfg.ship); + return urbit; +} +async function validateConfiguredCookie(): Promise { try { - const content = fs.readFileSync(filePath, "utf-8"); - const data = JSON.parse(content); - - if (!data.url) { - throw new Error(`Invalid config: must have url`); - } - - if (!data.code && !data.cookie) { - throw new Error(`Invalid config: must have code or cookie`); - } - - let ship = data.ship?.replace(/^~/, ""); - if (!ship && data.cookie) { - ship = parseShipFromCookie(data.cookie); - } - if (!ship) { - throw new Error(`Invalid config: must have ship (or cookie with ship in name)`); - } - - return { - url: data.url, - ship, - code: data.code || "", - cookie: data.cookie, - }; + await scry({ app: "hood", path: "/kiln/pikes" }); + return true; } catch (err: any) { - if (err.message.includes("Invalid config") || err.message.includes("not found")) { - throw err; - } - throw new Error(`Failed to parse config ${filePath}: ${err.message}`); + const is401 = + err?.status === 401 || + (typeof err?.message === "string" && err.message.includes("401")); + return !is401; } } -/** - * Set up subscriptions required for trackedPoke to receive acks. - * Only subscribes to requested apps to minimize overhead. - */ -async function setupSubscriptions(subs: Array<'groups' | 'channels' | 'chat' | 'lanyard'>): Promise { - if (subscribed) return; - - if (subs.includes('groups')) { - await subscribe({ app: 'groups', path: '/v1/groups' }, (e) => logSubscriptionUpdate("groups", e)); +function getAuthenticatedCookie(): string | undefined { + try { + return client.cookie; + } catch { + return undefined; } +} - if (subs.includes('channels')) { - await subscribe({ app: 'channels', path: '/v4' }, (e) => logSubscriptionUpdate("channels", e)); - } +export interface EnsureClientDeps { + resolve: () => CredentialResolution; + configureClient: typeof configureApiClient; + createCookieClient: (cfg: UrbitConfig, cookie: string) => Urbit; + validateCookie: () => Promise; + getAuthenticatedCookie: () => string | undefined; + cacheCookie: (url: string, ship: string, cookie: string) => void; + setupSubscriptions: (subs: SubscriptionApp[]) => Promise; +} - if (subs.includes('chat')) { - await subscribe({ app: 'chat', path: '/v4' }, (e) => logSubscriptionUpdate("chat", e)); +const defaultEnsureClientDeps: EnsureClientDeps = { + resolve: getCredentialResolution, + configureClient: configureApiClient, + createCookieClient, + validateCookie: validateConfiguredCookie, + getAuthenticatedCookie, + cacheCookie, + setupSubscriptions, +}; + +function describeOrigin(resolution: CredentialResolution): string { + switch (resolution.origin) { + case "cli": + return "CLI flags"; + case "config-file": + return resolution.provenance.configPath + ? `config file ${resolution.provenance.configPath}` + : "config file"; + case "env-cookie": + case "env-code": + return "environment variables"; + case "skill-dir": + return resolution.provenance.configPath + ? `skill-dir config ${resolution.provenance.configPath}` + : "skill-dir config"; + case "openclaw": + return resolution.provenance.openclawPath + ? `OpenClaw config ${resolution.provenance.openclawPath}` + : "OpenClaw config"; + case "ship-cache": + case "single-cache": + return resolution.provenance.cachePath + ? `cache ${resolution.provenance.cachePath}` + : "cache"; } +} - if (subs.includes('lanyard')) { - await subscribe({ app: 'lanyard', path: '/v1/records' }, (e) => logSubscriptionUpdate("lanyard", e)); +function cookieValidationError(resolution: CredentialResolution): Error { + const ship = preSig(resolution.config.ship); + if (resolution.authKind === "cached-cookie") { + return new Error( + `Cached cookie for ${ship} has expired and no access code is available to re-authenticate. ` + + "Provide --url + --ship + --code to refresh credentials." + ); } - subscribed = true; + return new Error( + `Cookie credentials for ${ship} from ${describeOrigin(resolution)} failed validation ` + + "and no fallback code is available. Provide --code or set URBIT_CODE/TLON_CODE." + ); } /** * Ensure @tloncorp/api client is configured, connected, and subscribed. * Pass required subscription apps to minimize connection overhead. */ -export async function ensureClient(subs: Array<'groups' | 'channels' | 'chat' | 'lanyard'> = []): Promise { - const cfg = getConfig(); +export async function ensureClient( + subs: SubscriptionApp[] = [], + depsOverride: Partial = {} +): Promise { + const deps: EnsureClientDeps = { ...defaultEnsureClientDeps, ...depsOverride }; + const resolution = deps.resolve(); + const cfg = resolution.config; if (!initialized) { - // Determine cookie to use: explicit > cached > none (use code) - let cookieToUse = cfg.cookie || getCachedCookie(cfg.url, cfg.ship); - let usedCachedCookie = !cfg.cookie && !!cookieToUse; let didFreshAuth = false; - if (cookieToUse) { - // Cookie-based auth - const urbit = new Urbit(cfg.url); - urbit.cookie = cookieToUse; - urbit.nodeId = preSig(cfg.ship); - - await configureClient({ + if (cfg.cookie) { + await deps.configureClient({ shipName: cfg.ship, shipUrl: cfg.url, - client: urbit, - getCode: cfg.code ? async () => cfg.code : undefined, + client: deps.createCookieClient(cfg, cfg.cookie), + getCode: resolution.fallbackCode ? async () => resolution.fallbackCode as string : undefined, }); - // Validate the cached cookie with a lightweight probe. - // If it returns 401, the cookie has expired — re-authenticate using the - // access code when available, so callers never see the failure. - let cookieValid = true; - try { - await scry({ app: "hood", path: "/kiln/pikes" }); - } catch (err: any) { - const is401 = - err?.status === 401 || - (typeof err?.message === "string" && err.message.includes("401")); - if (is401) { - cookieValid = false; - } else { - // Non-auth error (network, app not running, etc.) — treat cookie as - // valid and let the actual command surface the real error. - } - } - + const cookieValid = await deps.validateCookie(); if (!cookieValid) { - if (!cfg.code) { - throw new Error( - `Cached cookie for ~${cfg.ship} has expired and no access code is available to re-authenticate. ` + - `Provide --code or set URBIT_CODE.` - ); + const fallbackCode = resolution.fallbackCode || cfg.code; + if (!fallbackCode) { + throw cookieValidationError(resolution); } - // Re-authenticate with the access code - await configureClient({ + await deps.configureClient({ shipName: cfg.ship, shipUrl: cfg.url, - getCode: async () => cfg.code, + getCode: async () => fallbackCode, }); didFreshAuth = true; - } else { - // Warn if user passed credentials that weren't needed - if (usedCachedCookie && userProvidedCode) { - const cachedShips = getCachedShips(); - if (cachedShips.length === 1) { - console.error(`Note: Using cached credentials for ~${cfg.ship}. You can just run: tlon `); - } else { - console.error(`Note: Using cached credentials for ~${cfg.ship}. You can just run: tlon --ship ~${cfg.ship} `); - } - } } } else if (cfg.code) { - // Code-based auth (first time) - await configureClient({ + await deps.configureClient({ shipName: cfg.ship, shipUrl: cfg.url, getCode: async () => cfg.code, @@ -434,30 +310,32 @@ export async function ensureClient(subs: Array<'groups' | 'channels' | 'chat' | throw new Error("No cookie or code available for authentication"); } - // Cache the cookie for future invocations - if (client.cookie) { - cacheCookie(cfg.url, cfg.ship, client.cookie); - - // Notify on first auth that credentials are now cached - if (didFreshAuth) { - const cachedShips = getCachedShips(); - if (cachedShips.length === 1) { - console.error(`Note: Credentials cached for ~${cfg.ship}. Next time just run: tlon `); - } else { - console.error(`Note: Credentials cached for ~${cfg.ship}. Next time run: tlon --ship ~${cfg.ship} `); - } + if (didFreshAuth && resolution.mayWriteAuthCache) { + const freshCookie = deps.getAuthenticatedCookie(); + if (freshCookie) { + deps.cacheCookie(cfg.url, cfg.ship, freshCookie); + console.error(`Note: Credentials cached for ${preSig(cfg.ship)}. Next time run: tlon --ship ${preSig(cfg.ship)} `); } } - await setupSubscriptions(subs); + await deps.setupSubscriptions(subs); initialized = true; } return cfg; } +export function __resetApiClientForTests(): void { + initialized = false; + subscribed = false; + cachedConfig = null; + cachedResolution = null; + cliCredentialOverrides = null; + internalRemoveClient(); +} + /** - * Get current ship name (with ~) + * Get current ship name (with ~). */ export async function getCurrentShip(): Promise { const cfg = await ensureClient([]); @@ -465,7 +343,7 @@ export async function getCurrentShip(): Promise { } /** - * Normalize ship name to include ~ + * Normalize ship name to include ~. */ export function normalizeShip(ship: string): string { return preSig(ship); diff --git a/scripts/cli-test-matrix.ts b/scripts/cli-test-matrix.ts index 21732a9..ae1006e 100644 --- a/scripts/cli-test-matrix.ts +++ b/scripts/cli-test-matrix.ts @@ -330,6 +330,11 @@ export const LITERAL_OPTION_LIKE_VALUE_CASES: CliCase[] = [ "create", "--description", ]), + authRequiredCase("groups create exact global-flag-name title reaches auth", [ + "groups", + "create", + "--ship", + ]), authRequiredCase("groups create-owned title option-like value reaches auth", [ "groups", "create-owned", diff --git a/scripts/credential-flags.test.ts b/scripts/credential-flags.test.ts new file mode 100644 index 0000000..cb590db --- /dev/null +++ b/scripts/credential-flags.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import { CredentialFlagError, parseGlobalCliOptions } from "./credential-flags"; + +describe("credential flag parsing", () => { + it("parses valid credential forms without mutating command args", () => { + expect(parseGlobalCliOptions(["--config", "ship.json", "contacts", "self"])).toEqual({ + args: ["contacts", "self"], + verbose: false, + credentialOverrides: { kind: "config", configFile: "ship.json" }, + }); + + expect(parseGlobalCliOptions(["--url", "https://zod", "--cookie", "urbauth-~zod=0v", "contacts"])).toEqual({ + args: ["contacts"], + verbose: false, + credentialOverrides: { + kind: "cookie", + url: "https://zod", + cookie: "urbauth-~zod=0v", + ship: undefined, + code: undefined, + }, + }); + + expect(parseGlobalCliOptions(["--url", "https://zod", "--ship", "~zod", "--code", "code", "contacts"])).toEqual({ + args: ["contacts"], + verbose: false, + credentialOverrides: { + kind: "code", + url: "https://zod", + ship: "~zod", + code: "code", + }, + }); + + expect(parseGlobalCliOptions(["--ship=~zod", "--verbose", "contacts"])).toEqual({ + args: ["contacts"], + verbose: true, + credentialOverrides: { kind: "ship", ship: "~zod" }, + }); + }); + + it("rejects partial or conflicting credential forms", () => { + for (const args of [ + ["--url", "https://zod", "contacts"], + ["--cookie", "urbauth-~zod=0v", "contacts"], + ["--ship", "~zod", "--code", "code", "contacts"], + ["--url", "https://zod", "--ship", "~zod", "contacts"], + ["--config", "ship.json", "--cookie", "urbauth-~zod=0v", "contacts"], + ]) { + expect(() => parseGlobalCliOptions(args)).toThrow(CredentialFlagError); + } + }); + + it("rejects duplicate, empty, and missing credential flag values", () => { + expect(() => parseGlobalCliOptions(["--ship", "~zod", "--ship", "~bus", "contacts"])).toThrow( + "Duplicate credential flag: --ship" + ); + expect(() => parseGlobalCliOptions(["--cookie=", "contacts"])).toThrow("Missing value for --cookie"); + expect(() => parseGlobalCliOptions(["--url"])).toThrow("Missing value for --url"); + expect(() => parseGlobalCliOptions(["--url", "--cookie", "urbauth-~zod=0v", "contacts"])).toThrow( + "Missing value for --url" + ); + expect(() => parseGlobalCliOptions(["--url", "contacts", "self"])).toThrow("Missing value for --url"); + }); + + it("leaves credential-like tokens after the command boundary untouched", () => { + expect(parseGlobalCliOptions(["groups", "create", "--ship"])).toEqual({ + args: ["groups", "create", "--ship"], + verbose: false, + credentialOverrides: null, + }); + + expect(parseGlobalCliOptions(["--verbose", "channels", "create", "~host/group", "--url"])).toEqual({ + args: ["channels", "create", "~host/group", "--url"], + verbose: true, + credentialOverrides: null, + }); + + expect(parseGlobalCliOptions(["contacts", "--config", "ship.json"])).toEqual({ + args: ["contacts", "--config", "ship.json"], + verbose: false, + credentialOverrides: null, + }); + }); +}); diff --git a/scripts/credential-flags.ts b/scripts/credential-flags.ts new file mode 100644 index 0000000..46a203a --- /dev/null +++ b/scripts/credential-flags.ts @@ -0,0 +1,141 @@ +import type { CliCredentialOverrides } from "./credential-resolver"; + +const CREDENTIAL_FLAGS = ["config", "url", "ship", "code", "cookie"] as const; +type CredentialFlag = (typeof CREDENTIAL_FLAGS)[number]; + +const DEFAULT_COMMANDS = new Set([ + "activity", + "channels", + "contacts", + "dms", + "expose", + "groups", + "hooks", + "messages", + "notebook", + "posts", + "settings", + "upload", +]); + +export class CredentialFlagError extends Error { + constructor(message: string) { + super(message); + this.name = "CredentialFlagError"; + } +} + +export interface ParsedGlobalCliOptions { + args: string[]; + verbose: boolean; + credentialOverrides: CliCredentialOverrides | null; +} + +function isCredentialFlagName(name: string): name is CredentialFlag { + return (CREDENTIAL_FLAGS as readonly string[]).includes(name); +} + +function flagLabel(flag: CredentialFlag): string { + return `--${flag}`; +} + +function invalidFormsMessage(): string { + return ( + "Invalid credential flags: use one of " + + "--config , --url --cookie [--ship ] [--code ], " + + "--url --ship --code , or --ship ." + ); +} + +function buildCredentialOverrides(flags: Partial>): CliCredentialOverrides | null { + const present = CREDENTIAL_FLAGS.filter((flag) => flags[flag] !== undefined); + if (present.length === 0) return null; + + const configFile = flags.config; + const url = flags.url; + const ship = flags.ship; + const code = flags.code; + const cookie = flags.cookie; + + if (configFile) { + if (present.length > 1) { + throw new CredentialFlagError( + "Invalid credential flags: --config cannot be combined with --url, --ship, --code, or --cookie." + ); + } + return { kind: "config", configFile }; + } + + if (url && cookie) { + return { kind: "cookie", url, cookie, ship, code }; + } + + if (url && ship && code) { + return { kind: "code", url, ship, code }; + } + + if (ship && !url && !code && !cookie) { + return { kind: "ship", ship }; + } + + throw new CredentialFlagError(invalidFormsMessage()); +} + +export function parseGlobalCliOptions( + rawArgs: string[], + knownCommands: Set = DEFAULT_COMMANDS +): ParsedGlobalCliOptions { + const flags: Partial> = {}; + const seen = new Set(); + const args: string[] = []; + let verbose = false; + + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + + if (arg === "--verbose") { + verbose = true; + continue; + } + + if (arg.startsWith("--")) { + const inlineMatch = arg.match(/^--([^=]+)=(.*)$/); + const flagName = inlineMatch ? inlineMatch[1] : arg.slice(2); + + if (isCredentialFlagName(flagName)) { + const flag = flagName; + if (seen.has(flag)) { + throw new CredentialFlagError(`Duplicate credential flag: ${flagLabel(flag)}`); + } + seen.add(flag); + + if (inlineMatch) { + const value = inlineMatch[2]; + if (value.length === 0) { + throw new CredentialFlagError(`Missing value for ${flagLabel(flag)}`); + } + flags[flag] = value; + continue; + } + + const value = rawArgs[i + 1]; + if (!value || value.startsWith("--") || knownCommands.has(value)) { + throw new CredentialFlagError(`Missing value for ${flagLabel(flag)}`); + } + + flags[flag] = value; + i += 1; + continue; + } + } + + args.push(...rawArgs.slice(i)); + break; + } + + return { + args, + verbose, + credentialOverrides: buildCredentialOverrides(flags), + }; +} diff --git a/scripts/credential-resolver.test.ts b/scripts/credential-resolver.test.ts new file mode 100644 index 0000000..5729655 --- /dev/null +++ b/scripts/credential-resolver.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "path"; +import { + type CredentialResolverInput, + type ResolverFileSystem, + getCachePath, + readCachedEntryForShip, + resolveCredentials, +} from "./credential-resolver"; + +const HOME = "/tmp/tlon-home"; +const CACHE_DIR = "/tmp/tlon-cache"; +const SKILL_DIR = "/tmp/tlon-skill-dir"; + +function json(value: unknown): string { + return JSON.stringify(value); +} + +function memoryFs(files: Record): ResolverFileSystem { + return { + exists(filePath) { + if (files[filePath] !== undefined) return true; + return Object.keys(files).some((entry) => path.dirname(entry) === filePath); + }, + readFile(filePath) { + const value = files[filePath]; + if (value === undefined) { + throw new Error(`missing file: ${filePath}`); + } + return value; + }, + readDir(dirPath) { + return Object.keys(files) + .filter((entry) => path.dirname(entry) === dirPath) + .map((entry) => path.basename(entry)); + }, + }; +} + +function baseInput( + files: Record = {}, + env: Record = {} +): CredentialResolverInput { + return { + env, + fs: memoryFs(files), + cacheDir: CACHE_DIR, + homeDir: HOME, + }; +} + +function cacheFile(ship: string, url = `https://${ship}.tlon.network`, cookie = `urbauth-~${ship}=0v-cache`) { + return json({ url, ship, cookie, cachedAt: 1 }); +} + +describe("credential resolver", () => { + it("lets CLI URL+cookie beat ambient TLON_CONFIG_FILE", () => { + const result = resolveCredentials({ + ...baseInput({}, { TLON_CONFIG_FILE: "/tmp/missing.json" }), + cli: { + kind: "cookie", + url: "https://cli.tlon.network", + cookie: "urbauth-~zod=0v-cookie", + }, + }); + + expect(result.origin).toBe("cli"); + expect(result.authKind).toBe("cookie"); + expect(result.config).toMatchObject({ url: "https://cli.tlon.network", ship: "zod" }); + expect(result.mayReadAuthCache).toBe(false); + }); + + it("lets CLI --ship beat ambient URBIT_SHIP for targeted cache lookup", () => { + const files = { + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod"), + [getCachePath(CACHE_DIR, "bus")]: cacheFile("bus"), + }; + + const result = resolveCredentials({ + ...baseInput(files, { URBIT_SHIP: "~bus" }), + cli: { kind: "ship", ship: "~zod" }, + }); + + expect(result.origin).toBe("ship-cache"); + expect(result.config.ship).toBe("zod"); + expect(result.authKind).toBe("cached-cookie"); + expect(result.provenance.selectedBy).toBe("cli"); + }); + + it("lets CLI --ship plus TLON_SKILL_DIR beat ambient TLON_CONFIG_FILE and ship cache", () => { + const files = { + "/tmp/env-config.json": json({ + url: "https://env.tlon.network", + cookie: "urbauth-~env=0v-cookie", + }), + [path.join(SKILL_DIR, "ships", "zod.json")]: json({ + url: "https://skill.tlon.network", + cookie: "urbauth-~zod=0v-skill", + }), + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod", "https://cache.tlon.network"), + }; + + const result = resolveCredentials({ + ...baseInput(files, { TLON_CONFIG_FILE: "/tmp/env-config.json", TLON_SKILL_DIR: SKILL_DIR }), + cli: { kind: "ship", ship: "~zod" }, + }); + + expect(result.origin).toBe("skill-dir"); + expect(result.config.url).toBe("https://skill.tlon.network"); + expect(result.provenance.ship).toBe("cli"); + expect(result.mayReadAuthCache).toBe(false); + }); + + it("lets CLI --ship use cache when TLON_SKILL_DIR is set but the ship file is missing", () => { + const files = { + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod", "https://cache.tlon.network"), + }; + + const result = resolveCredentials({ + ...baseInput(files, { TLON_SKILL_DIR: SKILL_DIR }), + cli: { kind: "ship", ship: "~zod" }, + }); + + expect(result.origin).toBe("ship-cache"); + expect(result.config.url).toBe("https://cache.tlon.network"); + expect(result.provenance.selectedBy).toBe("cli"); + }); + + it("fails when CLI --ship finds an invalid TLON_SKILL_DIR file even if cache exists", () => { + const files = { + [path.join(SKILL_DIR, "ships", "zod.json")]: json({ url: "https://skill.tlon.network" }), + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod", "https://cache.tlon.network"), + }; + + expect(() => + resolveCredentials({ + ...baseInput(files, { TLON_SKILL_DIR: SKILL_DIR }), + cli: { kind: "ship", ship: "~zod" }, + }) + ).toThrow("Invalid config"); + }); + + it("preserves URBIT_* alias precedence while allowing mixed aliases for cookie auth", () => { + const result = resolveCredentials( + baseInput({}, { + URBIT_URL: "https://urbit-url.tlon.network", + TLON_URL: "https://tlon-url.tlon.network", + TLON_COOKIE: "urbauth-~zod=0v-cookie", + }) + ); + + expect(result.origin).toBe("env-cookie"); + expect(result.config.url).toBe("https://urbit-url.tlon.network"); + expect(result.config.ship).toBe("zod"); + }); + + it("preserves mixed aliases for URL + ship + code auth", () => { + const result = resolveCredentials( + baseInput({}, { + TLON_URL: "https://tlon-url.tlon.network", + URBIT_SHIP: "~bus", + TLON_CODE: "code-from-tlon", + }) + ); + + expect(result.origin).toBe("env-code"); + expect(result.authKind).toBe("code"); + expect(result.config).toMatchObject({ + url: "https://tlon-url.tlon.network", + ship: "bus", + code: "code-from-tlon", + }); + }); + + it("fails partial ambient credential env instead of falling through", () => { + expect(() => resolveCredentials(baseInput({}, { URBIT_URL: "https://zod.tlon.network" }))).toThrow( + "Invalid ambient credentials" + ); + expect(() => resolveCredentials(baseInput({}, { TLON_COOKIE: "urbauth-~zod=0v-cookie" }))).toThrow( + "Invalid ambient credentials" + ); + expect(() => resolveCredentials(baseInput({}, { TLON_SHIP: "~zod", TLON_CODE: "code" }))).toThrow( + "Invalid ambient credentials" + ); + }); + + it("allows ship-only ambient env for targeted cache lookup", () => { + const files = { [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod") }; + + const result = resolveCredentials(baseInput(files, { TLON_SHIP: "~zod" })); + + expect(result.origin).toBe("ship-cache"); + expect(result.config.ship).toBe("zod"); + expect(result.provenance.selectedBy).toBe("env"); + }); + + it("loads TLON_CONFIG_FILE with cookie credentials", () => { + const files = { + "/tmp/zod.json": json({ url: "https://zod.tlon.network", cookie: "urbauth-~zod=0v-cookie" }), + }; + + const result = resolveCredentials(baseInput(files, { TLON_CONFIG_FILE: "/tmp/zod.json" })); + + expect(result.origin).toBe("config-file"); + expect(result.authKind).toBe("cookie"); + expect(result.config.ship).toBe("zod"); + }); + + it("does not read cache when TLON_CONFIG_FILE provides code credentials", () => { + const files = { + "/tmp/zod.json": json({ url: "https://zod.tlon.network", ship: "~zod", code: "code" }), + [getCachePath(CACHE_DIR, "zod")]: "not json", + }; + + const result = resolveCredentials(baseInput(files, { TLON_CONFIG_FILE: "/tmp/zod.json" })); + + expect(result.origin).toBe("config-file"); + expect(result.authKind).toBe("code"); + expect(result.mayReadAuthCache).toBe(false); + }); + + it("derives ship from URL+cookie and lets explicit ship override the cookie ship", () => { + const derived = resolveCredentials( + baseInput({}, { + URBIT_URL: "https://zod.tlon.network", + URBIT_COOKIE: "urbauth-~zod=0v-cookie", + }) + ); + const explicit = resolveCredentials({ + ...baseInput(), + cli: { + kind: "cookie", + url: "https://bus.tlon.network", + ship: "~bus", + cookie: "urbauth-~zod=0v-cookie", + }, + }); + + expect(derived.config.ship).toBe("zod"); + expect(derived.provenance.ship).toBe("cookie"); + expect(explicit.config.ship).toBe("bus"); + expect(explicit.provenance.ship).toBe("cli"); + }); + + it("does not read cache for URL + ship + code credentials", () => { + const files = { [getCachePath(CACHE_DIR, "zod")]: "not json" }; + + const result = resolveCredentials( + baseInput(files, { + URBIT_URL: "https://zod.tlon.network", + URBIT_SHIP: "~zod", + URBIT_CODE: "code", + }) + ); + + expect(result.origin).toBe("env-code"); + expect(result.authKind).toBe("code"); + expect(result.mayReadAuthCache).toBe(false); + }); + + it("records fallback code for explicit cookie credentials", () => { + const result = resolveCredentials({ + ...baseInput(), + cli: { + kind: "cookie", + url: "https://zod.tlon.network", + cookie: "urbauth-~zod=0v-cookie", + code: "fallback-code", + }, + }); + + expect(result.authKind).toBe("cookie"); + expect(result.fallbackCode).toBe("fallback-code"); + expect(result.mayWriteAuthCache).toBe(true); + }); + + it("lets TLON_SHIP + TLON_SKILL_DIR beat an existing cache entry", () => { + const files = { + [path.join(SKILL_DIR, "ships", "zod.json")]: json({ + url: "https://skill.tlon.network", + cookie: "urbauth-~zod=0v-skill", + }), + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod", "https://cache.tlon.network"), + }; + + const result = resolveCredentials(baseInput(files, { TLON_SHIP: "~zod", TLON_SKILL_DIR: SKILL_DIR })); + + expect(result.origin).toBe("skill-dir"); + expect(result.config.url).toBe("https://skill.tlon.network"); + }); + + it("lets TLON_SHIP use cache when TLON_SKILL_DIR is set but the ship file is missing", () => { + const files = { + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod", "https://cache.tlon.network"), + }; + + const result = resolveCredentials(baseInput(files, { TLON_SHIP: "~zod", TLON_SKILL_DIR: SKILL_DIR })); + + expect(result.origin).toBe("ship-cache"); + expect(result.config.url).toBe("https://cache.tlon.network"); + expect(result.provenance.selectedBy).toBe("env"); + }); + + it("loads OpenClaw JSON fallback and preserves legacy JSON fallbacks", () => { + const openclawJson = path.join(HOME, ".openclaw", "openclaw.json"); + const clawdbotLegacyJson = path.join(HOME, ".clawdbot", "moltbot.json"); + const moltbotLegacyJson = path.join(HOME, ".moltbot", "moltbot.json"); + + const direct = resolveCredentials( + baseInput({ + [openclawJson]: json({ + channels: { tlon: { url: "https://zod.tlon.network", cookie: "urbauth-~zod=0v-openclaw" } }, + }), + }) + ); + const clawdbotLegacy = resolveCredentials( + baseInput({ + [clawdbotLegacyJson]: json({ + channels: { tlon: { url: "https://bus.tlon.network", ship: "~bus", code: "legacy-code" } }, + }), + }) + ); + const moltbotLegacy = resolveCredentials( + baseInput({ + [moltbotLegacyJson]: json({ + channels: { tlon: { url: "https://nec.tlon.network", ship: "~nec", code: "moltbot-code" } }, + }), + }) + ); + + expect(direct.origin).toBe("openclaw"); + expect(direct.authKind).toBe("cookie"); + expect(clawdbotLegacy.origin).toBe("openclaw"); + expect(clawdbotLegacy.config.ship).toBe("bus"); + expect(moltbotLegacy.origin).toBe("openclaw"); + expect(moltbotLegacy.config.ship).toBe("nec"); + }); + + it("skips unusable default OpenClaw JSON and continues to later defaults", () => { + const files = { + [path.join(HOME, ".openclaw", "openclaw.json")]: json({ channels: {} }), + [path.join(HOME, ".clawdbot", "moltbot.json")]: json({ + channels: { tlon: { url: "https://bus.tlon.network", ship: "~bus", code: "legacy-code" } }, + }), + }; + + const result = resolveCredentials(baseInput(files)); + + expect(result.origin).toBe("openclaw"); + expect(result.provenance.openclawPath).toBe(path.join(HOME, ".clawdbot", "moltbot.json")); + expect(result.config.ship).toBe("bus"); + }); + + it("treats explicit OPENCLAW_CONFIG parse errors and unusable JSON as local errors", () => { + expect(() => + resolveCredentials(baseInput({ "/tmp/openclaw.txt": "not json" }, { OPENCLAW_CONFIG: "/tmp/openclaw.txt" })) + ).toThrow("Failed to parse OpenClaw config"); + + expect(() => + resolveCredentials( + baseInput({ "/tmp/openclaw.json": json({ channels: {} }) }, { OPENCLAW_CONFIG: "/tmp/openclaw.json" }) + ) + ).toThrow("missing channels.tlon"); + + expect(() => + resolveCredentials( + baseInput( + { "/tmp/openclaw.json": json({ channels: { tlon: { url: "${TLON_URL}", ship: "~zod", code: "code" } } }) }, + { OPENCLAW_CONFIG: "/tmp/openclaw.json" } + ) + ) + ).toThrow("placeholder"); + + expect(() => + resolveCredentials( + baseInput( + { "/tmp/openclaw.json": json({ channels: { tlon: { url: "https://zod.tlon.network", code: "code" } } }) }, + { OPENCLAW_CONFIG: "/tmp/openclaw.json" } + ) + ) + ).toThrow("must have tlon.ship"); + + expect(() => + resolveCredentials( + baseInput( + { "/tmp/openclaw.json": json({ channels: { tlon: { url: "https://zod.tlon.network", ship: "~zod" } } }) }, + { OPENCLAW_CONFIG: "/tmp/openclaw.json" } + ) + ) + ).toThrow("must have tlon.code or tlon.cookie"); + }); + + it("does not let stale default YAML mask the real OpenClaw JSON path", () => { + const files = { + [path.join(HOME, ".openclaw", "openclaw.yaml")]: "not json", + [path.join(HOME, ".openclaw", "openclaw.json")]: json({ + channels: { tlon: { url: "https://zod.tlon.network", ship: "~zod", code: "json-code" } }, + }), + }; + + const result = resolveCredentials(baseInput(files)); + + expect(result.origin).toBe("openclaw"); + expect(result.config.code).toBe("json-code"); + }); + + it("fails targeted cache lookup when the cache file is invalid or mismatched", () => { + expect(() => readCachedEntryForShip(CACHE_DIR, "~zod", memoryFs({ [getCachePath(CACHE_DIR, "zod")]: "not json" }))).toThrow( + "Invalid cache entry" + ); + expect(() => + readCachedEntryForShip( + CACHE_DIR, + "~zod", + memoryFs({ [getCachePath(CACHE_DIR, "zod")]: json({ url: "https://zod.tlon.network", ship: "zod" }) }) + ) + ).toThrow("must have url, ship, and cookie"); + expect(() => + readCachedEntryForShip( + CACHE_DIR, + "~zod", + memoryFs({ [getCachePath(CACHE_DIR, "zod")]: json({ url: "https://zod.tlon.network", ship: "bus", cookie: "c" }) }) + ) + ).toThrow("does not match"); + expect(() => + readCachedEntryForShip( + CACHE_DIR, + "~zod", + memoryFs({ [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod", "https://old.tlon.network") }), + "https://new.tlon.network" + ) + ).toThrow("stored URL"); + }); + + it("auto-selects exactly one cached ship and errors on multiple or duplicate cached ships", () => { + const single = resolveCredentials(baseInput({ [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod") })); + expect(single.origin).toBe("single-cache"); + + const singleWithInvalid = resolveCredentials( + baseInput({ + [path.join(CACHE_DIR, "invalid.json")]: json({ url: "https://invalid.tlon.network", ship: "invalid" }), + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod"), + }) + ); + expect(singleWithInvalid.origin).toBe("single-cache"); + expect(singleWithInvalid.config.ship).toBe("zod"); + + expect(() => + resolveCredentials( + baseInput({ + [getCachePath(CACHE_DIR, "zod")]: cacheFile("zod"), + [getCachePath(CACHE_DIR, "bus")]: cacheFile("bus"), + }) + ) + ).toThrow("Multiple cached ships"); + + expect(() => + resolveCredentials( + baseInput({ + [path.join(CACHE_DIR, "zod.json")]: cacheFile("zod"), + [path.join(CACHE_DIR, "~zod.json")]: cacheFile("~zod"), + }) + ) + ).toThrow("Duplicate cached credentials"); + }); + + it("reports missing config when no source resolves", () => { + expect(() => resolveCredentials(baseInput())).toThrow("Missing Urbit config"); + }); +}); diff --git a/scripts/credential-resolver.ts b/scripts/credential-resolver.ts new file mode 100644 index 0000000..2cc617e --- /dev/null +++ b/scripts/credential-resolver.ts @@ -0,0 +1,587 @@ +import * as path from "path"; + +export interface UrbitConfig { + url: string; + ship: string; + /** Access code (required unless cookie is provided/cached) */ + code: string; + /** Pre-authenticated cookie (optional, bypasses code-based auth) */ + cookie?: string; +} + +export interface CachedAuth { + url: string; + ship: string; + cookie: string; + cachedAt?: number; +} + +export type CredentialOrigin = + | "cli" + | "config-file" + | "env-cookie" + | "env-code" + | "skill-dir" + | "openclaw" + | "ship-cache" + | "single-cache"; + +export type AuthKind = "cookie" | "code" | "cached-cookie"; + +export type ShipProvenance = "cli" | "env" | "cookie" | "config-file" | "skill-dir" | "openclaw" | "cache"; + +export interface CredentialResolution { + config: UrbitConfig; + origin: CredentialOrigin; + authKind: AuthKind; + mayReadAuthCache: boolean; + mayWriteAuthCache: boolean; + fallbackCode?: string; + provenance: { + selectedBy?: "cli" | "env" | "fallback"; + ship?: ShipProvenance; + configPath?: string; + cachePath?: string; + openclawPath?: string; + }; +} + +export type CliCredentialOverrides = + | { kind: "config"; configFile: string } + | { kind: "cookie"; url: string; cookie: string; ship?: string; code?: string } + | { kind: "code"; url: string; ship: string; code: string } + | { kind: "ship"; ship: string }; + +export interface ResolverFileSystem { + exists(filePath: string): boolean; + readFile(filePath: string): string; + readDir(dirPath: string): string[]; +} + +export interface CredentialResolverInput { + env: Record; + fs: ResolverFileSystem; + cacheDir: string; + homeDir: string; + cli?: CliCredentialOverrides | null; + openclawDefaultPaths?: string[]; +} + +interface CachedAuthCandidate extends CachedAuth { + cachePath: string; +} + +type TopLevelConfigSource = "config-file" | "skill-dir"; + +function nonEmpty(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +export function normalizeShipName(ship: string): string { + return ship.replace(/^~/, ""); +} + +function withSig(ship: string): string { + return `~${normalizeShipName(ship)}`; +} + +export function parseShipFromCookie(cookie: string): string | null { + const match = cookie.match(/urbauth-~?([a-z-]+)=/); + return match ? match[1] : null; +} + +function parseJsonFile(filePath: string, fsDeps: ResolverFileSystem): unknown { + try { + return JSON.parse(fsDeps.readFile(filePath)); + } catch (err: any) { + throw new Error(`Failed to parse config ${filePath}: ${err.message}`); + } +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function makeResolution( + config: UrbitConfig, + origin: CredentialOrigin, + authKind: AuthKind, + provenance: CredentialResolution["provenance"], + fallbackCode?: string +): CredentialResolution { + return { + config, + origin, + authKind, + mayReadAuthCache: authKind === "cached-cookie", + mayWriteAuthCache: authKind === "code" || !!fallbackCode, + fallbackCode, + provenance, + }; +} + +function resolutionFromTopLevelConfig( + filePath: string, + fsDeps: ResolverFileSystem, + origin: TopLevelConfigSource, + selectedBy: "cli" | "env", + shipProvenance: ShipProvenance = origin, + forcedShip?: string +): CredentialResolution { + if (!fsDeps.exists(filePath)) { + throw new Error(`Ship config not found: ${filePath}`); + } + + const data = asObject(parseJsonFile(filePath, fsDeps)); + if (!nonEmpty(data.url)) { + throw new Error("Invalid config: must have url"); + } + + const code = nonEmpty(data.code) ? data.code : ""; + const cookie = nonEmpty(data.cookie) ? data.cookie : undefined; + if (!code && !cookie) { + throw new Error("Invalid config: must have code or cookie"); + } + + let ship = forcedShip ? normalizeShipName(forcedShip) : nonEmpty(data.ship) ? normalizeShipName(data.ship) : null; + if (forcedShip && nonEmpty(data.ship) && normalizeShipName(data.ship) !== ship) { + throw new Error(`Invalid config: ship ${withSig(data.ship)} does not match requested ship ${withSig(forcedShip)}`); + } + let derivedShip = false; + if (!ship && cookie) { + ship = parseShipFromCookie(cookie); + derivedShip = !!ship; + } + if (!ship) { + throw new Error("Invalid config: must have ship (or cookie with ship in name)"); + } + + const config = { + url: data.url, + ship, + code, + cookie, + }; + const authKind: AuthKind = cookie ? "cookie" : "code"; + return makeResolution( + config, + origin, + authKind, + { + selectedBy, + ship: forcedShip ? shipProvenance : derivedShip ? "cookie" : shipProvenance, + configPath: filePath, + }, + cookie && code ? code : undefined + ); +} + +function envAlias( + env: Record, + urbitName: string, + tlonName: string +): string | undefined { + return nonEmpty(env[urbitName]) ? env[urbitName] : nonEmpty(env[tlonName]) ? env[tlonName] : undefined; +} + +function validateAmbientPartial( + url: string | undefined, + ship: string | undefined, + cookie: string | undefined, + code: string | undefined +): void { + const hasAnyCredential = !!url || !!ship || !!cookie || !!code; + if (!hasAnyCredential) return; + + const shipOnly = !!ship && !url && !cookie && !code; + const cookieForm = !!url && !!cookie; + const codeForm = !!url && !!ship && !!code; + if (shipOnly || cookieForm || codeForm) return; + + throw new Error( + "Invalid ambient credentials: use URBIT_URL/TLON_URL with URBIT_COOKIE/TLON_COOKIE, " + + "or URL + SHIP + CODE. Ship-only env is only valid for skill-dir/cache lookup." + ); +} + +function resolutionFromCookie( + origin: CredentialOrigin, + url: string, + cookie: string, + ship: string | undefined, + code: string | undefined, + selectedBy: "cli" | "env", + explicitShipProvenance: ShipProvenance +): CredentialResolution { + const derivedShip = ship ? normalizeShipName(ship) : parseShipFromCookie(cookie); + if (!derivedShip) { + throw new Error("Invalid config: must have ship (or cookie with ship in name)"); + } + + const fallbackCode = code || undefined; + return makeResolution( + { + url, + ship: derivedShip, + code: fallbackCode || "", + cookie, + }, + origin, + "cookie", + { + selectedBy, + ship: ship ? explicitShipProvenance : "cookie", + }, + fallbackCode + ); +} + +function resolutionFromCode( + origin: CredentialOrigin, + url: string, + ship: string, + code: string, + selectedBy: "cli" | "env", + shipProvenance: ShipProvenance +): CredentialResolution { + return makeResolution( + { + url, + ship: normalizeShipName(ship), + code, + }, + origin, + "code", + { + selectedBy, + ship: shipProvenance, + } + ); +} + +export function getDefaultOpenClawConfigPaths(homeDir: string): string[] { + return [ + path.join(homeDir, ".openclaw", "openclaw.json"), + path.join(homeDir, ".clawdbot", "moltbot.json"), + path.join(homeDir, ".moltbot", "moltbot.json"), + ]; +} + +function hasPlaceholder(value: string | undefined): boolean { + return !!value && (value.includes("${") || /^<[^>]+>$/.test(value)); +} + +function parseOpenClawConfig( + filePath: string, + fsDeps: ResolverFileSystem +): CredentialResolution { + let parsed: unknown; + try { + parsed = JSON.parse(fsDeps.readFile(filePath)); + } catch (err: any) { + throw new Error(`Failed to parse OpenClaw config ${filePath}: ${err.message}`); + } + + const root = asObject(parsed); + const channels = asObject(root.channels); + const tlon = asObject(channels.tlon); + + if (!root.channels || !channels.tlon) { + throw new Error(`OpenClaw config unusable: missing channels.tlon in ${filePath}`); + } + + const url = nonEmpty(tlon.url) ? tlon.url : undefined; + const code = nonEmpty(tlon.code) ? tlon.code : ""; + const cookie = nonEmpty(tlon.cookie) ? tlon.cookie : undefined; + const rawShip = nonEmpty(tlon.ship) ? tlon.ship : undefined; + + if (hasPlaceholder(url) || hasPlaceholder(code) || hasPlaceholder(cookie) || hasPlaceholder(rawShip)) { + throw new Error(`OpenClaw config unusable: contains placeholder values in ${filePath}`); + } + if (!url) { + throw new Error(`OpenClaw config unusable: missing tlon.url in ${filePath}`); + } + if (!code && !cookie) { + throw new Error(`OpenClaw config unusable: must have tlon.code or tlon.cookie in ${filePath}`); + } + + let ship = rawShip ? normalizeShipName(rawShip) : null; + let derivedShip = false; + if (!ship && cookie) { + ship = parseShipFromCookie(cookie); + derivedShip = !!ship; + } + if (!ship) { + throw new Error(`OpenClaw config unusable: must have tlon.ship or cookie ship in ${filePath}`); + } + + return makeResolution( + { + url, + ship, + code, + cookie, + }, + "openclaw", + cookie ? "cookie" : "code", + { + selectedBy: "fallback", + ship: derivedShip ? "cookie" : "openclaw", + openclawPath: filePath, + }, + cookie && code ? code : undefined + ); +} + +function resolveOpenClaw(input: CredentialResolverInput): CredentialResolution | null { + const explicitPath = input.env.OPENCLAW_CONFIG; + if (nonEmpty(explicitPath)) { + if (!input.fs.exists(explicitPath)) return null; + return parseOpenClawConfig(explicitPath, input.fs); + } + + const paths = input.openclawDefaultPaths ?? getDefaultOpenClawConfigPaths(input.homeDir); + for (const configPath of paths) { + if (!input.fs.exists(configPath)) continue; + try { + return parseOpenClawConfig(configPath, input.fs); + } catch { + // Default OpenClaw discovery is best-effort. Explicit OPENCLAW_CONFIG is authoritative. + } + } + + return null; +} + +export function getCachePath(cacheDir: string, ship: string): string { + return path.join(cacheDir, `${normalizeShipName(ship)}.json`); +} + +function cacheError(message: string): Error { + return new Error(`Invalid cache entry: ${message}`); +} + +function parseCacheFile( + filePath: string, + raw: string, + expected?: { ship?: string; url?: string } +): CachedAuthCandidate { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err: any) { + throw cacheError(`${filePath} is not valid JSON: ${err.message}`); + } + + const data = asObject(parsed); + if (!nonEmpty(data.url) || !nonEmpty(data.ship) || !nonEmpty(data.cookie)) { + throw cacheError(`${filePath} must have url, ship, and cookie`); + } + + const storedShip = normalizeShipName(data.ship); + const fileShip = normalizeShipName(path.basename(filePath, ".json")); + if (fileShip !== storedShip) { + throw cacheError(`${filePath} filename ship ${withSig(fileShip)} does not match stored ship ${withSig(storedShip)}`); + } + + if (expected?.ship && storedShip !== normalizeShipName(expected.ship)) { + throw cacheError(`${filePath} stored ship ${withSig(storedShip)} does not match requested ship ${withSig(expected.ship)}`); + } + + if (expected?.url && data.url !== expected.url) { + throw cacheError(`${filePath} stored URL ${data.url} does not match requested URL ${expected.url}`); + } + + return { + url: data.url, + ship: storedShip, + cookie: data.cookie, + cachedAt: typeof data.cachedAt === "number" ? data.cachedAt : undefined, + cachePath: filePath, + }; +} + +export function readCachedEntryForShip( + cacheDir: string, + ship: string, + fsDeps: ResolverFileSystem, + expectedUrl?: string +): CachedAuthCandidate | null { + const normalizedShip = normalizeShipName(ship); + const cachePath = getCachePath(cacheDir, normalizedShip); + if (!fsDeps.exists(cachePath)) return null; + return parseCacheFile(cachePath, fsDeps.readFile(cachePath), { + ship: normalizedShip, + url: expectedUrl, + }); +} + +export function readCachedShipCandidates( + cacheDir: string, + fsDeps: ResolverFileSystem, + options: { ignoreInvalid?: boolean } = {} +): CachedAuthCandidate[] { + if (!fsDeps.exists(cacheDir)) return []; + + let files: string[]; + try { + files = fsDeps.readDir(cacheDir).filter((file) => file.endsWith(".json")).sort(); + } catch { + return []; + } + + const candidates: CachedAuthCandidate[] = []; + const seenShips = new Map(); + + for (const file of files) { + const filePath = path.join(cacheDir, file); + let candidate: CachedAuthCandidate; + try { + candidate = parseCacheFile(filePath, fsDeps.readFile(filePath)); + } catch (err) { + if (options.ignoreInvalid) continue; + throw err; + } + + const previousPath = seenShips.get(candidate.ship); + if (previousPath) { + throw new Error( + `Duplicate cached credentials for ${withSig(candidate.ship)}: ${previousPath} and ${candidate.cachePath}` + ); + } + seenShips.set(candidate.ship, candidate.cachePath); + candidates.push(candidate); + } + + return candidates; +} + +function resolutionFromCachedEntry( + entry: CachedAuthCandidate, + origin: "ship-cache" | "single-cache", + selectedBy: "cli" | "env" | "fallback" = origin === "ship-cache" ? "env" : "fallback" +): CredentialResolution { + return makeResolution( + { + url: entry.url, + ship: entry.ship, + code: "", + cookie: entry.cookie, + }, + origin, + "cached-cookie", + { + selectedBy, + ship: "cache", + cachePath: entry.cachePath, + } + ); +} + +function resolveShipOnly( + input: CredentialResolverInput, + ship: string, + selectedBy: "cli" | "env", + terminalOnCacheMiss: boolean +): CredentialResolution | null { + const normalizedShip = normalizeShipName(ship); + const skillDir = input.env.TLON_SKILL_DIR; + if (nonEmpty(skillDir)) { + const skillConfigPath = path.join(skillDir, "ships", `${normalizedShip}.json`); + if (input.fs.exists(skillConfigPath)) { + return resolutionFromTopLevelConfig( + skillConfigPath, + input.fs, + "skill-dir", + selectedBy, + selectedBy === "cli" ? "cli" : "env", + normalizedShip + ); + } + } + + const cached = readCachedEntryForShip(input.cacheDir, normalizedShip, input.fs); + if (cached) { + return resolutionFromCachedEntry(cached, "ship-cache", selectedBy); + } + + if (terminalOnCacheMiss) { + throw new Error( + `No cached credentials found for ${withSig(normalizedShip)}. ` + + "Authenticate with --url + --ship + --code, or configure TLON_SKILL_DIR." + ); + } + return null; +} + +function resolveCli(input: CredentialResolverInput, cli: CliCredentialOverrides): CredentialResolution { + switch (cli.kind) { + case "config": + return resolutionFromTopLevelConfig(cli.configFile, input.fs, "config-file", "cli", "config-file"); + case "cookie": + return resolutionFromCookie("cli", cli.url, cli.cookie, cli.ship, cli.code, "cli", "cli"); + case "code": + return resolutionFromCode("cli", cli.url, cli.ship, cli.code, "cli", "cli"); + case "ship": + return resolveShipOnly(input, cli.ship, "cli", true) as CredentialResolution; + } +} + +function missingConfigError(): Error { + return new Error( + "Missing Urbit config. Either:\n" + + " - Use CLI flags: --config , --url + --cookie, --url + --ship + --code, or --ship \n" + + " - Set TLON_CONFIG_FILE to a JSON config file\n" + + " - Set URBIT_URL/TLON_URL with URBIT_COOKIE/TLON_COOKIE\n" + + " - Set URL + SHIP + CODE via URBIT_* or TLON_* env vars\n" + + " - Set TLON_SHIP + TLON_SKILL_DIR for ships/.json\n" + + " - Configure Tlon channel in OpenClaw JSON (~/.openclaw/openclaw.json)" + ); +} + +export function resolveCredentials(input: CredentialResolverInput): CredentialResolution { + if (input.cli) { + return resolveCli(input, input.cli); + } + + const configFile = input.env.TLON_CONFIG_FILE; + if (nonEmpty(configFile)) { + return resolutionFromTopLevelConfig(configFile, input.fs, "config-file", "env", "config-file"); + } + + const url = envAlias(input.env, "URBIT_URL", "TLON_URL"); + const ship = envAlias(input.env, "URBIT_SHIP", "TLON_SHIP"); + const cookie = envAlias(input.env, "URBIT_COOKIE", "TLON_COOKIE"); + const code = envAlias(input.env, "URBIT_CODE", "TLON_CODE"); + + validateAmbientPartial(url, ship, cookie, code); + + if (url && cookie) { + return resolutionFromCookie("env-cookie", url, cookie, ship, code, "env", ship ? "env" : "cookie"); + } + + if (url && ship && code) { + return resolutionFromCode("env-code", url, ship, code, "env", "env"); + } + + if (ship) { + const shipResolution = resolveShipOnly(input, ship, "env", false); + if (shipResolution) return shipResolution; + } + + const openclawResolution = resolveOpenClaw(input); + if (openclawResolution) return openclawResolution; + + const cachedShips = readCachedShipCandidates(input.cacheDir, input.fs, { ignoreInvalid: true }); + if (cachedShips.length === 1) { + return resolutionFromCachedEntry(cachedShips[0], "single-cache"); + } + if (cachedShips.length > 1) { + const shipList = cachedShips.map((entry) => ` ${withSig(entry.ship)}`).join("\n"); + throw new Error(`Multiple cached ships found. Specify which with --ship:\n${shipList}`); + } + + throw missingConfigError(); +} diff --git a/scripts/main.ts b/scripts/main.ts index 624dd3b..dbf5a06 100644 --- a/scripts/main.ts +++ b/scripts/main.ts @@ -17,6 +17,9 @@ * settings OpenClaw settings management */ +import { setCliCredentialOverrides } from "./api-client"; +import { CredentialFlagError, parseGlobalCliOptions } from "./credential-flags"; + // Version is injected at build time via --define declare const __VERSION__: string; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "dev"; @@ -44,9 +47,17 @@ Commands: Credential Options (override defaults): --config Path to JSON config file with url + cookie or url + ship + code --url Ship URL (e.g., https://your-ship.tlon.network) - --ship <~name> Ship name (uses cached credentials if available) + --ship <~name> Ship name (uses TLON_SKILL_DIR or cached credentials) --code Access code (e.g., sampel-ticlyt-migfun-falmel) - --cookie Pre-authenticated cookie (ship is parsed from cookie name) + --cookie Pre-authenticated cookie (ship is parsed from cookie name unless --ship is set) + +Valid credential forms: + --config + --url --cookie [--ship ] [--code ] + --url --ship --code + --ship + +Incomplete or conflicting credential flag sets fail locally instead of merging with env vars. Other Options: --verbose Enable verbose subscription logging @@ -54,14 +65,17 @@ Other Options: --version, -v Show version Config Resolution (first match wins): - 1. CLI flags (--config, or --url + --cookie, or --url + --ship + --code) + 1. CLI credential flags 2. TLON_CONFIG_FILE env var - 3. URBIT_URL + URBIT_COOKIE (ship derived from cookie) - 4. URBIT_URL + URBIT_SHIP + URBIT_CODE - 5. --ship with cached credentials (no url/code needed) - 6. TLON_SHIP + TLON_SKILL_DIR (loads ships/.json) - 7. OpenClaw config (~/.openclaw/openclaw.yaml) - 8. Cached ships (auto-select if only one) + 3. URBIT_URL/TLON_URL + URBIT_COOKIE/TLON_COOKIE + 4. URL + SHIP + CODE via URBIT_* or TLON_* env vars + 5. TLON_SHIP + TLON_SKILL_DIR (loads ships/.json) + 6. Ship-only cache lookup + 7. OpenClaw JSON config (~/.openclaw/openclaw.json) + 8. Single cached ship (auto-select if only one) + +Cache writes: + Code login and code fallback cache the fresh cookie. Provided-cookie flows do not copy cookies into cache. Examples: tlon contacts list @@ -79,114 +93,24 @@ Examples: async function main() { const rawArgs = process.argv.slice(2); - // Parse credential flags before command - let urlOverride: string | null = null; - let shipOverride: string | null = null; - let codeOverride: string | null = null; - let cookieOverride: string | null = null; - let configOverride: string | null = null; - let verbose = false; - const args: string[] = []; - - for (let i = 0; i < rawArgs.length; i += 1) { - const arg = rawArgs[i]; - - // --config - if (arg === "--config" && rawArgs[i + 1]) { - configOverride = rawArgs[i + 1]; - i += 1; - continue; - } - if (arg.startsWith("--config=")) { - configOverride = arg.split("=", 2)[1] || ""; - continue; - } - - // --url - if (arg === "--url" && rawArgs[i + 1]) { - urlOverride = rawArgs[i + 1]; - i += 1; - continue; - } - if (arg.startsWith("--url=")) { - urlOverride = arg.split("=", 2)[1] || ""; - continue; - } - - // --ship - if (arg === "--ship" && rawArgs[i + 1]) { - shipOverride = rawArgs[i + 1]; - i += 1; - continue; - } - if (arg.startsWith("--ship=")) { - shipOverride = arg.split("=", 2)[1] || ""; - continue; - } - - // --code - if (arg === "--code" && rawArgs[i + 1]) { - codeOverride = rawArgs[i + 1]; - i += 1; - continue; - } - if (arg.startsWith("--code=")) { - codeOverride = arg.split("=", 2)[1] || ""; - continue; - } - - // --cookie - if (arg === "--cookie" && rawArgs[i + 1]) { - cookieOverride = rawArgs[i + 1]; - i += 1; - continue; - } - if (arg.startsWith("--cookie=")) { - cookieOverride = arg.split("=", 2)[1] || ""; - continue; - } - - // --verbose - if (arg === "--verbose") { - verbose = true; - continue; + let parsed; + try { + parsed = parseGlobalCliOptions(rawArgs); + } catch (error: any) { + if (error instanceof CredentialFlagError) { + console.error("Error:", error.message); + console.error('Run "tlon --help" for usage information.'); + process.exit(1); } - - args.push(arg); + throw error; } - if (verbose) { + if (parsed.verbose) { process.env.TLON_VERBOSE = "1"; } - // Apply credential overrides - if (configOverride) { - process.env.TLON_CONFIG_FILE = configOverride; - } else if (urlOverride && cookieOverride) { - // URL + cookie: ship derived from cookie (or explicit --ship) - process.env.URBIT_URL = urlOverride; - process.env.URBIT_COOKIE = cookieOverride; - if (shipOverride) { - process.env.URBIT_SHIP = shipOverride.replace(/^~/, ""); - } - if (codeOverride) { - process.env.URBIT_CODE = codeOverride; - } - } else if (urlOverride && shipOverride && codeOverride) { - // URL + ship + code: traditional auth - process.env.URBIT_URL = urlOverride; - process.env.URBIT_SHIP = shipOverride.replace(/^~/, ""); - process.env.URBIT_CODE = codeOverride; - } else if (shipOverride && !urlOverride && !codeOverride && !cookieOverride) { - // Only --ship: set TLON_SHIP for cache lookup or ships/ directory - process.env.TLON_SHIP = shipOverride.replace(/^~/, ""); - } else if (urlOverride || codeOverride || cookieOverride) { - // Partial flags - set what we have (allows merging with env vars) - if (urlOverride) process.env.URBIT_URL = urlOverride; - if (shipOverride) process.env.URBIT_SHIP = shipOverride.replace(/^~/, ""); - if (codeOverride) process.env.URBIT_CODE = codeOverride; - if (cookieOverride) process.env.URBIT_COOKIE = cookieOverride; - } + setCliCredentialOverrides(parsed.credentialOverrides); + const args = parsed.args; const command = args[0]; diff --git a/tests/hermetic/cli.test.ts b/tests/hermetic/cli.test.ts index 688f023..6f92993 100644 --- a/tests/hermetic/cli.test.ts +++ b/tests/hermetic/cli.test.ts @@ -209,4 +209,84 @@ describe("CLI hermetic subprocess behavior", () => { }); } + describe("global credential flag validation", () => { + it("fails partial CLI credential flags before merging ambient env", async () => { + const result = await runCli(["--url", "https://cli.tlon.network", "contacts", "self"], { + env: { + URBIT_COOKIE: "urbauth-~zod=0v-cookie", + URBIT_SHIP: "~zod", + URBIT_CODE: "code", + }, + }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Invalid credential flags"); + expect(result.stderr).not.toContain("Missing Urbit config"); + }); + + it("fails conflicting credential forms before command import/auth lookup", async () => { + const result = await runCli([ + "--config", + "ship.json", + "--url", + "https://zod.tlon.network", + "--cookie", + "urbauth-~zod=0v-cookie", + "contacts", + "self", + ]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("--config cannot be combined"); + }); + + it("fails duplicate credential flags", async () => { + const result = await runCli(["--ship", "~zod", "--ship", "~bus", "contacts", "self"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Duplicate credential flag: --ship"); + }); + + it("fails empty credential flag values", async () => { + const result = await runCli(["--cookie=", "contacts", "self"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Missing value for --cookie"); + }); + + it("fails missing values when the next token is absent or the command", async () => { + const absent = await runCli(["--url"]); + const command = await runCli(["--url", "contacts", "self"]); + + expect(absent.exitCode).toBe(1); + expect(absent.stderr).toContain("Missing value for --url"); + expect(command.exitCode).toBe(1); + expect(command.stderr).toContain("Missing value for --url"); + }); + + it("accepts valid CLI credentials while ignoring ambient TLON_CONFIG_FILE during parsing", async () => { + const result = await runCli( + [ + "--url", + "https://cli.tlon.network", + "--cookie", + "urbauth-~zod=0v-cookie", + "definitely-not-a-command", + ], + { + prepare: ({ home }) => ({ + env: { TLON_CONFIG_FILE: join(home, "missing-ship.json") }, + }), + } + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Unknown command: definitely-not-a-command"); + expect(result.stderr).not.toContain("Ship config not found"); + }); + }); });