diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 1681155270..4a82af4991 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -1285,6 +1285,53 @@ describe("shared ACPX engine runtime behavior", () => { expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth); }); + it("sets GROK_HOME for a Grok run from the company Grok home, and leaves CODEX_HOME unchanged for a Codex run", async () => { + const root = await makeTempRoot(); + const paperclipHome = path.join(root, "paperclip-home"); + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + try { + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "default"; + + const grokRun = await runExecutor({ + agent: "grok", + agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state-grok"), + }); + expect(grokRun.sessionInputs[0]?.sessionOptions).toMatchObject({ + env: expect.objectContaining({ + GROK_HOME: path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "grok-home", + ), + }), + }); + + const codexHome = path.join(root, "codex-home"); + const codexRun = await runExecutor({ + agent: "codex", + stateDir: path.join(root, "state-codex"), + env: { CODEX_HOME: codexHome }, + paperclipRuntimeSkills: [], + paperclipSkillSync: { desiredSkills: [] }, + }); + const codexEnv = (codexRun.sessionInputs[0]?.sessionOptions as { env: Record }) + .env; + expect(codexEnv.CODEX_HOME).toBe(codexHome); + expect(codexEnv.GROK_HOME).toBeUndefined(); + } finally { + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId; + } + }); + it("uses direct registry commands and per-session env across ACPX agent changes", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 110ca0a30a..6ae17d80f1 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -647,6 +647,16 @@ function resolveManagedCodexHomeDir(companyId: string): string { return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home"); } +// Mirrors `resolveManagedGrokHomeDir` in +// `packages/adapters/grok-local/src/server/grok-home.ts` — this package +// cannot import that adapter package (it would invert the dependency +// direction), so the path scheme is duplicated here, the same way +// `resolveManagedCodexHomeDir` above duplicates the Codex adapter's own +// helper. +function resolveManagedGrokHomeDir(companyId: string): string { + return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "grok-home"); +} + // Walk up from startDir looking for `node_modules/.bin/`. This matches // npm/pnpm binary hoisting in packaged installs while preserving monorepo dev. export async function findAncestorBin(startDir: string, binName: string): Promise { @@ -1854,6 +1864,14 @@ async function buildRuntime(input: { skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); } else { + // A minimal, separate Grok seam: only the company-scoped `GROK_HOME` + // binding, so a Grok run authenticates from the credential a completed + // device login wrote. This never touches `prepareCodexSkillRuntime` above + // — that function stays Codex-only — and every other custom ACPX agent + // (for example `kimi`) falls through this branch unaffected. + if (acpxAgent === "grok") { + env.GROK_HOME = resolveManagedGrokHomeDir(agent.companyId); + } const desired = resolveLegacyPaperclipDesiredSkillNames( config, await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir), diff --git a/packages/adapters/codex-local/src/server/device-login-parse.test.ts b/packages/adapters/codex-local/src/server/device-login-parse.test.ts index 8768d18332..e721c9c38f 100644 --- a/packages/adapters/codex-local/src/server/device-login-parse.test.ts +++ b/packages/adapters/codex-local/src/server/device-login-parse.test.ts @@ -118,6 +118,33 @@ describe("parseDeviceLoginPrompt", () => { expect(parseDeviceLoginPrompt(withFragment)).toBeNull(); }); + it("parse_returns_the_printed_url_with_a_bare_trailing_fragment_marker", () => { + // A printed URL that ends with a bare `#` carries an empty fragment. The + // platform `URL` parser keeps the marker in its normalized form. The parser + // returns that normalized form, not the fixed constant. + const preamble = "2. Enter this one-time code (expires in 15 minutes)"; + const text = [`${EXACT_URL}#`, preamble, "ABCD-EFGHJ"].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe("https://auth.openai.com/codex/device#"); + }); + + it("parse_returns_the_normalized_url_for_an_explicit_default_port_or_an_uppercase_host", () => { + // The platform `URL` parser normalizes an explicit default port and an + // uppercase host. The parser returns that normalized form. + const preamble = "2. Enter this one-time code (expires in 15 minutes)"; + const withPort = [`${EXACT_URL.replace("auth.openai.com", "auth.openai.com:443")}`, preamble, "ABCD-EFGHJ"].join( + "\n", + ); + const withUppercaseHost = [ + `${EXACT_URL.replace("auth.openai.com", "AUTH.OPENAI.COM")}`, + preamble, + "ABCD-EFGHJ", + ].join("\n"); + expect(parseDeviceLoginPrompt(withPort)?.url).toBe(EXACT_URL); + expect(parseDeviceLoginPrompt(withUppercaseHost)?.url).toBe(EXACT_URL); + }); + it("parse_returns_null_for_wrong_origin_or_path", () => { const wrongOrigin = [ "Open this link", diff --git a/packages/adapters/codex-local/src/server/device-login-parse.ts b/packages/adapters/codex-local/src/server/device-login-parse.ts index 86dc3fb83e..76d0a6f10f 100644 --- a/packages/adapters/codex-local/src/server/device-login-parse.ts +++ b/packages/adapters/codex-local/src/server/device-login-parse.ts @@ -7,14 +7,19 @@ // structure `XXXX-XXXXX` (four characters, a hyphen, then five characters). The // parser never logs the URL, the code, or any input byte, and it keeps them out // of every thrown error. The parser is a pure function. +// +// The parser returns the printed URL after the platform `URL` parser validates +// and normalizes it. It never returns the raw matched token: the accepting +// rules above already bound the token to the exact origin, path, and empty +// query and fragment, so the normalized form carries no unvalidated byte. export interface DeviceLoginPrompt { url: string; code: string; } -// The one and only accepted device-login URL. The parser returns this exact -// constant string on a match, so the output is never a caller-controlled value. +// The one and only accepted device-login origin and path. `findExactDeviceUrl` +// checks a candidate token against these exact values before it accepts it. export const DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device"; // Codex CLI 0.128.0 and later wrap the login URL and the one-time code in ANSI @@ -74,12 +79,13 @@ interface DeviceUrlMatch { } /** - * Returns the exact device-login URL and its end index when `text` holds it as - * a standalone token with the exact origin `https://auth.openai.com` and the + * Returns the validated device-login URL and its end index when `text` holds it + * as a standalone token with the exact origin `https://auth.openai.com` and the * exact path `/codex/device` and no query, fragment, or credentials. Returns - * null otherwise. Returns the canonical {@link DEVICE_LOGIN_URL} constant on a - * match. The `end` index is the position of the first character after the - * matched token in `text`. + * null otherwise. Returns the platform `URL` parser's normalized form of the + * matched token, so the accepting rules bound the output even though it is not a + * fixed constant. The `end` index is the position of the first character after + * the matched token in `text`. */ function findExactDeviceUrl(text: string): DeviceUrlMatch | null { for (const match of text.matchAll(URL_TOKEN_RE)) { @@ -100,7 +106,7 @@ function findExactDeviceUrl(text: string): DeviceUrlMatch | null { parsed.username === "" && parsed.password === "" ) { - return { url: DEVICE_LOGIN_URL, end: (match.index ?? 0) + token.length }; + return { url: parsed.toString(), end: (match.index ?? 0) + token.length }; } } return null; diff --git a/packages/adapters/codex-local/src/server/device-login-runner.test.ts b/packages/adapters/codex-local/src/server/device-login-runner.test.ts index b9393e8bba..fdf5531154 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.test.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.test.ts @@ -194,4 +194,34 @@ describe("runDeviceLogin", () => { expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("codex"); expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("--device-auth"); }); + + it("uses the parser the caller supplies instead of the Codex parser", async () => { + // The caller's parser reads a shape the Codex parser rejects, so a call that + // reaches the Codex parser instead would never surface a prompt. + const { driver } = createFakeDriver({ + chunks: ["not a codex prompt, but the caller's own marker\n"], + exitCode: 0, + }); + const onPrompt = vi.fn(); + const parsePrompt = vi.fn((output: string) => + output.includes("marker") ? { url: "https://example.test/device", code: "AAAA-11111" } : null, + ); + const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000, parsePrompt }); + expect(result.outcome).toBe("success"); + expect(result.promptSurfaced).toBe(true); + expect(parsePrompt).toHaveBeenCalled(); + expect(onPrompt).toHaveBeenCalledWith({ url: "https://example.test/device", code: "AAAA-11111" }); + }); + + it("uses the Codex parser when the caller supplies none", async () => { + const { driver } = createFakeDriver({ + chunks: [`1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n${REAL_SHAPED_CODE}\nDone.\n`], + exitCode: 0, + }); + const onPrompt = vi.fn(); + const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 }); + expect(result.outcome).toBe("success"); + expect(result.promptSurfaced).toBe(true); + expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE }); + }); }); diff --git a/packages/adapters/codex-local/src/server/device-login-runner.ts b/packages/adapters/codex-local/src/server/device-login-runner.ts index 968031e113..9f488cebee 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.ts @@ -80,6 +80,9 @@ export type DeviceLoginResult = LoginRunnerResult; export interface RunDeviceLoginOptions extends LoginRunnerLifecycleOptions { /** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */ command?: string; + /** Parses the prompt from the login output. Defaults to + * {@link parseDeviceLoginPrompt}. */ + parsePrompt?: (output: string) => DeviceLoginPrompt | null; /** Receives the parsed prompt one time in memory. The caller displays it. */ onPrompt: DeviceLoginPromptSink; /** @@ -105,6 +108,7 @@ export async function runDeviceLogin( ): Promise { const { onPrompt, onCredential, authPath, timeoutMs, signal } = options; const command = options.command ?? CODEX_DEVICE_LOGIN_COMMAND; + const parsePrompt = options.parsePrompt ?? parseDeviceLoginPrompt; const log = options.log ?? (() => {}); let promptSurfaced = false; @@ -120,7 +124,7 @@ export async function runDeviceLogin( const onStdout = (chunk: string): void => { if (promptSurfaced) return; buffer += chunk; - const prompt = parseDeviceLoginPrompt(buffer); + const prompt = parsePrompt(buffer); if (prompt) { promptSurfaced = true; buffer = ""; diff --git a/packages/adapters/grok-local/package.json b/packages/adapters/grok-local/package.json index 381b23139e..7ed910857e 100644 --- a/packages/adapters/grok-local/package.json +++ b/packages/adapters/grok-local/package.json @@ -51,6 +51,7 @@ }, "dependencies": { "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/packages/adapters/grok-local/src/server/__fixtures__/README.md b/packages/adapters/grok-local/src/server/__fixtures__/README.md new file mode 100644 index 0000000000..1e4f779aae --- /dev/null +++ b/packages/adapters/grok-local/src/server/__fixtures__/README.md @@ -0,0 +1,32 @@ +# Device-login sample fixture + +This fixture holds redacted, real Grok device-login output. A capture step ran +`grok login --device-auth` inside a Daytona sandbox and recorded the output. The +capture step redacted every secret before it kept the text. The parser test +reads this fixture. The test never reads a live secret. + +## Source + +- Capture date (UTC): `2026-08-28`. +- CLI version: `grok 1.0.5 (5115b46bc9)`. +- Host: Daytona sandbox, Ubuntu 22.04, `x86_64`. +- Transport: a pipe with no pseudo-terminal. The Grok prompt reaches a plain + pipe, so the fixture holds line-feed-only line endings, with no carriage + return. + +## File + +| File | Condition | Expected parse result | +|---|---|---| +| `device-login-prompt.txt` | Normal prompt, no pseudo-terminal | a URL and a code | + +## Redaction + +The capture step transformed every real one-time code to the placeholder +`XXXX-XXXX`. The placeholder keeps the observed shape: four characters, a +hyphen, then four characters. The parser matches this grounded structure and +does not invent an alphabet, so the committed fixture parses to a code with no +real secret in the repository. + +The capture step checked the final text for common credential field names and +for an unredacted device-code pattern. It found no match. diff --git a/packages/adapters/grok-local/src/server/__fixtures__/device-login-prompt.txt b/packages/adapters/grok-local/src/server/__fixtures__/device-login-prompt.txt new file mode 100644 index 0000000000..42462ddd65 --- /dev/null +++ b/packages/adapters/grok-local/src/server/__fixtures__/device-login-prompt.txt @@ -0,0 +1,12 @@ + +To sign in, open this URL in your browser: + + https://accounts.x.ai/oauth2/device?user_code=XXXX-XXXX + +Confirm this code in your browser: + + XXXX-XXXX + +Only continue with a code you requested. Don't share it with anyone. + +Waiting for authorization... diff --git a/packages/adapters/grok-local/src/server/adapter-auth-promotion.test.ts b/packages/adapters/grok-local/src/server/adapter-auth-promotion.test.ts new file mode 100644 index 0000000000..f039f2b532 --- /dev/null +++ b/packages/adapters/grok-local/src/server/adapter-auth-promotion.test.ts @@ -0,0 +1,520 @@ +import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + assertUsableGrokAuthShape, + checkStagedGrokCredentialReadiness, + DeviceLoginReadinessError, + promoteGrokDeviceLoginCredential, + type CredentialReadinessResult, +} from "./adapter-auth-promotion.js"; +import { resolveManagedGrokHomeDir } from "./grok-home.js"; + +const COMPANY_A = "company-a"; +const COMPANY_B = "company-b"; +const ISSUER = "https://issuer.x.ai"; +const UUID_A = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; +const UUID_B = "8f14e45f-ceea-467e-a4b6-8b0e12345678"; +const TOKEN_SENTINEL = "SENTINEL_REFRESH_TOKEN_XYZ"; + +// This suite proves the Grok device-login credential promotion helper. It runs +// an independent readiness check on the exact staged credential first, then +// validates its shape, then writes only the company-scoped credential home. It +// writes only while the session holds the sole active claim on the slot, and +// only for a user-initiated login. It never writes the instance-global host, +// never crosses a company boundary, and never logs a secret. +describe("grok device-login credential promotion", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await chmod(dir, 0o700).catch(() => undefined); + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + async function makeInstanceRoot(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-grok-promotion-")); + cleanupDirs.push(dir); + return dir; + } + + function envFor(instanceHome: string): NodeJS.ProcessEnv { + return { + PAPERCLIP_HOME: instanceHome, + PAPERCLIP_INSTANCE_ID: "default", + }; + } + + function grokAuth(input: { uuid: string; issuer?: string; marker?: string }): Buffer { + const suffix = input.marker ?? input.uuid; + return Buffer.from( + JSON.stringify({ + [`${input.issuer ?? ISSUER}::${input.uuid}`]: { + key: `api-key-${suffix}`, + refresh_token: `${TOKEN_SENTINEL}-${suffix}`, + expires_at: "2026-01-01T00:00:00Z", + oidc_issuer: input.issuer ?? ISSUER, + oidc_client_id: "client-1", + email: `user-${suffix}@example.com`, + first_name: "Test", + last_name: "User", + user_id: `user-${suffix}`, + principal_id: `principal-${suffix}`, + team_id: `team-${suffix}`, + }, + }), + ); + } + + const ready = (): CredentialReadinessResult => ({ ready: true }); + const notReady = (): CredentialReadinessResult => ({ ready: false, reason: "auth_unusable" }); + const soleOwner = () => true; + const notSoleOwner = () => false; + const noopLog = (_line: string): void => {}; + + function companyHomeAuthPath(env: NodeJS.ProcessEnv, companyId: string): string { + return path.join(resolveManagedGrokHomeDir(env, companyId), "auth.json"); + } + + // --------------------------------------------------------------------- + // Shape validation. + // --------------------------------------------------------------------- + + describe("assertUsableGrokAuthShape", () => { + it("rejects empty bytes", () => { + expect(() => assertUsableGrokAuthShape(Buffer.alloc(0))).toThrow(/empty/); + }); + + it("rejects oversized bytes", () => { + const big = Buffer.alloc(64 * 1024 + 1, "a"); + expect(() => assertUsableGrokAuthShape(big)).toThrow(/oversized/); + }); + + it("rejects invalid JSON", () => { + expect(() => assertUsableGrokAuthShape(Buffer.from("{not json"))).toThrow(/invalid JSON/); + }); + + it("rejects an object with no top-level key", () => { + expect(() => assertUsableGrokAuthShape(Buffer.from("{}"))).toThrow(/single/); + }); + + it("rejects an object with more than one top-level key", () => { + const bytes = Buffer.from( + JSON.stringify({ + [`${ISSUER}::${UUID_A}`]: { key: "k", refresh_token: "r" }, + [`${ISSUER}::${UUID_B}`]: { key: "k2", refresh_token: "r2" }, + }), + ); + expect(() => assertUsableGrokAuthShape(bytes)).toThrow(/single/); + }); + + it("rejects a key that does not match the :: shape", () => { + const bytes = Buffer.from(JSON.stringify({ some_fixed_key: { key: "k", refresh_token: "r" } })); + expect(() => assertUsableGrokAuthShape(bytes)).toThrow(/single/); + }); + + it("rejects a value with no usable key or refresh_token", () => { + const bytes = Buffer.from(JSON.stringify({ [`${ISSUER}::${UUID_A}`]: { key: "k" } })); + expect(() => assertUsableGrokAuthShape(bytes)).toThrow(/usable/); + }); + + it("accepts an object whose single key matches :: and holds key + refresh_token", () => { + const payload = assertUsableGrokAuthShape(grokAuth({ uuid: UUID_A })); + expect(payload.identityKey).toBe(`${ISSUER}::${UUID_A}`); + expect(payload.value.key).toBe(`api-key-${UUID_A}`); + }); + }); + + // --------------------------------------------------------------------- + // Readiness. + // --------------------------------------------------------------------- + + describe("checkStagedGrokCredentialReadiness", () => { + it("reports not ready for empty bytes", async () => { + const result = await checkStagedGrokCredentialReadiness(Buffer.alloc(0)); + expect(result.ready).toBe(false); + }); + + it("reports not ready for a Codex-shaped payload", async () => { + const codexShaped = Buffer.from(JSON.stringify({ OPENAI_API_KEY: "sk-test" })); + const result = await checkStagedGrokCredentialReadiness(codexShaped); + expect(result.ready).toBe(false); + expect(result.reason).toBe("no_usable_auth"); + }); + + it("reports ready for a usable Grok payload", async () => { + const result = await checkStagedGrokCredentialReadiness(grokAuth({ uuid: UUID_A })); + expect(result.ready).toBe(true); + }); + }); + + // --------------------------------------------------------------------- + // The promotion order and gates. + // --------------------------------------------------------------------- + + it("a failed readiness check rejects and writes nothing", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const logs: string[] = []; + await expect( + promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: notReady, + isSoleActiveOwner: soleOwner, + env, + log: (line) => { + logs.push(line); + }, + }), + ).rejects.toBeInstanceOf(DeviceLoginReadinessError); + + await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow(); + expect(logs.join("\n")).not.toContain(TOKEN_SENTINEL); + expect(logs.join("\n")).not.toContain("example.com"); + }); + + it("an invalid shape rejects after a passed readiness check and writes nothing", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await expect( + promoteGrokDeviceLoginCredential({ + authBytes: Buffer.from("{}"), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }), + ).rejects.toThrow(/single/); + await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow(); + }); + + it("skips a background (non-user-initiated) login and writes nothing", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: false, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("background_skipped"); + await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow(); + }); + + it("skips a session that lost the sole active claim and writes nothing", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: notSoleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("not_sole_owner"); + await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow(); + }); + + it("creates the company Grok home at mode 0700 and writes auth.json at exact mode 0600", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("promoted"); + + const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A); + const dirStat = await lstat(companyHome); + expect(dirStat.mode & 0o777).toBe(0o700); + const authPath = path.join(companyHome, "auth.json"); + const fileStat = await lstat(authPath); + expect(fileStat.mode & 0o777).toBe(0o600); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(Object.keys(written)).toEqual([`${ISSUER}::${UUID_A}`]); + }); + + it("normalizes the company Grok home to mode 0700 when the home already exists at a broader mode", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A); + await mkdir(companyHome, { recursive: true, mode: 0o755 }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("promoted"); + + const dirStat = await lstat(companyHome); + expect(dirStat.mode & 0o777).toBe(0o700); + const authPath = path.join(companyHome, "auth.json"); + const fileStat = await lstat(authPath); + expect(fileStat.mode & 0o777).toBe(0o600); + }); + + it("keeps an occupied home that holds a different identity", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const first = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(first).toBe("promoted"); + + const second = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_B }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(second).toBe("kept_foreign_identity"); + + const authPath = companyHomeAuthPath(env, COMPANY_A); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(Object.keys(written)).toEqual([`${ISSUER}::${UUID_A}`]); + }); + + it("redacts the credential bytes and the account email the same way on a promoted and on a kept_foreign_identity outcome", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const logs: string[] = []; + const captureLog = (line: string): void => { + logs.push(line); + }; + + const promoted = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: captureLog, + }); + expect(promoted).toBe("promoted"); + + const keptForeign = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_B }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: captureLog, + }); + expect(keptForeign).toBe("kept_foreign_identity"); + + const allLogs = logs.join("\n"); + expect(allLogs).not.toContain(TOKEN_SENTINEL); + expect(allLogs).not.toContain(`user-${UUID_A}@example.com`); + expect(allLogs).not.toContain(`user-${UUID_B}@example.com`); + expect(allLogs).not.toContain(UUID_A); + expect(allLogs).not.toContain(UUID_B); + }); + + it("overwrites the home with a refreshed credential for the same identity", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "first" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "second" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("promoted"); + + const authPath = companyHomeAuthPath(env, COMPANY_A); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(written[`${ISSUER}::${UUID_A}`].refresh_token).toBe(`${TOKEN_SENTINEL}-second`); + }); + + // --------------------------------------------------------------------- + // Fail closed on an unreadable or unparseable existing home. + // --------------------------------------------------------------------- + + it("keeps a home whose auth.json holds corrupt JSON, and writes nothing", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A); + await mkdir(companyHome, { recursive: true, mode: 0o700 }); + const authPath = path.join(companyHome, "auth.json"); + const corruptBytes = Buffer.from("{not valid json"); + await writeFile(authPath, corruptBytes, { mode: 0o600 }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("kept_foreign_identity"); + + const afterBytes = await readFile(authPath); + expect(afterBytes.equals(corruptBytes)).toBe(true); + }); + + it("keeps a home whose auth.json holds a well-formed but unusable payload, and writes nothing", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A); + await mkdir(companyHome, { recursive: true, mode: 0o700 }); + const authPath = path.join(companyHome, "auth.json"); + const unusableBytes = Buffer.from(JSON.stringify({ some_fixed_key: { key: "k" } })); + await writeFile(authPath, unusableBytes, { mode: 0o600 }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("kept_foreign_identity"); + + const afterBytes = await readFile(authPath); + expect(afterBytes.equals(unusableBytes)).toBe(true); + }); + + // --------------------------------------------------------------------- + // Atomic write. + // --------------------------------------------------------------------- + + it("leaves no staged temporary file in the company home after a successful promotion", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("promoted"); + + const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A); + const entries = await readdir(companyHome); + expect(entries).toEqual(["auth.json"]); + }); + + // --------------------------------------------------------------------- + // Company isolation. + // --------------------------------------------------------------------- + + it("a promotion for company A creates no file, replaces no file, and reads no file under company B's home", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + + // Seed company B's home first, so a read-through would be observable. + const companyBHome = resolveManagedGrokHomeDir(env, COMPANY_B); + await mkdir(companyBHome, { recursive: true, mode: 0o700 }); + const companyBAuthPath = path.join(companyBHome, "auth.json"); + const companyBBytes = grokAuth({ uuid: UUID_B }); + await writeFile(companyBAuthPath, companyBBytes, { mode: 0o600 }); + const beforeBBytes = await readFile(companyBAuthPath); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("promoted"); + + // Company A's write landed only under company A's home. + const companyAAuthPath = companyHomeAuthPath(env, COMPANY_A); + expect(resolveManagedGrokHomeDir(env, COMPANY_A)).not.toBe(companyBHome); + await expect(lstat(companyAAuthPath)).resolves.toBeDefined(); + + // Company B's home is byte-identical to before the company A promotion. + const afterBBytes = await readFile(companyBAuthPath); + expect(afterBBytes.equals(beforeBBytes)).toBe(true); + }); + + it("rejects a companyId that holds a path separator", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await expect( + promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: `${COMPANY_A}/../${COMPANY_B}`, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }), + ).rejects.toThrow(/path separator/); + await expect(lstat(resolveManagedGrokHomeDir(env, COMPANY_B))).rejects.toThrow(); + }); + + it("rejects a companyId that is a parent-directory segment", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await expect( + promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A }), + companyId: "..", + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }), + ).rejects.toThrow(/relative path segment/); + }); +}); diff --git a/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts b/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts new file mode 100644 index 0000000000..860dc3e777 --- /dev/null +++ b/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts @@ -0,0 +1,335 @@ +import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { + grokHomeHasUsableAuth, + parseGrokAuthPayload, + hasUsableGrokAuthValue, + resolveManagedGrokHomeDir, + type GrokAuthPayload, +} from "./grok-home.js"; + +// The Grok device-login credential promotion. It runs after a successful +// device login, on the exact credential the login sandbox produced. It mirrors +// the fixed order the Codex promotion uses (see +// `packages/adapters/codex-local/src/server/adapter-auth-promotion.ts`): +// readiness check, credential validation, a user-initiated gate, a +// sole-active-owner gate, then the write. +// +// Grok needs no per-identity cache and no "strictly newer" merge decision: a +// completed device login is always the newest state for the account it logs +// in. So the write step is simpler than Codex's: it writes whenever the home +// is empty or already holds the SAME account, and it keeps the home untouched +// when a DIFFERENT account already occupies it. The helper never writes the +// instance-global home, only the company-scoped one. +// +// The helper treats the whole credential value as a secret: it never logs a +// token, a refresh token, or a personal field (email, name, user id). + +const AUTH_FILE_NAME = "auth.json"; +// A private directory (owner rwx only), reused from +// packages/adapters/codex-local/src/server/adapter-auth-promotion.ts:37-38. +const PRIVATE_DIR_MODE = 0o700; +// A private file (owner rw only). The write calls `chmod` after the write, so +// the process umask can never widen it — the same pattern +// packages/adapters/codex-local/src/server/codex-home.ts:524-526 uses for a +// staged credential file. +const PRIVATE_FILE_MODE = 0o600; + +// A bounded size for the credential payload, mirroring +// packages/adapters/codex-local/src/server/device-login-export.ts's +// `MAX_AUTH_JSON_BYTES`. A real `auth.json` is a few kilobytes. +const MAX_AUTH_JSON_BYTES = 64 * 1024; + +/** The independent readiness result for the exact staged credential. */ +export interface CredentialReadinessResult { + /** True when a run launched now with this exact credential would authenticate. */ + ready: boolean; + /** An optional non-secret reason code for a non-ready result. */ + reason?: string; +} + +/** Thrown when the independent readiness check does not return a ready result. + * The service maps this to a failed session and still deletes the sandbox. */ +export class DeviceLoginReadinessError extends Error { + readonly reason: string; + constructor(reason: string) { + super(`grok device-login promotion: the readiness check did not pass (${reason})`); + this.name = "DeviceLoginReadinessError"; + this.reason = reason; + } +} + +// A private directory (owner rwx only) for the throwaway readiness home. +const READINESS_HOME_DIR_MODE = 0o700; +// A private file (owner rw only) for the throwaway readiness credential. +const READINESS_AUTH_FILE_MODE = 0o600; + +/** + * The independent readiness check for a staged Grok device-login credential. It + * runs on the exact staged bytes, before any promotion write. It writes the + * bytes to a throwaway private home and runs the same usable-auth predicate the + * execute path uses. It always removes the throwaway home before it returns. + * + * It never uses the `grok models` exit code: that command exits `0` whether or + * not the user is authenticated, so it is not a usable auth signal. + */ +export async function checkStagedGrokCredentialReadiness( + authBytes: Buffer, +): Promise { + if (authBytes.length === 0) { + return { ready: false, reason: "empty_credential" }; + } + const scratchHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-grok-login-readiness-")); + try { + await mkdir(scratchHome, { recursive: true, mode: READINESS_HOME_DIR_MODE }); + await writeFile(path.join(scratchHome, AUTH_FILE_NAME), authBytes, { + mode: READINESS_AUTH_FILE_MODE, + }); + const ready = await grokHomeHasUsableAuth(scratchHome); + return ready ? { ready: true } : { ready: false, reason: "no_usable_auth" }; + } finally { + await rm(scratchHome, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * Validates the bounded-size, single-identity-key auth shape. Rejects an + * empty, an oversized, an invalid-JSON, a no-key, a multi-key, and an + * unusable-value payload. Never puts credential bytes into the thrown error. + */ +export function assertUsableGrokAuthShape(authBytes: Buffer): GrokAuthPayload { + if (authBytes.length === 0) { + throw new Error("grok device-login promotion: refused an empty auth payload"); + } + if (authBytes.length > MAX_AUTH_JSON_BYTES) { + throw new Error("grok device-login promotion: refused an oversized auth payload"); + } + let parsedJson: unknown; + try { + parsedJson = JSON.parse(authBytes.toString("utf8")); + } catch { + throw new Error("grok device-login promotion: refused invalid JSON"); + } + const payload = parseGrokAuthPayload(parsedJson); + if (!payload) { + throw new Error( + "grok device-login promotion: refused a payload with no single :: key", + ); + } + if (!hasUsableGrokAuthValue(payload.value)) { + throw new Error( + "grok device-login promotion: refused a credential with no usable key and refresh_token", + ); + } + return payload; +} + +/** + * The promotion outcome. + * + * - `promoted`: the helper wrote the company home, either seeding an empty home + * or refreshing the same account's credential. + * - `kept_foreign_identity`: the company home is occupied and this step could + * not confirm it holds the same account — either a DIFFERENT account already + * occupies it, or the existing file is present but this step cannot read it + * as a usable Grok credential (an unreadable or unparseable file fails + * closed the same way). The helper never clobbers an occupied home, so it + * wrote nothing. This is NOT a successful authentication; the caller must + * fail the session. + * - `not_sole_owner`: the sole-active-owner gate rejected the write. Nothing + * was written. + * - `background_skipped`: the user-initiated gate rejected the write (an + * automatic background path never seeds a company slot). Nothing was written. + */ +export type PromoteGrokDeviceLoginCredentialOutcome = + | "promoted" + | "kept_foreign_identity" + | "not_sole_owner" + | "background_skipped"; + +export interface PromoteGrokDeviceLoginCredentialInput { + /** The exact staged credential bytes the login sandbox produced. */ + authBytes: Buffer; + /** The company that owns this login. It must be a single safe path segment. */ + companyId: string; + /** + * True when a user started this login. Only a user-initiated login seeds the + * company slot; an automatic background path never seeds it. + */ + userInitiated: boolean; + /** + * The independent, machine-readable readiness check. It runs on the exact + * staged credential before any write. A non-ready result rejects the + * promotion (the session fails), and the helper writes nothing. + */ + checkReadiness: ( + authBytes: Buffer, + ) => Promise | CredentialReadinessResult; + /** + * Resolves true only while this session still holds the sole active claim on + * `(company_id, adapter_type)`. The helper writes only when it resolves true. + */ + isSoleActiveOwner: () => Promise | boolean; + /** A non-leaking progress sink. It receives only fixed status lines. */ + log: (line: string) => void | Promise; + env?: NodeJS.ProcessEnv; +} + +/** Rejects an empty or unsafe `companyId`, so a promotion can never resolve the + * instance-global home (`resolveManagedGrokHomeDir` with no `companyId`) or + * escape the company tree. */ +function requireSafeCompanyId(companyId: string): string { + const trimmed = typeof companyId === "string" ? companyId.trim() : ""; + if (trimmed.length === 0) { + throw new Error("grok device-login promotion: companyId is empty"); + } + if (trimmed === "." || trimmed === "..") { + throw new Error("grok device-login promotion: companyId is a relative path segment"); + } + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) { + throw new Error("grok device-login promotion: companyId contains a path separator"); + } + return trimmed; +} + +/** + * The existing home's state, read before a write decision. + * + * - `absent`: no file at the path. This is an empty slot. + * - `identity`: the file is present, readable, and holds a usable Grok + * payload. `identityKey` is that payload's composite key. + * - `unreadable`: the file is present, but the read failed, the JSON parse + * failed, or the payload is not a usable Grok payload. The caller must + * treat this the same as an occupied home with an unknown account: it + * fails closed and never overwrites the file. + */ +type ExistingHomeState = { kind: "absent" } | { kind: "identity"; identityKey: string } | { kind: "unreadable" }; + +function isEnoentError(error: unknown): boolean { + return typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** Reads the existing home's state at `authPath`. A read failure other than + * a missing file, an invalid-JSON payload, or a non-Grok payload all + * resolve to `unreadable`, so the caller fails closed on any of them. Only + * a missing file resolves to `absent`. */ +async function readExistingHomeState(authPath: string): Promise { + let existingBytes: Buffer; + try { + existingBytes = await readFile(authPath); + } catch (error) { + return isEnoentError(error) ? { kind: "absent" } : { kind: "unreadable" }; + } + let existingJson: unknown; + try { + existingJson = JSON.parse(existingBytes.toString("utf8")); + } catch { + return { kind: "unreadable" }; + } + const payload = parseGrokAuthPayload(existingJson); + return payload ? { kind: "identity", identityKey: payload.identityKey } : { kind: "unreadable" }; +} + +/** + * Writes `authBytes` to `authPath` atomically. It stages the bytes into a + * private (0600) temporary file in the same directory, then renames that + * file over `authPath`. The rename is an atomic same-directory swap, so a + * reader never observes a torn file, and a process that stops mid-write + * leaves the destination untouched. The temporary file is always removed, + * so a failed write never leaves stray bytes behind. + */ +async function writeAuthFileAtomically(authPath: string, authBytes: Buffer): Promise { + const stagedTempPath = path.join( + path.dirname(authPath), + `.auth-${process.pid}-${randomUUID()}.tmp`, + ); + // `wx` + explicit mode create the temp file private (0600) and fail if it + // already exists, so the write never goes through a pre-existing symlink. + const handle = await open(stagedTempPath, "wx", PRIVATE_FILE_MODE); + try { + await handle.writeFile(authBytes); + await handle.close(); + await rename(stagedTempPath, authPath); + await chmod(authPath, PRIVATE_FILE_MODE); + } finally { + await handle.close().catch(() => undefined); + await rm(stagedTempPath, { force: true }).catch(() => undefined); + } +} + +/** + * Promotes a Grok device-login credential into the company scope. The order is + * fixed: readiness check, credential validation, the user-initiated gate, the + * sole-active-owner gate, then the write. The readiness check and the write run + * while the caller still holds the active claim, so a second session cannot + * race the same slot. + */ +export async function promoteGrokDeviceLoginCredential( + input: PromoteGrokDeviceLoginCredentialInput, +): Promise { + const { authBytes, userInitiated, checkReadiness, isSoleActiveOwner, log } = input; + const env = input.env ?? process.env; + const companyId = requireSafeCompanyId(input.companyId); + + // 1. Independent readiness check on the exact staged credential. A non-ready + // result rejects the promotion before any validation or write. + const readiness = await checkReadiness(authBytes); + if (!readiness.ready) { + throw new DeviceLoginReadinessError(readiness.reason ?? "not_ready"); + } + + // 2. Validate the credential shape: the single `::` key, and a + // usable `key` and `refresh_token` in its value. + const payload = assertUsableGrokAuthShape(authBytes); + + // 3. Only a user-initiated login seeds the company slot. + if (!userInitiated) { + await log( + "[paperclip] Grok device-login promotion: skipped (an automatic background login never seeds a company slot).", + ); + return "background_skipped"; + } + + // 4. Write only while the session still owns the active slot. + const soleOwner = await isSoleActiveOwner(); + if (!soleOwner) { + await log( + "[paperclip] Grok device-login promotion: skipped (the session no longer holds the sole active claim on the slot).", + ); + return "not_sole_owner"; + } + + // 5. Never clobber an occupied home. A present file that this step cannot + // read as the same identity — absent from a read failure, invalid JSON, + // or a non-Grok payload — is treated as a foreign identity, so the + // promotion fails closed and writes nothing. + const companyHome = resolveManagedGrokHomeDir(env, companyId); + const authPath = path.join(companyHome, AUTH_FILE_NAME); + const existingState = await readExistingHomeState(authPath); + if (existingState.kind === "unreadable") { + await log( + "[paperclip] Grok device-login promotion: kept the company credential home (the existing file is present but this step cannot read it as a usable Grok credential).", + ); + return "kept_foreign_identity"; + } + if (existingState.kind === "identity" && existingState.identityKey !== payload.identityKey) { + await log( + "[paperclip] Grok device-login promotion: kept the company credential home (the login is a different account than the one already set for this company).", + ); + return "kept_foreign_identity"; + } + + // 6. Write the company credential home. `mkdir` applies `mode` only when it + // creates the directory, so an explicit `chmod` follows it. This keeps + // the directory mode exact both for a new home and for a home that + // already existed at a broader mode. The write itself is atomic: it + // stages the bytes into a private temporary file in the same directory, + // then renames that file over `auth.json`. + await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE }); + await chmod(companyHome, PRIVATE_DIR_MODE); + await writeAuthFileAtomically(authPath, authBytes); + await log("[paperclip] Grok device-login promotion: wrote the company credential home at mode 0600."); + return "promoted"; +} diff --git a/packages/adapters/grok-local/src/server/device-login-parse.test.ts b/packages/adapters/grok-local/src/server/device-login-parse.test.ts new file mode 100644 index 0000000000..afe9ca86a8 --- /dev/null +++ b/packages/adapters/grok-local/src/server/device-login-parse.test.ts @@ -0,0 +1,163 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parseGrokDeviceLoginPrompt } from "./device-login-parse.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__"); + +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), "utf8"); +} + +const PREAMBLE = "Confirm this code in your browser:"; +const ORIGIN = "https://accounts.x.ai"; +const PATH = "/oauth2/device"; + +function buildPrompt(code: string, urlCode: string = code): string { + return [ + "", + "To sign in, open this URL in your browser:", + "", + ` ${ORIGIN}${PATH}?user_code=${urlCode}`, + "", + PREAMBLE, + "", + ` ${code}`, + "", + ].join("\n"); +} + +describe("parseGrokDeviceLoginPrompt", () => { + it("parse_returns_url_and_code_from_the_captured_fixture", () => { + const result = parseGrokDeviceLoginPrompt(readFixture("device-login-prompt.txt")); + expect(result).not.toBeNull(); + expect(result?.url).toBe(`${ORIGIN}${PATH}?user_code=XXXX-XXXX`); + expect(result?.code).toBe("XXXX-XXXX"); + }); + + it("parse_returns_null_when_the_url_code_differs_from_the_standalone_code", () => { + const text = buildPrompt("ABCD-EFGH", "WXYZ-1234"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_a_query_that_repeats_user_code", () => { + const text = [ + "To sign in, open this URL in your browser:", + ` ${ORIGIN}${PATH}?user_code=ABCD-EFGH&user_code=ABCD-EFGH`, + PREAMBLE, + " ABCD-EFGH", + ].join("\n"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_a_query_that_adds_a_second_key", () => { + const text = [ + "To sign in, open this URL in your browser:", + ` ${ORIGIN}${PATH}?user_code=ABCD-EFGH&session=1`, + PREAMBLE, + " ABCD-EFGH", + ].join("\n"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_a_wrong_origin", () => { + const text = [ + "To sign in, open this URL in your browser:", + ` https://accounts.example.com${PATH}?user_code=ABCD-EFGH`, + PREAMBLE, + " ABCD-EFGH", + ].join("\n"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_a_wrong_path", () => { + const text = [ + "To sign in, open this URL in your browser:", + ` ${ORIGIN}/oauth2/device/extra?user_code=ABCD-EFGH`, + PREAMBLE, + " ABCD-EFGH", + ].join("\n"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_a_url_with_a_fragment", () => { + const text = [ + "To sign in, open this URL in your browser:", + ` ${ORIGIN}${PATH}?user_code=ABCD-EFGH#section`, + PREAMBLE, + " ABCD-EFGH", + ].join("\n"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_a_five_character_code_group", () => { + const text = [ + "To sign in, open this URL in your browser:", + ` ${ORIGIN}${PATH}?user_code=ABCDE-FGHIJ`, + PREAMBLE, + " ABCDE-FGHIJ", + ].join("\n"); + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_when_the_prompt_is_absent", () => { + const text = "Some unrelated log line\nNothing to see here\n"; + expect(parseGrokDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_reads_the_prompt_from_output_that_arrives_in_two_chunks", () => { + const full = buildPrompt("ABCD-EFGH"); + const splitAt = Math.floor(full.length / 2); + const combined = full.slice(0, splitAt) + full.slice(splitAt); + const result = parseGrokDeviceLoginPrompt(combined); + expect(result).not.toBeNull(); + expect(result?.code).toBe("ABCD-EFGH"); + }); + + it("parse_reads_the_prompt_when_the_url_and_the_code_carry_ansi_sequences", () => { + const cyan = "\x1b[36m"; + const bold = "\x1b[1m"; + const reset = "\x1b[0m"; + const text = [ + "To sign in, open this URL in your browser:", + ` ${cyan}${ORIGIN}${PATH}?user_code=ABCD-EFGH${reset}`, + PREAMBLE, + ` ${bold}ABCD-EFGH${reset}`, + ].join("\n"); + const result = parseGrokDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(`${ORIGIN}${PATH}?user_code=ABCD-EFGH`); + expect(result?.code).toBe("ABCD-EFGH"); + }); + + it("parse_reads_the_prompt_from_pipe_output_that_ends_every_line_with_a_line_feed_only", () => { + // The captured pipe transport ends every line with `\x0a` only, with no + // `\x0d`. The fixture already exercises this; this test pins the same + // shape with the exact captured line order and the two-space indent. + const text = + "\n" + + "To sign in, open this URL in your browser:\n" + + "\n" + + ` ${ORIGIN}${PATH}?user_code=ABCD-EFGH\n` + + "\n" + + `${PREAMBLE}\n` + + "\n" + + " ABCD-EFGH\n" + + "\n" + + "\x1b[90mOnly continue with a code you requested. Don't share it with anyone.\x1b[0m\n" + + "\n" + + "Waiting for authorization...\n"; + const result = parseGrokDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(`${ORIGIN}${PATH}?user_code=ABCD-EFGH`); + expect(result?.code).toBe("ABCD-EFGH"); + }); + + it("keeps the url and the code out of a thrown error", () => { + // @ts-expect-error deliberate wrong type + expect(parseGrokDeviceLoginPrompt(undefined)).toBeNull(); + // @ts-expect-error deliberate wrong type + expect(parseGrokDeviceLoginPrompt(12345)).toBeNull(); + }); +}); diff --git a/packages/adapters/grok-local/src/server/device-login-parse.ts b/packages/adapters/grok-local/src/server/device-login-parse.ts new file mode 100644 index 0000000000..8ec77a74df --- /dev/null +++ b/packages/adapters/grok-local/src/server/device-login-parse.ts @@ -0,0 +1,166 @@ +// The device-login output parser. It reads the Grok `login --device-auth` +// output and returns the authorization URL and the one-time code, or null. +// +// Security (Control 1 — strict validation): the parser accepts only the exact +// origin `https://accounts.x.ai` and the exact path `/oauth2/device`, and it +// rejects any fragment, a different origin, or a different path. Unlike the +// Codex device-login URL, the Grok URL carries a query: the parser accepts +// exactly one query key, `user_code`, and rejects a repeated key or an extra +// key. It requires the `user_code` value to match the strict short-code +// pattern and to equal the code that stands alone on its own line. The parser +// returns the printed URL after the platform `URL` parser validates and +// normalizes it, so the output is never a raw, unvalidated token. The parser +// never logs the URL, the code, or any input byte, and it keeps them out of +// every thrown error. The parser is a pure function. + +export interface DeviceLoginPrompt { + url: string; + code: string; +} + +/** The one and only accepted device-login command. */ +export const GROK_DEVICE_LOGIN_COMMAND = "grok login --device-auth"; + +/** The one and only accepted device-login URL origin. */ +export const GROK_DEVICE_LOGIN_URL_ORIGIN = "https://accounts.x.ai"; + +/** The one and only accepted device-login URL path. */ +export const GROK_DEVICE_LOGIN_URL_PATH = "/oauth2/device"; + +// Grok CLI wraps the URL and the code in ANSI color sequences on a later +// release, the same way Codex CLI 0.128.0 and later do. A color sequence is a +// Control Sequence Introducer (CSI): the ESC control byte, a `[`, zero or more +// parameter bytes (0x30-0x3F), zero or more intermediate bytes (0x20-0x2F), and +// one final byte (0x40-0x7E). The parser removes every CSI sequence first, so a +// colored URL or code reads the same as a plain one. The strip only normalizes +// the input; the URL and the code still pass the strict validation below. +// eslint-disable-next-line no-control-regex +const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g; + +// A candidate URL token is a run of non-space characters that starts with an +// http or https scheme. The parser validates each candidate with the `URL` +// class; the regular expression only splits tokens out of the text. +const URL_TOKEN_RE = /https?:\/\/\S+/g; + +// Trailing punctuation that prose commonly puts right after a URL. The parser +// strips only these characters. It never strips `?` or `#`, so a URL with a +// fragment stays malformed and the parser rejects it. +const TRAILING_PUNCTUATION_RE = /[)\].,;:!]+$/; + +// The one-time code structure: four characters, a hyphen, then four +// characters, and nothing else. Every observed grok code matched this shape. +// The alphabet is not published, so the pattern binds the token class to +// alphanumerics rather than a fixed character set. +const CODE_PATTERN = /^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/; + +// The prompt line that introduces the one-time code, printed exactly this way. +const CODE_PREAMBLE = "Confirm this code in your browser:"; + +// The maximum number of characters between the end of the URL and the start of +// the code preamble. The captured prompt prints the preamble a few characters +// after the URL. The parser looks for the preamble only inside this window +// after the URL, so a preamble far away in the output cannot bind to the URL. +const MAX_URL_TO_PREAMBLE_GAP = 256; + +// The maximum number of characters after the end of the preamble line that the +// parser reads for the code. The captured prompt prints the code on the line +// right after the preamble line. +const MAX_PREAMBLE_TO_CODE_GAP = 128; + +// The result of a URL search: the validated URL, the code the query carries, +// and the index of the first character after the matched URL token. The code +// search starts at this index. +interface DeviceUrlMatch { + url: string; + code: string; + end: number; +} + +/** + * Returns the validated device-login URL, the code its `user_code` query + * carries, and the end index, when `text` holds a standalone token with the + * exact origin {@link GROK_DEVICE_LOGIN_URL_ORIGIN} and the exact path + * {@link GROK_DEVICE_LOGIN_URL_PATH}, no fragment, no credentials, exactly one + * query key named `user_code`, and a `user_code` value that matches the strict + * short-code pattern. Returns null otherwise. Returns the platform `URL` + * parser's normalized form of the matched token, so the accepting rules bound + * the output even though it is not a fixed constant. The `end` index is the + * position of the first character after the matched token in `text`. + */ +function findExactDeviceUrl(text: string): DeviceUrlMatch | null { + for (const match of text.matchAll(URL_TOKEN_RE)) { + const token = match[0]; + const cleaned = token.replace(TRAILING_PUNCTUATION_RE, ""); + let parsed: URL; + try { + parsed = new URL(cleaned); + } catch { + continue; + } + if ( + parsed.protocol !== "https:" || + parsed.host !== "accounts.x.ai" || + parsed.pathname !== GROK_DEVICE_LOGIN_URL_PATH || + parsed.hash !== "" || + parsed.username !== "" || + parsed.password !== "" + ) { + continue; + } + // Accept exactly one query key, `user_code`. Comparing the whole key list + // rejects a repeated key and an extra key alike. + const keys = Array.from(parsed.searchParams.keys()); + if (keys.length !== 1 || keys[0] !== "user_code") continue; + const code = parsed.searchParams.get("user_code"); + if (!code || !CODE_PATTERN.test(code)) continue; + return { url: parsed.toString(), code, end: (match.index ?? 0) + token.length }; + } + return null; +} + +/** + * Returns the one-time code when `text` holds the code preamble after + * `fromIndex` and a dedicated code line after that preamble line. Mirrors the + * Codex parser's proximity-bound search: the preamble must appear in a window + * after the URL, and the code must be the first non-blank line after the + * preamble line, trimmed, and hold nothing else. Returns null when the + * preamble is absent, when the preamble has no line break after it, or when + * the first non-blank line after the preamble line is not a code line. + */ +function findStandaloneCode(text: string, fromIndex: number): string | null { + const preambleWindow = text.slice(fromIndex, fromIndex + MAX_URL_TO_PREAMBLE_GAP); + const preambleIndex = preambleWindow.indexOf(CODE_PREAMBLE); + if (preambleIndex === -1) return null; + const preambleEnd = fromIndex + preambleIndex + CODE_PREAMBLE.length; + const lineBreak = text.indexOf("\n", preambleEnd); + if (lineBreak === -1) return null; + const codeWindow = text.slice(lineBreak + 1, lineBreak + 1 + MAX_PREAMBLE_TO_CODE_GAP); + for (const line of codeWindow.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + return CODE_PATTERN.test(trimmed) ? trimmed : null; + } + return null; +} + +/** + * Parses Grok device-login output. Removes ANSI color sequences first. The + * parser reads the URL first, validates its query and its embedded code, then + * finds the code preamble after the URL and reads the code from the dedicated + * code line right after the preamble line. It returns the prompt only when the + * code on that dedicated line equals the code the URL query carries. Returns + * null for any other input, including a non-string input, an absent prompt, a + * wrong origin or path, a URL with a fragment, a malformed or mismatched query, + * and a malformed short code. Never throws on input, and never puts the URL or + * the code into a log or an error. + */ +export function parseGrokDeviceLoginPrompt(text: string): DeviceLoginPrompt | null { + if (typeof text !== "string" || text.length === 0) return null; + const clean = text.replace(ANSI_CSI_RE, ""); + const urlMatch = findExactDeviceUrl(clean); + if (!urlMatch) return null; + const lineCode = findStandaloneCode(clean, urlMatch.end); + if (!lineCode) return null; + if (lineCode !== urlMatch.code) return null; + return { url: urlMatch.url, code: lineCode }; +} diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts index 46899fec54..bbdb340d72 100644 --- a/packages/adapters/grok-local/src/server/execute.test.ts +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -30,6 +30,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ })); import { execute } from "./execute.js"; +import { resolveManagedGrokHomeDir } from "./grok-home.js"; const tempRoots: string[] = []; @@ -204,6 +205,53 @@ describe("grok_local execute", () => { } }); + it("sets GROK_HOME to the company home in subscription mode, and leaves it unset when XAI_API_KEY exists", async () => { + let seenEnv: Record = {}; + runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { + seenEnv = options.env; + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }), + stderr: "", + }; + }); + + const makeCtx = async (runId: string): Promise => ({ + runId, + agent: { + id: "agent-1", + companyId: "company-1", + name: "Grok Agent", + adapterType: "grok_local", + adapterConfig: {}, + }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { cwd: await makeTempRoot() }, + context: {}, + authToken: "run-token", + onLog: async () => {}, + }); + + const previousApiKey = process.env.XAI_API_KEY; + try { + delete process.env.XAI_API_KEY; + await execute(await makeCtx("run-subscription-home")); + expect(seenEnv.GROK_HOME).toBe(resolveManagedGrokHomeDir(process.env, "company-1")); + + // The XAI_API_KEY path stays unchanged: no GROK_HOME is set when the key + // exists, because the CLI authenticates via the environment variable + // directly, not from the company Grok home's auth.json. + process.env.XAI_API_KEY = "test-key"; + await execute(await makeCtx("run-api-home")); + expect(seenEnv.GROK_HOME).toBeUndefined(); + } finally { + if (previousApiKey === undefined) delete process.env.XAI_API_KEY; + else process.env.XAI_API_KEY = previousApiKey; + } + }); + it("passes an explicitly configured permissionMode through to the CLI", async () => { let seenArgs: string[] = []; runProcessMock.mockImplementation(async (_runId, _target, _command, args) => { diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index ba2fef1a8b..f041e26f44 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -40,6 +40,7 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { resolveManagedGrokHomeDir } from "./grok-home.js"; import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -297,6 +298,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise, "XAI_API_KEY")) { + env.GROK_HOME = resolveManagedGrokHomeDir(process.env, agent.companyId); + } const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( executionTarget, diff --git a/packages/adapters/grok-local/src/server/grok-home.ts b/packages/adapters/grok-local/src/server/grok-home.ts new file mode 100644 index 0000000000..1a98399294 --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-home.ts @@ -0,0 +1,95 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; + +// The Grok credential home. `GROK_HOME` replaces `~/.grok` and holds one file, +// `auth.json`. Unlike Codex, a Grok `auth.json` has no fixed top-level key: it +// holds exactly one key, and that key is a composite `::` value. +// This module resolves the company-scoped home path and reads its usable-auth +// shape. It never writes the file; {@link promoteGrokDeviceLoginCredential} in +// `adapter-auth-promotion.ts` owns the write. + +const AUTH_FILE_NAME = "auth.json"; + +// Matches the composite `::` top-level key. The issuer is a +// non-empty string — an OIDC issuer URL such as `https://issuer.x.ai` holds +// colons of its own — so this anchors on the LAST `::` before a standard +// 8-4-4-4-12 hex UUID at the end of the string (the greedy `.+` backtracks +// to that last separator). +const GROK_IDENTITY_KEY_RE = + /^.+::[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** One parsed Grok auth payload: the composite identity key and its value object. */ +export interface GrokAuthPayload { + identityKey: string; + value: Record; +} + +/** + * Parses a decoded JSON value into a {@link GrokAuthPayload}. Returns null when + * the value is not an object, holds zero or more than one top-level key, or the + * single key does not match the `::` shape, or its value is not an + * object. Never assumes a fixed key name. + */ +export function parseGrokAuthPayload(raw: unknown): GrokAuthPayload | null { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const keys = Object.keys(raw as Record); + if (keys.length !== 1) return null; + const [identityKey] = keys as [string]; + if (!GROK_IDENTITY_KEY_RE.test(identityKey)) return null; + const value = (raw as Record)[identityKey]; + if (value === null || typeof value !== "object" || Array.isArray(value)) return null; + return { identityKey, value: value as Record }; +} + +/** True when the payload value holds the two fields a run needs to authenticate. */ +export function hasUsableGrokAuthValue(value: Record): boolean { + const key = value.key; + const refreshToken = value.refresh_token; + return ( + typeof key === "string" && + key.trim().length > 0 && + typeof refreshToken === "string" && + refreshToken.trim().length > 0 + ); +} + +/** + * True when `home` has a usable `auth.json`: a single `::` + * top-level key whose value holds a non-empty `key` and `refresh_token`. A + * missing file, invalid JSON, or an unusable shape all resolve false. + */ +export async function grokHomeHasUsableAuth(home: string): Promise { + const authPath = path.join(home, AUTH_FILE_NAME); + try { + const raw = await fs.readFile(authPath, "utf8"); + const parsed = parseGrokAuthPayload(JSON.parse(raw)); + return parsed !== null && hasUsableGrokAuthValue(parsed.value); + } catch { + return false; + } +} + +/** + * Resolves the managed Grok home directory. With a `companyId`, it resolves the + * company-scoped home under the Paperclip instance tree, the same isolation + * boundary `resolveManagedCodexHomeDir` uses. Without one, it resolves the + * instance-global home, which a promotion must never write. + */ +export function resolveManagedGrokHomeDir( + env: NodeJS.ProcessEnv, + companyId?: string, +): string { + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); + return companyId + ? path.resolve(instanceRoot, "companies", companyId, "grok-home") + : path.resolve(instanceRoot, "grok-home"); +} diff --git a/packages/adapters/grok-local/src/server/index.ts b/packages/adapters/grok-local/src/server/index.ts index 127128cfd7..8655065085 100644 --- a/packages/adapters/grok-local/src/server/index.ts +++ b/packages/adapters/grok-local/src/server/index.ts @@ -64,3 +64,19 @@ export { execute } from "./execute.js"; export { listGrokSkills, syncGrokSkills } from "./skills.js"; export { testEnvironment } from "./test.js"; export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js"; +export { + GROK_DEVICE_LOGIN_COMMAND, + GROK_DEVICE_LOGIN_URL_ORIGIN, + GROK_DEVICE_LOGIN_URL_PATH, + parseGrokDeviceLoginPrompt, + type DeviceLoginPrompt as GrokDeviceLoginPrompt, +} from "./device-login-parse.js"; +export { resolveManagedGrokHomeDir, grokHomeHasUsableAuth } from "./grok-home.js"; +export { + promoteGrokDeviceLoginCredential, + checkStagedGrokCredentialReadiness, + DeviceLoginReadinessError as GrokDeviceLoginReadinessError, + type CredentialReadinessResult as GrokCredentialReadinessResult, + type PromoteGrokDeviceLoginCredentialInput, + type PromoteGrokDeviceLoginCredentialOutcome, +} from "./adapter-auth-promotion.js"; diff --git a/packages/adapters/grok-local/src/server/test.test.ts b/packages/adapters/grok-local/src/server/test.test.ts index e2d9e495c7..0e96382f40 100644 --- a/packages/adapters/grok-local/src/server/test.test.ts +++ b/packages/adapters/grok-local/src/server/test.test.ts @@ -217,4 +217,65 @@ describe("grok_local testEnvironment", () => { ]), ); }); + + it("emits the canonical adapter_auth_missing check for a sandbox target with missing authentication", async () => { + // The user interface reads this neutral canonical code to decide login + // eligibility for the sandbox; it does not parse the message text. + runProcessMock + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }) + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { command: "grok", cwd: "/tmp/project" }, + executionTarget: { kind: "remote", transport: "sandbox" } as never, + }); + + expect(result.checks.some((check: { code: string }) => check.code === "adapter_auth_missing")).toBe( + true, + ); + }); + + it("emits no adapter_auth_missing check for a local target with missing authentication", async () => { + // The canonical check gates sandbox login eligibility only. A local target + // has no sandbox login to offer, so the check must not appear. + runProcessMock + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }) + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "Not logged in. Run `grok login`.", + }); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "grok_local", + config: { command: "grok", cwd: "/tmp/project" }, + }); + + expect(result.checks.some((check: { code: string }) => check.code === "adapter_auth_missing")).toBe( + false, + ); + }); }); diff --git a/packages/adapters/grok-local/src/server/test.ts b/packages/adapters/grok-local/src/server/test.ts index f166d3c6af..81c9617d5a 100644 --- a/packages/adapters/grok-local/src/server/test.ts +++ b/packages/adapters/grok-local/src/server/test.ts @@ -19,6 +19,7 @@ import { } from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; import { parseGrokJsonl } from "./parse.js"; +import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; export interface GrokModelsProbe { authenticated: boolean; @@ -108,6 +109,7 @@ export async function testEnvironment( const command = asString(config.command, "grok"); const target = ctx.executionTarget ?? null; const targetIsRemote = target?.kind === "remote"; + const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox"; const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); const targetLabel = targetIsRemote ? ctx.environmentName ?? describeAdapterExecutionTarget(target) @@ -202,6 +204,18 @@ export async function testEnvironment( detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null), hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, }); + if (authRequired && targetIsSandbox) { + // Emit the neutral canonical check so the user interface can decide + // login eligibility from a stable code. The user interface does not + // read the message text or the top-level status. + checks.push({ + code: ADAPTER_AUTH_MISSING_CHECK_CODE, + level: "warn", + message: "This environment has no ready authentication for this adapter.", + detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null), + hint: "Provide credentials for this adapter, or start login in the environment.", + }); + } } else { checks.push({ code: "grok_models_probe_passed", @@ -295,6 +309,18 @@ export async function testEnvironment( ...(detail ? { detail } : {}), hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined, }); + if (authRequired && targetIsSandbox) { + // Emit the neutral canonical check so the user interface can decide + // login eligibility from a stable code. The user interface does not + // read the message text or the top-level status. + checks.push({ + code: ADAPTER_AUTH_MISSING_CHECK_CODE, + level: "warn", + message: "This environment has no ready authentication for this adapter.", + ...(detail ? { detail } : {}), + hint: "Provide credentials for this adapter, or start login in the environment.", + }); + } } else if (/\bhello\b/i.test(parsed.summary)) { checks.push({ code: "grok_hello_probe_passed", diff --git a/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts b/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts index 518cd8715f..aa09590f57 100644 --- a/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/login-pty.test.ts @@ -21,6 +21,7 @@ const HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555" const CLAUDE: LoginPtyLaunchDescriptor = { loginCommandKey: "claude", sessionHome: HOME }; const CODEX: LoginPtyLaunchDescriptor = { loginCommandKey: "codex", sessionHome: HOME }; +const GROK: LoginPtyLaunchDescriptor = { loginCommandKey: "grok", sessionHome: HOME }; /** * A fake login-home filesystem. It records each path the caller asks to create @@ -157,6 +158,29 @@ describe("composeLaunchLine", () => { expect(line.match(/CODEX_HOME=/g)).toHaveLength(1); expect(line.endsWith("codex login --device-auth")).toBe(true); }); + + it("composes the Grok line with exactly one encoded GROK_HOME", () => { + const line = composeLaunchLine(GROK); + expect(line).toBe(`exec env GROK_HOME='${HOME}' grok login --device-auth`); + // Exactly one GROK_HOME assignment, and the exact approved Grok command. + expect(line.match(/GROK_HOME=/g)).toHaveLength(1); + expect(line.endsWith("grok login --device-auth")).toBe(true); + // The Codex encoded variable never leaks into the Grok line. + expect(line).not.toContain("CODEX_HOME"); + }); + + it("composes the launch line from the closed command map only, and ignores a command string smuggled onto the descriptor", () => { + // Condition 4: a command string in the request confers no command + // authority. The module maps the closed key to its own fixed command, so a + // caller cannot select or override it. + const tampered = { + ...GROK, + command: "rm -rf /", + } as unknown as LoginPtyLaunchDescriptor; + const line = composeLaunchLine(tampered); + expect(line).toBe(`exec env GROK_HOME='${HOME}' grok login --device-auth`); + expect(line).not.toContain("rm -rf"); + }); }); describe("openDaytonaLoginPtySession — session home", () => { diff --git a/packages/plugins/sandbox-providers/daytona/src/login-pty.ts b/packages/plugins/sandbox-providers/daytona/src/login-pty.ts index e3430b6b1c..0c4e6b5a2d 100644 --- a/packages/plugins/sandbox-providers/daytona/src/login-pty.ts +++ b/packages/plugins/sandbox-providers/daytona/src/login-pty.ts @@ -10,9 +10,10 @@ // no command string. This module maps the key to a compile-time command, so a // caller cannot select or override the command. For the Codex key the module // composes `exec env CODEX_HOME= `. For the -// Claude key it composes `exec ` with no CODEX_HOME. The -// module encodes the dynamic home with a POSIX shell-argument encoder, so the -// home cannot add a second shell token or a second command. +// Grok key it composes `exec env GROK_HOME= `. +// For the Claude key it composes `exec ` with no home +// variable. The module encodes the dynamic home with a POSIX shell-argument +// encoder, so the home cannot add a second shell token or a second command. // // Session home: the module revalidates the descriptor and the home shape, then // creates the session home directory with one `mkdir -p` command. The command @@ -52,7 +53,7 @@ import { sendPtyInputInChunks } from "./pty-chunked-input.js"; * compile-time command. A value outside this set fails closed before the module * touches the filesystem. */ -export type LoginCommandKey = "claude" | "codex"; +export type LoginCommandKey = "claude" | "codex" | "grok"; /** * The host-resolved launch descriptor. It carries the closed command key and the @@ -76,6 +77,7 @@ export interface LoginPtyLaunchDescriptor { const LOGIN_COMMAND_BY_KEY: Readonly> = { claude: "claude setup-token", codex: "codex login --device-auth", + grok: "grok login --device-auth", }; /** The fixed root for a login session home. */ @@ -107,22 +109,28 @@ export function encodePosixShellArg(value: string): string { /** Reports whether a value is a member of the closed login command key set. */ export function isLoginCommandKey(value: unknown): value is LoginCommandKey { - return value === "claude" || value === "codex"; + return value === "claude" || value === "codex" || value === "grok"; } /** - * Composes the launch line for the descriptor. For the Codex key it prefixes one - * safely encoded `CODEX_HOME` assignment with `env`. For the Claude key it adds no - * `CODEX_HOME`. It replaces the interactive shell with the command through `exec`, - * so the pseudo-terminal runs the command directly and its exit code becomes the - * PTY exit code. + * Composes the launch line for the descriptor. The module reads only + * {@link LOGIN_COMMAND_BY_KEY} and the closed command key on the descriptor, so + * a command string smuggled onto the descriptor object confers no authority. + * For the Codex key it prefixes one safely encoded `CODEX_HOME` assignment with + * `env`. For the Grok key it prefixes one safely encoded `GROK_HOME` assignment + * with `env`. For the Claude key it adds no home variable. It replaces the + * interactive shell with the command through `exec`, so the pseudo-terminal + * runs the command directly and its exit code becomes the PTY exit code. */ export function composeLaunchLine(descriptor: LoginPtyLaunchDescriptor): string { const command = LOGIN_COMMAND_BY_KEY[descriptor.loginCommandKey]; + const encodedHome = encodePosixShellArg(descriptor.sessionHome); if (descriptor.loginCommandKey === "codex") { - const encodedHome = encodePosixShellArg(descriptor.sessionHome); return `exec env CODEX_HOME=${encodedHome} ${command}`; } + if (descriptor.loginCommandKey === "grok") { + return `exec env GROK_HOME=${encodedHome} ${command}`; + } return `exec ${command}`; } diff --git a/packages/plugins/sandbox-providers/daytona/vitest.config.ts b/packages/plugins/sandbox-providers/daytona/vitest.config.ts index ba82e20c26..68bcaa36fc 100644 --- a/packages/plugins/sandbox-providers/daytona/vitest.config.ts +++ b/packages/plugins/sandbox-providers/daytona/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ }, }, test: { - include: ["packages/plugins/sandbox-providers/daytona/src/**/*.test.ts"], + include: [path.join(dirname, "src/**/*.test.ts")], environment: "node", }, }); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index ee8d6d3559..af1a36b868 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1000,7 +1000,7 @@ export interface PluginRenderCloseEvent { * key to a compile-time command. The open request carries no command string, so a * caller cannot select or override the command. */ -export type PluginLoginCommandKey = "claude" | "codex"; +export type PluginLoginCommandKey = "claude" | "codex" | "grok"; /** The open request for one live login pseudo-terminal. The worker registers the terminal by `hostRouteId`. */ export interface PluginLoginPtyOpenParams { diff --git a/scripts/general-server-shard-durations.json b/scripts/general-server-shard-durations.json index 8f8885be24..cec33814cc 100644 --- a/scripts/general-server-shard-durations.json +++ b/scripts/general-server-shard-durations.json @@ -49,15 +49,15 @@ "server/src/__tests__/cloud-image-bundled-plugins.test.ts": 211, "server/src/__tests__/cloud-instance.test.ts": 227, "server/src/__tests__/codex-auth-reconciliation.test.ts": 1256, - "server/src/__tests__/codex-device-login-credential-read.test.ts": 605, - "server/src/__tests__/codex-device-login-reaper.test.ts": 1310, - "server/src/__tests__/codex-device-login-service.test.ts": 4054, + "server/src/__tests__/device-login-credential-read.test.ts": 605, + "server/src/__tests__/device-login-reaper.test.ts": 1310, + "server/src/__tests__/device-login-service.test.ts": 4054, "server/src/__tests__/codex-local-adapter-environment.test.ts": 722, "server/src/__tests__/codex-local-adapter.test.ts": 739, "server/src/__tests__/codex-local-execute.test.ts": 2000, "server/src/__tests__/codex-local-skill-injection.test.ts": 674, "server/src/__tests__/codex-local-skill-sync.test.ts": 673, - "server/src/__tests__/codex-login-session-runtime.test.ts": 1272, + "server/src/__tests__/login-session-runtime.test.ts": 1272, "server/src/__tests__/companies-service.test.ts": 6166, "server/src/__tests__/company-artifacts-service.test.ts": 6065, "server/src/__tests__/company-cloud-floor.test.ts": 1279, diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index e77a94c30c..8caf881dea 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -24,8 +24,10 @@ const nonServerProjects = [ "@paperclipai/adapter-utils", "@paperclipai/adapter-claude-local", "@paperclipai/adapter-codex-local", + "@paperclipai/adapter-grok-local", "@paperclipai/adapter-openclaw-gateway", "@paperclipai/adapter-opencode-local", + "@paperclipai/plugin-daytona", "@paperclipai/plugin-sdk", "@paperclipai/create-paperclip-plugin", "@paperclipai/ui", diff --git a/server/src/__tests__/agent-device-login-routes.test.ts b/server/src/__tests__/agent-device-login-routes.test.ts index acccead5b5..23d094f340 100644 --- a/server/src/__tests__/agent-device-login-routes.test.ts +++ b/server/src/__tests__/agent-device-login-routes.test.ts @@ -1,14 +1,14 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AdapterAuthSessionConflictError } from "../services/codex-device-login-service.js"; +import { AdapterAuthSessionConflictError } from "../services/device-login-service.js"; import type { AdapterAuthSessionRow, AdapterAuthSessionStore, AcquireLoginLeaseInput, LoginSessionLease, LoginSessionRuntime, -} from "../services/codex-device-login-service.js"; +} from "../services/device-login-service.js"; // The company-scoped adapter device-login routes. These tests drive the real // login-session service through the route layer. A fake in-memory store models @@ -30,6 +30,18 @@ const SANDBOX_ENV_2 = "22222222-2222-4222-8222-222222222222"; const DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device"; const PROMPT_CODE = "ABCD-EFGHI"; const PROMPT_OUTPUT = `Open ${DEVICE_LOGIN_URL} in your browser.\nEnter the one-time code below:\n${PROMPT_CODE}\n`; + +// The Grok device-login URL and the one-time code the fake sandbox streams for +// a `grok_local` session. The Grok parser requires the `user_code` query to +// equal the code that stands alone on its own line after the preamble. +const GROK_CODE = "WXYZ-ABCD"; +const GROK_DEVICE_LOGIN_URL = `https://accounts.x.ai/oauth2/device?user_code=${GROK_CODE}`; +const GROK_PROMPT_OUTPUT = [ + "To sign in, open this URL in your browser:", + ` ${GROK_DEVICE_LOGIN_URL}`, + "Confirm this code in your browser:", + ` ${GROK_CODE}`, +].join("\n"); // A credential byte string the fake sandbox returns. The routes and the activity // must never log it. const CREDENTIAL_BYTES = '{"tokens":{"access":"SECRET-ACCESS-TOKEN"}}'; @@ -176,8 +188,8 @@ vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => { // Keep the real login-session service and the real conflict error. Replace only // the store factory and the production runtime factory with the harness fakes. -vi.mock("../services/codex-device-login-service.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../services/device-login-service.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, createDbAdapterAuthSessionStore: () => harness.store, @@ -269,7 +281,7 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map { expect(second.body.status).toBe(first.body.status); }); + it("starts a Grok session, delivers the Grok prompt once, and a codex_local read finds no row", async () => { + // The Grok parser reaches this session because the profile map resolves it + // by adapter type. The fake sandbox streams a Grok-shaped prompt, not a + // Codex-shaped one, so a surfaced prompt proves the Grok parser ran. + harness.runtime = createFakeRuntime(GROK_PROMPT_OUTPUT); + const app = await createApp(); + + const start = await request(app) + .post(loginPath(COMPANY_1, "grok_local")) + .send({ environmentId: SANDBOX_ENV_1 }); + expect(start.status, JSON.stringify(start.body)).toBe(201); + const sessionId = start.body.sessionId as string; + + const first = await request(app).get(`${loginPath(COMPANY_1, "grok_local")}/${sessionId}`); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE }); + + // The row belongs to `grok_local`, not `codex_local`. Reading it through the + // `codex_local` path finds no row for the owner. + const wrongAdapterRead = await request(app).get(`${loginPath(COMPANY_1, "codex_local")}/${sessionId}`); + expect(wrongAdapterRead.status, JSON.stringify(wrongAdapterRead.body)).toBe(404); + + const cancel = await request(app).post(`${loginPath(COMPANY_1, "grok_local")}/${sessionId}/cancel`); + expect(cancel.status, JSON.stringify(cancel.body)).toBe(200); + // The cancel resolves the public terminal status at once. Internally the row + // holds `cleanup_pending`, which encodes the cancelled terminal until the + // reaper finalizes it — the same durable-cancel contract every adapter uses. + expect(cancel.body.status).toBe("cancelled"); + + const store = harness.store as ReturnType; + const row = await store.getByPublicId(sessionId, COMPANY_1); + expect(row?.status).toBe("cleanup_pending"); + }); + it("returns 404 for a wrong-user status, prompt, and cancel", async () => { const app = await createApp(); diff --git a/server/src/__tests__/codex-device-login-credential-read.test.ts b/server/src/__tests__/device-login-credential-read.test.ts similarity index 99% rename from server/src/__tests__/codex-device-login-credential-read.test.ts rename to server/src/__tests__/device-login-credential-read.test.ts index 4b434544ab..e7f8ca76a4 100644 --- a/server/src/__tests__/codex-device-login-credential-read.test.ts +++ b/server/src/__tests__/device-login-credential-read.test.ts @@ -10,7 +10,7 @@ import { MAX_AUTH_JSON_BYTES, decodeAuthReadOutput, runDescriptorBoundAuthRead, -} from "../services/codex-device-login-credential-read.ts"; +} from "../services/device-login-credential-read.ts"; // The helper runs on node and walks the path with `/proc/self/fd`, which is // Linux only. A non-Linux host skips the script tests and keeps the pure-decode @@ -60,7 +60,7 @@ describeLinux("the descriptor-bound read helper script", () => { let root: string; beforeAll(() => { - root = mkdtempSync(path.join(tmpdir(), "codex-auth-read-")); + root = mkdtempSync(path.join(tmpdir(), "adapter-auth-read-")); }); afterAll(() => { rmSync(root, { recursive: true, force: true }); diff --git a/server/src/__tests__/codex-device-login-reaper.test.ts b/server/src/__tests__/device-login-reaper.test.ts similarity index 92% rename from server/src/__tests__/codex-device-login-reaper.test.ts rename to server/src/__tests__/device-login-reaper.test.ts index 3c7c81b53a..7b2a7d12c8 100644 --- a/server/src/__tests__/codex-device-login-reaper.test.ts +++ b/server/src/__tests__/device-login-reaper.test.ts @@ -2,17 +2,17 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; import type { AgentAdapterType } from "@paperclipai/shared"; import { - createCodexDeviceLoginReaper, + createDeviceLoginReaper, type LoginLeaseRef, type LoginSessionCleanupRuntime, type TaggedLease, -} from "../services/codex-device-login-reaper.ts"; +} from "../services/device-login-reaper.ts"; import { ADAPTER_AUTH_ACTIVE_STATUSES, type AdapterAuthReaperStore, type AdapterAuthSessionRow, type SandboxDeleteResult, -} from "../services/codex-device-login-service.ts"; +} from "../services/device-login-service.ts"; const ADAPTER_TYPE: AgentAdapterType = "codex_local"; const NOW = new Date("2026-02-01T00:05:00.000Z"); @@ -114,7 +114,7 @@ function createFakeRuntime(opts: FakeRuntimeOptions = {}) { return { runtime, deleteCalls }; } -describe("codex device login reaper", () => { +describe("device login reaper", () => { it("deletes the sandbox and marks a persisted expired non-terminal session timed_out", async () => { const store = createMemoryReaperStore(); const environmentId = randomUUID(); @@ -126,7 +126,7 @@ describe("codex device login reaper", () => { expiresAt: PAST, }); const { runtime, deleteCalls } = createFakeRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const result = await reaper.sweep(); @@ -146,7 +146,7 @@ describe("codex device login reaper", () => { expiresAt: FUTURE, }); const { runtime, deleteCalls } = createFakeRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const result = await reaper.sweep(); @@ -166,7 +166,7 @@ describe("codex device login reaper", () => { promotionExpiresAt: FUTURE, }); const { runtime, deleteCalls } = createFakeRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const result = await reaper.sweep(); @@ -189,7 +189,7 @@ describe("codex device login reaper", () => { promotionExpiresAt: PAST, }); const { runtime, deleteCalls } = createFakeRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const result = await reaper.sweep(); @@ -208,7 +208,7 @@ describe("codex device login reaper", () => { expiresAt: PAST, }); const { runtime, deleteCalls } = createFakeRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const result = await reaper.sweep(); @@ -235,7 +235,7 @@ describe("codex device login reaper", () => { return { outcome: "not_found" }; }, }); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); // First sweep: the delete rejects, so the row stays cleanup_pending. const first = await reaper.sweep(); @@ -273,7 +273,7 @@ describe("codex device login reaper", () => { { environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" }, ], }); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const result = await reaper.sweep(); @@ -294,7 +294,7 @@ describe("codex device login reaper", () => { return { outcome: "deleted" }; }, }); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); const first = await reaper.sweep(); expect(first.orphanLeasesDeleted).toBe(0); @@ -319,7 +319,7 @@ describe("codex device login reaper", () => { tagged: [{ environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" }], deleteImpl: async () => ({ outcome: "not_found" }), }); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW }); await expect(reaper.sweep()).resolves.toBeDefined(); // The second sweep runs over the now-terminal session and the same orphan diff --git a/server/src/__tests__/codex-device-login-service.test.ts b/server/src/__tests__/device-login-service.test.ts similarity index 74% rename from server/src/__tests__/codex-device-login-service.test.ts rename to server/src/__tests__/device-login-service.test.ts index 510a043629..73dea1d5dd 100644 --- a/server/src/__tests__/codex-device-login-service.test.ts +++ b/server/src/__tests__/device-login-service.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { adapterAuthSessions, companies, createDb, environments } from "@paperclipai/db"; +import { adapterAuthSessions, companies, createDb, environmentLeases, environments } from "@paperclipai/db"; import type { AgentAdapterType } from "@paperclipai/shared"; import { DEVICE_LOGIN_URL } from "@paperclipai/adapter-codex-local/server"; import { @@ -11,10 +11,13 @@ import { import { AdapterAuthSessionConflictError, buildSandboxLoginDriver, - CODEX_DEVICE_LOGIN_TIMEOUT_MS, - createCodexDeviceLoginService, + DEVICE_LOGIN_TIMEOUT_MS, + DISPLAYED_CODE_ADAPTER_TYPES, + DISPLAYED_CODE_PROFILES, + LOGIN_LEASE_SESSION_TAG_KEY, + createDeviceLoginService, createDbAdapterAuthSessionStore, - sessionCodexHomePath, + sessionLoginHomePath, sessionCredentialPath, type AcquireLoginLeaseInput, type AdapterAuthSessionRow, @@ -24,11 +27,12 @@ import { type LoginSessionLease, type LoginSessionRuntime, type SandboxDeleteResult, -} from "../services/codex-device-login-service.ts"; +} from "../services/device-login-service.ts"; import { - createCodexDeviceLoginReaper, + createDeviceLoginReaper, + createProductionLoginSessionReaperRuntime, type LoginSessionCleanupRuntime, -} from "../services/codex-device-login-reaper.ts"; +} from "../services/device-login-reaper.ts"; // A cleanup runtime for the reaper. It confirms every delete and reports no // tagged lease, so the reaper only reclaims the seeded session rows. @@ -51,11 +55,27 @@ function createReaperRuntime() { // still supplies a promotion that accepts the credential. const passingPromotion: CredentialPromotion = { promote: () => {} }; -// Build the service with the passing promotion by default. A test that checks -// the promotion path passes its own `promotion` to override the default. -type ServiceDeps = Parameters[0]; -function makeService(deps: Omit & { promotion?: CredentialPromotion }) { - return createCodexDeviceLoginService({ promotion: passingPromotion, ...deps }); +// Build the service with the passing promotion by default, applied to every +// adapter type. A test that checks the promotion path passes its own +// `promotion` to override the default for every adapter type, or its own +// `promotionByAdapterType` to give each adapter type a distinct promotion (for +// example, to prove a `grok_local` login never runs the Codex promotion). +type ServiceDeps = Parameters[0]; +function makeService( + deps: Omit & { + promotion?: CredentialPromotion; + promotionByAdapterType?: ServiceDeps["promotionByAdapterType"]; + }, +) { + const { promotion, promotionByAdapterType, ...rest } = deps; + return createDeviceLoginService({ + promotionByAdapterType: + promotionByAdapterType ?? { + codex_local: promotion ?? passingPromotion, + grok_local: promotion ?? passingPromotion, + }, + ...rest, + }); } const ADAPTER_TYPE: AgentAdapterType = "codex_local"; @@ -87,6 +107,18 @@ async function waitForStatus( const PROMPT_OUTPUT = `Open ${DEVICE_LOGIN_URL} in your browser.\nEnter the one-time code below:\nABCD-EFGHI\n`; const PROMPT_CODE = "ABCD-EFGHI"; +// A valid Grok device-login output. The Grok parser requires the `user_code` +// query to equal the code that stands alone on its own line after the +// preamble. The Codex parser accepts none of this shape. +const GROK_CODE = "WXYZ-ABCD"; +const GROK_DEVICE_LOGIN_URL = `https://accounts.x.ai/oauth2/device?user_code=${GROK_CODE}`; +const GROK_PROMPT_OUTPUT = [ + "To sign in, open this URL in your browser:", + ` ${GROK_DEVICE_LOGIN_URL}`, + "Confirm this code in your browser:", + ` ${GROK_CODE}`, +].join("\n"); + type ExecController = { onStdout: (chunk: string) => void; input: AcquireLoginLeaseInput }; type ExecBehavior = (c: ExecController) => Promise<{ exitCode: number | null }>; @@ -235,7 +267,7 @@ function createMemoryStore(): AdapterAuthSessionStore & { }; } -describe("codex device login service", () => { +describe("device login service", () => { it("inserts the session row before it acquires the lease", async () => { const store = createMemoryStore(); let rowPresentAtAcquire = false; @@ -350,6 +382,197 @@ describe("codex device login service", () => { expect(JSON.stringify(activity)).not.toContain(PROMPT_CODE); }); + it("gives a Grok prompt to a grok_local session, and the session surfaces the Grok code and URL", async () => { + // This proves the Grok parser ran: the profile map resolves the parser from + // the trusted adapter type, so a `grok_local` session runs the Grok parser, + // not the Codex parser. + const store = createMemoryStore(); + const execGrokSuccess: ExecBehavior = async ({ onStdout }) => { + onStdout(GROK_PROMPT_OUTPUT); + return { exitCode: 0 }; + }; + const { runtime } = createFakeRuntime({ exec: execGrokSuccess, authBytes: Buffer.from("{}") }); + const companyId = randomUUID(); + const service = makeService({ store, runtime }); + const { session, completed } = await service.start({ + companyId, + environmentId: randomUUID(), + adapterType: "grok_local", + startedByUserId: OWNER_A, + }); + + await waitForStatus(store, session.sessionId, companyId, "waiting_for_user"); + await new Promise((resolve) => setImmediate(resolve)); + + const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); + expect(owner?.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE }); + + await completed; + }); + + it("gives the same Grok prompt to a codex_local session, and the session surfaces no prompt", async () => { + // This proves the Codex parser stays strict: a Grok-shaped prompt never + // satisfies the Codex parser's origin, path, and code-length rules, so the + // row never leaves `starting` and the owner read carries no prompt. + const store = createMemoryStore(); + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const execGrokGated: ExecBehavior = async ({ onStdout }) => { + onStdout(GROK_PROMPT_OUTPUT); + await gate; + return { exitCode: 0 }; + }; + const { runtime } = createFakeRuntime({ exec: execGrokGated, authBytes: Buffer.from("{}") }); + const companyId = randomUUID(); + const service = makeService({ store, runtime }); + const { session, completed } = await service.start({ + companyId, + environmentId: randomUUID(), + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + }); + + // Give the run several event-loop turns. The row must never reach + // `waiting_for_user`, because the Codex parser never matches the Grok + // prompt. + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + const row = await store.getByPublicId(session.sessionId, companyId); + expect(row?.status).toBe("starting"); + + const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); + expect(owner?.prompt).toBeNull(); + + releaseGate(); + await completed; + }); + + it("runs the Grok promotion (never the Codex one) for a grok_local login, and the Codex promotion (never the Grok one) for a codex_local login", async () => { + // This proves the promotion dispatch is adapter-scoped: `resolveProfile` + // must pick the promotion for the login's own adapter type, not always the + // same injected value. A test that only asserts the map carries a + // `grok_local` member would not prove this — it follows the value all the + // way to the call that runs it. + const codexPromoteCalls: string[] = []; + const grokPromoteCalls: string[] = []; + const service = makeService({ + store: createMemoryStore(), + runtime: createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") }).runtime, + promotionByAdapterType: { + codex_local: { + promote: (_bytes, context) => { + codexPromoteCalls.push(context.sessionId); + }, + }, + grok_local: { + promote: (_bytes, context) => { + grokPromoteCalls.push(context.sessionId); + }, + }, + }, + }); + + const grokRun = await service.start({ + companyId: randomUUID(), + environmentId: randomUUID(), + adapterType: "grok_local", + startedByUserId: OWNER_A, + }); + await grokRun.completed; + expect(grokPromoteCalls).toHaveLength(1); + expect(codexPromoteCalls).toHaveLength(0); + + const codexRun = await service.start({ + companyId: randomUUID(), + environmentId: randomUUID(), + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + }); + await codexRun.completed; + expect(codexPromoteCalls).toHaveLength(1); + expect(grokPromoteCalls).toHaveLength(1); + }); + + it("holds no prompt value, account email, or credential byte in an activity record, an API response, or a database row for a Grok session", async () => { + // A realistic Grok auth.json: the composite :: identity key, + // a refresh token, and the personal fields the payload carries (email, + // name, ids). None of these may reach the service's own generic surfaces: + // the activity record, the session/owner API responses, or the database + // row. (The promotion's own log lines are proven redacted separately, in + // the grok-local `adapter-auth-promotion.test.ts` suite.) + const grokIssuer = "https://issuer.x.ai"; + const grokUuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6"; + const grokEmail = "grok-user@example.com"; + const refreshTokenSentinel = "SENTINEL_GROK_REFRESH_TOKEN"; + const grokAuthBytes = Buffer.from( + JSON.stringify({ + [`${grokIssuer}::${grokUuid}`]: { + key: "api-key-value", + refresh_token: refreshTokenSentinel, + expires_at: "2026-01-01T00:00:00Z", + oidc_issuer: grokIssuer, + oidc_client_id: "client-1", + email: grokEmail, + first_name: "Test", + last_name: "User", + user_id: "user-1", + principal_id: "principal-1", + team_id: "team-1", + }, + }), + ); + + const store = createMemoryStore(); + const activity: LoginSessionActivityEvent[] = []; + const { runtime } = createFakeRuntime({ + exec: async ({ onStdout }) => { + onStdout(GROK_PROMPT_OUTPUT); + return { exitCode: 0 }; + }, + authBytes: grokAuthBytes, + }); + const companyId = randomUUID(); + const service = makeService({ + store, + runtime, + recordActivity: (event) => activity.push(event), + promotionByAdapterType: { grok_local: { promote: () => {} } }, + }); + + const { session, completed } = await service.start({ + companyId, + environmentId: randomUUID(), + adapterType: "grok_local", + startedByUserId: OWNER_A, + }); + await waitForStatus(store, session.sessionId, companyId, "waiting_for_user"); + await new Promise((resolve) => setImmediate(resolve)); + + const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); + const outcome = await completed; + expect(outcome.status).toBe("authenticated"); + + const forbidden = [grokEmail, refreshTokenSentinel, "api-key-value", grokUuid]; + const activityText = JSON.stringify(activity); + const sessionText = JSON.stringify(session); + const ownerText = JSON.stringify(owner); + const outcomeText = JSON.stringify(outcome); + const row = await store.getByPublicId(session.sessionId, companyId); + const rowText = JSON.stringify(row); + for (const secret of forbidden) { + expect(activityText).not.toContain(secret); + expect(sessionText).not.toContain(secret); + expect(ownerText).not.toContain(secret); + expect(outcomeText).not.toContain(secret); + expect(rowText).not.toContain(secret); + } + // The owner read still carries the (non-secret) device-login prompt. + expect(owner?.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE }); + }); + it("fails closed and never promotes when a success outcome carries no credential", async () => { const store = createMemoryStore(); let promoteCalls = 0; @@ -811,7 +1034,7 @@ describe("codex device login service", () => { // One millisecond before five minutes: the run still holds the active // claim. - await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS - 1); + await vi.advanceTimersByTimeAsync(DEVICE_LOGIN_TIMEOUT_MS - 1); expect(settled).toBe(false); const midRow = await store.getByPublicId(session.sessionId, companyId); expect(["starting", "waiting_for_user"]).toContain(midRow?.status); @@ -847,7 +1070,7 @@ describe("codex device login service", () => { adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, }); - await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS); + await vi.advanceTimersByTimeAsync(DEVICE_LOGIN_TIMEOUT_MS); const outcome = await completed; expect(outcome.status).toBe("timed_out"); expect(outcome.cleanupPending).toBe(true); @@ -863,7 +1086,7 @@ describe("codex device login service", () => { it("runs the login over the shared pseudo-terminal and reads the credential with the descriptor-bound read", async () => { const sessionId = randomUUID(); - const sessionHome = sessionCodexHomePath(sessionId); + const sessionHome = sessionLoginHomePath(sessionId); const authPath = sessionCredentialPath(sessionId); // The fake pseudo-terminal session streams the prompt and exits with code @@ -912,7 +1135,7 @@ describe("codex device login service", () => { environment: { id: "env", driver: "sandbox" } as never, lease: { id: "lease" } as never, sessionHome, - timeoutMs: CODEX_DEVICE_LOGIN_TIMEOUT_MS, + timeoutMs: DEVICE_LOGIN_TIMEOUT_MS, }); const chunks: string[] = []; @@ -950,12 +1173,12 @@ if (!embeddedPostgresSupport.supported) { ); } -describeEmbeddedPostgres("codex device login service concurrency (embedded postgres)", () => { +describeEmbeddedPostgres("device login service concurrency (embedded postgres)", () => { let stopDb: (() => Promise) | undefined; let db!: ReturnType; beforeAll(async () => { - const started = await startEmbeddedPostgresTestDatabase("codex-device-login"); + const started = await startEmbeddedPostgresTestDatabase("device-login"); stopDb = started.stop; db = createDb(started.connectionString); }); @@ -1178,7 +1401,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg const sessionId = await seedStalePromotingRow(companyId, environmentId); const { runtime } = createReaperRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => new Date() }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => new Date() }); // Run the ownership check and the credential write inside the promotion lock. // The write flag stands in for the filesystem credential write. A gate holds @@ -1227,7 +1450,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg const sessionId = await seedStalePromotingRow(companyId, environmentId); const { runtime } = createReaperRuntime(); - const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => new Date() }); + const reaper = createDeviceLoginReaper({ store, runtime, now: () => new Date() }); // The reaper reclaims the stale row first and times it out. const sweep = await reaper.sweep(); @@ -1286,10 +1509,10 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg // The reaper reclaims the expired row and times it out. const { runtime: reaperRuntime } = createReaperRuntime(); - const reaper = createCodexDeviceLoginReaper({ + const reaper = createDeviceLoginReaper({ store, runtime: reaperRuntime, - now: () => new Date(Date.now() + CODEX_DEVICE_LOGIN_TIMEOUT_MS + 60_000), + now: () => new Date(Date.now() + DEVICE_LOGIN_TIMEOUT_MS + 60_000), }); const sweep = await reaper.sweep(); expect(sweep.expiredTimedOut).toBe(1); @@ -1391,4 +1614,209 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg await started.completed; }); }); + + describe("the displayed-code adapter scope", () => { + it("reads a session row of a second displayed-code adapter by its public id", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const store = createDbAdapterAuthSessionStore(db); + const publicSessionId = randomUUID(); + await db.insert(adapterAuthSessions).values({ + id: randomUUID(), + companyId, + environmentId, + adapterType: "grok_local", + startedByUserId: OWNER_A, + publicSessionId, + status: "starting", + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const row = await store.getByPublicId(publicSessionId, companyId); + expect(row).not.toBeNull(); + expect(row?.adapterType).toBe("grok_local"); + }); + + it("the expiry scan and the cleanup scan include a row of a second displayed-code adapter", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const store = createDbAdapterAuthSessionStore(db); + const past = new Date(Date.now() - 60_000); + const expiredId = randomUUID(); + await db.insert(adapterAuthSessions).values({ + id: expiredId, + companyId, + environmentId, + adapterType: "grok_local", + startedByUserId: OWNER_A, + publicSessionId: randomUUID(), + status: "starting", + expiresAt: past, + createdAt: past, + updatedAt: past, + }); + const pendingId = randomUUID(); + await db.insert(adapterAuthSessions).values({ + id: pendingId, + companyId, + environmentId, + adapterType: "grok_local", + startedByUserId: OWNER_A, + publicSessionId: randomUUID(), + status: "cleanup_pending", + failureReason: "failed|login_command_failed", + finishedAt: past, + createdAt: past, + updatedAt: past, + }); + + const expired = await store.listExpiredActiveSessions(new Date()); + expect(expired.map((row) => row.id)).toContain(expiredId); + + const pending = await store.listCleanupPendingSessions(); + expect(pending.map((row) => row.id)).toContain(pendingId); + }); + + it("the orphan lease scan includes a session row of a second displayed-code adapter, and excludes a Claude setup-token row", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const reaperRuntime = createProductionLoginSessionReaperRuntime({ + db, + // The orphan scan under test reads only the store and the lease list; it + // never reaches the environment runtime driver. + environmentRuntime: {} as never, + }); + + // A grok_local session row that still owns its lease. `grok_local` is in + // the displayed-code adapter set, so the scan's candidate query reaches + // this row's environment. + const grokSessionId = randomUUID(); + await db.insert(adapterAuthSessions).values({ + id: randomUUID(), + companyId, + environmentId, + adapterType: "grok_local", + startedByUserId: OWNER_A, + publicSessionId: randomUUID(), + providerLeaseId: "grok-lease-1", + status: "waiting_for_user", + expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(environmentLeases).values({ + companyId, + environmentId, + status: "active", + providerLeaseId: "grok-lease-1", + metadata: { [LOGIN_LEASE_SESSION_TAG_KEY]: grokSessionId }, + }); + + // A Claude setup-token row in a second environment, in the shared + // `cleanup_pending` state the two login flows both use. `claude_local` is + // outside the displayed-code adapter set, and no other row makes this + // second environment a scan candidate, so the scan must never read it. + const claudeEnvironmentId = await seedEnvironment(companyId); + const claudeSessionId = randomUUID(); + await db.insert(adapterAuthSessions).values({ + id: randomUUID(), + companyId, + environmentId: claudeEnvironmentId, + adapterType: "claude_local", + startedByUserId: OWNER_B, + publicSessionId: randomUUID(), + providerLeaseId: "claude-lease-1", + status: "cleanup_pending", + failureReason: "failed", + finishedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(environmentLeases).values({ + companyId, + environmentId: claudeEnvironmentId, + status: "active", + providerLeaseId: "claude-lease-1", + metadata: { [LOGIN_LEASE_SESSION_TAG_KEY]: claudeSessionId }, + }); + + const tagged = await reaperRuntime.listTaggedLeases(); + const taggedProviderLeaseIds = tagged.map((lease) => lease.providerLeaseId); + expect(taggedProviderLeaseIds).toContain("grok-lease-1"); + expect(taggedProviderLeaseIds).not.toContain("claude-lease-1"); + }); + + it("carries the CODEX_HOME variable name on the codex_local profile", () => { + // The home variable name has no consuming call site yet in this phase; the + // profile only carries it, ready for a later phase to read it. + expect(DISPLAYED_CODE_PROFILES.codex_local?.homeEnvVar).toBe("CODEX_HOME"); + }); + + it("runLogin and the start ttl read the command, the parser, and the timeout from the resolved profile", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const store = createDbAdapterAuthSessionStore(db); + const originalProfile = DISPLAYED_CODE_PROFILES.codex_local!; + const customPrompt = { url: "https://example.test/device", code: "ZZZZ-99999" }; + const customTimeoutMs = 12_345_000; + const capturedCommands: string[] = []; + // The map is a plain object at runtime; only its TypeScript type reads + // `Readonly`. Mutate the one entry for this test, then restore it, so no + // other test in this file observes the override. + const profiles = DISPLAYED_CODE_PROFILES as Record; + profiles.codex_local = { + ...originalProfile, + command: "custom-login-command", + timeoutMs: customTimeoutMs, + parsePrompt: (output) => (output.includes("MARKER") ? customPrompt : null), + }; + try { + const runtime: LoginSessionRuntime = { + async acquireLoginLease(input) { + return { + providerLeaseId: `lease-${input.sessionId}`, + authPath: sessionCredentialPath(input.sessionId), + driver: { + start: (command, onData) => { + capturedCommands.push(command); + onData("a MARKER line\n"); + return new Promise<{ exitCode: number | null }>(() => {}); + }, + readFile: async () => Buffer.from("{}"), + dispose: async () => {}, + }, + deleteSandbox: async () => ({ outcome: "deleted" }), + release: async () => {}, + }; + }, + }; + const service = makeService({ store, runtime }); + const controller = new AbortController(); + const started = await service.start({ + companyId, + environmentId, + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + signal: controller.signal, + }); + + // The start ttl read: `session.expiresAt` reflects the profile's + // `timeoutMs`, not the fixed `DEVICE_LOGIN_TIMEOUT_MS`. + const expectedExpiresAt = Date.now() + customTimeoutMs; + expect(Math.abs(new Date(started.session.expiresAt).getTime() - expectedExpiresAt)).toBeLessThan( + 5000, + ); + + // `runLogin`: the driver received the profile's command, and the + // profile's parser produced the owner-visible prompt. + await waitForStatus(store, started.session.sessionId, companyId, "waiting_for_user"); + const owner = await service.readOwnerSession(started.session.sessionId, companyId, OWNER_A); + expect(capturedCommands).toEqual(["custom-login-command"]); + expect(owner?.prompt).toEqual(customPrompt); + + controller.abort(); + await started.completed; + } finally { + profiles.codex_local = originalProfile; + } + }); + }); }); diff --git a/server/src/__tests__/codex-login-session-runtime.test.ts b/server/src/__tests__/login-session-runtime.test.ts similarity index 90% rename from server/src/__tests__/codex-login-session-runtime.test.ts rename to server/src/__tests__/login-session-runtime.test.ts index 5f82e75b1d..d5cfb03faa 100644 --- a/server/src/__tests__/codex-login-session-runtime.test.ts +++ b/server/src/__tests__/login-session-runtime.test.ts @@ -15,12 +15,12 @@ vi.mock("../services/environments.js", () => ({ })); import { - createCodexWorkerBoundLoginPtyOpener, + createWorkerBoundLoginPtyOpener, createProductionLoginSessionRuntime, - sessionCodexHomePath, - CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, + sessionLoginHomePath, + DEVICE_LOGIN_PROVIDER_UNSUPPORTED, type LoginPtySessionBinding, -} from "../services/codex-device-login-service.js"; +} from "../services/device-login-service.js"; // A fake worker pseudo-terminal session. It satisfies the shared transport // contract, so the opener can return it. @@ -44,7 +44,7 @@ function makeBinding(overrides: Partial = {}): LoginPtyS environmentId: "env-1", adapterType: "codex_local" as AgentAdapterType, providerLeaseId: "provider-lease-9", - sessionHome: sessionCodexHomePath(randomUUID()), + sessionHome: sessionLoginHomePath(randomUUID()), environment: { id: "env-1", driver: "sandbox" } as never, lease: { id: "lease-1", @@ -54,14 +54,14 @@ function makeBinding(overrides: Partial = {}): LoginPtyS }; } -describe("createCodexWorkerBoundLoginPtyOpener", () => { +describe("createWorkerBoundLoginPtyOpener", () => { it("passes the binding's server-controlled session home to the worker route", async () => { - const sessionHome = sessionCodexHomePath(randomUUID()); + const sessionHome = sessionLoginHomePath(randomUUID()); const session = fakeWorkerSession(); const openLoginPtySession = vi.fn( async (_pluginId: string, _input: Record) => session, ); - const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({ + const openLivePtySession = createWorkerBoundLoginPtyOpener({ workerManager: { openLoginPtySession }, }); @@ -90,7 +90,7 @@ describe("createCodexWorkerBoundLoginPtyOpener", () => { it("fails closed when the lease carries no sandbox worker binding", async () => { const openLoginPtySession = vi.fn(); - const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({ + const openLivePtySession = createWorkerBoundLoginPtyOpener({ workerManager: { openLoginPtySession }, }); @@ -102,7 +102,7 @@ describe("createCodexWorkerBoundLoginPtyOpener", () => { it("fails closed when the adapter type has no login command key", async () => { const openLoginPtySession = vi.fn(); - const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({ + const openLivePtySession = createWorkerBoundLoginPtyOpener({ workerManager: { openLoginPtySession }, }); @@ -131,7 +131,7 @@ describe("createProductionLoginSessionRuntime lease-acquisition gate", () => { // The capability flipped to unsupported after the route gate. The lease gate // reads the current capability and fails closed. const assertProviderSupportsLoginPty = vi.fn(async () => { - throw new Error(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED); + throw new Error(DEVICE_LOGIN_PROVIDER_UNSUPPORTED); }); const runtime = createProductionLoginSessionRuntime({ db: {} as never, @@ -141,7 +141,7 @@ describe("createProductionLoginSessionRuntime lease-acquisition gate", () => { await expect( runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() }), - ).rejects.toThrow(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED); + ).rejects.toThrow(DEVICE_LOGIN_PROVIDER_UNSUPPORTED); // The runtime re-checked the current capability from the environment id. expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1"); // The gate failed before the provider lease, so no lease was acquired. diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts index 0282d4290a..a8278f8e40 100644 --- a/server/src/adapters/registry.test.ts +++ b/server/src/adapters/registry.test.ts @@ -20,6 +20,18 @@ describe("built-in adapter login capabilities", () => { expect(() => assertValidAdapterLoginCapability(capability, "codex_local")).not.toThrow(); }); + it("registers the Grok device-login capability", () => { + const capability = requireServerAdapter("grok_local").loginCapability; + expect(capability).toBeDefined(); + if (!capability) return; + expect(capability.panelMode).toBe("displayed_code"); + expect(capability.timeoutPolicy).toBe("caller_bounded"); + expect(capability.completionClaim).toBeUndefined(); + expect(typeof capability.getCommand).toBe("function"); + expect(typeof capability.parsePrompt).toBe("function"); + expect(() => assertValidAdapterLoginCapability(capability, "grok_local")).not.toThrow(); + }); + it("registers the Claude setup-token capability", () => { const capability = requireServerAdapter("claude_local").loginCapability; expect(capability).toBeDefined(); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index df4c45af4d..ec1da8b585 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -83,6 +83,8 @@ import { syncGrokSkills, testEnvironment as grokTestEnvironment, sessionCodec as grokSessionCodec, + GROK_DEVICE_LOGIN_COMMAND, + parseGrokDeviceLoginPrompt, } from "@paperclipai/adapter-grok-local/server"; import { agentConfigurationDoc as grokAgentConfigurationDoc, @@ -234,6 +236,24 @@ const codexLoginCapability: AdapterLoginCapability = { }, }; +// The Grok interactive login capability. Grok runs `grok login --device-auth` +// on a real pseudo-terminal, the same way Codex does. The flow shows a +// one-time code that the user enters in the browser. The caller sets the +// host-side timeout. The device-login flow writes its credential inside the +// sandbox, so the capability declares no terminal credential capture and no +// completion claim. `getCommand` is descriptive only: the login path selects +// the real command from the closed key map in `login-command.ts`, never from +// this member. +const grokLoginCapability: AdapterLoginCapability = { + panelMode: "displayed_code", + timeoutPolicy: "caller_bounded", + getCommand: () => GROK_DEVICE_LOGIN_COMMAND, + parsePrompt: (output) => { + const prompt = parseGrokDeviceLoginPrompt(output); + return prompt ? { url: prompt.url, code: prompt.code } : null; + }, +}; + const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: stampClaudeAgentIdHeader(claudeExecute), @@ -466,6 +486,7 @@ const grokLocalAdapter: ServerAdapterModule = { installCommand: null, }), agentConfigurationDoc: grokAgentConfigurationDoc, + loginCapability: grokLoginCapability, }; const kimiLocalAdapter: ServerAdapterModule = { diff --git a/server/src/index.ts b/server/src/index.ts index 9f0dd8e3da..2b7816eb18 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -72,11 +72,11 @@ import { questionResponseDeliveryService } from "./services/question-response-de import { queueIssueAssignmentWakeup } from "./services/issue-assignment-wakeup.js"; import { createSecretProposalsService } from "./services/secret-proposals.js"; import { environmentRuntimeService } from "./services/environment-runtime.js"; -import { createDbAdapterAuthSessionStore } from "./services/codex-device-login-service.js"; +import { createDbAdapterAuthSessionStore } from "./services/device-login-service.js"; import { - createCodexDeviceLoginReaper, + createDeviceLoginReaper, createProductionLoginSessionReaperRuntime, -} from "./services/codex-device-login-reaper.js"; +} from "./services/device-login-reaper.js"; import { createProductionSetupTokenReaper } from "./services/setup-token-reaper.js"; import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js"; import { @@ -1176,7 +1176,7 @@ export async function startServer(): Promise { // any expired non-terminal session, retries the delete for any terminal // session left in `cleanup_pending`, and deletes a tagged lease that no live // session references. - const adapterLoginReaper = createCodexDeviceLoginReaper({ + const adapterLoginReaper = createDeviceLoginReaper({ store: createDbAdapterAuthSessionStore(db as any), runtime: createProductionLoginSessionReaperRuntime({ db: db as any, diff --git a/server/src/routes/adapters.test.ts b/server/src/routes/adapters.test.ts index b5d8e9f138..7a058f57e9 100644 --- a/server/src/routes/adapters.test.ts +++ b/server/src/routes/adapters.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AdapterLoginCapability, ServerAdapterModule } from "@paperclipai/adapter-utils"; +import { requireServerAdapter } from "../adapters/registry.js"; import { buildAdapterCapabilities } from "./adapters.js"; // The adapter listing projects the safe scalar login fields to the client. The @@ -63,4 +64,14 @@ describe("buildAdapterCapabilities login projection", () => { expect(caps.login).not.toHaveProperty("captureCredential"); expect(caps.login).not.toHaveProperty("completionClaim"); }); + + it("projects panelMode and timeoutPolicy for the registered grok_local adapter, with no function member", () => { + const caps = buildAdapterCapabilities(requireServerAdapter("grok_local")); + expect(caps.login).toEqual({ + panelMode: "displayed_code", + timeoutPolicy: "caller_bounded", + }); + expect(caps.login).not.toHaveProperty("getCommand"); + expect(caps.login).not.toHaveProperty("parsePrompt"); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cfff6bb3d1..89960672bc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -32,6 +32,7 @@ import { startAdapterAuthSessionRequestSchema, startClaudeSetupTokenSessionRequestSchema, submitBrowserCodeRequestSchema, + type AgentAdapterType, } from "@paperclipai/shared"; import { isForbiddenConfigEnvKey, @@ -145,15 +146,20 @@ import { checkStagedCredentialReadiness, promoteDeviceLoginCredential, } from "@paperclipai/adapter-codex-local/server"; +import { + checkStagedGrokCredentialReadiness, + promoteGrokDeviceLoginCredential, +} from "@paperclipai/adapter-grok-local/server"; import { AdapterAuthSessionConflictError, - createCodexDeviceLoginService, - createCodexWorkerBoundLoginPtyOpener, + createDeviceLoginService, + createWorkerBoundLoginPtyOpener, createDbAdapterAuthSessionStore, createProductionLoginSessionRuntime, - CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, - CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE, -} from "../services/codex-device-login-service.js"; + DEVICE_LOGIN_PROVIDER_UNSUPPORTED, + DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE, + type CredentialPromotion, +} from "../services/device-login-service.js"; import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; @@ -496,7 +502,7 @@ export function agentRoutes( // process owns one instance, so the in-memory prompt and the cancellation // controllers persist across requests. const adapterLoginStore = createDbAdapterAuthSessionStore(db); - const adapterLoginService = createCodexDeviceLoginService({ + const adapterLoginService = createDeviceLoginService({ store: adapterLoginStore, runtime: createProductionLoginSessionRuntime({ db, @@ -514,73 +520,119 @@ export function agentRoutes( // opens. When no worker manager is bound, the runtime keeps its fail-closed // opener and the login fails closed. openLivePtySession: options.pluginWorkerManager - ? createCodexWorkerBoundLoginPtyOpener({ + ? createWorkerBoundLoginPtyOpener({ workerManager: options.pluginWorkerManager, log: (line) => logger.info(line), }) : undefined, }), - // The mandatory credential promotion. A successful login authenticates only - // after this promotion validates the exact staged credential, runs an - // independent readiness check, confirms the session still holds the sole - // active claim, and writes the credential into the company scope. A rejected - // or unready credential fails the session and writes nothing. - promotion: { - async promote(authBytes, context) { - // Hold the promotion critical-section lock across the ownership check and - // the credential write. The reaper takes the same lock before it reclaims - // a stale `promoting` row. So a reclaim never interleaves with a live - // write: the reaper either wins the lock first and the ownership check - // then reads a reclaimed row and writes nothing, or the write finishes - // first under the lock and the reaper reclaims only after it completes. A - // read-only fence is not enough, because the filesystem write can start - // after the fence; the lock spans the whole section. - const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock( - context.companyId, - context.startedByUserId, - context.adapterType, - () => - promoteDeviceLoginCredential({ - authBytes, - companyId: context.companyId, - userInitiated: true, - checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes), - isSoleActiveOwner: async () => { - // The partial unique index allows one active row per company and - // adapter. So a `promoting` row for this session is the sole - // active owner of the company credential slot. The read runs - // inside the lock, so it observes a reaper reclaim that committed - // before this section acquired the lock. - const row = await adapterLoginStore.get(context.sessionId); - return row?.status === "promoting" && row.companyId === context.companyId; - }, - log: (line) => { - // The promotion lines carry no token bytes and no raw account id, - // so it is safe to log them with the session identifier. - logger.info({ sessionId: context.sessionId }, line); - }, - }), - ); - // A resolved promotion is not necessarily an accepted promotion. In - // particular, a reaper/expiry race can revoke this session's sole - // ownership between the service transition and Decision H. Fail closed: - // only a credential write or a deliberate safe keep can authenticate. - if (outcome === "kept_foreign_identity") { - // The login produced a different account than the one the company - // credential home already holds. The promotion never clobbers an - // occupied home, so this login installed nothing durable, and the - // identity-anchored vend can never select it: a later run keeps the - // existing account. Fail the session, so the operator never sees a - // false `authenticated` for an account the system will not use. - throw new Error( - "device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept", + // The mandatory credential promotion, keyed by adapter type. A successful + // login authenticates only after the promotion for its own adapter type + // validates the exact staged credential, runs an independent readiness + // check, confirms the session still holds the sole active claim, and + // writes the credential into the company scope. A rejected or unready + // credential fails the session and writes nothing. Keying by adapter type + // keeps a `grok_local` login from ever running the Codex promotion (and + // vice versa): each entry closes over its own readiness check and its own + // promotion function. + promotionByAdapterType: { + codex_local: { + async promote(authBytes, context) { + // Hold the promotion critical-section lock across the ownership check and + // the credential write. The reaper takes the same lock before it reclaims + // a stale `promoting` row. So a reclaim never interleaves with a live + // write: the reaper either wins the lock first and the ownership check + // then reads a reclaimed row and writes nothing, or the write finishes + // first under the lock and the reaper reclaims only after it completes. A + // read-only fence is not enough, because the filesystem write can start + // after the fence; the lock spans the whole section. + const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock( + context.companyId, + context.startedByUserId, + context.adapterType, + () => + promoteDeviceLoginCredential({ + authBytes, + companyId: context.companyId, + userInitiated: true, + checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes), + isSoleActiveOwner: async () => { + // The partial unique index allows one active row per company and + // adapter. So a `promoting` row for this session is the sole + // active owner of the company credential slot. The read runs + // inside the lock, so it observes a reaper reclaim that committed + // before this section acquired the lock. + const row = await adapterLoginStore.get(context.sessionId); + return row?.status === "promoting" && row.companyId === context.companyId; + }, + log: (line) => { + // The promotion lines carry no token bytes and no raw account id, + // so it is safe to log them with the session identifier. + logger.info({ sessionId: context.sessionId }, line); + }, + }), ); - } - if (outcome !== "promoted" && outcome !== "kept") { - throw new Error(`device-login credential promotion rejected: ${outcome}`); - } + // A resolved promotion is not necessarily an accepted promotion. In + // particular, a reaper/expiry race can revoke this session's sole + // ownership between the service transition and Decision H. Fail closed: + // only a credential write or a deliberate safe keep can authenticate. + if (outcome === "kept_foreign_identity") { + // The login produced a different account than the one the company + // credential home already holds. The promotion never clobbers an + // occupied home, so this login installed nothing durable, and the + // identity-anchored vend can never select it: a later run keeps the + // existing account. Fail the session, so the operator never sees a + // false `authenticated` for an account the system will not use. + throw new Error( + "device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept", + ); + } + if (outcome !== "promoted" && outcome !== "kept") { + throw new Error(`device-login credential promotion rejected: ${outcome}`); + } + }, }, - }, + grok_local: { + async promote(authBytes, context) { + // The same promotion critical-section lock as the Codex entry above, + // keyed by the same `(companyId, startedByUserId, adapterType)` tuple, + // so a Grok reclaim and a Grok write never interleave. + const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock( + context.companyId, + context.startedByUserId, + context.adapterType, + () => + promoteGrokDeviceLoginCredential({ + authBytes, + companyId: context.companyId, + userInitiated: true, + checkReadiness: (bytes) => checkStagedGrokCredentialReadiness(bytes), + isSoleActiveOwner: async () => { + const row = await adapterLoginStore.get(context.sessionId); + return row?.status === "promoting" && row.companyId === context.companyId; + }, + log: (line) => { + // The promotion lines carry no token bytes and no personal + // field, so it is safe to log them with the session identifier. + logger.info({ sessionId: context.sessionId }, line); + }, + }), + ); + if (outcome === "kept_foreign_identity") { + // The login produced a different account than the one the company + // credential home already holds. Fail the session, so the operator + // never sees a false `authenticated` for an account the system will + // not use. + throw new Error( + "device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept", + ); + } + if (outcome !== "promoted") { + throw new Error(`device-login credential promotion rejected: ${outcome}`); + } + }, + }, + } satisfies Partial>, recordActivity: (event) => { // The event carries no URL, no code, no credential, no account identifier, // and no lease identifier, so it is safe to log. @@ -1357,8 +1409,8 @@ export function agentRoutes( */ async function assertCodexLoginProviderCapability(environmentId: string): Promise { if (!(await resolveProviderSupportsLoginPty(environmentId))) { - throw unprocessable(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, { - code: CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE, + throw unprocessable(DEVICE_LOGIN_PROVIDER_UNSUPPORTED, { + code: DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE, }); } } diff --git a/server/src/services/codex-device-login-credential-read.ts b/server/src/services/device-login-credential-read.ts similarity index 97% rename from server/src/services/codex-device-login-credential-read.ts rename to server/src/services/device-login-credential-read.ts index 499e987b41..24b02e0eec 100644 --- a/server/src/services/codex-device-login-credential-read.ts +++ b/server/src/services/device-login-credential-read.ts @@ -58,7 +58,7 @@ export const DEVICE_LOGIN_AUTH_READ_ERROR = * and runs it in the sandbox as `node -e