Merge remote-tracking branch 'origin/master' into fix/liv-9803-ci-isolation-20260901
This commit is contained in:
commit
1d50219497
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue