Merge remote-tracking branch 'origin/master' into fix/liv-9803-ci-isolation-20260901

This commit is contained in:
Engineer 2026-09-01 20:40:40 -05:00
commit 1d50219497
15 changed files with 1391 additions and 45 deletions

View File

@ -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"
},

View File

@ -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.
// ---------------------------------------------------------------------

View File

@ -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<boolean> {
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,
);
}

View File

@ -85,6 +85,46 @@ async function pathExists(candidate: string): Promise<boolean> {
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<Buffer> }) => Promise<void> }>,
) {
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<Buffer> }) => Promise<void>;
}>;
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<Buffer> }) => Promise<void>;
}>;
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<Buffer> }) => Promise<void>;
}>;
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 }),
);
});
});
});

View File

@ -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<AdapterExec
onProgress: (line) => 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 = () =>

View File

@ -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<string> {
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<Buffer>);
hostAuth?: string;
hostHomeDir?: string;
}): Promise<{
outcome: Awaited<ReturnType<typeof copyBackGrokAuth>>;
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");
});
});

View File

@ -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<Buffer>;
/**
* 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<void>;
/** 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<CopyBackGrokAuthOutcome> {
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;
}
}

View File

@ -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 `<issuer>::<uuid>` 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,
};

View File

@ -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<string, unknown> = {
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<number> {
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);
});
});

View File

@ -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<number> {
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)`,
);
}

View File

@ -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}`);
}
},

View File

@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
let heartbeat: ReturnType<typeof heartbeatService>;
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,

View File

@ -229,6 +229,53 @@ describe("OnboardingWizard — which step it lands on", () => {
vi.clearAllMocks();
});
/**
* The progress strip counts the walk the customer is actually on, and the two
* runs that enter on the agent step are on different walks.
*
* Both have a company already, so `entryStep` cannot tell them apart. What
* does is `enableManagedSandboxOnly` — the cloud-tenant shape. A cloud tenant
* was asked for its organization's name by Cloud, one screen earlier, so its
* walk is four and this is the second. A self-hosted company that simply has
* no agents yet was asked nothing before this, so its walk is three.
*/
describe("progress strip length", () => {
function announcedCount(): string | null {
return (
[...document.querySelectorAll(".sr-only")]
.map((element) => element.textContent?.trim() ?? "")
.find((text) => /^Step \d+ of \d+$/.test(text)) ?? null
);
}
it("counts four on a cloud tenant, continuing the count Cloud started", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: true,
});
routerState.pathname = "/PC1/onboarding";
await render();
await settle();
expect(currentStep()).toBe("agent");
expect(announcedCount()).toBe("Step 2 of 4");
});
it("counts three on a self-hosted company that has no agents yet", async () => {
// Nothing was asked before this step here, so a fourth segment would be
// one the run can never fill — and it would credit the customer with a
// step they never walked.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: false,
});
routerState.pathname = "/PC1/onboarding";
await render();
await settle();
expect(currentStep()).toBe("agent");
expect(announcedCount()).toBe("Step 1 of 3");
});
});
it("opens a company that already has its mission on the agent step", async () => {
// The point of the change: Cloud collected the mission at signup and the
// seed wrote it as a company-level goal, so asking for it again asks a

View File

@ -890,10 +890,13 @@ function OnboardingWizardInner({
queryFn: () => instanceSettingsApi.get(),
enabled: effectiveOnboardingOpen && step === 4,
});
// Wanted across the whole arc, not just the connect step. The progress strip
// reads it too — see `enteredFromCloud` — and a value fetched only on step 4
// would let the strip change length as the customer walked through it.
const { data: experimentalSettingsForLogin } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
enabled: effectiveOnboardingOpen && step === 4,
enabled: effectiveOnboardingOpen && step >= 3 && step <= 5,
});
const resolvedLoginEnvironmentId = useMemo(() => {
try {
@ -2004,7 +2007,21 @@ function OnboardingWizardInner({
}
const isAgentArcStep = agentArcStepFor(step) !== null;
const showsAgentArcStepper = isAgentArcStep && entryStep >= 3;
/**
* True when the organization was named in Cloud rather than here.
*
* `enableManagedSandboxOnly` is the cloud-tenant shape — the connect step
* already resolves its login environment through it. A tenant wearing it did
* not ask for the organization's name, because Cloud did, so the walk the
* customer is on is four steps and this is the second.
*
* A self-hosted run that enters at the agent step is a different case with
* the same `entryStep`: an existing company that has no agents yet. There was
* no naming screen before it, so its walk really is three, and it keeps the
* shorter strip.
*/
const enteredFromCloud = experimentalSettingsForLogin?.enableManagedSandboxOnly === true;
const showsAgentArcStepper = isAgentArcStep && entryStep >= 3 && !enteredFromCloud;
const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);