feat(grok-local): copy a refreshed sandbox credential back to the host (#12696)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip runs agents through provider-specific adapters in local and remote environments > - A remote Grok run can refresh its credential inside its sandbox > - The host copy can become stale when teardown discards that refreshed credential > - This pull request copies the refreshed credential back through a locked, fail-closed teardown path > - The benefit is that later Grok runs can use the refreshed host credential without another login ## Linked Issues or Issue Description Refs: #12618 **Agent or provider** Grok local adapter. **Why this adapter is useful** A remote Grok run can refresh its access token during a run. Copying the refreshed credential back to the host keeps later runs ready to use. **How the agent is invoked** Paperclip invokes the Grok local adapter through its remote subscription run path. The adapter stages the company Grok home as a sandbox asset. The change adds a copy-out step on the teardown path. ## What Changed - `grok-auth-merge-decision.cjs` adds a host predicate in its own process. It compares the whole `<issuer>::<uuid>` identity key of the two files. It reads `expires_at` as an ISO-8601 string, an epoch-seconds number, or an epoch-milliseconds number. It exits 10 to use the source, 20 to keep the destination, 21 when the expiry shape is unreadable, and 22 when the source expiry sits more than 400 days after the host clock. It fails closed in every unclear case: an unusable side, a different identity, an absent expiry, a tie, an unreadable expiry, and an implausible expiry all keep the destination. - `grok-auth-merge-decision.ts` adds a wrapper that runs the predicate and maps the exit code to a typed result. - `grok-auth-copyback.ts` adds `copyBackGrokAuth({ hostHomeDir, readSandboxAuth, log, env })`. It locks on `hostHomeDir` with `withDirectoryMergeLock`, stages the sandbox bytes into a private `0600` temporary file, runs the predicate, and installs the file with an atomic rename in the same directory. It keeps no backup of the displaced credential. It leaves no temporary file on the success path, the keep path, or an error path. On an error it logs the `errno` code only, then re-throws. - `execute.ts` adds a `restore` callback to the Grok `home` asset. The callback takes the destination from `resolveManagedGrokHomeDir(process.env, agent.companyId)`, never from `env.GROK_HOME`. A copy-out failure does not fail the run. - `package.json` updates the `build` script to copy `grok-auth-merge-decision.cjs` into `dist/server/`, because `tsc` does not copy a `.cjs` file. **The credential shape this predicate reads** A redacted sample of a real vendor credential answered four structural questions. The answers hold no credential bytes, no account identifier, no file path, and no timestamp value. 1. `expires_at` is present. 2. `expires_at` sits inside the value object, under the `<issuer>::<uuid>` key. It is not a top-level field. 3. `expires_at` is an ISO-8601 string. It carries UTC time with a trailing `Z` and six fractional-second digits. 4. A normal run rewrites `auth.json`. The value object carries a `refresh_token` next to `expires_at`, so the client refreshes the access token and rewrites the file. ## Verification - [x] `pnpm vitest run packages/adapters/grok-local` — 114 tests in 12 files pass. - [x] `pnpm --filter @paperclipai/adapter-grok-local typecheck` — clean. - [x] `pnpm --filter @paperclipai/adapter-grok-local build` — succeeds, and `dist/server/grok-auth-merge-decision.cjs` exists after the build. - [x] Continuous integration is green on every check. ## Risks The predicate keeps the host credential when identity, expiry, file access, or freshness data is unclear. The copy-out path can log an error and leave the run successful when it cannot install the refreshed credential. The atomic rename and directory lock protect the host file from partial writes and concurrent copy-out actions. ## Model Used OpenAI GPT-5, current deployment. The exact runtime version and context window are not exposed to this agent. The model used tool calls and code inspection. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
0b00029672
commit
b4f302d040
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue