From 136d5058069ced7bb95720c99f4bcbb53258c313 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 4 May 2026 14:39:39 -0700 Subject: [PATCH] fix(cli): bundle pty-daemon.js + pass through OAuth JWT to relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs reported on cli-v0.2.4 across both linux-x64 and darwin-arm64: 1) [supervisor] bootstrap failed: script not found at /home/pty-daemon/dist/pty-daemon.js — has the daemon binary been bundled? build-dist.ts only built host-service.js, never pty-daemon.js. The host-service's resolveSupervisorScriptPath() looked for pty-daemon.js side-by-side with host-service.js, missed it, and fell back to the workspace path packages/pty-daemon/dist/pty-daemon.js — which on a user's machine resolved to a nonsense /home/pty-daemon/... path. Fix: add buildPtyDaemon() and copy the output next to host-service.js in the staged bundle. Add a smoke-test assertion (CI workflow + docker harness) that both files exist in the tarball, so this can't ship broken again. 2) [host-service] failed to register/connect relay: Failed to mint JWT: 401 The CLI's `superset auth login` stores an OAuth access token from the code+PKCE flow. JwtApiAuthProvider.getJwt() blindly POSTed it to better-auth's /api/auth/token, which only accepts session tokens and API keys (not OAuth bearer tokens) and 401s. But the OAuth access token *is already a JWT*, signed by the same JWKS the relay verifies against, with `organizationIds` baked in via customAccessTokenClaims (packages/auth/src/server.ts:228-251). So we can pass it straight through — no exchange needed. Fix: detect three-segment JWTs and return as-is. Restore the sk_live_ → x-api-key behavior for api-key auth (mirrors what the CLI's tRPC client already does in api-client.ts:24). --- .github/workflows/build-cli.yml | 2 ++ .../cli/scripts/build-dist-linux-docker.sh | 2 ++ packages/cli/scripts/build-dist.ts | 14 ++++++++++++ .../auth/JwtAuthProvider/JwtAuthProvider.ts | 22 ++++++++++++++++++- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 0c34e5867bb..e77b4d761d1 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -72,6 +72,8 @@ jobs: "$DIST/bin/superset" --version "$DIST/bin/superset" --help "$DIST/lib/node" --version + test -f "$DIST/lib/host-service.js" || (echo "missing host-service.js" >&2; exit 1) + test -f "$DIST/lib/pty-daemon.js" || (echo "missing pty-daemon.js" >&2; exit 1) NODE_PATH="$DIST/lib/node_modules" "$DIST/lib/node" -e ' for (const m of ["better-sqlite3", "node-pty", "@parcel/watcher", "libsql"]) { require(m); diff --git a/packages/cli/scripts/build-dist-linux-docker.sh b/packages/cli/scripts/build-dist-linux-docker.sh index 2adcf75d109..5365d959052 100755 --- a/packages/cli/scripts/build-dist-linux-docker.sh +++ b/packages/cli/scripts/build-dist-linux-docker.sh @@ -66,6 +66,8 @@ docker run --rm --platform "$PLATFORM" \ "$DIST/bin/superset" --version "$DIST/bin/superset" --help | head -5 "$DIST/lib/node" --version + test -f "$DIST/lib/host-service.js" || (echo "missing host-service.js" >&2; exit 1) + test -f "$DIST/lib/pty-daemon.js" || (echo "missing pty-daemon.js" >&2; exit 1) NODE_PATH="$DIST/lib/node_modules" "$DIST/lib/node" -e " for (const m of [\"better-sqlite3\", \"node-pty\", \"@parcel/watcher\", \"libsql\"]) { require(m); diff --git a/packages/cli/scripts/build-dist.ts b/packages/cli/scripts/build-dist.ts index a454411e7e8..206a186a01e 100644 --- a/packages/cli/scripts/build-dist.ts +++ b/packages/cli/scripts/build-dist.ts @@ -365,6 +365,12 @@ async function buildHostService(): Promise { return join(hostServiceDir, "dist", "host-service.js"); } +async function buildPtyDaemon(): Promise { + const ptyDaemonDir = resolve(import.meta.dir, "../../pty-daemon"); + await exec("bun", ["run", "build:daemon"], ptyDaemonDir); + return join(ptyDaemonDir, "dist", "pty-daemon.js"); +} + function writeHostWrapper(binDir: string): void { const wrapper = `#!/bin/sh SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -396,6 +402,14 @@ async function main(): Promise { const hostServiceBundle = await buildHostService(); cpSync(hostServiceBundle, join(stagingRoot, "lib", "host-service.js")); + // pty-daemon ships side-by-side with host-service.js. The host-service + // resolves the script path via `resolveSupervisorScriptPath()` which + // looks for `pty-daemon.js` next to itself first; without this copy the + // supervisor falls back to the workspace path and bricks at spawn time. + console.log("[build-dist] building pty-daemon bundle"); + const ptyDaemonBundle = await buildPtyDaemon(); + cpSync(ptyDaemonBundle, join(stagingRoot, "lib", "pty-daemon.js")); + console.log("[build-dist] fetching Node.js"); await downloadAndExtractNode(target, join(stagingRoot, "lib")); diff --git a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts index f6a71ccd512..228947e327c 100644 --- a/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts +++ b/packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts @@ -3,6 +3,11 @@ import type { ApiAuthProvider } from "../types"; const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; const JWT_CACHE_DURATION_MS = 55 * 60 * 1000; +function looksLikeJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(Boolean); +} + export class JwtApiAuthProvider implements ApiAuthProvider { private readonly sessionToken: string; private readonly apiUrl: string; @@ -20,6 +25,16 @@ export class JwtApiAuthProvider implements ApiAuthProvider { } async getJwt(): Promise { + // CLI OAuth code+PKCE login stores the OAuth access token directly, + // which is already a JWT signed by the same JWKS the relay verifies + // against and carries `organizationIds` via customAccessTokenClaims. + // Pass it through — no /api/auth/token exchange needed (and the + // better-auth jwt plugin endpoint doesn't accept OAuth tokens + // anyway, only sessions and api keys). + if (looksLikeJwt(this.sessionToken)) { + return this.sessionToken; + } + if ( this.cachedJwt && Date.now() < this.cachedJwtExpiresAt - JWT_REFRESH_BUFFER_MS @@ -27,8 +42,13 @@ export class JwtApiAuthProvider implements ApiAuthProvider { return this.cachedJwt; } + // better-auth's apiKey plugin reads `sk_live_…` from x-api-key, not + // Authorization: Bearer; mirror what the CLI's tRPC client does in + // packages/cli/src/lib/api-client.ts. const response = await fetch(`${this.apiUrl}/api/auth/token`, { - headers: { Authorization: `Bearer ${this.sessionToken}` }, + headers: this.sessionToken.startsWith("sk_live_") + ? { "x-api-key": this.sessionToken } + : { Authorization: `Bearer ${this.sessionToken}` }, }); if (!response.ok) { throw new Error(`Failed to mint JWT: ${response.status}`);