diff --git a/packages/adapters/grok-local/package.json b/packages/adapters/grok-local/package.json index 7ed910857e..3821f8e5c8 100644 --- a/packages/adapters/grok-local/package.json +++ b/packages/adapters/grok-local/package.json @@ -45,7 +45,7 @@ "dist" ], "scripts": { - "build": "tsc", + "build": "tsc && cp src/server/grok-auth-merge-decision.cjs dist/server/", "clean": "rm -rf dist", "typecheck": "tsc --noEmit" }, 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 index f039f2b532..cbc1e309e6 100644 --- a/packages/adapters/grok-local/src/server/adapter-auth-promotion.test.ts +++ b/packages/adapters/grok-local/src/server/adapter-auth-promotion.test.ts @@ -23,8 +23,12 @@ const TOKEN_SENTINEL = "SENTINEL_REFRESH_TOKEN_XYZ"; // 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. +// only for a user-initiated login. For a home that already holds the SAME +// account, it installs the login only when the login is strictly newer than +// the existing credential (the same freshness predicate the teardown +// copy-back path uses), so it never replaces a fresher remote copy-back +// credential. 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[] = []; @@ -50,14 +54,19 @@ describe("grok device-login credential promotion", () => { }; } - function grokAuth(input: { uuid: string; issuer?: string; marker?: string }): Buffer { + function grokAuth(input: { + uuid: string; + issuer?: string; + marker?: string; + expiresAt?: 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", + expires_at: input.expiresAt ?? "2026-01-01T00:00:00Z", oidc_issuer: input.issuer ?? ISSUER, oidc_client_id: "client-1", email: `user-${suffix}@example.com`, @@ -345,11 +354,11 @@ describe("grok device-login credential promotion", () => { expect(allLogs).not.toContain(UUID_B); }); - it("overwrites the home with a refreshed credential for the same identity", async () => { + it("overwrites the home with a strictly newer credential for the same identity", async () => { const home = await makeInstanceRoot(); const env = envFor(home); await promoteGrokDeviceLoginCredential({ - authBytes: grokAuth({ uuid: UUID_A, marker: "first" }), + authBytes: grokAuth({ uuid: UUID_A, marker: "first", expiresAt: "2026-01-01T00:00:00Z" }), companyId: COMPANY_A, userInitiated: true, checkReadiness: ready, @@ -358,7 +367,7 @@ describe("grok device-login credential promotion", () => { log: noopLog, }); const outcome = await promoteGrokDeviceLoginCredential({ - authBytes: grokAuth({ uuid: UUID_A, marker: "second" }), + authBytes: grokAuth({ uuid: UUID_A, marker: "second", expiresAt: "2026-06-01T00:00:00Z" }), companyId: COMPANY_A, userInitiated: true, checkReadiness: ready, @@ -373,6 +382,135 @@ describe("grok device-login credential promotion", () => { expect(written[`${ISSUER}::${UUID_A}`].refresh_token).toBe(`${TOKEN_SENTINEL}-second`); }); + // --------------------------------------------------------------------- + // The freshness gate: a same-identity login never replaces a home + // credential that is at least as new, so a remote copy-back cannot lose + // its fresher credential to an older device login. + // --------------------------------------------------------------------- + + it("keeps the home when the login is older than the existing same-identity credential", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "fresher", expiresAt: "2026-06-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "staler", expiresAt: "2026-01-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("kept"); + + const authPath = companyHomeAuthPath(env, COMPANY_A); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(written[`${ISSUER}::${UUID_A}`].refresh_token).toBe(`${TOKEN_SENTINEL}-fresher`); + }); + + it("keeps the home when the login expiry ties the existing same-identity credential", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "first", expiresAt: "2026-01-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "second", expiresAt: "2026-01-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("kept"); + + const authPath = companyHomeAuthPath(env, COMPANY_A); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(written[`${ISSUER}::${UUID_A}`].refresh_token).toBe(`${TOKEN_SENTINEL}-first`); + }); + + it("leaves no staged temporary file in the company home after a kept (not-fresher) outcome", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "fresher", expiresAt: "2026-06-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "staler", expiresAt: "2026-01-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: noopLog, + }); + expect(outcome).toBe("kept"); + + const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A); + const entries = await readdir(companyHome); + expect(entries).toEqual(["auth.json"]); + }); + + it("redacts the credential bytes on a kept (not-fresher) outcome the same way as a promoted outcome", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const logs: string[] = []; + const captureLog = (line: string): void => { + logs.push(line); + }; + + await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "fresher", expiresAt: "2026-06-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: captureLog, + }); + + const outcome = await promoteGrokDeviceLoginCredential({ + authBytes: grokAuth({ uuid: UUID_A, marker: "staler", expiresAt: "2026-01-01T00:00:00Z" }), + companyId: COMPANY_A, + userInitiated: true, + checkReadiness: ready, + isSoleActiveOwner: soleOwner, + env, + log: captureLog, + }); + expect(outcome).toBe("kept"); + + const allLogs = logs.join("\n"); + expect(allLogs).not.toContain(TOKEN_SENTINEL); + expect(allLogs).not.toContain(`user-fresher@example.com`); + expect(allLogs).not.toContain(`user-staler@example.com`); + expect(allLogs).not.toContain(UUID_A); + }); + // --------------------------------------------------------------------- // Fail closed on an unreadable or unparseable existing home. // --------------------------------------------------------------------- diff --git a/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts b/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts index 860dc3e777..06a3a5934f 100644 --- a/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts +++ b/packages/adapters/grok-local/src/server/adapter-auth-promotion.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from "no import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; +import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; import { grokHomeHasUsableAuth, parseGrokAuthPayload, @@ -9,6 +10,7 @@ import { resolveManagedGrokHomeDir, type GrokAuthPayload, } from "./grok-home.js"; +import { USE_SOURCE_EXIT, decideGrokAuthMerge } from "./grok-auth-merge-decision.js"; // The Grok device-login credential promotion. It runs after a successful // device login, on the exact credential the login sandbox produced. It mirrors @@ -17,12 +19,23 @@ import { // 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. +// Grok needs no per-identity cache, but it does need a "strictly newer" merge +// decision: a completed device login is NOT always the newest state for the +// account it logs in, because a teardown copy-back can install a fresher +// same-identity credential (see `grok-auth-copyback.ts`) while this session +// is still logging in. So the write step seeds an empty home, keeps the home +// untouched when a DIFFERENT account already occupies it, and for the SAME +// account runs the shared freshness predicate (`grok-auth-merge-decision.ts`) +// so it replaces the existing credential only when the login is strictly +// newer. The helper never writes the instance-global home, only the +// company-scoped one. +// +// The comparison-and-install step runs under `withDirectoryMergeLock` on the +// company Grok home, the same lock `copyBackGrokAuth` (see +// `grok-auth-copyback.ts`) takes on the same directory at teardown. Both +// resolve the lock key from the directory's canonical real path, so a +// promotion and a teardown copy-back for the same company can never +// interleave their read-decide-write sections. // // 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). @@ -130,7 +143,12 @@ export function assertUsableGrokAuthShape(authBytes: Buffer): GrokAuthPayload { * The promotion outcome. * * - `promoted`: the helper wrote the company home, either seeding an empty home - * or refreshing the same account's credential. + * or installing a strictly-newer same-account credential. + * - `kept`: the login carried the SAME account as the company home, and the + * home already held a same-identity credential that is not older than the + * one this login produced (a tie, or the home credential is newer), so the + * helper kept the home. This IS a successful authentication: a later run + * vends and uses the same account. * - `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 @@ -145,6 +163,7 @@ export function assertUsableGrokAuthShape(authBytes: Buffer): GrokAuthPayload { */ export type PromoteGrokDeviceLoginCredentialOutcome = | "promoted" + | "kept" | "kept_foreign_identity" | "not_sole_owner" | "background_skipped"; @@ -259,6 +278,43 @@ async function writeAuthFileAtomically(authPath: string, authBytes: Buffer): Pro } } +/** + * Runs the shared freshness predicate (the same one `copyBackGrokAuth` uses) + * and installs `authBytes` over `authPath` only on a use-source decision. It + * stages `authBytes` into a private (0600) temporary file next to `authPath`, + * so the predicate can compare both sides by path, then renames that file + * over `authPath` on a use-source decision. The staged temp is always + * removed: the rename consumes it on the write path, and the `finally` + * removes it otherwise. Returns true when it wrote, false when it kept the + * existing file. + */ +async function writeSameIdentityCredentialIfFresher( + authBytes: Buffer, + authPath: string, +): Promise { + const stagedTempPath = path.join( + path.dirname(authPath), + `.auth-${process.pid}-${randomUUID()}.tmp`, + ); + const handle = await open(stagedTempPath, "wx", PRIVATE_FILE_MODE); + try { + await handle.writeFile(authBytes); + await handle.close(); + const decision = await decideGrokAuthMerge(stagedTempPath, authPath, { + errorLabel: "grok device-login promotion", + }); + if (decision !== USE_SOURCE_EXIT) { + return false; + } + await rename(stagedTempPath, authPath); + await chmod(authPath, PRIVATE_FILE_MODE); + return true; + } 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 @@ -305,31 +361,63 @@ export async function promoteGrokDeviceLoginCredential( // 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`. + // already existed at a broader mode. An empty home is always seeded. A + // home that already holds the SAME identity is refreshed only when the + // login is strictly newer than the existing credential — the shared + // freshness predicate in `grok-auth-merge-decision.ts` decides this, the + // same predicate the teardown copy-back uses, so a fresher remote + // copy-back credential can never lose to an older device login. Every + // write is atomic: it stages the bytes into a private temporary file in + // the same directory, then renames that file over `auth.json`. + // + // Steps 5 and 6 run under `withDirectoryMergeLock` on the company home, the + // same lock the teardown copy-back takes on the same directory (see + // `grok-auth-copyback.ts`). The directory must exist before the lock can + // resolve a canonical real path, so `mkdir` runs once, up front, at the + // final private mode; a home that already exists keeps its current mode + // until the write path below re-asserts it. + const companyHome = resolveManagedGrokHomeDir(env, companyId); 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"; + return await withDirectoryMergeLock( + companyHome, + async (canonicalCompanyHome) => { + const authPath = path.join(canonicalCompanyHome, 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"; + } + + await chmod(canonicalCompanyHome, PRIVATE_DIR_MODE); + + if (existingState.kind === "identity") { + const wrote = await writeSameIdentityCredentialIfFresher(authBytes, authPath); + if (!wrote) { + await log( + "[paperclip] Grok device-login promotion: kept the company credential home (the existing same-identity credential is not older than the device-login credential).", + ); + return "kept"; + } + await log("[paperclip] Grok device-login promotion: wrote the company credential home at mode 0600."); + return "promoted"; + } + + await writeAuthFileAtomically(authPath, authBytes); + await log("[paperclip] Grok device-login promotion: wrote the company credential home at mode 0600."); + return "promoted"; + }, + env, + ); } diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts index bce2f7f8d5..36a0ebee1b 100644 --- a/packages/adapters/grok-local/src/server/execute.test.ts +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -85,6 +85,46 @@ async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } +const GROK_IDENTITY = "https://auth.x.ai::11111111-1111-1111-1111-111111111111"; +const NOW = Date.now(); +const NEWER_EXPIRY = new Date(NOW + 2 * 60_000).toISOString(); +const OLDER_EXPIRY = new Date(NOW + 60_000).toISOString(); + +function grokAuth(input: { key: string; expiresAt: string }): string { + return JSON.stringify({ + [GROK_IDENTITY]: { key: input.key, refresh_token: `${input.key}-refresh`, expires_at: input.expiresAt }, + }); +} + +// Captures the sandbox `auth.json` bytes a mocked remote teardown hands back +// to the `home` asset's `restore` contribution — mirrors the sandbox core's +// own restore closure without needing a live sandbox. `error`, when set, makes +// the injected `readFile` reject instead of resolving. +const sandboxAuthFixture: { bytes: Buffer | null; error: (Error & { code?: string }) | null } = { + bytes: null, + error: null, +}; + +function makeRestoreWorkspace( + assets: Array<{ restore?: (ctx: { assetDir: string; readFile: (path: string) => Promise }) => Promise }>, +) { + return async () => { + for (const asset of assets) { + if (!asset.restore) continue; + await asset.restore({ + assetDir: "/remote/workspace/.paperclip-runtime/grok/home", + readFile: async () => { + if (sandboxAuthFixture.error) throw sandboxAuthFixture.error; + if (sandboxAuthFixture.bytes === null) { + throw Object.assign(new Error("ENOENT: no such file or directory, open 'auth.json'"), { code: "ENOENT" }); + } + return sandboxAuthFixture.bytes; + }, + }); + } + }; +} + function makeSuccessfulRunResult(overrides: Partial<{ sessionId: string }> = {}) { return { exitCode: 0, @@ -379,6 +419,8 @@ describe("grok_local execute", () => { // touches a real developer or CI-host `~/.paperclip` tree. paperclipHomeRoot = await makeTempRoot(); process.env.PAPERCLIP_HOME = paperclipHomeRoot; + sandboxAuthFixture.bytes = null; + sandboxAuthFixture.error = null; }); afterEach(() => { @@ -431,7 +473,7 @@ describe("grok_local execute", () => { expect(assetCount).toBe(1); expect(homeAssetShape).toMatchObject({ key: "home", followSymlinks: true }); expect((homeAssetShape as { provision?: unknown } | null)?.provision).toBeUndefined(); - expect((homeAssetShape as { restore?: unknown } | null)?.restore).toBeUndefined(); + expect(typeof (homeAssetShape as { restore?: unknown } | null)?.restore).toBe("function"); expect(stagedAuthContents).toBe(JSON.stringify({ live: "token" })); }); @@ -563,5 +605,87 @@ describe("grok_local execute", () => { expect(stagedDir).not.toBe(""); expect(await pathExists(stagedDir)).toBe(false); }); + + it("a remote run copies the refreshed sandbox credential to the company Grok home", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + const hostGrokHome = await seedHostGrokAuth(grokAuth({ key: "host-key", expiresAt: OLDER_EXPIRY })); + const refreshedAuth = grokAuth({ key: "refreshed-key", expiresAt: NEWER_EXPIRY }); + sandboxAuthFixture.bytes = Buffer.from(refreshedAuth, "utf8"); + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + prepareRuntimeMock.mockImplementationOnce(async (input: { + assets?: Array<{ key: string; localDir: string; followSymlinks?: boolean; provision?: unknown; restore?: unknown }>; + }) => { + const assets = (input.assets ?? []) as Array<{ + restore?: (ctx: { assetDir: string; readFile: (path: string) => Promise }) => Promise; + }>; + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: makeRestoreWorkspace(assets), + }; + }); + + await execute(await makeCtx("run-copyout-e2e", await makeTempRoot())); + + expect(await fs.readFile(path.join(hostGrokHome, "auth.json"), "utf8")).toBe(refreshedAuth); + }); + + it("the copy-out installs to the resolver directory when env.GROK_HOME names a different directory, and the named directory stays empty", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + const resolverDir = await seedHostGrokAuth(grokAuth({ key: "host-key", expiresAt: OLDER_EXPIRY })); + const attackerDir = await makeTempRoot(); + const refreshedAuth = grokAuth({ key: "refreshed-key", expiresAt: NEWER_EXPIRY }); + sandboxAuthFixture.bytes = Buffer.from(refreshedAuth, "utf8"); + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + prepareRuntimeMock.mockImplementationOnce(async (input: { + assets?: Array<{ key: string; localDir: string; followSymlinks?: boolean; provision?: unknown; restore?: unknown }>; + }) => { + const assets = (input.assets ?? []) as Array<{ + restore?: (ctx: { assetDir: string; readFile: (path: string) => Promise }) => Promise; + }>; + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: makeRestoreWorkspace(assets), + }; + }); + + const ctx = await makeCtx("run-copyout-pinning", await makeTempRoot()); + ctx.config = { ...ctx.config, env: { GROK_HOME: attackerDir } }; + await execute(ctx); + + expect(await fs.readFile(path.join(resolverDir, "auth.json"), "utf8")).toBe(refreshedAuth); + await expect(fs.readFile(path.join(attackerDir, "auth.json"), "utf8")).rejects.toThrow(); + }); + + it("a copy-out failure does not fail the run", async () => { + delete process.env.XAI_API_KEY; + mocks.state.isRemote = true; + const hostGrokHome = await seedHostGrokAuth(grokAuth({ key: "host-key", expiresAt: OLDER_EXPIRY })); + sandboxAuthFixture.error = Object.assign(new Error("sandbox read boom"), { code: "EIO" }); + runProcessMock.mockImplementation(async () => makeSuccessfulRunResult()); + prepareRuntimeMock.mockImplementationOnce(async (input: { + assets?: Array<{ key: string; localDir: string; followSymlinks?: boolean; provision?: unknown; restore?: unknown }>; + }) => { + const assets = (input.assets ?? []) as Array<{ + restore?: (ctx: { assetDir: string; readFile: (path: string) => Promise }) => Promise; + }>; + return { + workspaceRemoteDir: "/remote/workspace", + assetDirs: { home: "/remote/workspace/.paperclip-runtime/grok/home" }, + restoreWorkspace: makeRestoreWorkspace(assets), + }; + }); + + const result = await execute(await makeCtx("run-copyout-failure", await makeTempRoot())); + + expect(result.exitCode).toBe(0); + // The host credential is untouched by the failed copy-out. + expect(await fs.readFile(path.join(hostGrokHome, "auth.json"), "utf8")).toBe( + grokAuth({ key: "host-key", expiresAt: OLDER_EXPIRY }), + ); + }); }); }); diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 063d4b373f..98880c00fe 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -41,6 +41,7 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js"; +import { copyBackGrokAuth } from "./grok-auth-copyback.js"; import { resolveManagedGrokHomeDir, stageGrokHomeForSync } from "./grok-home.js"; import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js"; @@ -358,7 +359,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise onLog("stdout", line), onRuntimeProgress: ctx.onRuntimeProgress, assets: stagedGrokHomeDir - ? [{ key: "home", localDir: stagedGrokHomeDir, followSymlinks: true }] + ? [ + { + key: "home", + localDir: stagedGrokHomeDir, + followSymlinks: true, + // Outbound (sandbox to host) copy-out: at teardown, read the + // sandbox's `auth.json` and — guarded by the direction- + // agnostic decision predicate under a directory lock — + // atomically install it onto the shared host credential when + // it is a strictly-later same-identity copy. The destination + // is always the server-derived managed Grok home, never a + // value read from `env.GROK_HOME`. A copy-out failure never + // fails the run: `copyBackGrokAuth` logs the errno code and + // rethrows, and this callback swallows that rejection. + restore: async ({ assetDir, readFile }) => + void (await copyBackGrokAuth({ + readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")), + hostHomeDir: hostGrokHome, + log: (line) => onLog("stdout", `${line}\n`), + env: process.env, + }).catch(() => undefined)), + }, + ] : undefined, }); restoreRemoteWorkspace = () => diff --git a/packages/adapters/grok-local/src/server/grok-auth-copyback.test.ts b/packages/adapters/grok-local/src/server/grok-auth-copyback.test.ts new file mode 100644 index 0000000000..4d36b1f0d9 --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-auth-copyback.test.ts @@ -0,0 +1,304 @@ +import { chmod, lstat, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { copyBackGrokAuth } from "./grok-auth-copyback.js"; + +// The copy-out module reuses the direction-agnostic decision predicate +// (`grok-auth-merge-decision.cjs`): the sandbox copy is always the `source` +// and the host copy is always the `destination`, so exit 10 (use source) +// installs the sandbox credential onto the host and every other exit keeps +// the host credential untouched. This suite drives the REAL `.cjs` through +// the module (no stub predicate) against a real host tmp filesystem, +// injecting only the sandbox read. +describe("copyBackGrokAuth", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + // Re-open perms in case a test tightened them, so cleanup always succeeds. + await chmod(dir, 0o700).catch(() => undefined); + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + const IDENTITY = "https://auth.x.ai::11111111-1111-1111-1111-111111111111"; + const OTHER_IDENTITY = "https://auth.x.ai::22222222-2222-2222-2222-222222222222"; + + function auth(input: { identityKey?: string; expiresAt?: string; marker: string }): string { + return JSON.stringify( + { + [input.identityKey ?? IDENTITY]: { + key: `key-${input.marker}`, + refresh_token: `refresh-${input.marker}`, + ...(input.expiresAt ? { expires_at: input.expiresAt } : {}), + }, + }, + null, + 2, + ); + } + + async function makeHostDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-grok-copyback-")); + cleanupDirs.push(dir); + return dir; + } + + const now = Date.now(); + const NEWER = new Date(now + 2 * 60_000).toISOString(); + const OLDER = new Date(now + 60_000).toISOString(); + + async function runCopyBack(input: { + sandboxAuth: string | (() => Promise); + hostAuth?: string; + hostHomeDir?: string; + }): Promise<{ + outcome: Awaited>; + finalHostAuth: string | null; + finalHostMode: number | null; + logs: string[]; + leftoverEntries: string[]; + }> { + const hostHomeDir = input.hostHomeDir ?? (await makeHostDir()); + const hostAuthPath = path.join(hostHomeDir, "auth.json"); + if (input.hostAuth !== undefined) { + await writeFile(hostAuthPath, input.hostAuth, { mode: 0o600 }); + } + + const readSandboxAuth = + typeof input.sandboxAuth === "function" + ? input.sandboxAuth + : async () => Buffer.from(input.sandboxAuth as string, "utf8"); + + const logs: string[] = []; + const outcome = await copyBackGrokAuth({ + readSandboxAuth, + hostHomeDir, + log: (line) => { + logs.push(line); + }, + }); + + const finalHostAuth = await readFile(hostAuthPath, "utf8").catch((error: NodeJS.ErrnoException) => + error.code === "ENOENT" ? null : Promise.reject(error), + ); + const finalHostMode = finalHostAuth === null ? null : (await lstat(hostAuthPath)).mode & 0o777; + const leftoverEntries = (await readdir(hostHomeDir)).filter((name) => name !== "auth.json"); + return { outcome, finalHostAuth, finalHostMode, logs, leftoverEntries }; + } + + it("installs a strictly-later same-identity credential at mode 0600", async () => { + const sandboxAuth = auth({ expiresAt: NEWER, marker: "sandbox-newer-SENTINEL" }); + const hostAuth = auth({ expiresAt: OLDER, marker: "host-older-SENTINEL" }); + + const result = await runCopyBack({ sandboxAuth, hostAuth }); + + expect(result.outcome).toBe("copied"); + expect(result.finalHostAuth).toBe(sandboxAuth); + expect(result.finalHostMode).toBe(0o600); + // Temp staging file must be gone once the swap completes. + expect(result.leftoverEntries).toEqual([]); + // Never leak token bytes in log output. + expect(result.logs.join("\n")).not.toContain("SENTINEL"); + }); + + it("keeps the host credential when the predicate keeps the destination", async () => { + const hostKeep = auth({ expiresAt: OLDER, marker: "host-keep" }); + const cases: { name: string; sandboxAuth: string; hostAuth: string }[] = [ + { + name: "tie", + sandboxAuth: auth({ expiresAt: NEWER, marker: "sandbox-tie" }), + hostAuth: auth({ expiresAt: NEWER, marker: "host-tie" }), + }, + { + name: "sandbox older", + sandboxAuth: auth({ expiresAt: OLDER, marker: "sandbox-older" }), + hostAuth: auth({ expiresAt: NEWER, marker: "host-newer" }), + }, + { + name: "identity mismatch", + sandboxAuth: auth({ identityKey: OTHER_IDENTITY, expiresAt: NEWER, marker: "sandbox-other" }), + hostAuth: hostKeep, + }, + { + name: "sandbox unusable JSON", + sandboxAuth: "{not valid json", + hostAuth: hostKeep, + }, + ]; + + for (const entry of cases) { + const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth }); + expect(result.outcome, entry.name).toBe("kept-host"); + expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth); + expect(result.finalHostMode, entry.name).toBe(0o600); + expect(result.leftoverEntries, entry.name).toEqual([]); + } + }); + + it("creates no host credential when the destination auth.json does not exist, through stage, predicate, and install steps", async () => { + const hostHomeDir = await makeHostDir(); + const sandboxAuth = auth({ expiresAt: NEWER, marker: "sandbox-no-dest" }); + + const result = await runCopyBack({ sandboxAuth, hostHomeDir }); + + expect(result.outcome).toBe("kept-host"); + expect(result.finalHostAuth).toBeNull(); + expect(result.leftoverEntries).toEqual([]); + }); + + it("returns kept-host and writes nothing when the sandbox auth.json is absent", async () => { + const hostAuth = auth({ expiresAt: OLDER, marker: "host-intact" }); + const enoent = Object.assign(new Error("ENOENT: no such file or directory, open 'auth.json'"), { + code: "ENOENT", + }); + + const result = await runCopyBack({ + sandboxAuth: async () => { + throw enoent; + }, + hostAuth, + }); + + expect(result.outcome).toBe("kept-host"); + expect(result.finalHostAuth).toBe(hostAuth); + // No staging temp is ever created on the ENOENT path. + expect(result.leftoverEntries).toEqual([]); + expect(result.logs.join("\n")).toContain("no sandbox credential to copy back"); + }); + + it("rethrows a sandbox read error that is not ENOENT", async () => { + const hostHomeDir = await makeHostDir(); + const hostAuth = auth({ expiresAt: OLDER, marker: "host-intact" }); + const hostAuthPath = path.join(hostHomeDir, "auth.json"); + await writeFile(hostAuthPath, hostAuth, { mode: 0o600 }); + + await expect( + copyBackGrokAuth({ + readSandboxAuth: async () => { + throw new Error("sandbox read boom"); + }, + hostHomeDir, + log: () => {}, + }), + ).rejects.toThrow(/sandbox read boom/); + + expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth); + expect((await readdir(hostHomeDir)).filter((name) => name !== "auth.json")).toEqual([]); + }); + + it("serializes two concurrent copy-out operations on one home", async () => { + const hostHomeDir = await makeHostDir(); + const hostAuthPath = path.join(hostHomeDir, "auth.json"); + await writeFile(hostAuthPath, auth({ expiresAt: OLDER, marker: "host" }), { mode: 0o600 }); + + const run = (marker: string, expiresAt: string) => + copyBackGrokAuth({ + readSandboxAuth: async () => Buffer.from(auth({ expiresAt, marker }), "utf8"), + hostHomeDir, + log: () => {}, + }); + + await Promise.all([ + run("a", new Date(now + 3 * 60_000).toISOString()), + run("b", new Date(now + 4 * 60_000).toISOString()), + ]); + + // Exactly one valid file, no leftover staging temp, regardless of which + // concurrent write won the lock first. + expect(await readdir(hostHomeDir)).toEqual(["auth.json"]); + const finalAuth = JSON.parse(await readFile(hostAuthPath, "utf8")); + expect(Object.keys(finalAuth)).toEqual([IDENTITY]); + }); + + it("leaves no temporary file after a successful install", async () => { + const result = await runCopyBack({ + sandboxAuth: auth({ expiresAt: NEWER, marker: "sandbox" }), + hostAuth: auth({ expiresAt: OLDER, marker: "host" }), + }); + expect(result.outcome).toBe("copied"); + expect(result.leftoverEntries).toEqual([]); + }); + + it("leaves no temporary file when the predicate keeps the destination", async () => { + const result = await runCopyBack({ + sandboxAuth: auth({ expiresAt: OLDER, marker: "sandbox" }), + hostAuth: auth({ expiresAt: NEWER, marker: "host" }), + }); + expect(result.outcome).toBe("kept-host"); + expect(result.leftoverEntries).toEqual([]); + }); + + it("leaves no temporary file after an install error", async () => { + const hostHomeDir = await makeHostDir(); + const hostAuth = auth({ expiresAt: OLDER, marker: "host-intact" }); + const hostAuthPath = path.join(hostHomeDir, "auth.json"); + await writeFile(hostAuthPath, hostAuth, { mode: 0o600 }); + const before = await stat(hostAuthPath); + + await chmod(hostHomeDir, 0o500); // r-x: readable/traversable, not writable + try { + const sandboxAuth = auth({ expiresAt: NEWER, marker: "sandbox-newer" }); + await expect( + copyBackGrokAuth({ + readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"), + hostHomeDir, + log: () => {}, + }), + ).rejects.toThrow(); + } finally { + await chmod(hostHomeDir, 0o700); + } + + const after = await stat(hostAuthPath); + expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth); + expect(after.mode & 0o777).toBe(0o600); + expect(after.mtimeMs).toBe(before.mtimeMs); + expect((await readdir(hostHomeDir)).filter((name) => name !== "auth.json")).toEqual([]); + }); + + it("keeps no backup copy of the displaced credential in the destination directory", async () => { + const hostAuth = auth({ expiresAt: OLDER, marker: "host-displaced" }); + const sandboxAuth = auth({ expiresAt: NEWER, marker: "sandbox-newer" }); + + const result = await runCopyBack({ sandboxAuth, hostAuth }); + + expect(result.outcome).toBe("copied"); + // The staged temp is the only extra file the copy-out may ever create, + // and it is always removed. No backup of the displaced `host-displaced` + // credential survives anywhere in the destination directory. + expect(result.leftoverEntries).toEqual([]); + }); + + it("logs no token bytes and no home path on an error", async () => { + const marker = "SECRET-ACCOUNT-HANDLE"; + const root = await mkdtemp(path.join(os.tmpdir(), `paperclip-grok-copyback-${marker}-`)); + cleanupDirs.push(root); + const hostAuth = auth({ expiresAt: OLDER, marker: "HOST-TOKEN-SENTINEL" }); + await writeFile(path.join(root, "auth.json"), hostAuth, { mode: 0o600 }); + + await chmod(root, 0o500); + const logs: string[] = []; + try { + await copyBackGrokAuth({ + readSandboxAuth: async () => Buffer.from(auth({ expiresAt: NEWER, marker: "SANDBOX-TOKEN-SENTINEL" }), "utf8"), + hostHomeDir: root, + log: (line) => { + logs.push(line); + }, + }).catch(() => undefined); + } finally { + await chmod(root, 0o700); + } + + const combined = logs.join("\n"); + expect(combined).toContain("EACCES"); + expect(combined).toContain("failed"); + expect(combined).not.toContain(marker); + expect(combined).not.toContain("SENTINEL"); + }); +}); diff --git a/packages/adapters/grok-local/src/server/grok-auth-copyback.ts b/packages/adapters/grok-local/src/server/grok-auth-copyback.ts new file mode 100644 index 0000000000..062c369eba --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-auth-copyback.ts @@ -0,0 +1,138 @@ +import { mkdir, open, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { USE_SOURCE_EXIT, decideGrokAuthMerge } from "./grok-auth-merge-decision.js"; + +// The copy-out runs the direction-agnostic decision predicate with the +// sandbox credential as the `source` and the shared host credential as the +// `destination`: exit 10 (use source) installs the sandbox copy onto the +// host; every other exit (20, 21, 22) keeps the host copy. The predicate +// only ever reads the two files and exits with a code; it never prints +// token bytes. + +/** Outcome of a copy-out attempt. No token material is ever surfaced. */ +export type CopyBackGrokAuthOutcome = "copied" | "kept-host"; + +export interface CopyBackGrokAuthInput { + /** + * Reads the sandbox `auth.json` bytes back from the (about-to-be- + * destroyed) sandbox. In production this is bound to the managed-runtime + * restore context's `readFile` for `${assetDir}/auth.json`. + */ + readSandboxAuth: () => Promise; + /** + * The managed Grok home directory to (maybe) update. Callers must resolve + * this from `resolveManagedGrokHomeDir` — never from a user- or + * environment-supplied value — so the copy-out destination always stays + * server-derived. + */ + hostHomeDir: string; + /** Non-leaking progress sink: receives decision/outcome lines only. */ + log: (line: string) => void | Promise; + /** Environment for the directory-merge-lock root resolution. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; +} + +/** + * Guards, locks, and atomically installs a strictly-later same-identity + * sandbox Grok `auth.json` onto the shared host credential at teardown. + * + * Sequence, all under `withDirectoryMergeLock` on `hostHomeDir` so a + * concurrent copy-out can never interleave with another: + * 1. Read the sandbox credential bytes. A genuinely absent sandbox + * `auth.json` (ENOENT) means there is nothing to copy back, so this + * resolves to `kept-host` (benign no-op, host untouched); every other + * read error stays fail-loud. + * 2. Stage the bytes to a `0600` temp file inside `hostHomeDir`, which + * doubles as the predicate `source`. + * 3. Run the decision predicate (`source` = staged temp, `destination` = + * the host `auth.json`, which may not exist yet). Exit 10 installs the + * sandbox copy; every other exit keeps the host copy untouched + * (including a wholly absent one). + * 4. On install, `rename` the staged temp over the host `auth.json` — an + * atomic same-directory swap that preserves mode `0600`. + * The staged temp is always removed (the rename consumes it on the install + * path; the `finally` removes it otherwise), so no failure ever leaves a + * temporary file, and the displaced host credential is never kept as a + * backup anywhere. Never logs token bytes. On error, only the `errno` code + * is logged, then the error is rethrown — the message would embed the home + * path, and the path embeds the account handle. + */ +export async function copyBackGrokAuth(input: CopyBackGrokAuthInput): Promise { + const { readSandboxAuth, hostHomeDir, log, env } = input; + + // Read first (outside the lock) — a read never mutates the host, so there + // is nothing to serialize yet. A genuinely absent sandbox `auth.json` + // (ENOENT — a non-provisioned edge, or Grok removed it mid-run) is a + // "nothing to copy back" no-op, not a teardown failure. Every other read + // error stays fail-loud so a real read fault is never silently mistaken + // for "nothing to copy back". + let sandboxAuthBytes: Buffer; + try { + sandboxAuthBytes = await readSandboxAuth(); + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") { + await log( + "[paperclip] Grok auth copy-out: no sandbox credential to copy back (absent auth.json); host credential kept.", + ); + return "kept-host"; + } + throw error; + } + + try { + await mkdir(hostHomeDir, { recursive: true }); + return await withDirectoryMergeLock( + hostHomeDir, + async (canonicalHostHomeDir) => { + const hostAuthPath = path.join(canonicalHostHomeDir, "auth.json"); + // Stage on the same filesystem as the host target so both the + // predicate read and the final rename stay device-local (rename + // across devices is not atomic and would fail with EXDEV). + const stagedTempPath = path.join( + canonicalHostHomeDir, + `.auth.json.copyback-${process.pid}-${randomUUID()}.tmp`, + ); + // `wx` + explicit mode create the temp private (0600) and fail if it + // somehow already exists, so this never writes through a + // pre-existing symlink. + const handle = await open(stagedTempPath, "wx", 0o600); + try { + await handle.writeFile(sandboxAuthBytes); + await handle.close(); + + const decision = await decideGrokAuthMerge(stagedTempPath, hostAuthPath, { + errorLabel: "grok auth copy-out", + }); + if (decision === USE_SOURCE_EXIT) { + // Atomic same-directory swap; rename preserves the temp's 0600 mode. + await rename(stagedTempPath, hostAuthPath); + await log( + "[paperclip] Grok auth copy-out: sandbox credential is strictly newer for the same identity; installed to the host at mode 0600.", + ); + return "copied"; + } + + await log( + "[paperclip] Grok auth copy-out: host credential kept (sandbox copy is not a strictly-newer same-identity credential).", + ); + return "kept-host"; + } finally { + // The temp is the thing that must never linger. On the install + // path rename already consumed it (force makes the removal a + // no-op); on every other path this deletes the staged bytes. + await handle.close().catch(() => undefined); + await rm(stagedTempPath, { force: true }).catch(() => undefined); + } + }, + env, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code ?? "unknown"; + await Promise.resolve( + log(`[paperclip] Grok auth copy-out failed (${code}); host credential kept.`), + ).catch(() => undefined); + throw error; + } +} diff --git a/packages/adapters/grok-local/src/server/grok-auth-merge-decision.cjs b/packages/adapters/grok-local/src/server/grok-auth-merge-decision.cjs new file mode 100644 index 0000000000..5c21dc95ee --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-auth-merge-decision.cjs @@ -0,0 +1,157 @@ +const fs = require("fs"); + +// Co-change notice: parseAuthValue below mirrors parseGrokAuthPayload and +// hasUsableGrokAuthValue in +// packages/adapters/grok-local/src/server/grok-home.ts. If the auth format +// changes (new shape, renamed field), update both sites together. + +// Matches the composite `::` top-level key. See grok-home.ts +// for the full rationale (the greedy `.+` backtracks to the last `::`). +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}$/; + +// Parses one already-decoded JSON value into `{ kind: "unusable" }` or +// `{ kind: "usable", identityKey, expiresAtRaw }`. `expiresAtRaw` is the raw, +// still-undecoded `expires_at` field (or `undefined` when absent) — decoding +// it is `readExpiry`'s job, kept separate so a caller can compare two already +// -parsed shapes without touching the filesystem (see `decide` below). +function parseAuthValue(raw) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { kind: "unusable" }; + const keys = Object.keys(raw); + if (keys.length !== 1) return { kind: "unusable" }; + const [identityKey] = keys; + if (!GROK_IDENTITY_KEY_RE.test(identityKey)) return { kind: "unusable" }; + const value = raw[identityKey]; + if (value === null || typeof value !== "object" || Array.isArray(value)) return { kind: "unusable" }; + const key = value.key; + const refreshToken = value.refresh_token; + const hasUsableValue = + typeof key === "string" && + key.trim().length > 0 && + typeof refreshToken === "string" && + refreshToken.trim().length > 0; + if (!hasUsableValue) return { kind: "unusable" }; + return { kind: "usable", identityKey, expiresAtRaw: value.expires_at }; +} + +function parseAuthFile(filePath) { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return { kind: "unusable" }; + } + return parseAuthValue(parsed); +} + +const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; +// Below this magnitude a numeric `expires_at` is epoch seconds; at or above +// it, epoch milliseconds. A modern epoch-seconds instant is around 1.7e9; a +// modern epoch-millisecond instant is around 1.7e12, so this threshold never +// confuses the two for a real-world timestamp. +const EPOCH_SECONDS_MAX = 1e12; + +// Reads one raw `expires_at` value into `{ present, unreadable, ms }`. +// - `present: false` — the field is absent (missing or null). +// - `unreadable: true` — the field is present but not one of the three +// accepted encodings (ISO-8601 string, epoch-seconds number, epoch- +// milliseconds number). +// - otherwise `ms` holds the decoded epoch-millisecond value. +function readExpiry(raw) { + if (raw === undefined || raw === null) return { present: false, unreadable: false, ms: null }; + if (typeof raw === "string") { + if (ISO_8601_RE.test(raw)) { + const ms = Date.parse(raw); + if (Number.isFinite(ms)) return { present: true, unreadable: false, ms }; + } + return { present: true, unreadable: true, ms: null }; + } + if (typeof raw === "number" && Number.isFinite(raw)) { + const ms = raw < EPOCH_SECONDS_MAX ? raw * 1000 : raw; + return { present: true, unreadable: false, ms }; + } + return { present: true, unreadable: true, ms: null }; +} + +// Exit contract. Exit 10 = use source; exit 20 = keep destination; exit 21 = +// keep destination, an expiry was present but not a recognized encoding; +// exit 22 = keep destination, the source expiry sat further ahead of the +// host clock than the plausible bound. +const USE_SOURCE = 10; +const KEEP_DESTINATION = 20; +const UNREADABLE_EXPIRY = 21; +const IMPLAUSIBLE_EXPIRY = 22; +const MAX_PLAUSIBLE_EXPIRY_MS = 400 * 24 * 60 * 60 * 1000; + +// This predicate answers one direction-agnostic question: should the caller +// replace `destination` with `source`? The caller picks which copy is source +// and which is destination from its own frame of reference (the outbound +// copy-out is the only caller today) purely by argument order — there is no +// `--direction` flag: +// +// argv[0] (first positional) = source auth.json path +// argv[1] (second positional) = destination auth.json path +// +// `decide` takes already-parsed `{ kind, identityKey, expiresAtRaw }` shapes +// plus a caller-supplied `nowMs`, so a test can drive the exact plausibility +// bound without spawning a process and racing wall-clock drift. Guard order, +// first match wins: +// 1. Either side unusable, or the identity keys differ -> KEEP_DESTINATION. +// 2. Either side's expiry is absent -> KEEP_DESTINATION. +// 3. Either side's expiry is present but unreadable -> UNREADABLE_EXPIRY. +// 4. The source expiry sits further ahead of `nowMs` than +// MAX_PLAUSIBLE_EXPIRY_MS -> IMPLAUSIBLE_EXPIRY. Only the source is +// bounded, and only against the caller's clock: the source is the +// sandbox-supplied side, so it must never be allowed to supply the +// reference time. +// 5. The source expiry is strictly later than the destination expiry +// -> USE_SOURCE. +// 6. Otherwise (a tie, or the source is older) -> KEEP_DESTINATION. +// +// The predicate only ever reads the two files and exits with a code; it +// never prints token bytes. +function decide(source, destination, nowMs) { + if ( + source.kind === "unusable" || + destination.kind === "unusable" || + source.identityKey !== destination.identityKey + ) { + return KEEP_DESTINATION; + } + + const sourceExpiry = readExpiry(source.expiresAtRaw); + const destinationExpiry = readExpiry(destination.expiresAtRaw); + + if (!sourceExpiry.present || !destinationExpiry.present) { + return KEEP_DESTINATION; + } + if (sourceExpiry.unreadable || destinationExpiry.unreadable) { + return UNREADABLE_EXPIRY; + } + if (sourceExpiry.ms - nowMs > MAX_PLAUSIBLE_EXPIRY_MS) { + return IMPLAUSIBLE_EXPIRY; + } + if (sourceExpiry.ms > destinationExpiry.ms) { + return USE_SOURCE; + } + return KEEP_DESTINATION; +} + +if (require.main === module) { + const [sourceAuthPath, destinationAuthPath] = process.argv.slice(2); + const source = parseAuthFile(sourceAuthPath); + const destination = parseAuthFile(destinationAuthPath); + process.exit(decide(source, destination, Date.now())); +} + +module.exports = { + decide, + parseAuthValue, + parseAuthFile, + readExpiry, + USE_SOURCE, + KEEP_DESTINATION, + UNREADABLE_EXPIRY, + IMPLAUSIBLE_EXPIRY, + MAX_PLAUSIBLE_EXPIRY_MS, +}; diff --git a/packages/adapters/grok-local/src/server/grok-auth-merge-decision.test.ts b/packages/adapters/grok-local/src/server/grok-auth-merge-decision.test.ts new file mode 100644 index 0000000000..4dda97016d --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-auth-merge-decision.test.ts @@ -0,0 +1,218 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +const execFile = promisify(execFileCallback); + +const decisionScriptPath = fileURLToPath(new URL("./grok-auth-merge-decision.cjs", import.meta.url)); + +// The exact-boundary cases below drive the predicate's pure `decide` function +// directly (via `require`, never spawning a process), so the 400-day +// plausibility bound can be tested to the exact millisecond without racing +// subprocess-spawn wall-clock drift. Every other case drives the REAL `.cjs` +// through a spawned `node` process (no stub), matching how the copy-out +// module invokes it in production. +const decisionModule = createRequire(import.meta.url)(decisionScriptPath) as { + decide: ( + source: { kind: "usable" | "unusable"; identityKey?: string; expiresAtRaw?: unknown }, + destination: { kind: "usable" | "unusable"; identityKey?: string; expiresAtRaw?: unknown }, + nowMs: number, + ) => number; + USE_SOURCE: number; + KEEP_DESTINATION: number; + UNREADABLE_EXPIRY: number; + IMPLAUSIBLE_EXPIRY: number; + MAX_PLAUSIBLE_EXPIRY_MS: number; +}; +const { decide, USE_SOURCE, KEEP_DESTINATION, UNREADABLE_EXPIRY, IMPLAUSIBLE_EXPIRY, MAX_PLAUSIBLE_EXPIRY_MS } = + decisionModule; + +const IDENTITY_A = "https://auth.x.ai::11111111-1111-1111-1111-111111111111"; +const IDENTITY_B = "https://auth.x.ai::22222222-2222-2222-2222-222222222222"; + +describe("grok-auth-merge-decision predicate", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + function usableAuthJson(input: { identityKey?: string; expiresAt?: unknown; marker: string }): string { + const value: Record = { + key: `key-${input.marker}`, + refresh_token: `refresh-${input.marker}`, + }; + if (input.expiresAt !== undefined) value.expires_at = input.expiresAt; + return JSON.stringify({ [input.identityKey ?? IDENTITY_A]: value }); + } + + const ABSENT = Symbol("absent"); + + async function runDecision(input: { + sourceAuth: string | typeof ABSENT; + destinationAuth: string | typeof ABSENT; + }): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-grok-merge-decision-")); + cleanupDirs.push(dir); + const sourcePath = path.join(dir, "source-auth.json"); + const destinationPath = path.join(dir, "destination-auth.json"); + if (input.sourceAuth !== ABSENT) await writeFile(sourcePath, input.sourceAuth, { mode: 0o600 }); + if (input.destinationAuth !== ABSENT) await writeFile(destinationPath, input.destinationAuth, { mode: 0o600 }); + try { + await execFile("node", [decisionScriptPath, sourcePath, destinationPath]); + return 0; + } catch (error) { + const failure = error as { code?: unknown }; + if (typeof failure.code === "number") return failure.code; + throw error; + } + } + + it("uses the source when the identity matches and the source expiry is later", async () => { + const now = Date.now(); + const sourceAuth = usableAuthJson({ marker: "src", expiresAt: new Date(now + 2 * 60_000).toISOString() }); + const destinationAuth = usableAuthJson({ marker: "dst", expiresAt: new Date(now + 60_000).toISOString() }); + expect(await runDecision({ sourceAuth, destinationAuth })).toBe(USE_SOURCE); + }); + + it("keeps the destination when the identity key differs", async () => { + const now = Date.now(); + const sourceAuth = usableAuthJson({ + identityKey: IDENTITY_A, + marker: "src", + expiresAt: new Date(now + 2 * 60_000).toISOString(), + }); + const destinationAuth = usableAuthJson({ + identityKey: IDENTITY_B, + marker: "dst", + expiresAt: new Date(now + 60_000).toISOString(), + }); + expect(await runDecision({ sourceAuth, destinationAuth })).toBe(KEEP_DESTINATION); + }); + + it("keeps the destination when the source expiry equals the destination expiry", async () => { + const sameExpiry = new Date(Date.now() + 60_000).toISOString(); + const sourceAuth = usableAuthJson({ marker: "src", expiresAt: sameExpiry }); + const destinationAuth = usableAuthJson({ marker: "dst", expiresAt: sameExpiry }); + expect(await runDecision({ sourceAuth, destinationAuth })).toBe(KEEP_DESTINATION); + }); + + it("keeps the destination when either side is unusable", async () => { + const now = Date.now(); + const usable = usableAuthJson({ marker: "ok", expiresAt: new Date(now + 60_000).toISOString() }); + const cases: { name: string; sourceAuth: string | typeof ABSENT; destinationAuth: string | typeof ABSENT }[] = [ + { name: "source malformed JSON", sourceAuth: "{not valid json", destinationAuth: usable }, + { + name: "source missing refresh_token", + sourceAuth: JSON.stringify({ [IDENTITY_A]: { key: "k" } }), + destinationAuth: usable, + }, + { name: "destination absent", sourceAuth: usable, destinationAuth: ABSENT }, + { name: "destination malformed JSON", sourceAuth: usable, destinationAuth: "{not valid json" }, + ]; + for (const entry of cases) { + const code = await runDecision({ sourceAuth: entry.sourceAuth, destinationAuth: entry.destinationAuth }); + expect(code, entry.name).toBe(KEEP_DESTINATION); + } + }); + + it("keeps the destination when the payload holds more than one top-level key", async () => { + const now = Date.now(); + const destinationAuth = usableAuthJson({ marker: "dst", expiresAt: new Date(now + 60_000).toISOString() }); + const multiKeySource = JSON.stringify({ + [IDENTITY_A]: { key: "k", refresh_token: "r", expires_at: new Date(now + 120_000).toISOString() }, + extra: { key: "k2", refresh_token: "r2" }, + }); + expect(await runDecision({ sourceAuth: multiKeySource, destinationAuth })).toBe(KEEP_DESTINATION); + }); + + it("reads an ISO-8601 expiry, an epoch-seconds expiry, and an epoch-milliseconds expiry", async () => { + const now = Date.now(); + const encodings: { name: string; source: unknown; destination: unknown }[] = [ + { + name: "ISO-8601", + source: new Date(now + 2 * 60_000).toISOString(), + destination: new Date(now + 60_000).toISOString(), + }, + { + name: "epoch seconds", + source: Math.round((now + 2 * 60_000) / 1000), + destination: Math.round((now + 60_000) / 1000), + }, + { name: "epoch milliseconds", source: now + 2 * 60_000, destination: now + 60_000 }, + ]; + for (const entry of encodings) { + const sourceAuth = usableAuthJson({ marker: "src", expiresAt: entry.source }); + const destinationAuth = usableAuthJson({ marker: "dst", expiresAt: entry.destination }); + const code = await runDecision({ sourceAuth, destinationAuth }); + expect(code, entry.name).toBe(USE_SOURCE); + } + }); + + it("exits 21 and keeps the destination for an unreadable expiry shape", async () => { + const now = Date.now(); + const destinationAuth = usableAuthJson({ marker: "dst", expiresAt: new Date(now + 60_000).toISOString() }); + const badShapes: unknown[] = [true, "not-a-date", "2026/01/01 00:00:00", {}, []]; + for (const badExpiry of badShapes) { + const sourceAuth = usableAuthJson({ marker: "src", expiresAt: badExpiry }); + const code = await runDecision({ sourceAuth, destinationAuth }); + expect(code, JSON.stringify(badExpiry)).toBe(UNREADABLE_EXPIRY); + } + }); + + it("uses the source for a source expiry exactly at the 400-day bound", () => { + const nowMs = Date.now(); + const source = { kind: "usable" as const, identityKey: IDENTITY_A, expiresAtRaw: nowMs + MAX_PLAUSIBLE_EXPIRY_MS }; + const destination = { kind: "usable" as const, identityKey: IDENTITY_A, expiresAtRaw: nowMs }; + expect(decide(source, destination, nowMs)).toBe(USE_SOURCE); + }); + + it("exits 22 and keeps the destination for a source expiry one millisecond beyond the 400-day bound", () => { + const nowMs = Date.now(); + const source = { + kind: "usable" as const, + identityKey: IDENTITY_A, + expiresAtRaw: nowMs + MAX_PLAUSIBLE_EXPIRY_MS + 1, + }; + const destination = { kind: "usable" as const, identityKey: IDENTITY_A, expiresAtRaw: nowMs }; + expect(decide(source, destination, nowMs)).toBe(IMPLAUSIBLE_EXPIRY); + }); + + it("measures the 400-day bound against the host clock and not against a sandbox-supplied time", () => { + // Fixed, unambiguous epoch-millisecond instants (both well above the + // epoch-seconds/epoch-milliseconds threshold). Neither parsed side + // changes between the two assertions below — only the caller-supplied + // `nowMs` moves — so a passing/failing bound can only be explained by the + // caller's clock, never by a value embedded in either payload. + const sourceExpiryMs = 2_000_000_000_000; + const destinationExpiryMs = 1_000_000_000_000; + const source = { kind: "usable" as const, identityKey: IDENTITY_A, expiresAtRaw: sourceExpiryMs }; + const destination = { kind: "usable" as const, identityKey: IDENTITY_A, expiresAtRaw: destinationExpiryMs }; + + expect(decide(source, destination, sourceExpiryMs - MAX_PLAUSIBLE_EXPIRY_MS)).toBe(USE_SOURCE); + expect(decide(source, destination, sourceExpiryMs - MAX_PLAUSIBLE_EXPIRY_MS - 1)).toBe(IMPLAUSIBLE_EXPIRY); + }); + + it("keeps the destination for a forged source that copies the destination identity and a far-future expiry", async () => { + const now = Date.now(); + const destinationAuth = usableAuthJson({ marker: "dst", expiresAt: new Date(now + 60_000).toISOString() }); + // 500 days ahead is comfortably beyond both the 400-day plausibility + // bound and any subprocess scheduling delay, so this integration-level + // check never depends on millisecond timing. + const forgedSourceAuth = usableAuthJson({ + marker: "forged", + expiresAt: new Date(now + 500 * 24 * 60 * 60 * 1000).toISOString(), + }); + const code = await runDecision({ sourceAuth: forgedSourceAuth, destinationAuth }); + expect(code).toBe(IMPLAUSIBLE_EXPIRY); + }); +}); diff --git a/packages/adapters/grok-local/src/server/grok-auth-merge-decision.ts b/packages/adapters/grok-local/src/server/grok-auth-merge-decision.ts new file mode 100644 index 0000000000..154270f220 --- /dev/null +++ b/packages/adapters/grok-local/src/server/grok-auth-merge-decision.ts @@ -0,0 +1,77 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCallback); + +// The single identity-and-freshness predicate for the Grok credential +// copy-out lives in `grok-auth-merge-decision.cjs`. It reads only the two +// files and exits with a code; it never prints token bytes. Argument order +// sets source and destination (first = source, second = destination), so the +// caller frames the direction. Exit 10 = use source; exit 20 = keep +// destination; exit 21 = keep destination, an expiry was present but not a +// recognized encoding; exit 22 = keep destination, the source expiry sat +// further ahead of the host clock than the plausible bound. This module +// gives every caller one shared entry point, so the predicate contract can +// never drift between callers. + +const DECISION_SCRIPT_PATH = fileURLToPath( + new URL("./grok-auth-merge-decision.cjs", import.meta.url), +); + +/** Exit code: install the source credential over the destination. */ +export const USE_SOURCE_EXIT = 10; +/** Exit code: keep the destination credential. */ +export const KEEP_DESTINATION_EXIT = 20; +/** Exit code: keep the destination; an expiry was present but unreadable. */ +export const UNREADABLE_EXPIRY_EXIT = 21; +/** Exit code: keep the destination; the source expiry was implausibly far ahead. */ +export const IMPLAUSIBLE_EXPIRY_EXIT = 22; + +const KNOWN_EXIT_CODES = new Set([ + USE_SOURCE_EXIT, + KEEP_DESTINATION_EXIT, + UNREADABLE_EXPIRY_EXIT, + IMPLAUSIBLE_EXPIRY_EXIT, +]); + +export interface DecideGrokAuthMergeOptions { + /** The caller name that prefixes a predicate error, for example + * `grok auth copy-out`. */ + errorLabel: string; +} + +/** + * Runs the shared decision predicate and returns its exit code (10, 20, 21, + * or 22). Any other exit, or a failure to run `node`, is a hard failure: this + * throws so a broken predicate is never mistaken for a "keep destination" + * decision. + */ +export async function decideGrokAuthMerge( + sourcePath: string, + destinationPath: string, + options: DecideGrokAuthMergeOptions, +): Promise { + try { + await execFile("node", [DECISION_SCRIPT_PATH, sourcePath, destinationPath]); + } catch (error) { + const code = (error as { code?: unknown }).code; + if (typeof code === "number" && KNOWN_EXIT_CODES.has(code)) { + return code; + } + const detail = + typeof code === "string" + ? `node could not be executed (${code})` + : typeof code === "number" + ? `unexpected predicate exit code ${code}` + : error instanceof Error + ? error.message + : String(error); + throw new Error(`${options.errorLabel} decision predicate failed: ${detail}`); + } + // `execFile` resolved, so the predicate exited 0. The predicate always + // exits 10, 20, 21, or 22, so a clean exit 0 is unexpected; fail loud. + throw new Error( + `${options.errorLabel} decision predicate exited 0 (expected 10, 20, 21, or 22)`, + ); +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c118630bf9..471849479c 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -657,7 +657,12 @@ export function agentRoutes( "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") { + // A `kept` outcome is a successful login too: the company home + // already holds a same-account credential that is not older than + // this one (for example, a teardown copy-back installed a fresher + // copy while this login was in progress), so a later run still + // authenticates as the same account. + if (outcome !== "promoted" && outcome !== "kept") { throw new Error(`device-login credential promotion rejected: ${outcome}`); } }, diff --git a/server/src/services/native-runtime/native-question-bridge.test.ts b/server/src/services/native-runtime/native-question-bridge.test.ts index e444ff9eda..45873531a2 100644 --- a/server/src/services/native-runtime/native-question-bridge.test.ts +++ b/server/src/services/native-runtime/native-question-bridge.test.ts @@ -19,6 +19,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "../../__tests__/helpers/embedded-postgres.js"; +import { drainHeartbeatRunsToQuiescence } from "../../__tests__/helpers/drain-heartbeat-runs.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; import { deliverNativeQuestionResponse, @@ -49,6 +50,7 @@ if (!embeddedPostgresSupport.supported) { describeEmbeddedPostgres("native question bridge", () => { let temporary: Awaited> | null = null; let db: ReturnType; + let heartbeat: ReturnType; let companyId: string; let issueId: string; let agentId: string; @@ -59,9 +61,17 @@ describeEmbeddedPostgres("native question bridge", () => { beforeAll(async () => { temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-question-"); db = createDb(temporary.connectionString); + heartbeat = heartbeatService(db); }, 20_000); afterEach(async () => { + // A cancelled or reaped run can promote and dispatch its agent's next + // queued run fire-and-forget (see startNextQueuedRunForAgent in + // heartbeat.ts), so that dispatch can still be writing heartbeat_runs, + // issues, or activity_log rows when this hook starts. Drain every + // in-flight run to quiescence first, or its late write races the + // TRUNCATE below and can deadlock. + await drainHeartbeatRunsToQuiescence(db, heartbeat); nativeQuestionBridgeInternals.resetForTests(); await db.execute(sql.raw(` TRUNCATE TABLE @@ -382,7 +392,7 @@ describeEmbeddedPostgres("native question bridge", () => { }); // Simulate process exit before executeIssuePostCommitActions can run. - await heartbeatService(db).reapOrphanedRuns(); + await heartbeat.reapOrphanedRuns(); const [persistedRun] = await db.select({ status: heartbeatRuns.status, @@ -431,7 +441,7 @@ describeEmbeddedPostgres("native question bridge", () => { }, }); - await heartbeatService(db).reapOrphanedRuns(); + await heartbeat.reapOrphanedRuns(); const [cancelledRun] = await db.select({ status: heartbeatRuns.status,