feat: add Grok device login to the sandbox login panel (#12469)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip uses adapters to connect agents and model providers to its
control plane
> - The sandbox login panel supports displayed-code login for selected
adapters
> - Grok users need the same login path and a private credential home
for later runs
> - This pull request adds Grok support to the shared device-login path
and preserves the existing Codex path
> - The benefit is one secure login flow for both adapters with
company-scoped credential storage

## Linked Issues or Issue Description

**Agent or provider**

Grok Local needs displayed-code login support in the sandbox login
panel.

**Why this adapter is useful**

This change lets users sign in to Grok from the sandbox login panel. It
also gives later Grok runs access to the stored credential.

**How the agent is invoked**

The Grok local adapter uses its login command through the shared
displayed-code login flow. Later runs receive the managed home through
`GROK_HOME`.

**Additional context**

The change uses adapter-scoped login lifecycle handling. It stores the
credential in a company-scoped directory with mode `0700`, and it stores
the credential file with mode `0600`.

## What Changed

- Rename the shared device-login modules to adapter-neutral names.
- Scope the shared login lifecycle to a closed adapter set.
- Return the device-login URL that the provider prints.
- Add the Grok prompt parser, login command, capability, and login panel
entry.
- Store the Grok credential in a private, company-scoped home directory.
- Pass `GROK_HOME` to later Grok runs.
- Add tests for the Grok adapter, the Daytona sandbox provider, the
server login path, and the user interface.

## Verification

- Run `pnpm vitest run
packages/adapters/grok-local/src/server/adapter-auth-promotion.test.ts`.
- Run the Grok adapter package suite.
- Run the Daytona sandbox provider suite.
- Run the server device-login suites.
- Run the user interface suite.
- Confirm the full CI suite passes.

## Risks

The change extends shared login lifecycle code to another adapter. A
regression could affect Codex login. The credential path uses explicit
`chmod` calls to keep the directory at mode `0700` and the file at mode
`0600`.

## Model Used

OpenAI Codex, GPT-5. The runtime used tool calls and code review
support. The runtime did not provide a context-window value.

## 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
- [ ] 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:
Nicky Leach 2026-08-28 21:48:34 -07:00 committed by GitHub
parent cec675ffca
commit a20a4944ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2656 additions and 222 deletions

View File

@ -1285,6 +1285,53 @@ describe("shared ACPX engine runtime behavior", () => {
expect(path.resolve(path.dirname(managedAuth), await fs.readlink(managedAuth))).toBe(sourceAuth);
});
it("sets GROK_HOME for a Grok run from the company Grok home, and leaves CODEX_HOME unchanged for a Codex run", async () => {
const root = await makeTempRoot();
const paperclipHome = path.join(root, "paperclip-home");
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
try {
process.env.PAPERCLIP_HOME = paperclipHome;
process.env.PAPERCLIP_INSTANCE_ID = "default";
const grokRun = await runExecutor({
agent: "grok",
agentCommand: "node ./fake-acp.js",
stateDir: path.join(root, "state-grok"),
});
expect(grokRun.sessionInputs[0]?.sessionOptions).toMatchObject({
env: expect.objectContaining({
GROK_HOME: path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"grok-home",
),
}),
});
const codexHome = path.join(root, "codex-home");
const codexRun = await runExecutor({
agent: "codex",
stateDir: path.join(root, "state-codex"),
env: { CODEX_HOME: codexHome },
paperclipRuntimeSkills: [],
paperclipSkillSync: { desiredSkills: [] },
});
const codexEnv = (codexRun.sessionInputs[0]?.sessionOptions as { env: Record<string, string> })
.env;
expect(codexEnv.CODEX_HOME).toBe(codexHome);
expect(codexEnv.GROK_HOME).toBeUndefined();
} finally {
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
}
});
it("uses direct registry commands and per-session env across ACPX agent changes", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");

View File

@ -647,6 +647,16 @@ function resolveManagedCodexHomeDir(companyId: string): string {
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home");
}
// Mirrors `resolveManagedGrokHomeDir` in
// `packages/adapters/grok-local/src/server/grok-home.ts` — this package
// cannot import that adapter package (it would invert the dependency
// direction), so the path scheme is duplicated here, the same way
// `resolveManagedCodexHomeDir` above duplicates the Codex adapter's own
// helper.
function resolveManagedGrokHomeDir(companyId: string): string {
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "grok-home");
}
// Walk up from startDir looking for `node_modules/.bin/<binName>`. This matches
// npm/pnpm binary hoisting in packaged installs while preserving monorepo dev.
export async function findAncestorBin(startDir: string, binName: string): Promise<string | null> {
@ -1854,6 +1864,14 @@ async function buildRuntime(input: {
skillsIdentity = preparedSkills.identity;
skillCommandNotes.push(...preparedSkills.commandNotes);
} else {
// A minimal, separate Grok seam: only the company-scoped `GROK_HOME`
// binding, so a Grok run authenticates from the credential a completed
// device login wrote. This never touches `prepareCodexSkillRuntime` above
// — that function stays Codex-only — and every other custom ACPX agent
// (for example `kimi`) falls through this branch unaffected.
if (acpxAgent === "grok") {
env.GROK_HOME = resolveManagedGrokHomeDir(agent.companyId);
}
const desired = resolveLegacyPaperclipDesiredSkillNames(
config,
await readPaperclipRuntimeSkillEntries(config, input.engine.moduleDir),

View File

@ -118,6 +118,33 @@ describe("parseDeviceLoginPrompt", () => {
expect(parseDeviceLoginPrompt(withFragment)).toBeNull();
});
it("parse_returns_the_printed_url_with_a_bare_trailing_fragment_marker", () => {
// A printed URL that ends with a bare `#` carries an empty fragment. The
// platform `URL` parser keeps the marker in its normalized form. The parser
// returns that normalized form, not the fixed constant.
const preamble = "2. Enter this one-time code (expires in 15 minutes)";
const text = [`${EXACT_URL}#`, preamble, "ABCD-EFGHJ"].join("\n");
const result = parseDeviceLoginPrompt(text);
expect(result).not.toBeNull();
expect(result?.url).toBe("https://auth.openai.com/codex/device#");
});
it("parse_returns_the_normalized_url_for_an_explicit_default_port_or_an_uppercase_host", () => {
// The platform `URL` parser normalizes an explicit default port and an
// uppercase host. The parser returns that normalized form.
const preamble = "2. Enter this one-time code (expires in 15 minutes)";
const withPort = [`${EXACT_URL.replace("auth.openai.com", "auth.openai.com:443")}`, preamble, "ABCD-EFGHJ"].join(
"\n",
);
const withUppercaseHost = [
`${EXACT_URL.replace("auth.openai.com", "AUTH.OPENAI.COM")}`,
preamble,
"ABCD-EFGHJ",
].join("\n");
expect(parseDeviceLoginPrompt(withPort)?.url).toBe(EXACT_URL);
expect(parseDeviceLoginPrompt(withUppercaseHost)?.url).toBe(EXACT_URL);
});
it("parse_returns_null_for_wrong_origin_or_path", () => {
const wrongOrigin = [
"Open this link",

View File

@ -7,14 +7,19 @@
// structure `XXXX-XXXXX` (four characters, a hyphen, then five characters). The
// parser never logs the URL, the code, or any input byte, and it keeps them out
// of every thrown error. The parser is a pure function.
//
// The parser returns the printed URL after the platform `URL` parser validates
// and normalizes it. It never returns the raw matched token: the accepting
// rules above already bound the token to the exact origin, path, and empty
// query and fragment, so the normalized form carries no unvalidated byte.
export interface DeviceLoginPrompt {
url: string;
code: string;
}
// The one and only accepted device-login URL. The parser returns this exact
// constant string on a match, so the output is never a caller-controlled value.
// The one and only accepted device-login origin and path. `findExactDeviceUrl`
// checks a candidate token against these exact values before it accepts it.
export const DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device";
// Codex CLI 0.128.0 and later wrap the login URL and the one-time code in ANSI
@ -74,12 +79,13 @@ interface DeviceUrlMatch {
}
/**
* Returns the exact device-login URL and its end index when `text` holds it as
* a standalone token with the exact origin `https://auth.openai.com` and the
* Returns the validated device-login URL and its end index when `text` holds it
* as a standalone token with the exact origin `https://auth.openai.com` and the
* exact path `/codex/device` and no query, fragment, or credentials. Returns
* null otherwise. Returns the canonical {@link DEVICE_LOGIN_URL} constant on a
* match. The `end` index is the position of the first character after the
* matched token in `text`.
* null otherwise. Returns the platform `URL` parser's normalized form of the
* matched token, so the accepting rules bound the output even though it is not a
* fixed constant. The `end` index is the position of the first character after
* the matched token in `text`.
*/
function findExactDeviceUrl(text: string): DeviceUrlMatch | null {
for (const match of text.matchAll(URL_TOKEN_RE)) {
@ -100,7 +106,7 @@ function findExactDeviceUrl(text: string): DeviceUrlMatch | null {
parsed.username === "" &&
parsed.password === ""
) {
return { url: DEVICE_LOGIN_URL, end: (match.index ?? 0) + token.length };
return { url: parsed.toString(), end: (match.index ?? 0) + token.length };
}
}
return null;

View File

@ -194,4 +194,34 @@ describe("runDeviceLogin", () => {
expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("codex");
expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("--device-auth");
});
it("uses the parser the caller supplies instead of the Codex parser", async () => {
// The caller's parser reads a shape the Codex parser rejects, so a call that
// reaches the Codex parser instead would never surface a prompt.
const { driver } = createFakeDriver({
chunks: ["not a codex prompt, but the caller's own marker\n"],
exitCode: 0,
});
const onPrompt = vi.fn();
const parsePrompt = vi.fn((output: string) =>
output.includes("marker") ? { url: "https://example.test/device", code: "AAAA-11111" } : null,
);
const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000, parsePrompt });
expect(result.outcome).toBe("success");
expect(result.promptSurfaced).toBe(true);
expect(parsePrompt).toHaveBeenCalled();
expect(onPrompt).toHaveBeenCalledWith({ url: "https://example.test/device", code: "AAAA-11111" });
});
it("uses the Codex parser when the caller supplies none", async () => {
const { driver } = createFakeDriver({
chunks: [`1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n${REAL_SHAPED_CODE}\nDone.\n`],
exitCode: 0,
});
const onPrompt = vi.fn();
const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 });
expect(result.outcome).toBe("success");
expect(result.promptSurfaced).toBe(true);
expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE });
});
});

View File

@ -80,6 +80,9 @@ export type DeviceLoginResult = LoginRunnerResult;
export interface RunDeviceLoginOptions extends LoginRunnerLifecycleOptions {
/** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */
command?: string;
/** Parses the prompt from the login output. Defaults to
* {@link parseDeviceLoginPrompt}. */
parsePrompt?: (output: string) => DeviceLoginPrompt | null;
/** Receives the parsed prompt one time in memory. The caller displays it. */
onPrompt: DeviceLoginPromptSink;
/**
@ -105,6 +108,7 @@ export async function runDeviceLogin(
): Promise<DeviceLoginResult> {
const { onPrompt, onCredential, authPath, timeoutMs, signal } = options;
const command = options.command ?? CODEX_DEVICE_LOGIN_COMMAND;
const parsePrompt = options.parsePrompt ?? parseDeviceLoginPrompt;
const log = options.log ?? (() => {});
let promptSurfaced = false;
@ -120,7 +124,7 @@ export async function runDeviceLogin(
const onStdout = (chunk: string): void => {
if (promptSurfaced) return;
buffer += chunk;
const prompt = parseDeviceLoginPrompt(buffer);
const prompt = parsePrompt(buffer);
if (prompt) {
promptSurfaced = true;
buffer = "";

View File

@ -51,6 +51,7 @@
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {

View File

@ -0,0 +1,32 @@
# Device-login sample fixture
This fixture holds redacted, real Grok device-login output. A capture step ran
`grok login --device-auth` inside a Daytona sandbox and recorded the output. The
capture step redacted every secret before it kept the text. The parser test
reads this fixture. The test never reads a live secret.
## Source
- Capture date (UTC): `2026-08-28`.
- CLI version: `grok 1.0.5 (5115b46bc9)`.
- Host: Daytona sandbox, Ubuntu 22.04, `x86_64`.
- Transport: a pipe with no pseudo-terminal. The Grok prompt reaches a plain
pipe, so the fixture holds line-feed-only line endings, with no carriage
return.
## File
| File | Condition | Expected parse result |
|---|---|---|
| `device-login-prompt.txt` | Normal prompt, no pseudo-terminal | a URL and a code |
## Redaction
The capture step transformed every real one-time code to the placeholder
`XXXX-XXXX`. The placeholder keeps the observed shape: four characters, a
hyphen, then four characters. The parser matches this grounded structure and
does not invent an alphabet, so the committed fixture parses to a code with no
real secret in the repository.
The capture step checked the final text for common credential field names and
for an unredacted device-code pattern. It found no match.

View File

@ -0,0 +1,12 @@
To sign in, open this URL in your browser:
https://accounts.x.ai/oauth2/device?user_code=XXXX-XXXX
Confirm this code in your browser:
XXXX-XXXX
Only continue with a code you requested. Don't share it with anyone.
Waiting for authorization...

View File

@ -0,0 +1,520 @@
import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertUsableGrokAuthShape,
checkStagedGrokCredentialReadiness,
DeviceLoginReadinessError,
promoteGrokDeviceLoginCredential,
type CredentialReadinessResult,
} from "./adapter-auth-promotion.js";
import { resolveManagedGrokHomeDir } from "./grok-home.js";
const COMPANY_A = "company-a";
const COMPANY_B = "company-b";
const ISSUER = "https://issuer.x.ai";
const UUID_A = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
const UUID_B = "8f14e45f-ceea-467e-a4b6-8b0e12345678";
const TOKEN_SENTINEL = "SENTINEL_REFRESH_TOKEN_XYZ";
// This suite proves the Grok device-login credential promotion helper. It runs
// an independent readiness check on the exact staged credential first, then
// validates its shape, then writes only the company-scoped credential home. It
// writes only while the session holds the sole active claim on the slot, and
// only for a user-initiated login. It never writes the instance-global host,
// never crosses a company boundary, and never logs a secret.
describe("grok device-login credential promotion", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await chmod(dir, 0o700).catch(() => undefined);
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
async function makeInstanceRoot(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-grok-promotion-"));
cleanupDirs.push(dir);
return dir;
}
function envFor(instanceHome: string): NodeJS.ProcessEnv {
return {
PAPERCLIP_HOME: instanceHome,
PAPERCLIP_INSTANCE_ID: "default",
};
}
function grokAuth(input: { uuid: string; issuer?: string; marker?: string }): Buffer {
const suffix = input.marker ?? input.uuid;
return Buffer.from(
JSON.stringify({
[`${input.issuer ?? ISSUER}::${input.uuid}`]: {
key: `api-key-${suffix}`,
refresh_token: `${TOKEN_SENTINEL}-${suffix}`,
expires_at: "2026-01-01T00:00:00Z",
oidc_issuer: input.issuer ?? ISSUER,
oidc_client_id: "client-1",
email: `user-${suffix}@example.com`,
first_name: "Test",
last_name: "User",
user_id: `user-${suffix}`,
principal_id: `principal-${suffix}`,
team_id: `team-${suffix}`,
},
}),
);
}
const ready = (): CredentialReadinessResult => ({ ready: true });
const notReady = (): CredentialReadinessResult => ({ ready: false, reason: "auth_unusable" });
const soleOwner = () => true;
const notSoleOwner = () => false;
const noopLog = (_line: string): void => {};
function companyHomeAuthPath(env: NodeJS.ProcessEnv, companyId: string): string {
return path.join(resolveManagedGrokHomeDir(env, companyId), "auth.json");
}
// ---------------------------------------------------------------------
// Shape validation.
// ---------------------------------------------------------------------
describe("assertUsableGrokAuthShape", () => {
it("rejects empty bytes", () => {
expect(() => assertUsableGrokAuthShape(Buffer.alloc(0))).toThrow(/empty/);
});
it("rejects oversized bytes", () => {
const big = Buffer.alloc(64 * 1024 + 1, "a");
expect(() => assertUsableGrokAuthShape(big)).toThrow(/oversized/);
});
it("rejects invalid JSON", () => {
expect(() => assertUsableGrokAuthShape(Buffer.from("{not json"))).toThrow(/invalid JSON/);
});
it("rejects an object with no top-level key", () => {
expect(() => assertUsableGrokAuthShape(Buffer.from("{}"))).toThrow(/single/);
});
it("rejects an object with more than one top-level key", () => {
const bytes = Buffer.from(
JSON.stringify({
[`${ISSUER}::${UUID_A}`]: { key: "k", refresh_token: "r" },
[`${ISSUER}::${UUID_B}`]: { key: "k2", refresh_token: "r2" },
}),
);
expect(() => assertUsableGrokAuthShape(bytes)).toThrow(/single/);
});
it("rejects a key that does not match the <issuer>::<uuid> shape", () => {
const bytes = Buffer.from(JSON.stringify({ some_fixed_key: { key: "k", refresh_token: "r" } }));
expect(() => assertUsableGrokAuthShape(bytes)).toThrow(/single/);
});
it("rejects a value with no usable key or refresh_token", () => {
const bytes = Buffer.from(JSON.stringify({ [`${ISSUER}::${UUID_A}`]: { key: "k" } }));
expect(() => assertUsableGrokAuthShape(bytes)).toThrow(/usable/);
});
it("accepts an object whose single key matches <issuer>::<uuid> and holds key + refresh_token", () => {
const payload = assertUsableGrokAuthShape(grokAuth({ uuid: UUID_A }));
expect(payload.identityKey).toBe(`${ISSUER}::${UUID_A}`);
expect(payload.value.key).toBe(`api-key-${UUID_A}`);
});
});
// ---------------------------------------------------------------------
// Readiness.
// ---------------------------------------------------------------------
describe("checkStagedGrokCredentialReadiness", () => {
it("reports not ready for empty bytes", async () => {
const result = await checkStagedGrokCredentialReadiness(Buffer.alloc(0));
expect(result.ready).toBe(false);
});
it("reports not ready for a Codex-shaped payload", async () => {
const codexShaped = Buffer.from(JSON.stringify({ OPENAI_API_KEY: "sk-test" }));
const result = await checkStagedGrokCredentialReadiness(codexShaped);
expect(result.ready).toBe(false);
expect(result.reason).toBe("no_usable_auth");
});
it("reports ready for a usable Grok payload", async () => {
const result = await checkStagedGrokCredentialReadiness(grokAuth({ uuid: UUID_A }));
expect(result.ready).toBe(true);
});
});
// ---------------------------------------------------------------------
// The promotion order and gates.
// ---------------------------------------------------------------------
it("a failed readiness check rejects and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const logs: string[] = [];
await expect(
promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: notReady,
isSoleActiveOwner: soleOwner,
env,
log: (line) => {
logs.push(line);
},
}),
).rejects.toBeInstanceOf(DeviceLoginReadinessError);
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
expect(logs.join("\n")).not.toContain(TOKEN_SENTINEL);
expect(logs.join("\n")).not.toContain("example.com");
});
it("an invalid shape rejects after a passed readiness check and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await expect(
promoteGrokDeviceLoginCredential({
authBytes: Buffer.from("{}"),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
}),
).rejects.toThrow(/single/);
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
});
it("skips a background (non-user-initiated) login and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: false,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("background_skipped");
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
});
it("skips a session that lost the sole active claim and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: notSoleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("not_sole_owner");
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
});
it("creates the company Grok home at mode 0700 and writes auth.json at exact mode 0600", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A);
const dirStat = await lstat(companyHome);
expect(dirStat.mode & 0o777).toBe(0o700);
const authPath = path.join(companyHome, "auth.json");
const fileStat = await lstat(authPath);
expect(fileStat.mode & 0o777).toBe(0o600);
const written = JSON.parse(await readFile(authPath, "utf8"));
expect(Object.keys(written)).toEqual([`${ISSUER}::${UUID_A}`]);
});
it("normalizes the company Grok home to mode 0700 when the home already exists at a broader mode", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A);
await mkdir(companyHome, { recursive: true, mode: 0o755 });
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
const dirStat = await lstat(companyHome);
expect(dirStat.mode & 0o777).toBe(0o700);
const authPath = path.join(companyHome, "auth.json");
const fileStat = await lstat(authPath);
expect(fileStat.mode & 0o777).toBe(0o600);
});
it("keeps an occupied home that holds a different identity", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const first = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(first).toBe("promoted");
const second = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_B }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(second).toBe("kept_foreign_identity");
const authPath = companyHomeAuthPath(env, COMPANY_A);
const written = JSON.parse(await readFile(authPath, "utf8"));
expect(Object.keys(written)).toEqual([`${ISSUER}::${UUID_A}`]);
});
it("redacts the credential bytes and the account email the same way on a promoted and on a kept_foreign_identity outcome", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const logs: string[] = [];
const captureLog = (line: string): void => {
logs.push(line);
};
const promoted = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: captureLog,
});
expect(promoted).toBe("promoted");
const keptForeign = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_B }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: captureLog,
});
expect(keptForeign).toBe("kept_foreign_identity");
const allLogs = logs.join("\n");
expect(allLogs).not.toContain(TOKEN_SENTINEL);
expect(allLogs).not.toContain(`user-${UUID_A}@example.com`);
expect(allLogs).not.toContain(`user-${UUID_B}@example.com`);
expect(allLogs).not.toContain(UUID_A);
expect(allLogs).not.toContain(UUID_B);
});
it("overwrites the home with a refreshed credential for the same identity", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A, marker: "first" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A, marker: "second" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
const authPath = companyHomeAuthPath(env, COMPANY_A);
const written = JSON.parse(await readFile(authPath, "utf8"));
expect(written[`${ISSUER}::${UUID_A}`].refresh_token).toBe(`${TOKEN_SENTINEL}-second`);
});
// ---------------------------------------------------------------------
// Fail closed on an unreadable or unparseable existing home.
// ---------------------------------------------------------------------
it("keeps a home whose auth.json holds corrupt JSON, and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A);
await mkdir(companyHome, { recursive: true, mode: 0o700 });
const authPath = path.join(companyHome, "auth.json");
const corruptBytes = Buffer.from("{not valid json");
await writeFile(authPath, corruptBytes, { mode: 0o600 });
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("kept_foreign_identity");
const afterBytes = await readFile(authPath);
expect(afterBytes.equals(corruptBytes)).toBe(true);
});
it("keeps a home whose auth.json holds a well-formed but unusable payload, and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A);
await mkdir(companyHome, { recursive: true, mode: 0o700 });
const authPath = path.join(companyHome, "auth.json");
const unusableBytes = Buffer.from(JSON.stringify({ some_fixed_key: { key: "k" } }));
await writeFile(authPath, unusableBytes, { mode: 0o600 });
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("kept_foreign_identity");
const afterBytes = await readFile(authPath);
expect(afterBytes.equals(unusableBytes)).toBe(true);
});
// ---------------------------------------------------------------------
// Atomic write.
// ---------------------------------------------------------------------
it("leaves no staged temporary file in the company home after a successful promotion", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
const companyHome = resolveManagedGrokHomeDir(env, COMPANY_A);
const entries = await readdir(companyHome);
expect(entries).toEqual(["auth.json"]);
});
// ---------------------------------------------------------------------
// Company isolation.
// ---------------------------------------------------------------------
it("a promotion for company A creates no file, replaces no file, and reads no file under company B's home", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
// Seed company B's home first, so a read-through would be observable.
const companyBHome = resolveManagedGrokHomeDir(env, COMPANY_B);
await mkdir(companyBHome, { recursive: true, mode: 0o700 });
const companyBAuthPath = path.join(companyBHome, "auth.json");
const companyBBytes = grokAuth({ uuid: UUID_B });
await writeFile(companyBAuthPath, companyBBytes, { mode: 0o600 });
const beforeBBytes = await readFile(companyBAuthPath);
const outcome = await promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
// Company A's write landed only under company A's home.
const companyAAuthPath = companyHomeAuthPath(env, COMPANY_A);
expect(resolveManagedGrokHomeDir(env, COMPANY_A)).not.toBe(companyBHome);
await expect(lstat(companyAAuthPath)).resolves.toBeDefined();
// Company B's home is byte-identical to before the company A promotion.
const afterBBytes = await readFile(companyBAuthPath);
expect(afterBBytes.equals(beforeBBytes)).toBe(true);
});
it("rejects a companyId that holds a path separator", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await expect(
promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: `${COMPANY_A}/../${COMPANY_B}`,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
}),
).rejects.toThrow(/path separator/);
await expect(lstat(resolveManagedGrokHomeDir(env, COMPANY_B))).rejects.toThrow();
});
it("rejects a companyId that is a parent-directory segment", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await expect(
promoteGrokDeviceLoginCredential({
authBytes: grokAuth({ uuid: UUID_A }),
companyId: "..",
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
}),
).rejects.toThrow(/relative path segment/);
});
});

View File

@ -0,0 +1,335 @@
import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import {
grokHomeHasUsableAuth,
parseGrokAuthPayload,
hasUsableGrokAuthValue,
resolveManagedGrokHomeDir,
type GrokAuthPayload,
} from "./grok-home.js";
// The Grok device-login credential promotion. It runs after a successful
// device login, on the exact credential the login sandbox produced. It mirrors
// the fixed order the Codex promotion uses (see
// `packages/adapters/codex-local/src/server/adapter-auth-promotion.ts`):
// readiness check, credential validation, a user-initiated gate, a
// sole-active-owner gate, then the write.
//
// Grok needs no per-identity cache and no "strictly newer" merge decision: a
// completed device login is always the newest state for the account it logs
// in. So the write step is simpler than Codex's: it writes whenever the home
// is empty or already holds the SAME account, and it keeps the home untouched
// when a DIFFERENT account already occupies it. The helper never writes the
// instance-global home, only the company-scoped one.
//
// The helper treats the whole credential value as a secret: it never logs a
// token, a refresh token, or a personal field (email, name, user id).
const AUTH_FILE_NAME = "auth.json";
// A private directory (owner rwx only), reused from
// packages/adapters/codex-local/src/server/adapter-auth-promotion.ts:37-38.
const PRIVATE_DIR_MODE = 0o700;
// A private file (owner rw only). The write calls `chmod` after the write, so
// the process umask can never widen it — the same pattern
// packages/adapters/codex-local/src/server/codex-home.ts:524-526 uses for a
// staged credential file.
const PRIVATE_FILE_MODE = 0o600;
// A bounded size for the credential payload, mirroring
// packages/adapters/codex-local/src/server/device-login-export.ts's
// `MAX_AUTH_JSON_BYTES`. A real `auth.json` is a few kilobytes.
const MAX_AUTH_JSON_BYTES = 64 * 1024;
/** The independent readiness result for the exact staged credential. */
export interface CredentialReadinessResult {
/** True when a run launched now with this exact credential would authenticate. */
ready: boolean;
/** An optional non-secret reason code for a non-ready result. */
reason?: string;
}
/** Thrown when the independent readiness check does not return a ready result.
* The service maps this to a failed session and still deletes the sandbox. */
export class DeviceLoginReadinessError extends Error {
readonly reason: string;
constructor(reason: string) {
super(`grok device-login promotion: the readiness check did not pass (${reason})`);
this.name = "DeviceLoginReadinessError";
this.reason = reason;
}
}
// A private directory (owner rwx only) for the throwaway readiness home.
const READINESS_HOME_DIR_MODE = 0o700;
// A private file (owner rw only) for the throwaway readiness credential.
const READINESS_AUTH_FILE_MODE = 0o600;
/**
* The independent readiness check for a staged Grok device-login credential. It
* runs on the exact staged bytes, before any promotion write. It writes the
* bytes to a throwaway private home and runs the same usable-auth predicate the
* execute path uses. It always removes the throwaway home before it returns.
*
* It never uses the `grok models` exit code: that command exits `0` whether or
* not the user is authenticated, so it is not a usable auth signal.
*/
export async function checkStagedGrokCredentialReadiness(
authBytes: Buffer,
): Promise<CredentialReadinessResult> {
if (authBytes.length === 0) {
return { ready: false, reason: "empty_credential" };
}
const scratchHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-grok-login-readiness-"));
try {
await mkdir(scratchHome, { recursive: true, mode: READINESS_HOME_DIR_MODE });
await writeFile(path.join(scratchHome, AUTH_FILE_NAME), authBytes, {
mode: READINESS_AUTH_FILE_MODE,
});
const ready = await grokHomeHasUsableAuth(scratchHome);
return ready ? { ready: true } : { ready: false, reason: "no_usable_auth" };
} finally {
await rm(scratchHome, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Validates the bounded-size, single-identity-key auth shape. Rejects an
* empty, an oversized, an invalid-JSON, a no-key, a multi-key, and an
* unusable-value payload. Never puts credential bytes into the thrown error.
*/
export function assertUsableGrokAuthShape(authBytes: Buffer): GrokAuthPayload {
if (authBytes.length === 0) {
throw new Error("grok device-login promotion: refused an empty auth payload");
}
if (authBytes.length > MAX_AUTH_JSON_BYTES) {
throw new Error("grok device-login promotion: refused an oversized auth payload");
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(authBytes.toString("utf8"));
} catch {
throw new Error("grok device-login promotion: refused invalid JSON");
}
const payload = parseGrokAuthPayload(parsedJson);
if (!payload) {
throw new Error(
"grok device-login promotion: refused a payload with no single <issuer>::<uuid> key",
);
}
if (!hasUsableGrokAuthValue(payload.value)) {
throw new Error(
"grok device-login promotion: refused a credential with no usable key and refresh_token",
);
}
return payload;
}
/**
* The promotion outcome.
*
* - `promoted`: the helper wrote the company home, either seeding an empty home
* or refreshing the same account's credential.
* - `kept_foreign_identity`: the company home is occupied and this step could
* not confirm it holds the same account either a DIFFERENT account already
* occupies it, or the existing file is present but this step cannot read it
* as a usable Grok credential (an unreadable or unparseable file fails
* closed the same way). The helper never clobbers an occupied home, so it
* wrote nothing. This is NOT a successful authentication; the caller must
* fail the session.
* - `not_sole_owner`: the sole-active-owner gate rejected the write. Nothing
* was written.
* - `background_skipped`: the user-initiated gate rejected the write (an
* automatic background path never seeds a company slot). Nothing was written.
*/
export type PromoteGrokDeviceLoginCredentialOutcome =
| "promoted"
| "kept_foreign_identity"
| "not_sole_owner"
| "background_skipped";
export interface PromoteGrokDeviceLoginCredentialInput {
/** The exact staged credential bytes the login sandbox produced. */
authBytes: Buffer;
/** The company that owns this login. It must be a single safe path segment. */
companyId: string;
/**
* True when a user started this login. Only a user-initiated login seeds the
* company slot; an automatic background path never seeds it.
*/
userInitiated: boolean;
/**
* The independent, machine-readable readiness check. It runs on the exact
* staged credential before any write. A non-ready result rejects the
* promotion (the session fails), and the helper writes nothing.
*/
checkReadiness: (
authBytes: Buffer,
) => Promise<CredentialReadinessResult> | CredentialReadinessResult;
/**
* Resolves true only while this session still holds the sole active claim on
* `(company_id, adapter_type)`. The helper writes only when it resolves true.
*/
isSoleActiveOwner: () => Promise<boolean> | boolean;
/** A non-leaking progress sink. It receives only fixed status lines. */
log: (line: string) => void | Promise<void>;
env?: NodeJS.ProcessEnv;
}
/** Rejects an empty or unsafe `companyId`, so a promotion can never resolve the
* instance-global home (`resolveManagedGrokHomeDir` with no `companyId`) or
* escape the company tree. */
function requireSafeCompanyId(companyId: string): string {
const trimmed = typeof companyId === "string" ? companyId.trim() : "";
if (trimmed.length === 0) {
throw new Error("grok device-login promotion: companyId is empty");
}
if (trimmed === "." || trimmed === "..") {
throw new Error("grok device-login promotion: companyId is a relative path segment");
}
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
throw new Error("grok device-login promotion: companyId contains a path separator");
}
return trimmed;
}
/**
* The existing home's state, read before a write decision.
*
* - `absent`: no file at the path. This is an empty slot.
* - `identity`: the file is present, readable, and holds a usable Grok
* payload. `identityKey` is that payload's composite key.
* - `unreadable`: the file is present, but the read failed, the JSON parse
* failed, or the payload is not a usable Grok payload. The caller must
* treat this the same as an occupied home with an unknown account: it
* fails closed and never overwrites the file.
*/
type ExistingHomeState = { kind: "absent" } | { kind: "identity"; identityKey: string } | { kind: "unreadable" };
function isEnoentError(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT";
}
/** Reads the existing home's state at `authPath`. A read failure other than
* a missing file, an invalid-JSON payload, or a non-Grok payload all
* resolve to `unreadable`, so the caller fails closed on any of them. Only
* a missing file resolves to `absent`. */
async function readExistingHomeState(authPath: string): Promise<ExistingHomeState> {
let existingBytes: Buffer;
try {
existingBytes = await readFile(authPath);
} catch (error) {
return isEnoentError(error) ? { kind: "absent" } : { kind: "unreadable" };
}
let existingJson: unknown;
try {
existingJson = JSON.parse(existingBytes.toString("utf8"));
} catch {
return { kind: "unreadable" };
}
const payload = parseGrokAuthPayload(existingJson);
return payload ? { kind: "identity", identityKey: payload.identityKey } : { kind: "unreadable" };
}
/**
* Writes `authBytes` to `authPath` atomically. It stages the bytes into a
* private (0600) temporary file in the same directory, then renames that
* file over `authPath`. The rename is an atomic same-directory swap, so a
* reader never observes a torn file, and a process that stops mid-write
* leaves the destination untouched. The temporary file is always removed,
* so a failed write never leaves stray bytes behind.
*/
async function writeAuthFileAtomically(authPath: string, authBytes: Buffer): Promise<void> {
const stagedTempPath = path.join(
path.dirname(authPath),
`.auth-${process.pid}-${randomUUID()}.tmp`,
);
// `wx` + explicit mode create the temp file private (0600) and fail if it
// already exists, so the write never goes through a pre-existing symlink.
const handle = await open(stagedTempPath, "wx", PRIVATE_FILE_MODE);
try {
await handle.writeFile(authBytes);
await handle.close();
await rename(stagedTempPath, authPath);
await chmod(authPath, PRIVATE_FILE_MODE);
} finally {
await handle.close().catch(() => undefined);
await rm(stagedTempPath, { force: true }).catch(() => undefined);
}
}
/**
* Promotes a Grok device-login credential into the company scope. The order is
* fixed: readiness check, credential validation, the user-initiated gate, the
* sole-active-owner gate, then the write. The readiness check and the write run
* while the caller still holds the active claim, so a second session cannot
* race the same slot.
*/
export async function promoteGrokDeviceLoginCredential(
input: PromoteGrokDeviceLoginCredentialInput,
): Promise<PromoteGrokDeviceLoginCredentialOutcome> {
const { authBytes, userInitiated, checkReadiness, isSoleActiveOwner, log } = input;
const env = input.env ?? process.env;
const companyId = requireSafeCompanyId(input.companyId);
// 1. Independent readiness check on the exact staged credential. A non-ready
// result rejects the promotion before any validation or write.
const readiness = await checkReadiness(authBytes);
if (!readiness.ready) {
throw new DeviceLoginReadinessError(readiness.reason ?? "not_ready");
}
// 2. Validate the credential shape: the single `<issuer>::<uuid>` key, and a
// usable `key` and `refresh_token` in its value.
const payload = assertUsableGrokAuthShape(authBytes);
// 3. Only a user-initiated login seeds the company slot.
if (!userInitiated) {
await log(
"[paperclip] Grok device-login promotion: skipped (an automatic background login never seeds a company slot).",
);
return "background_skipped";
}
// 4. Write only while the session still owns the active slot.
const soleOwner = await isSoleActiveOwner();
if (!soleOwner) {
await log(
"[paperclip] Grok device-login promotion: skipped (the session no longer holds the sole active claim on the slot).",
);
return "not_sole_owner";
}
// 5. Never clobber an occupied home. A present file that this step cannot
// read as the same identity — absent from a read failure, invalid JSON,
// or a non-Grok payload — is treated as a foreign identity, so the
// promotion fails closed and writes nothing.
const companyHome = resolveManagedGrokHomeDir(env, companyId);
const authPath = path.join(companyHome, AUTH_FILE_NAME);
const existingState = await readExistingHomeState(authPath);
if (existingState.kind === "unreadable") {
await log(
"[paperclip] Grok device-login promotion: kept the company credential home (the existing file is present but this step cannot read it as a usable Grok credential).",
);
return "kept_foreign_identity";
}
if (existingState.kind === "identity" && existingState.identityKey !== payload.identityKey) {
await log(
"[paperclip] Grok device-login promotion: kept the company credential home (the login is a different account than the one already set for this company).",
);
return "kept_foreign_identity";
}
// 6. Write the company credential home. `mkdir` applies `mode` only when it
// creates the directory, so an explicit `chmod` follows it. This keeps
// the directory mode exact both for a new home and for a home that
// already existed at a broader mode. The write itself is atomic: it
// stages the bytes into a private temporary file in the same directory,
// then renames that file over `auth.json`.
await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE });
await chmod(companyHome, PRIVATE_DIR_MODE);
await writeAuthFileAtomically(authPath, authBytes);
await log("[paperclip] Grok device-login promotion: wrote the company credential home at mode 0600.");
return "promoted";
}

View File

@ -0,0 +1,163 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parseGrokDeviceLoginPrompt } from "./device-login-parse.js";
const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__");
function readFixture(name: string): string {
return readFileSync(path.join(fixturesDir, name), "utf8");
}
const PREAMBLE = "Confirm this code in your browser:";
const ORIGIN = "https://accounts.x.ai";
const PATH = "/oauth2/device";
function buildPrompt(code: string, urlCode: string = code): string {
return [
"",
"To sign in, open this URL in your browser:",
"",
` ${ORIGIN}${PATH}?user_code=${urlCode}`,
"",
PREAMBLE,
"",
` ${code}`,
"",
].join("\n");
}
describe("parseGrokDeviceLoginPrompt", () => {
it("parse_returns_url_and_code_from_the_captured_fixture", () => {
const result = parseGrokDeviceLoginPrompt(readFixture("device-login-prompt.txt"));
expect(result).not.toBeNull();
expect(result?.url).toBe(`${ORIGIN}${PATH}?user_code=XXXX-XXXX`);
expect(result?.code).toBe("XXXX-XXXX");
});
it("parse_returns_null_when_the_url_code_differs_from_the_standalone_code", () => {
const text = buildPrompt("ABCD-EFGH", "WXYZ-1234");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_for_a_query_that_repeats_user_code", () => {
const text = [
"To sign in, open this URL in your browser:",
` ${ORIGIN}${PATH}?user_code=ABCD-EFGH&user_code=ABCD-EFGH`,
PREAMBLE,
" ABCD-EFGH",
].join("\n");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_for_a_query_that_adds_a_second_key", () => {
const text = [
"To sign in, open this URL in your browser:",
` ${ORIGIN}${PATH}?user_code=ABCD-EFGH&session=1`,
PREAMBLE,
" ABCD-EFGH",
].join("\n");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_for_a_wrong_origin", () => {
const text = [
"To sign in, open this URL in your browser:",
` https://accounts.example.com${PATH}?user_code=ABCD-EFGH`,
PREAMBLE,
" ABCD-EFGH",
].join("\n");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_for_a_wrong_path", () => {
const text = [
"To sign in, open this URL in your browser:",
` ${ORIGIN}/oauth2/device/extra?user_code=ABCD-EFGH`,
PREAMBLE,
" ABCD-EFGH",
].join("\n");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_for_a_url_with_a_fragment", () => {
const text = [
"To sign in, open this URL in your browser:",
` ${ORIGIN}${PATH}?user_code=ABCD-EFGH#section`,
PREAMBLE,
" ABCD-EFGH",
].join("\n");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_for_a_five_character_code_group", () => {
const text = [
"To sign in, open this URL in your browser:",
` ${ORIGIN}${PATH}?user_code=ABCDE-FGHIJ`,
PREAMBLE,
" ABCDE-FGHIJ",
].join("\n");
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_returns_null_when_the_prompt_is_absent", () => {
const text = "Some unrelated log line\nNothing to see here\n";
expect(parseGrokDeviceLoginPrompt(text)).toBeNull();
});
it("parse_reads_the_prompt_from_output_that_arrives_in_two_chunks", () => {
const full = buildPrompt("ABCD-EFGH");
const splitAt = Math.floor(full.length / 2);
const combined = full.slice(0, splitAt) + full.slice(splitAt);
const result = parseGrokDeviceLoginPrompt(combined);
expect(result).not.toBeNull();
expect(result?.code).toBe("ABCD-EFGH");
});
it("parse_reads_the_prompt_when_the_url_and_the_code_carry_ansi_sequences", () => {
const cyan = "\x1b[36m";
const bold = "\x1b[1m";
const reset = "\x1b[0m";
const text = [
"To sign in, open this URL in your browser:",
` ${cyan}${ORIGIN}${PATH}?user_code=ABCD-EFGH${reset}`,
PREAMBLE,
` ${bold}ABCD-EFGH${reset}`,
].join("\n");
const result = parseGrokDeviceLoginPrompt(text);
expect(result).not.toBeNull();
expect(result?.url).toBe(`${ORIGIN}${PATH}?user_code=ABCD-EFGH`);
expect(result?.code).toBe("ABCD-EFGH");
});
it("parse_reads_the_prompt_from_pipe_output_that_ends_every_line_with_a_line_feed_only", () => {
// The captured pipe transport ends every line with `\x0a` only, with no
// `\x0d`. The fixture already exercises this; this test pins the same
// shape with the exact captured line order and the two-space indent.
const text =
"\n" +
"To sign in, open this URL in your browser:\n" +
"\n" +
` ${ORIGIN}${PATH}?user_code=ABCD-EFGH\n` +
"\n" +
`${PREAMBLE}\n` +
"\n" +
" ABCD-EFGH\n" +
"\n" +
"\x1b[90mOnly continue with a code you requested. Don't share it with anyone.\x1b[0m\n" +
"\n" +
"Waiting for authorization...\n";
const result = parseGrokDeviceLoginPrompt(text);
expect(result).not.toBeNull();
expect(result?.url).toBe(`${ORIGIN}${PATH}?user_code=ABCD-EFGH`);
expect(result?.code).toBe("ABCD-EFGH");
});
it("keeps the url and the code out of a thrown error", () => {
// @ts-expect-error deliberate wrong type
expect(parseGrokDeviceLoginPrompt(undefined)).toBeNull();
// @ts-expect-error deliberate wrong type
expect(parseGrokDeviceLoginPrompt(12345)).toBeNull();
});
});

View File

@ -0,0 +1,166 @@
// The device-login output parser. It reads the Grok `login --device-auth`
// output and returns the authorization URL and the one-time code, or null.
//
// Security (Control 1 — strict validation): the parser accepts only the exact
// origin `https://accounts.x.ai` and the exact path `/oauth2/device`, and it
// rejects any fragment, a different origin, or a different path. Unlike the
// Codex device-login URL, the Grok URL carries a query: the parser accepts
// exactly one query key, `user_code`, and rejects a repeated key or an extra
// key. It requires the `user_code` value to match the strict short-code
// pattern and to equal the code that stands alone on its own line. The parser
// returns the printed URL after the platform `URL` parser validates and
// normalizes it, so the output is never a raw, unvalidated token. The parser
// never logs the URL, the code, or any input byte, and it keeps them out of
// every thrown error. The parser is a pure function.
export interface DeviceLoginPrompt {
url: string;
code: string;
}
/** The one and only accepted device-login command. */
export const GROK_DEVICE_LOGIN_COMMAND = "grok login --device-auth";
/** The one and only accepted device-login URL origin. */
export const GROK_DEVICE_LOGIN_URL_ORIGIN = "https://accounts.x.ai";
/** The one and only accepted device-login URL path. */
export const GROK_DEVICE_LOGIN_URL_PATH = "/oauth2/device";
// Grok CLI wraps the URL and the code in ANSI color sequences on a later
// release, the same way Codex CLI 0.128.0 and later do. A color sequence is a
// Control Sequence Introducer (CSI): the ESC control byte, a `[`, zero or more
// parameter bytes (0x30-0x3F), zero or more intermediate bytes (0x20-0x2F), and
// one final byte (0x40-0x7E). The parser removes every CSI sequence first, so a
// colored URL or code reads the same as a plain one. The strip only normalizes
// the input; the URL and the code still pass the strict validation below.
// eslint-disable-next-line no-control-regex
const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
// A candidate URL token is a run of non-space characters that starts with an
// http or https scheme. The parser validates each candidate with the `URL`
// class; the regular expression only splits tokens out of the text.
const URL_TOKEN_RE = /https?:\/\/\S+/g;
// Trailing punctuation that prose commonly puts right after a URL. The parser
// strips only these characters. It never strips `?` or `#`, so a URL with a
// fragment stays malformed and the parser rejects it.
const TRAILING_PUNCTUATION_RE = /[)\].,;:!]+$/;
// The one-time code structure: four characters, a hyphen, then four
// characters, and nothing else. Every observed grok code matched this shape.
// The alphabet is not published, so the pattern binds the token class to
// alphanumerics rather than a fixed character set.
const CODE_PATTERN = /^[A-Za-z0-9]{4}-[A-Za-z0-9]{4}$/;
// The prompt line that introduces the one-time code, printed exactly this way.
const CODE_PREAMBLE = "Confirm this code in your browser:";
// The maximum number of characters between the end of the URL and the start of
// the code preamble. The captured prompt prints the preamble a few characters
// after the URL. The parser looks for the preamble only inside this window
// after the URL, so a preamble far away in the output cannot bind to the URL.
const MAX_URL_TO_PREAMBLE_GAP = 256;
// The maximum number of characters after the end of the preamble line that the
// parser reads for the code. The captured prompt prints the code on the line
// right after the preamble line.
const MAX_PREAMBLE_TO_CODE_GAP = 128;
// The result of a URL search: the validated URL, the code the query carries,
// and the index of the first character after the matched URL token. The code
// search starts at this index.
interface DeviceUrlMatch {
url: string;
code: string;
end: number;
}
/**
* Returns the validated device-login URL, the code its `user_code` query
* carries, and the end index, when `text` holds a standalone token with the
* exact origin {@link GROK_DEVICE_LOGIN_URL_ORIGIN} and the exact path
* {@link GROK_DEVICE_LOGIN_URL_PATH}, no fragment, no credentials, exactly one
* query key named `user_code`, and a `user_code` value that matches the strict
* short-code pattern. Returns null otherwise. Returns the platform `URL`
* parser's normalized form of the matched token, so the accepting rules bound
* the output even though it is not a fixed constant. The `end` index is the
* position of the first character after the matched token in `text`.
*/
function findExactDeviceUrl(text: string): DeviceUrlMatch | null {
for (const match of text.matchAll(URL_TOKEN_RE)) {
const token = match[0];
const cleaned = token.replace(TRAILING_PUNCTUATION_RE, "");
let parsed: URL;
try {
parsed = new URL(cleaned);
} catch {
continue;
}
if (
parsed.protocol !== "https:" ||
parsed.host !== "accounts.x.ai" ||
parsed.pathname !== GROK_DEVICE_LOGIN_URL_PATH ||
parsed.hash !== "" ||
parsed.username !== "" ||
parsed.password !== ""
) {
continue;
}
// Accept exactly one query key, `user_code`. Comparing the whole key list
// rejects a repeated key and an extra key alike.
const keys = Array.from(parsed.searchParams.keys());
if (keys.length !== 1 || keys[0] !== "user_code") continue;
const code = parsed.searchParams.get("user_code");
if (!code || !CODE_PATTERN.test(code)) continue;
return { url: parsed.toString(), code, end: (match.index ?? 0) + token.length };
}
return null;
}
/**
* Returns the one-time code when `text` holds the code preamble after
* `fromIndex` and a dedicated code line after that preamble line. Mirrors the
* Codex parser's proximity-bound search: the preamble must appear in a window
* after the URL, and the code must be the first non-blank line after the
* preamble line, trimmed, and hold nothing else. Returns null when the
* preamble is absent, when the preamble has no line break after it, or when
* the first non-blank line after the preamble line is not a code line.
*/
function findStandaloneCode(text: string, fromIndex: number): string | null {
const preambleWindow = text.slice(fromIndex, fromIndex + MAX_URL_TO_PREAMBLE_GAP);
const preambleIndex = preambleWindow.indexOf(CODE_PREAMBLE);
if (preambleIndex === -1) return null;
const preambleEnd = fromIndex + preambleIndex + CODE_PREAMBLE.length;
const lineBreak = text.indexOf("\n", preambleEnd);
if (lineBreak === -1) return null;
const codeWindow = text.slice(lineBreak + 1, lineBreak + 1 + MAX_PREAMBLE_TO_CODE_GAP);
for (const line of codeWindow.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
return CODE_PATTERN.test(trimmed) ? trimmed : null;
}
return null;
}
/**
* Parses Grok device-login output. Removes ANSI color sequences first. The
* parser reads the URL first, validates its query and its embedded code, then
* finds the code preamble after the URL and reads the code from the dedicated
* code line right after the preamble line. It returns the prompt only when the
* code on that dedicated line equals the code the URL query carries. Returns
* null for any other input, including a non-string input, an absent prompt, a
* wrong origin or path, a URL with a fragment, a malformed or mismatched query,
* and a malformed short code. Never throws on input, and never puts the URL or
* the code into a log or an error.
*/
export function parseGrokDeviceLoginPrompt(text: string): DeviceLoginPrompt | null {
if (typeof text !== "string" || text.length === 0) return null;
const clean = text.replace(ANSI_CSI_RE, "");
const urlMatch = findExactDeviceUrl(clean);
if (!urlMatch) return null;
const lineCode = findStandaloneCode(clean, urlMatch.end);
if (!lineCode) return null;
if (lineCode !== urlMatch.code) return null;
return { url: urlMatch.url, code: lineCode };
}

View File

@ -30,6 +30,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
}));
import { execute } from "./execute.js";
import { resolveManagedGrokHomeDir } from "./grok-home.js";
const tempRoots: string[] = [];
@ -204,6 +205,53 @@ describe("grok_local execute", () => {
}
});
it("sets GROK_HOME to the company home in subscription mode, and leaves it unset when XAI_API_KEY exists", async () => {
let seenEnv: Record<string, string> = {};
runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => {
seenEnv = options.env;
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
stderr: "",
};
});
const makeCtx = async (runId: string): Promise<AdapterExecutionContext> => ({
runId,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Grok Agent",
adapterType: "grok_local",
adapterConfig: {},
},
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
config: { cwd: await makeTempRoot() },
context: {},
authToken: "run-token",
onLog: async () => {},
});
const previousApiKey = process.env.XAI_API_KEY;
try {
delete process.env.XAI_API_KEY;
await execute(await makeCtx("run-subscription-home"));
expect(seenEnv.GROK_HOME).toBe(resolveManagedGrokHomeDir(process.env, "company-1"));
// The XAI_API_KEY path stays unchanged: no GROK_HOME is set when the key
// exists, because the CLI authenticates via the environment variable
// directly, not from the company Grok home's auth.json.
process.env.XAI_API_KEY = "test-key";
await execute(await makeCtx("run-api-home"));
expect(seenEnv.GROK_HOME).toBeUndefined();
} finally {
if (previousApiKey === undefined) delete process.env.XAI_API_KEY;
else process.env.XAI_API_KEY = previousApiKey;
}
});
it("passes an explicitly configured permissionMode through to the CLI", async () => {
let seenArgs: string[] = [];
runProcessMock.mockImplementation(async (_runId, _target, _command, args) => {

View File

@ -40,6 +40,7 @@ import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { resolveManagedGrokHomeDir } from "./grok-home.js";
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -297,6 +298,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
// Subscription mode (no XAI_API_KEY): point the run at the company-scoped
// Grok home a completed device login wrote. Leaves the API-key path below
// (`resolveBillingType`) unchanged when the key exists.
if (!hasNonEmptyEnvValue(env, "XAI_API_KEY") && !hasNonEmptyEnvValue(process.env as Record<string, string>, "XAI_API_KEY")) {
env.GROK_HOME = resolveManagedGrokHomeDir(process.env, agent.companyId);
}
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,

View File

@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
// The Grok credential home. `GROK_HOME` replaces `~/.grok` and holds one file,
// `auth.json`. Unlike Codex, a Grok `auth.json` has no fixed top-level key: it
// holds exactly one key, and that key is a composite `<issuer>::<uuid>` value.
// This module resolves the company-scoped home path and reads its usable-auth
// shape. It never writes the file; {@link promoteGrokDeviceLoginCredential} in
// `adapter-auth-promotion.ts` owns the write.
const AUTH_FILE_NAME = "auth.json";
// Matches the composite `<issuer>::<uuid>` top-level key. The issuer is a
// non-empty string — an OIDC issuer URL such as `https://issuer.x.ai` holds
// colons of its own — so this anchors on the LAST `::` before a standard
// 8-4-4-4-12 hex UUID at the end of the string (the greedy `.+` backtracks
// to that last separator).
const GROK_IDENTITY_KEY_RE =
/^.+::[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
/** One parsed Grok auth payload: the composite identity key and its value object. */
export interface GrokAuthPayload {
identityKey: string;
value: Record<string, unknown>;
}
/**
* Parses a decoded JSON value into a {@link GrokAuthPayload}. Returns null when
* the value is not an object, holds zero or more than one top-level key, or the
* single key does not match the `<issuer>::<uuid>` shape, or its value is not an
* object. Never assumes a fixed key name.
*/
export function parseGrokAuthPayload(raw: unknown): GrokAuthPayload | null {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
const keys = Object.keys(raw as Record<string, unknown>);
if (keys.length !== 1) return null;
const [identityKey] = keys as [string];
if (!GROK_IDENTITY_KEY_RE.test(identityKey)) return null;
const value = (raw as Record<string, unknown>)[identityKey];
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
return { identityKey, value: value as Record<string, unknown> };
}
/** True when the payload value holds the two fields a run needs to authenticate. */
export function hasUsableGrokAuthValue(value: Record<string, unknown>): boolean {
const key = value.key;
const refreshToken = value.refresh_token;
return (
typeof key === "string" &&
key.trim().length > 0 &&
typeof refreshToken === "string" &&
refreshToken.trim().length > 0
);
}
/**
* True when `home` has a usable `auth.json`: a single `<issuer>::<uuid>`
* top-level key whose value holds a non-empty `key` and `refresh_token`. A
* missing file, invalid JSON, or an unusable shape all resolve false.
*/
export async function grokHomeHasUsableAuth(home: string): Promise<boolean> {
const authPath = path.join(home, AUTH_FILE_NAME);
try {
const raw = await fs.readFile(authPath, "utf8");
const parsed = parseGrokAuthPayload(JSON.parse(raw));
return parsed !== null && hasUsableGrokAuthValue(parsed.value);
} catch {
return false;
}
}
/**
* Resolves the managed Grok home directory. With a `companyId`, it resolves the
* company-scoped home under the Paperclip instance tree, the same isolation
* boundary `resolveManagedCodexHomeDir` uses. Without one, it resolves the
* instance-global home, which a promotion must never write.
*/
export function resolveManagedGrokHomeDir(
env: NodeJS.ProcessEnv,
companyId?: string,
): string {
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
env,
});
return companyId
? path.resolve(instanceRoot, "companies", companyId, "grok-home")
: path.resolve(instanceRoot, "grok-home");
}

View File

@ -64,3 +64,19 @@ export { execute } from "./execute.js";
export { listGrokSkills, syncGrokSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js";
export {
GROK_DEVICE_LOGIN_COMMAND,
GROK_DEVICE_LOGIN_URL_ORIGIN,
GROK_DEVICE_LOGIN_URL_PATH,
parseGrokDeviceLoginPrompt,
type DeviceLoginPrompt as GrokDeviceLoginPrompt,
} from "./device-login-parse.js";
export { resolveManagedGrokHomeDir, grokHomeHasUsableAuth } from "./grok-home.js";
export {
promoteGrokDeviceLoginCredential,
checkStagedGrokCredentialReadiness,
DeviceLoginReadinessError as GrokDeviceLoginReadinessError,
type CredentialReadinessResult as GrokCredentialReadinessResult,
type PromoteGrokDeviceLoginCredentialInput,
type PromoteGrokDeviceLoginCredentialOutcome,
} from "./adapter-auth-promotion.js";

View File

@ -217,4 +217,65 @@ describe("grok_local testEnvironment", () => {
]),
);
});
it("emits the canonical adapter_auth_missing check for a sandbox target with missing authentication", async () => {
// The user interface reads this neutral canonical code to decide login
// eligibility for the sandbox; it does not parse the message text.
runProcessMock
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
})
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "grok_local",
config: { command: "grok", cwd: "/tmp/project" },
executionTarget: { kind: "remote", transport: "sandbox" } as never,
});
expect(result.checks.some((check: { code: string }) => check.code === "adapter_auth_missing")).toBe(
true,
);
});
it("emits no adapter_auth_missing check for a local target with missing authentication", async () => {
// The canonical check gates sandbox login eligibility only. A local target
// has no sandbox login to offer, so the check must not appear.
runProcessMock
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
})
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "grok_local",
config: { command: "grok", cwd: "/tmp/project" },
});
expect(result.checks.some((check: { code: string }) => check.code === "adapter_auth_missing")).toBe(
false,
);
});
});

View File

@ -19,6 +19,7 @@ import {
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { parseGrokJsonl } from "./parse.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
export interface GrokModelsProbe {
authenticated: boolean;
@ -108,6 +109,7 @@ export async function testEnvironment(
const command = asString(config.command, "grok");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@ -202,6 +204,18 @@ export async function testEnvironment(
detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null),
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
});
if (authRequired && targetIsSandbox) {
// Emit the neutral canonical check so the user interface can decide
// login eligibility from a stable code. The user interface does not
// read the message text or the top-level status.
checks.push({
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn",
message: "This environment has no ready authentication for this adapter.",
detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null),
hint: "Provide credentials for this adapter, or start login in the environment.",
});
}
} else {
checks.push({
code: "grok_models_probe_passed",
@ -295,6 +309,18 @@ export async function testEnvironment(
...(detail ? { detail } : {}),
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
});
if (authRequired && targetIsSandbox) {
// Emit the neutral canonical check so the user interface can decide
// login eligibility from a stable code. The user interface does not
// read the message text or the top-level status.
checks.push({
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn",
message: "This environment has no ready authentication for this adapter.",
...(detail ? { detail } : {}),
hint: "Provide credentials for this adapter, or start login in the environment.",
});
}
} else if (/\bhello\b/i.test(parsed.summary)) {
checks.push({
code: "grok_hello_probe_passed",

View File

@ -21,6 +21,7 @@ const HOME = "/tmp/paperclip-adapter-login/11111111-2222-4333-8444-555555555555"
const CLAUDE: LoginPtyLaunchDescriptor = { loginCommandKey: "claude", sessionHome: HOME };
const CODEX: LoginPtyLaunchDescriptor = { loginCommandKey: "codex", sessionHome: HOME };
const GROK: LoginPtyLaunchDescriptor = { loginCommandKey: "grok", sessionHome: HOME };
/**
* A fake login-home filesystem. It records each path the caller asks to create
@ -157,6 +158,29 @@ describe("composeLaunchLine", () => {
expect(line.match(/CODEX_HOME=/g)).toHaveLength(1);
expect(line.endsWith("codex login --device-auth")).toBe(true);
});
it("composes the Grok line with exactly one encoded GROK_HOME", () => {
const line = composeLaunchLine(GROK);
expect(line).toBe(`exec env GROK_HOME='${HOME}' grok login --device-auth`);
// Exactly one GROK_HOME assignment, and the exact approved Grok command.
expect(line.match(/GROK_HOME=/g)).toHaveLength(1);
expect(line.endsWith("grok login --device-auth")).toBe(true);
// The Codex encoded variable never leaks into the Grok line.
expect(line).not.toContain("CODEX_HOME");
});
it("composes the launch line from the closed command map only, and ignores a command string smuggled onto the descriptor", () => {
// Condition 4: a command string in the request confers no command
// authority. The module maps the closed key to its own fixed command, so a
// caller cannot select or override it.
const tampered = {
...GROK,
command: "rm -rf /",
} as unknown as LoginPtyLaunchDescriptor;
const line = composeLaunchLine(tampered);
expect(line).toBe(`exec env GROK_HOME='${HOME}' grok login --device-auth`);
expect(line).not.toContain("rm -rf");
});
});
describe("openDaytonaLoginPtySession — session home", () => {

View File

@ -10,9 +10,10 @@
// no command string. This module maps the key to a compile-time command, so a
// caller cannot select or override the command. For the Codex key the module
// composes `exec env CODEX_HOME=<encoded-home> <fixed-codex-command>`. For the
// Claude key it composes `exec <fixed-claude-command>` with no CODEX_HOME. The
// module encodes the dynamic home with a POSIX shell-argument encoder, so the
// home cannot add a second shell token or a second command.
// Grok key it composes `exec env GROK_HOME=<encoded-home> <fixed-grok-command>`.
// For the Claude key it composes `exec <fixed-claude-command>` with no home
// variable. The module encodes the dynamic home with a POSIX shell-argument
// encoder, so the home cannot add a second shell token or a second command.
//
// Session home: the module revalidates the descriptor and the home shape, then
// creates the session home directory with one `mkdir -p` command. The command
@ -52,7 +53,7 @@ import { sendPtyInputInChunks } from "./pty-chunked-input.js";
* compile-time command. A value outside this set fails closed before the module
* touches the filesystem.
*/
export type LoginCommandKey = "claude" | "codex";
export type LoginCommandKey = "claude" | "codex" | "grok";
/**
* The host-resolved launch descriptor. It carries the closed command key and the
@ -76,6 +77,7 @@ export interface LoginPtyLaunchDescriptor {
const LOGIN_COMMAND_BY_KEY: Readonly<Record<LoginCommandKey, string>> = {
claude: "claude setup-token",
codex: "codex login --device-auth",
grok: "grok login --device-auth",
};
/** The fixed root for a login session home. */
@ -107,22 +109,28 @@ export function encodePosixShellArg(value: string): string {
/** Reports whether a value is a member of the closed login command key set. */
export function isLoginCommandKey(value: unknown): value is LoginCommandKey {
return value === "claude" || value === "codex";
return value === "claude" || value === "codex" || value === "grok";
}
/**
* Composes the launch line for the descriptor. For the Codex key it prefixes one
* safely encoded `CODEX_HOME` assignment with `env`. For the Claude key it adds no
* `CODEX_HOME`. It replaces the interactive shell with the command through `exec`,
* so the pseudo-terminal runs the command directly and its exit code becomes the
* PTY exit code.
* Composes the launch line for the descriptor. The module reads only
* {@link LOGIN_COMMAND_BY_KEY} and the closed command key on the descriptor, so
* a command string smuggled onto the descriptor object confers no authority.
* For the Codex key it prefixes one safely encoded `CODEX_HOME` assignment with
* `env`. For the Grok key it prefixes one safely encoded `GROK_HOME` assignment
* with `env`. For the Claude key it adds no home variable. It replaces the
* interactive shell with the command through `exec`, so the pseudo-terminal
* runs the command directly and its exit code becomes the PTY exit code.
*/
export function composeLaunchLine(descriptor: LoginPtyLaunchDescriptor): string {
const command = LOGIN_COMMAND_BY_KEY[descriptor.loginCommandKey];
const encodedHome = encodePosixShellArg(descriptor.sessionHome);
if (descriptor.loginCommandKey === "codex") {
const encodedHome = encodePosixShellArg(descriptor.sessionHome);
return `exec env CODEX_HOME=${encodedHome} ${command}`;
}
if (descriptor.loginCommandKey === "grok") {
return `exec env GROK_HOME=${encodedHome} ${command}`;
}
return `exec ${command}`;
}

View File

@ -13,7 +13,7 @@ export default defineConfig({
},
},
test: {
include: ["packages/plugins/sandbox-providers/daytona/src/**/*.test.ts"],
include: [path.join(dirname, "src/**/*.test.ts")],
environment: "node",
},
});

View File

@ -1000,7 +1000,7 @@ export interface PluginRenderCloseEvent {
* key to a compile-time command. The open request carries no command string, so a
* caller cannot select or override the command.
*/
export type PluginLoginCommandKey = "claude" | "codex";
export type PluginLoginCommandKey = "claude" | "codex" | "grok";
/** The open request for one live login pseudo-terminal. The worker registers the terminal by `hostRouteId`. */
export interface PluginLoginPtyOpenParams {

View File

@ -49,15 +49,15 @@
"server/src/__tests__/cloud-image-bundled-plugins.test.ts": 211,
"server/src/__tests__/cloud-instance.test.ts": 227,
"server/src/__tests__/codex-auth-reconciliation.test.ts": 1256,
"server/src/__tests__/codex-device-login-credential-read.test.ts": 605,
"server/src/__tests__/codex-device-login-reaper.test.ts": 1310,
"server/src/__tests__/codex-device-login-service.test.ts": 4054,
"server/src/__tests__/device-login-credential-read.test.ts": 605,
"server/src/__tests__/device-login-reaper.test.ts": 1310,
"server/src/__tests__/device-login-service.test.ts": 4054,
"server/src/__tests__/codex-local-adapter-environment.test.ts": 722,
"server/src/__tests__/codex-local-adapter.test.ts": 739,
"server/src/__tests__/codex-local-execute.test.ts": 2000,
"server/src/__tests__/codex-local-skill-injection.test.ts": 674,
"server/src/__tests__/codex-local-skill-sync.test.ts": 673,
"server/src/__tests__/codex-login-session-runtime.test.ts": 1272,
"server/src/__tests__/login-session-runtime.test.ts": 1272,
"server/src/__tests__/companies-service.test.ts": 6166,
"server/src/__tests__/company-artifacts-service.test.ts": 6065,
"server/src/__tests__/company-cloud-floor.test.ts": 1279,

View File

@ -24,8 +24,10 @@ const nonServerProjects = [
"@paperclipai/adapter-utils",
"@paperclipai/adapter-claude-local",
"@paperclipai/adapter-codex-local",
"@paperclipai/adapter-grok-local",
"@paperclipai/adapter-openclaw-gateway",
"@paperclipai/adapter-opencode-local",
"@paperclipai/plugin-daytona",
"@paperclipai/plugin-sdk",
"@paperclipai/create-paperclip-plugin",
"@paperclipai/ui",

View File

@ -1,14 +1,14 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AdapterAuthSessionConflictError } from "../services/codex-device-login-service.js";
import { AdapterAuthSessionConflictError } from "../services/device-login-service.js";
import type {
AdapterAuthSessionRow,
AdapterAuthSessionStore,
AcquireLoginLeaseInput,
LoginSessionLease,
LoginSessionRuntime,
} from "../services/codex-device-login-service.js";
} from "../services/device-login-service.js";
// The company-scoped adapter device-login routes. These tests drive the real
// login-session service through the route layer. A fake in-memory store models
@ -30,6 +30,18 @@ const SANDBOX_ENV_2 = "22222222-2222-4222-8222-222222222222";
const DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device";
const PROMPT_CODE = "ABCD-EFGHI";
const PROMPT_OUTPUT = `Open ${DEVICE_LOGIN_URL} in your browser.\nEnter the one-time code below:\n${PROMPT_CODE}\n`;
// The Grok device-login URL and the one-time code the fake sandbox streams for
// a `grok_local` session. The Grok parser requires the `user_code` query to
// equal the code that stands alone on its own line after the preamble.
const GROK_CODE = "WXYZ-ABCD";
const GROK_DEVICE_LOGIN_URL = `https://accounts.x.ai/oauth2/device?user_code=${GROK_CODE}`;
const GROK_PROMPT_OUTPUT = [
"To sign in, open this URL in your browser:",
` ${GROK_DEVICE_LOGIN_URL}`,
"Confirm this code in your browser:",
` ${GROK_CODE}`,
].join("\n");
// A credential byte string the fake sandbox returns. The routes and the activity
// must never log it.
const CREDENTIAL_BYTES = '{"tokens":{"access":"SECRET-ACCESS-TOKEN"}}';
@ -176,8 +188,8 @@ vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => {
// Keep the real login-session service and the real conflict error. Replace only
// the store factory and the production runtime factory with the harness fakes.
vi.mock("../services/codex-device-login-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/codex-device-login-service.js")>();
vi.mock("../services/device-login-service.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../services/device-login-service.js")>();
return {
...actual,
createDbAdapterAuthSessionStore: () => harness.store,
@ -269,7 +281,7 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
// A fake runtime. It streams the prompt, then waits on the harness gate, so the
// login run stays active while the test reads and cancels it. The gate resolves
// in `afterEach`, so no run and no timer survives the test.
function createFakeRuntime(): LoginSessionRuntime {
function createFakeRuntime(promptOutput: string = PROMPT_OUTPUT): LoginSessionRuntime {
return {
async acquireLoginLease(input) {
harness.acquisitions.push(input);
@ -278,7 +290,7 @@ function createFakeRuntime(): LoginSessionRuntime {
authPath: `/tmp/paperclip-adapter-login/${input.sessionId}/auth.json`,
driver: {
async start(_command, onData) {
onData(PROMPT_OUTPUT);
onData(promptOutput);
await harness.gate;
return { exitCode: 0 };
},
@ -618,6 +630,40 @@ describe("adapter device-login routes", () => {
expect(second.body.status).toBe(first.body.status);
});
it("starts a Grok session, delivers the Grok prompt once, and a codex_local read finds no row", async () => {
// The Grok parser reaches this session because the profile map resolves it
// by adapter type. The fake sandbox streams a Grok-shaped prompt, not a
// Codex-shaped one, so a surfaced prompt proves the Grok parser ran.
harness.runtime = createFakeRuntime(GROK_PROMPT_OUTPUT);
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1, "grok_local"))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
const first = await request(app).get(`${loginPath(COMPANY_1, "grok_local")}/${sessionId}`);
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE });
// The row belongs to `grok_local`, not `codex_local`. Reading it through the
// `codex_local` path finds no row for the owner.
const wrongAdapterRead = await request(app).get(`${loginPath(COMPANY_1, "codex_local")}/${sessionId}`);
expect(wrongAdapterRead.status, JSON.stringify(wrongAdapterRead.body)).toBe(404);
const cancel = await request(app).post(`${loginPath(COMPANY_1, "grok_local")}/${sessionId}/cancel`);
expect(cancel.status, JSON.stringify(cancel.body)).toBe(200);
// The cancel resolves the public terminal status at once. Internally the row
// holds `cleanup_pending`, which encodes the cancelled terminal until the
// reaper finalizes it — the same durable-cancel contract every adapter uses.
expect(cancel.body.status).toBe("cancelled");
const store = harness.store as ReturnType<typeof createMemoryStore>;
const row = await store.getByPublicId(sessionId, COMPANY_1);
expect(row?.status).toBe("cleanup_pending");
});
it("returns 404 for a wrong-user status, prompt, and cancel", async () => {
const app = await createApp();

View File

@ -10,7 +10,7 @@ import {
MAX_AUTH_JSON_BYTES,
decodeAuthReadOutput,
runDescriptorBoundAuthRead,
} from "../services/codex-device-login-credential-read.ts";
} from "../services/device-login-credential-read.ts";
// The helper runs on node and walks the path with `/proc/self/fd`, which is
// Linux only. A non-Linux host skips the script tests and keeps the pure-decode
@ -60,7 +60,7 @@ describeLinux("the descriptor-bound read helper script", () => {
let root: string;
beforeAll(() => {
root = mkdtempSync(path.join(tmpdir(), "codex-auth-read-"));
root = mkdtempSync(path.join(tmpdir(), "adapter-auth-read-"));
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });

View File

@ -2,17 +2,17 @@ import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { AgentAdapterType } from "@paperclipai/shared";
import {
createCodexDeviceLoginReaper,
createDeviceLoginReaper,
type LoginLeaseRef,
type LoginSessionCleanupRuntime,
type TaggedLease,
} from "../services/codex-device-login-reaper.ts";
} from "../services/device-login-reaper.ts";
import {
ADAPTER_AUTH_ACTIVE_STATUSES,
type AdapterAuthReaperStore,
type AdapterAuthSessionRow,
type SandboxDeleteResult,
} from "../services/codex-device-login-service.ts";
} from "../services/device-login-service.ts";
const ADAPTER_TYPE: AgentAdapterType = "codex_local";
const NOW = new Date("2026-02-01T00:05:00.000Z");
@ -114,7 +114,7 @@ function createFakeRuntime(opts: FakeRuntimeOptions = {}) {
return { runtime, deleteCalls };
}
describe("codex device login reaper", () => {
describe("device login reaper", () => {
it("deletes the sandbox and marks a persisted expired non-terminal session timed_out", async () => {
const store = createMemoryReaperStore();
const environmentId = randomUUID();
@ -126,7 +126,7 @@ describe("codex device login reaper", () => {
expiresAt: PAST,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
@ -146,7 +146,7 @@ describe("codex device login reaper", () => {
expiresAt: FUTURE,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
@ -166,7 +166,7 @@ describe("codex device login reaper", () => {
promotionExpiresAt: FUTURE,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
@ -189,7 +189,7 @@ describe("codex device login reaper", () => {
promotionExpiresAt: PAST,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
@ -208,7 +208,7 @@ describe("codex device login reaper", () => {
expiresAt: PAST,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
@ -235,7 +235,7 @@ describe("codex device login reaper", () => {
return { outcome: "not_found" };
},
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
// First sweep: the delete rejects, so the row stays cleanup_pending.
const first = await reaper.sweep();
@ -273,7 +273,7 @@ describe("codex device login reaper", () => {
{ environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" },
],
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
@ -294,7 +294,7 @@ describe("codex device login reaper", () => {
return { outcome: "deleted" };
},
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
const first = await reaper.sweep();
expect(first.orphanLeasesDeleted).toBe(0);
@ -319,7 +319,7 @@ describe("codex device login reaper", () => {
tagged: [{ environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" }],
deleteImpl: async () => ({ outcome: "not_found" }),
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => NOW });
await expect(reaper.sweep()).resolves.toBeDefined();
// The second sweep runs over the now-terminal session and the same orphan

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { adapterAuthSessions, companies, createDb, environments } from "@paperclipai/db";
import { adapterAuthSessions, companies, createDb, environmentLeases, environments } from "@paperclipai/db";
import type { AgentAdapterType } from "@paperclipai/shared";
import { DEVICE_LOGIN_URL } from "@paperclipai/adapter-codex-local/server";
import {
@ -11,10 +11,13 @@ import {
import {
AdapterAuthSessionConflictError,
buildSandboxLoginDriver,
CODEX_DEVICE_LOGIN_TIMEOUT_MS,
createCodexDeviceLoginService,
DEVICE_LOGIN_TIMEOUT_MS,
DISPLAYED_CODE_ADAPTER_TYPES,
DISPLAYED_CODE_PROFILES,
LOGIN_LEASE_SESSION_TAG_KEY,
createDeviceLoginService,
createDbAdapterAuthSessionStore,
sessionCodexHomePath,
sessionLoginHomePath,
sessionCredentialPath,
type AcquireLoginLeaseInput,
type AdapterAuthSessionRow,
@ -24,11 +27,12 @@ import {
type LoginSessionLease,
type LoginSessionRuntime,
type SandboxDeleteResult,
} from "../services/codex-device-login-service.ts";
} from "../services/device-login-service.ts";
import {
createCodexDeviceLoginReaper,
createDeviceLoginReaper,
createProductionLoginSessionReaperRuntime,
type LoginSessionCleanupRuntime,
} from "../services/codex-device-login-reaper.ts";
} from "../services/device-login-reaper.ts";
// A cleanup runtime for the reaper. It confirms every delete and reports no
// tagged lease, so the reaper only reclaims the seeded session rows.
@ -51,11 +55,27 @@ function createReaperRuntime() {
// still supplies a promotion that accepts the credential.
const passingPromotion: CredentialPromotion = { promote: () => {} };
// Build the service with the passing promotion by default. A test that checks
// the promotion path passes its own `promotion` to override the default.
type ServiceDeps = Parameters<typeof createCodexDeviceLoginService>[0];
function makeService(deps: Omit<ServiceDeps, "promotion"> & { promotion?: CredentialPromotion }) {
return createCodexDeviceLoginService({ promotion: passingPromotion, ...deps });
// Build the service with the passing promotion by default, applied to every
// adapter type. A test that checks the promotion path passes its own
// `promotion` to override the default for every adapter type, or its own
// `promotionByAdapterType` to give each adapter type a distinct promotion (for
// example, to prove a `grok_local` login never runs the Codex promotion).
type ServiceDeps = Parameters<typeof createDeviceLoginService>[0];
function makeService(
deps: Omit<ServiceDeps, "promotionByAdapterType"> & {
promotion?: CredentialPromotion;
promotionByAdapterType?: ServiceDeps["promotionByAdapterType"];
},
) {
const { promotion, promotionByAdapterType, ...rest } = deps;
return createDeviceLoginService({
promotionByAdapterType:
promotionByAdapterType ?? {
codex_local: promotion ?? passingPromotion,
grok_local: promotion ?? passingPromotion,
},
...rest,
});
}
const ADAPTER_TYPE: AgentAdapterType = "codex_local";
@ -87,6 +107,18 @@ async function waitForStatus(
const PROMPT_OUTPUT = `Open ${DEVICE_LOGIN_URL} in your browser.\nEnter the one-time code below:\nABCD-EFGHI\n`;
const PROMPT_CODE = "ABCD-EFGHI";
// A valid Grok device-login output. The Grok parser requires the `user_code`
// query to equal the code that stands alone on its own line after the
// preamble. The Codex parser accepts none of this shape.
const GROK_CODE = "WXYZ-ABCD";
const GROK_DEVICE_LOGIN_URL = `https://accounts.x.ai/oauth2/device?user_code=${GROK_CODE}`;
const GROK_PROMPT_OUTPUT = [
"To sign in, open this URL in your browser:",
` ${GROK_DEVICE_LOGIN_URL}`,
"Confirm this code in your browser:",
` ${GROK_CODE}`,
].join("\n");
type ExecController = { onStdout: (chunk: string) => void; input: AcquireLoginLeaseInput };
type ExecBehavior = (c: ExecController) => Promise<{ exitCode: number | null }>;
@ -235,7 +267,7 @@ function createMemoryStore(): AdapterAuthSessionStore & {
};
}
describe("codex device login service", () => {
describe("device login service", () => {
it("inserts the session row before it acquires the lease", async () => {
const store = createMemoryStore();
let rowPresentAtAcquire = false;
@ -350,6 +382,197 @@ describe("codex device login service", () => {
expect(JSON.stringify(activity)).not.toContain(PROMPT_CODE);
});
it("gives a Grok prompt to a grok_local session, and the session surfaces the Grok code and URL", async () => {
// This proves the Grok parser ran: the profile map resolves the parser from
// the trusted adapter type, so a `grok_local` session runs the Grok parser,
// not the Codex parser.
const store = createMemoryStore();
const execGrokSuccess: ExecBehavior = async ({ onStdout }) => {
onStdout(GROK_PROMPT_OUTPUT);
return { exitCode: 0 };
};
const { runtime } = createFakeRuntime({ exec: execGrokSuccess, authBytes: Buffer.from("{}") });
const companyId = randomUUID();
const service = makeService({ store, runtime });
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: "grok_local",
startedByUserId: OWNER_A,
});
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
await new Promise((resolve) => setImmediate(resolve));
const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(owner?.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE });
await completed;
});
it("gives the same Grok prompt to a codex_local session, and the session surfaces no prompt", async () => {
// This proves the Codex parser stays strict: a Grok-shaped prompt never
// satisfies the Codex parser's origin, path, and code-length rules, so the
// row never leaves `starting` and the owner read carries no prompt.
const store = createMemoryStore();
let releaseGate!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGate = resolve;
});
const execGrokGated: ExecBehavior = async ({ onStdout }) => {
onStdout(GROK_PROMPT_OUTPUT);
await gate;
return { exitCode: 0 };
};
const { runtime } = createFakeRuntime({ exec: execGrokGated, authBytes: Buffer.from("{}") });
const companyId = randomUUID();
const service = makeService({ store, runtime });
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
// Give the run several event-loop turns. The row must never reach
// `waiting_for_user`, because the Codex parser never matches the Grok
// prompt.
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("starting");
const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(owner?.prompt).toBeNull();
releaseGate();
await completed;
});
it("runs the Grok promotion (never the Codex one) for a grok_local login, and the Codex promotion (never the Grok one) for a codex_local login", async () => {
// This proves the promotion dispatch is adapter-scoped: `resolveProfile`
// must pick the promotion for the login's own adapter type, not always the
// same injected value. A test that only asserts the map carries a
// `grok_local` member would not prove this — it follows the value all the
// way to the call that runs it.
const codexPromoteCalls: string[] = [];
const grokPromoteCalls: string[] = [];
const service = makeService({
store: createMemoryStore(),
runtime: createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") }).runtime,
promotionByAdapterType: {
codex_local: {
promote: (_bytes, context) => {
codexPromoteCalls.push(context.sessionId);
},
},
grok_local: {
promote: (_bytes, context) => {
grokPromoteCalls.push(context.sessionId);
},
},
},
});
const grokRun = await service.start({
companyId: randomUUID(),
environmentId: randomUUID(),
adapterType: "grok_local",
startedByUserId: OWNER_A,
});
await grokRun.completed;
expect(grokPromoteCalls).toHaveLength(1);
expect(codexPromoteCalls).toHaveLength(0);
const codexRun = await service.start({
companyId: randomUUID(),
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
await codexRun.completed;
expect(codexPromoteCalls).toHaveLength(1);
expect(grokPromoteCalls).toHaveLength(1);
});
it("holds no prompt value, account email, or credential byte in an activity record, an API response, or a database row for a Grok session", async () => {
// A realistic Grok auth.json: the composite <issuer>::<uuid> identity key,
// a refresh token, and the personal fields the payload carries (email,
// name, ids). None of these may reach the service's own generic surfaces:
// the activity record, the session/owner API responses, or the database
// row. (The promotion's own log lines are proven redacted separately, in
// the grok-local `adapter-auth-promotion.test.ts` suite.)
const grokIssuer = "https://issuer.x.ai";
const grokUuid = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
const grokEmail = "grok-user@example.com";
const refreshTokenSentinel = "SENTINEL_GROK_REFRESH_TOKEN";
const grokAuthBytes = Buffer.from(
JSON.stringify({
[`${grokIssuer}::${grokUuid}`]: {
key: "api-key-value",
refresh_token: refreshTokenSentinel,
expires_at: "2026-01-01T00:00:00Z",
oidc_issuer: grokIssuer,
oidc_client_id: "client-1",
email: grokEmail,
first_name: "Test",
last_name: "User",
user_id: "user-1",
principal_id: "principal-1",
team_id: "team-1",
},
}),
);
const store = createMemoryStore();
const activity: LoginSessionActivityEvent[] = [];
const { runtime } = createFakeRuntime({
exec: async ({ onStdout }) => {
onStdout(GROK_PROMPT_OUTPUT);
return { exitCode: 0 };
},
authBytes: grokAuthBytes,
});
const companyId = randomUUID();
const service = makeService({
store,
runtime,
recordActivity: (event) => activity.push(event),
promotionByAdapterType: { grok_local: { promote: () => {} } },
});
const { session, completed } = await service.start({
companyId,
environmentId: randomUUID(),
adapterType: "grok_local",
startedByUserId: OWNER_A,
});
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
await new Promise((resolve) => setImmediate(resolve));
const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
const outcome = await completed;
expect(outcome.status).toBe("authenticated");
const forbidden = [grokEmail, refreshTokenSentinel, "api-key-value", grokUuid];
const activityText = JSON.stringify(activity);
const sessionText = JSON.stringify(session);
const ownerText = JSON.stringify(owner);
const outcomeText = JSON.stringify(outcome);
const row = await store.getByPublicId(session.sessionId, companyId);
const rowText = JSON.stringify(row);
for (const secret of forbidden) {
expect(activityText).not.toContain(secret);
expect(sessionText).not.toContain(secret);
expect(ownerText).not.toContain(secret);
expect(outcomeText).not.toContain(secret);
expect(rowText).not.toContain(secret);
}
// The owner read still carries the (non-secret) device-login prompt.
expect(owner?.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE });
});
it("fails closed and never promotes when a success outcome carries no credential", async () => {
const store = createMemoryStore();
let promoteCalls = 0;
@ -811,7 +1034,7 @@ describe("codex device login service", () => {
// One millisecond before five minutes: the run still holds the active
// claim.
await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS - 1);
await vi.advanceTimersByTimeAsync(DEVICE_LOGIN_TIMEOUT_MS - 1);
expect(settled).toBe(false);
const midRow = await store.getByPublicId(session.sessionId, companyId);
expect(["starting", "waiting_for_user"]).toContain(midRow?.status);
@ -847,7 +1070,7 @@ describe("codex device login service", () => {
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS);
await vi.advanceTimersByTimeAsync(DEVICE_LOGIN_TIMEOUT_MS);
const outcome = await completed;
expect(outcome.status).toBe("timed_out");
expect(outcome.cleanupPending).toBe(true);
@ -863,7 +1086,7 @@ describe("codex device login service", () => {
it("runs the login over the shared pseudo-terminal and reads the credential with the descriptor-bound read", async () => {
const sessionId = randomUUID();
const sessionHome = sessionCodexHomePath(sessionId);
const sessionHome = sessionLoginHomePath(sessionId);
const authPath = sessionCredentialPath(sessionId);
// The fake pseudo-terminal session streams the prompt and exits with code
@ -912,7 +1135,7 @@ describe("codex device login service", () => {
environment: { id: "env", driver: "sandbox" } as never,
lease: { id: "lease" } as never,
sessionHome,
timeoutMs: CODEX_DEVICE_LOGIN_TIMEOUT_MS,
timeoutMs: DEVICE_LOGIN_TIMEOUT_MS,
});
const chunks: string[] = [];
@ -950,12 +1173,12 @@ if (!embeddedPostgresSupport.supported) {
);
}
describeEmbeddedPostgres("codex device login service concurrency (embedded postgres)", () => {
describeEmbeddedPostgres("device login service concurrency (embedded postgres)", () => {
let stopDb: (() => Promise<void>) | undefined;
let db!: ReturnType<typeof createDb>;
beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("codex-device-login");
const started = await startEmbeddedPostgresTestDatabase("device-login");
stopDb = started.stop;
db = createDb(started.connectionString);
});
@ -1178,7 +1401,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
const sessionId = await seedStalePromotingRow(companyId, environmentId);
const { runtime } = createReaperRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => new Date() });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => new Date() });
// Run the ownership check and the credential write inside the promotion lock.
// The write flag stands in for the filesystem credential write. A gate holds
@ -1227,7 +1450,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
const sessionId = await seedStalePromotingRow(companyId, environmentId);
const { runtime } = createReaperRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => new Date() });
const reaper = createDeviceLoginReaper({ store, runtime, now: () => new Date() });
// The reaper reclaims the stale row first and times it out.
const sweep = await reaper.sweep();
@ -1286,10 +1509,10 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
// The reaper reclaims the expired row and times it out.
const { runtime: reaperRuntime } = createReaperRuntime();
const reaper = createCodexDeviceLoginReaper({
const reaper = createDeviceLoginReaper({
store,
runtime: reaperRuntime,
now: () => new Date(Date.now() + CODEX_DEVICE_LOGIN_TIMEOUT_MS + 60_000),
now: () => new Date(Date.now() + DEVICE_LOGIN_TIMEOUT_MS + 60_000),
});
const sweep = await reaper.sweep();
expect(sweep.expiredTimedOut).toBe(1);
@ -1391,4 +1614,209 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
await started.completed;
});
});
describe("the displayed-code adapter scope", () => {
it("reads a session row of a second displayed-code adapter by its public id", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const store = createDbAdapterAuthSessionStore(db);
const publicSessionId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: randomUUID(),
companyId,
environmentId,
adapterType: "grok_local",
startedByUserId: OWNER_A,
publicSessionId,
status: "starting",
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
});
const row = await store.getByPublicId(publicSessionId, companyId);
expect(row).not.toBeNull();
expect(row?.adapterType).toBe("grok_local");
});
it("the expiry scan and the cleanup scan include a row of a second displayed-code adapter", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const store = createDbAdapterAuthSessionStore(db);
const past = new Date(Date.now() - 60_000);
const expiredId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: expiredId,
companyId,
environmentId,
adapterType: "grok_local",
startedByUserId: OWNER_A,
publicSessionId: randomUUID(),
status: "starting",
expiresAt: past,
createdAt: past,
updatedAt: past,
});
const pendingId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: pendingId,
companyId,
environmentId,
adapterType: "grok_local",
startedByUserId: OWNER_A,
publicSessionId: randomUUID(),
status: "cleanup_pending",
failureReason: "failed|login_command_failed",
finishedAt: past,
createdAt: past,
updatedAt: past,
});
const expired = await store.listExpiredActiveSessions(new Date());
expect(expired.map((row) => row.id)).toContain(expiredId);
const pending = await store.listCleanupPendingSessions();
expect(pending.map((row) => row.id)).toContain(pendingId);
});
it("the orphan lease scan includes a session row of a second displayed-code adapter, and excludes a Claude setup-token row", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const reaperRuntime = createProductionLoginSessionReaperRuntime({
db,
// The orphan scan under test reads only the store and the lease list; it
// never reaches the environment runtime driver.
environmentRuntime: {} as never,
});
// A grok_local session row that still owns its lease. `grok_local` is in
// the displayed-code adapter set, so the scan's candidate query reaches
// this row's environment.
const grokSessionId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: randomUUID(),
companyId,
environmentId,
adapterType: "grok_local",
startedByUserId: OWNER_A,
publicSessionId: randomUUID(),
providerLeaseId: "grok-lease-1",
status: "waiting_for_user",
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(environmentLeases).values({
companyId,
environmentId,
status: "active",
providerLeaseId: "grok-lease-1",
metadata: { [LOGIN_LEASE_SESSION_TAG_KEY]: grokSessionId },
});
// A Claude setup-token row in a second environment, in the shared
// `cleanup_pending` state the two login flows both use. `claude_local` is
// outside the displayed-code adapter set, and no other row makes this
// second environment a scan candidate, so the scan must never read it.
const claudeEnvironmentId = await seedEnvironment(companyId);
const claudeSessionId = randomUUID();
await db.insert(adapterAuthSessions).values({
id: randomUUID(),
companyId,
environmentId: claudeEnvironmentId,
adapterType: "claude_local",
startedByUserId: OWNER_B,
publicSessionId: randomUUID(),
providerLeaseId: "claude-lease-1",
status: "cleanup_pending",
failureReason: "failed",
finishedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(environmentLeases).values({
companyId,
environmentId: claudeEnvironmentId,
status: "active",
providerLeaseId: "claude-lease-1",
metadata: { [LOGIN_LEASE_SESSION_TAG_KEY]: claudeSessionId },
});
const tagged = await reaperRuntime.listTaggedLeases();
const taggedProviderLeaseIds = tagged.map((lease) => lease.providerLeaseId);
expect(taggedProviderLeaseIds).toContain("grok-lease-1");
expect(taggedProviderLeaseIds).not.toContain("claude-lease-1");
});
it("carries the CODEX_HOME variable name on the codex_local profile", () => {
// The home variable name has no consuming call site yet in this phase; the
// profile only carries it, ready for a later phase to read it.
expect(DISPLAYED_CODE_PROFILES.codex_local?.homeEnvVar).toBe("CODEX_HOME");
});
it("runLogin and the start ttl read the command, the parser, and the timeout from the resolved profile", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const store = createDbAdapterAuthSessionStore(db);
const originalProfile = DISPLAYED_CODE_PROFILES.codex_local!;
const customPrompt = { url: "https://example.test/device", code: "ZZZZ-99999" };
const customTimeoutMs = 12_345_000;
const capturedCommands: string[] = [];
// The map is a plain object at runtime; only its TypeScript type reads
// `Readonly`. Mutate the one entry for this test, then restore it, so no
// other test in this file observes the override.
const profiles = DISPLAYED_CODE_PROFILES as Record<string, typeof originalProfile>;
profiles.codex_local = {
...originalProfile,
command: "custom-login-command",
timeoutMs: customTimeoutMs,
parsePrompt: (output) => (output.includes("MARKER") ? customPrompt : null),
};
try {
const runtime: LoginSessionRuntime = {
async acquireLoginLease(input) {
return {
providerLeaseId: `lease-${input.sessionId}`,
authPath: sessionCredentialPath(input.sessionId),
driver: {
start: (command, onData) => {
capturedCommands.push(command);
onData("a MARKER line\n");
return new Promise<{ exitCode: number | null }>(() => {});
},
readFile: async () => Buffer.from("{}"),
dispose: async () => {},
},
deleteSandbox: async () => ({ outcome: "deleted" }),
release: async () => {},
};
},
};
const service = makeService({ store, runtime });
const controller = new AbortController();
const started = await service.start({
companyId,
environmentId,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
signal: controller.signal,
});
// The start ttl read: `session.expiresAt` reflects the profile's
// `timeoutMs`, not the fixed `DEVICE_LOGIN_TIMEOUT_MS`.
const expectedExpiresAt = Date.now() + customTimeoutMs;
expect(Math.abs(new Date(started.session.expiresAt).getTime() - expectedExpiresAt)).toBeLessThan(
5000,
);
// `runLogin`: the driver received the profile's command, and the
// profile's parser produced the owner-visible prompt.
await waitForStatus(store, started.session.sessionId, companyId, "waiting_for_user");
const owner = await service.readOwnerSession(started.session.sessionId, companyId, OWNER_A);
expect(capturedCommands).toEqual(["custom-login-command"]);
expect(owner?.prompt).toEqual(customPrompt);
controller.abort();
await started.completed;
} finally {
profiles.codex_local = originalProfile;
}
});
});
});

View File

@ -15,12 +15,12 @@ vi.mock("../services/environments.js", () => ({
}));
import {
createCodexWorkerBoundLoginPtyOpener,
createWorkerBoundLoginPtyOpener,
createProductionLoginSessionRuntime,
sessionCodexHomePath,
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
sessionLoginHomePath,
DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
type LoginPtySessionBinding,
} from "../services/codex-device-login-service.js";
} from "../services/device-login-service.js";
// A fake worker pseudo-terminal session. It satisfies the shared transport
// contract, so the opener can return it.
@ -44,7 +44,7 @@ function makeBinding(overrides: Partial<LoginPtySessionBinding> = {}): LoginPtyS
environmentId: "env-1",
adapterType: "codex_local" as AgentAdapterType,
providerLeaseId: "provider-lease-9",
sessionHome: sessionCodexHomePath(randomUUID()),
sessionHome: sessionLoginHomePath(randomUUID()),
environment: { id: "env-1", driver: "sandbox" } as never,
lease: {
id: "lease-1",
@ -54,14 +54,14 @@ function makeBinding(overrides: Partial<LoginPtySessionBinding> = {}): LoginPtyS
};
}
describe("createCodexWorkerBoundLoginPtyOpener", () => {
describe("createWorkerBoundLoginPtyOpener", () => {
it("passes the binding's server-controlled session home to the worker route", async () => {
const sessionHome = sessionCodexHomePath(randomUUID());
const sessionHome = sessionLoginHomePath(randomUUID());
const session = fakeWorkerSession();
const openLoginPtySession = vi.fn(
async (_pluginId: string, _input: Record<string, unknown>) => session,
);
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
});
@ -90,7 +90,7 @@ describe("createCodexWorkerBoundLoginPtyOpener", () => {
it("fails closed when the lease carries no sandbox worker binding", async () => {
const openLoginPtySession = vi.fn();
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
});
@ -102,7 +102,7 @@ describe("createCodexWorkerBoundLoginPtyOpener", () => {
it("fails closed when the adapter type has no login command key", async () => {
const openLoginPtySession = vi.fn();
const openLivePtySession = createCodexWorkerBoundLoginPtyOpener({
const openLivePtySession = createWorkerBoundLoginPtyOpener({
workerManager: { openLoginPtySession },
});
@ -131,7 +131,7 @@ describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
// The capability flipped to unsupported after the route gate. The lease gate
// reads the current capability and fails closed.
const assertProviderSupportsLoginPty = vi.fn(async () => {
throw new Error(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
throw new Error(DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
});
const runtime = createProductionLoginSessionRuntime({
db: {} as never,
@ -141,7 +141,7 @@ describe("createProductionLoginSessionRuntime lease-acquisition gate", () => {
await expect(
runtime.acquireLoginLease({ ...acquireInput, sessionId: randomUUID() }),
).rejects.toThrow(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
).rejects.toThrow(DEVICE_LOGIN_PROVIDER_UNSUPPORTED);
// The runtime re-checked the current capability from the environment id.
expect(assertProviderSupportsLoginPty).toHaveBeenCalledWith("env-1");
// The gate failed before the provider lease, so no lease was acquired.

View File

@ -20,6 +20,18 @@ describe("built-in adapter login capabilities", () => {
expect(() => assertValidAdapterLoginCapability(capability, "codex_local")).not.toThrow();
});
it("registers the Grok device-login capability", () => {
const capability = requireServerAdapter("grok_local").loginCapability;
expect(capability).toBeDefined();
if (!capability) return;
expect(capability.panelMode).toBe("displayed_code");
expect(capability.timeoutPolicy).toBe("caller_bounded");
expect(capability.completionClaim).toBeUndefined();
expect(typeof capability.getCommand).toBe("function");
expect(typeof capability.parsePrompt).toBe("function");
expect(() => assertValidAdapterLoginCapability(capability, "grok_local")).not.toThrow();
});
it("registers the Claude setup-token capability", () => {
const capability = requireServerAdapter("claude_local").loginCapability;
expect(capability).toBeDefined();

View File

@ -83,6 +83,8 @@ import {
syncGrokSkills,
testEnvironment as grokTestEnvironment,
sessionCodec as grokSessionCodec,
GROK_DEVICE_LOGIN_COMMAND,
parseGrokDeviceLoginPrompt,
} from "@paperclipai/adapter-grok-local/server";
import {
agentConfigurationDoc as grokAgentConfigurationDoc,
@ -234,6 +236,24 @@ const codexLoginCapability: AdapterLoginCapability = {
},
};
// The Grok interactive login capability. Grok runs `grok login --device-auth`
// on a real pseudo-terminal, the same way Codex does. The flow shows a
// one-time code that the user enters in the browser. The caller sets the
// host-side timeout. The device-login flow writes its credential inside the
// sandbox, so the capability declares no terminal credential capture and no
// completion claim. `getCommand` is descriptive only: the login path selects
// the real command from the closed key map in `login-command.ts`, never from
// this member.
const grokLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
timeoutPolicy: "caller_bounded",
getCommand: () => GROK_DEVICE_LOGIN_COMMAND,
parsePrompt: (output) => {
const prompt = parseGrokDeviceLoginPrompt(output);
return prompt ? { url: prompt.url, code: prompt.code } : null;
},
};
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: stampClaudeAgentIdHeader(claudeExecute),
@ -466,6 +486,7 @@ const grokLocalAdapter: ServerAdapterModule = {
installCommand: null,
}),
agentConfigurationDoc: grokAgentConfigurationDoc,
loginCapability: grokLoginCapability,
};
const kimiLocalAdapter: ServerAdapterModule = {

View File

@ -72,11 +72,11 @@ import { questionResponseDeliveryService } from "./services/question-response-de
import { queueIssueAssignmentWakeup } from "./services/issue-assignment-wakeup.js";
import { createSecretProposalsService } from "./services/secret-proposals.js";
import { environmentRuntimeService } from "./services/environment-runtime.js";
import { createDbAdapterAuthSessionStore } from "./services/codex-device-login-service.js";
import { createDbAdapterAuthSessionStore } from "./services/device-login-service.js";
import {
createCodexDeviceLoginReaper,
createDeviceLoginReaper,
createProductionLoginSessionReaperRuntime,
} from "./services/codex-device-login-reaper.js";
} from "./services/device-login-reaper.js";
import { createProductionSetupTokenReaper } from "./services/setup-token-reaper.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
@ -1176,7 +1176,7 @@ export async function startServer(): Promise<StartedServer> {
// any expired non-terminal session, retries the delete for any terminal
// session left in `cleanup_pending`, and deletes a tagged lease that no live
// session references.
const adapterLoginReaper = createCodexDeviceLoginReaper({
const adapterLoginReaper = createDeviceLoginReaper({
store: createDbAdapterAuthSessionStore(db as any),
runtime: createProductionLoginSessionReaperRuntime({
db: db as any,

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AdapterLoginCapability, ServerAdapterModule } from "@paperclipai/adapter-utils";
import { requireServerAdapter } from "../adapters/registry.js";
import { buildAdapterCapabilities } from "./adapters.js";
// The adapter listing projects the safe scalar login fields to the client. The
@ -63,4 +64,14 @@ describe("buildAdapterCapabilities login projection", () => {
expect(caps.login).not.toHaveProperty("captureCredential");
expect(caps.login).not.toHaveProperty("completionClaim");
});
it("projects panelMode and timeoutPolicy for the registered grok_local adapter, with no function member", () => {
const caps = buildAdapterCapabilities(requireServerAdapter("grok_local"));
expect(caps.login).toEqual({
panelMode: "displayed_code",
timeoutPolicy: "caller_bounded",
});
expect(caps.login).not.toHaveProperty("getCommand");
expect(caps.login).not.toHaveProperty("parsePrompt");
});
});

View File

@ -32,6 +32,7 @@ import {
startAdapterAuthSessionRequestSchema,
startClaudeSetupTokenSessionRequestSchema,
submitBrowserCodeRequestSchema,
type AgentAdapterType,
} from "@paperclipai/shared";
import {
isForbiddenConfigEnvKey,
@ -145,15 +146,20 @@ import {
checkStagedCredentialReadiness,
promoteDeviceLoginCredential,
} from "@paperclipai/adapter-codex-local/server";
import {
checkStagedGrokCredentialReadiness,
promoteGrokDeviceLoginCredential,
} from "@paperclipai/adapter-grok-local/server";
import {
AdapterAuthSessionConflictError,
createCodexDeviceLoginService,
createCodexWorkerBoundLoginPtyOpener,
createDeviceLoginService,
createWorkerBoundLoginPtyOpener,
createDbAdapterAuthSessionStore,
createProductionLoginSessionRuntime,
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
} from "../services/codex-device-login-service.js";
DEVICE_LOGIN_PROVIDER_UNSUPPORTED,
DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
type CredentialPromotion,
} from "../services/device-login-service.js";
import type { AdapterAuthSessionOwnerResponse } from "@paperclipai/shared";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
@ -496,7 +502,7 @@ export function agentRoutes(
// process owns one instance, so the in-memory prompt and the cancellation
// controllers persist across requests.
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
const adapterLoginService = createCodexDeviceLoginService({
const adapterLoginService = createDeviceLoginService({
store: adapterLoginStore,
runtime: createProductionLoginSessionRuntime({
db,
@ -514,73 +520,119 @@ export function agentRoutes(
// opens. When no worker manager is bound, the runtime keeps its fail-closed
// opener and the login fails closed.
openLivePtySession: options.pluginWorkerManager
? createCodexWorkerBoundLoginPtyOpener({
? createWorkerBoundLoginPtyOpener({
workerManager: options.pluginWorkerManager,
log: (line) => logger.info(line),
})
: undefined,
}),
// The mandatory credential promotion. A successful login authenticates only
// after this promotion validates the exact staged credential, runs an
// independent readiness check, confirms the session still holds the sole
// active claim, and writes the credential into the company scope. A rejected
// or unready credential fails the session and writes nothing.
promotion: {
async promote(authBytes, context) {
// Hold the promotion critical-section lock across the ownership check and
// the credential write. The reaper takes the same lock before it reclaims
// a stale `promoting` row. So a reclaim never interleaves with a live
// write: the reaper either wins the lock first and the ownership check
// then reads a reclaimed row and writes nothing, or the write finishes
// first under the lock and the reaper reclaims only after it completes. A
// read-only fence is not enough, because the filesystem write can start
// after the fence; the lock spans the whole section.
const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock(
context.companyId,
context.startedByUserId,
context.adapterType,
() =>
promoteDeviceLoginCredential({
authBytes,
companyId: context.companyId,
userInitiated: true,
checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes),
isSoleActiveOwner: async () => {
// The partial unique index allows one active row per company and
// adapter. So a `promoting` row for this session is the sole
// active owner of the company credential slot. The read runs
// inside the lock, so it observes a reaper reclaim that committed
// before this section acquired the lock.
const row = await adapterLoginStore.get(context.sessionId);
return row?.status === "promoting" && row.companyId === context.companyId;
},
log: (line) => {
// The promotion lines carry no token bytes and no raw account id,
// so it is safe to log them with the session identifier.
logger.info({ sessionId: context.sessionId }, line);
},
}),
);
// A resolved promotion is not necessarily an accepted promotion. In
// particular, a reaper/expiry race can revoke this session's sole
// ownership between the service transition and Decision H. Fail closed:
// only a credential write or a deliberate safe keep can authenticate.
if (outcome === "kept_foreign_identity") {
// The login produced a different account than the one the company
// credential home already holds. The promotion never clobbers an
// occupied home, so this login installed nothing durable, and the
// identity-anchored vend can never select it: a later run keeps the
// existing account. Fail the session, so the operator never sees a
// false `authenticated` for an account the system will not use.
throw new Error(
"device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept",
// The mandatory credential promotion, keyed by adapter type. A successful
// login authenticates only after the promotion for its own adapter type
// validates the exact staged credential, runs an independent readiness
// check, confirms the session still holds the sole active claim, and
// writes the credential into the company scope. A rejected or unready
// credential fails the session and writes nothing. Keying by adapter type
// keeps a `grok_local` login from ever running the Codex promotion (and
// vice versa): each entry closes over its own readiness check and its own
// promotion function.
promotionByAdapterType: {
codex_local: {
async promote(authBytes, context) {
// Hold the promotion critical-section lock across the ownership check and
// the credential write. The reaper takes the same lock before it reclaims
// a stale `promoting` row. So a reclaim never interleaves with a live
// write: the reaper either wins the lock first and the ownership check
// then reads a reclaimed row and writes nothing, or the write finishes
// first under the lock and the reaper reclaims only after it completes. A
// read-only fence is not enough, because the filesystem write can start
// after the fence; the lock spans the whole section.
const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock(
context.companyId,
context.startedByUserId,
context.adapterType,
() =>
promoteDeviceLoginCredential({
authBytes,
companyId: context.companyId,
userInitiated: true,
checkReadiness: (bytes) => checkStagedCredentialReadiness(bytes),
isSoleActiveOwner: async () => {
// The partial unique index allows one active row per company and
// adapter. So a `promoting` row for this session is the sole
// active owner of the company credential slot. The read runs
// inside the lock, so it observes a reaper reclaim that committed
// before this section acquired the lock.
const row = await adapterLoginStore.get(context.sessionId);
return row?.status === "promoting" && row.companyId === context.companyId;
},
log: (line) => {
// The promotion lines carry no token bytes and no raw account id,
// so it is safe to log them with the session identifier.
logger.info({ sessionId: context.sessionId }, line);
},
}),
);
}
if (outcome !== "promoted" && outcome !== "kept") {
throw new Error(`device-login credential promotion rejected: ${outcome}`);
}
// A resolved promotion is not necessarily an accepted promotion. In
// particular, a reaper/expiry race can revoke this session's sole
// ownership between the service transition and Decision H. Fail closed:
// only a credential write or a deliberate safe keep can authenticate.
if (outcome === "kept_foreign_identity") {
// The login produced a different account than the one the company
// credential home already holds. The promotion never clobbers an
// occupied home, so this login installed nothing durable, and the
// identity-anchored vend can never select it: a later run keeps the
// existing account. Fail the session, so the operator never sees a
// false `authenticated` for an account the system will not use.
throw new Error(
"device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept",
);
}
if (outcome !== "promoted" && outcome !== "kept") {
throw new Error(`device-login credential promotion rejected: ${outcome}`);
}
},
},
},
grok_local: {
async promote(authBytes, context) {
// The same promotion critical-section lock as the Codex entry above,
// keyed by the same `(companyId, startedByUserId, adapterType)` tuple,
// so a Grok reclaim and a Grok write never interleave.
const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock(
context.companyId,
context.startedByUserId,
context.adapterType,
() =>
promoteGrokDeviceLoginCredential({
authBytes,
companyId: context.companyId,
userInitiated: true,
checkReadiness: (bytes) => checkStagedGrokCredentialReadiness(bytes),
isSoleActiveOwner: async () => {
const row = await adapterLoginStore.get(context.sessionId);
return row?.status === "promoting" && row.companyId === context.companyId;
},
log: (line) => {
// The promotion lines carry no token bytes and no personal
// field, so it is safe to log them with the session identifier.
logger.info({ sessionId: context.sessionId }, line);
},
}),
);
if (outcome === "kept_foreign_identity") {
// The login produced a different account than the one the company
// credential home already holds. Fail the session, so the operator
// never sees a false `authenticated` for an account the system will
// not use.
throw new Error(
"device-login credential promotion rejected: the login is a different account than the one already set for this company; the existing account was kept",
);
}
if (outcome !== "promoted") {
throw new Error(`device-login credential promotion rejected: ${outcome}`);
}
},
},
} satisfies Partial<Record<AgentAdapterType, CredentialPromotion>>,
recordActivity: (event) => {
// The event carries no URL, no code, no credential, no account identifier,
// and no lease identifier, so it is safe to log.
@ -1357,8 +1409,8 @@ export function agentRoutes(
*/
async function assertCodexLoginProviderCapability(environmentId: string): Promise<void> {
if (!(await resolveProviderSupportsLoginPty(environmentId))) {
throw unprocessable(CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED, {
code: CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
throw unprocessable(DEVICE_LOGIN_PROVIDER_UNSUPPORTED, {
code: DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE,
});
}
}

View File

@ -58,7 +58,7 @@ export const DEVICE_LOGIN_AUTH_READ_ERROR =
* and runs it in the sandbox as `node -e <script> <sessionHome> <maxBytes>
* [<expectedUid>]`. The sandbox already runs node for the Paperclip bridge, so the
* helper needs no extra runtime. The helper source lives in
* `scripts/codex-auth-read.cjs`, so no large script stays as a string literal in
* `scripts/adapter-auth-read.cjs`, so no large script stays as a string literal in
* this module.
*
* The helper opens the filesystem root, then opens each session-home path
@ -71,7 +71,7 @@ export const DEVICE_LOGIN_AUTH_READ_ERROR =
* it, so the helper uses the login user's own id.
*/
export const DEVICE_LOGIN_AUTH_READ_SCRIPT = readFileSync(
fileURLToPath(new URL("./scripts/codex-auth-read.cjs", import.meta.url)),
fileURLToPath(new URL("./scripts/adapter-auth-read.cjs", import.meta.url)),
"utf8",
);

View File

@ -1,16 +1,16 @@
import { and, eq, inArray } from "drizzle-orm";
import { and, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { adapterAuthSessions } from "@paperclipai/db";
import {
ADAPTER_AUTH_ACTIVE_STATUSES,
CODEX_DEVICE_LOGIN_ADAPTER_TYPE,
DISPLAYED_CODE_ADAPTER_TYPES,
decodePendingTerminal,
LOGIN_LEASE_SESSION_TAG_KEY,
observeSandboxDelete,
terminalCleanupWrite,
type AdapterAuthReaperStore,
type SandboxDeleteResult,
} from "./codex-device-login-service.js";
} from "./device-login-service.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { environmentService } from "./environments.js";
@ -77,13 +77,13 @@ export interface ReaperSweepResult {
cleanupPendingRemaining: number;
}
export interface CodexDeviceLoginReaperDeps {
export interface DeviceLoginReaperDeps {
store: AdapterAuthReaperStore;
runtime: LoginSessionCleanupRuntime;
now?: () => Date;
}
export function createCodexDeviceLoginReaper(deps: CodexDeviceLoginReaperDeps) {
export function createDeviceLoginReaper(deps: DeviceLoginReaperDeps) {
const { store, runtime } = deps;
const now = deps.now ?? (() => new Date());
@ -254,7 +254,7 @@ export function createCodexDeviceLoginReaper(deps: CodexDeviceLoginReaperDeps) {
return { sweep };
}
export type CodexDeviceLoginReaper = ReturnType<typeof createCodexDeviceLoginReaper>;
export type DeviceLoginReaper = ReturnType<typeof createDeviceLoginReaper>;
// ---------------------------------------------------------------------------
// The production runtime binding.
@ -317,8 +317,9 @@ export function createProductionLoginSessionReaperRuntime(
.where(
and(
// The shared table also holds the setup-token rows. Filter by the
// device-login adapter, so the orphan sweep reads only Codex rows.
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
// closed set of displayed-code adapter types, so the orphan sweep
// reads only displayed-code rows.
inArray(adapterAuthSessions.adapterType, DISPLAYED_CODE_ADAPTER_TYPES),
inArray(adapterAuthSessions.status, [
...ADAPTER_AUTH_ACTIVE_STATUSES,
"cleanup_pending",

View File

@ -14,12 +14,18 @@ import type {
} from "@paperclipai/shared";
import { toPublicAdapterAuthSessionStatus } from "@paperclipai/shared";
import {
CODEX_DEVICE_LOGIN_COMMAND,
CODEX_DEVICE_LOGIN_COMMAND as DEFAULT_CODEX_LOGIN_COMMAND,
parseDeviceLoginPrompt,
runDeviceLogin,
type DeviceLoginOutcome,
type DeviceLoginOutcome as RunnerDeviceLoginOutcome,
type DeviceLoginPrompt,
type SandboxLoginDriver,
} from "@paperclipai/adapter-codex-local/server";
import {
GROK_DEVICE_LOGIN_COMMAND as DEFAULT_GROK_LOGIN_COMMAND,
parseGrokDeviceLoginPrompt,
} from "@paperclipai/adapter-grok-local/server";
import type { AdapterLoginPrompt } from "@paperclipai/adapter-utils";
import {
createLoginPtyTransport,
type LoginPtySessionOpener,
@ -27,7 +33,7 @@ import {
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { environmentService } from "./environments.js";
import { runDescriptorBoundAuthRead } from "./codex-device-login-credential-read.js";
import { runDescriptorBoundAuthRead } from "./device-login-credential-read.js";
import {
resolveLoginCommandKey,
validateLoginSessionHome,
@ -47,7 +53,7 @@ import type { LoginPtyWorkerManagerLike } from "./setup-token-transport-binding.
// through the owner read path.
/** The host timeout for the sandbox login command. It is exactly five minutes. */
export const CODEX_DEVICE_LOGIN_TIMEOUT_MS = 300_000;
export const DEVICE_LOGIN_TIMEOUT_MS = 300_000;
// The fixed error for a sandbox provider that does not advertise the login
// pseudo-terminal capability. The Codex device login runs the login command on a
@ -55,9 +61,9 @@ export const CODEX_DEVICE_LOGIN_TIMEOUT_MS = 300_000;
// host the login. The route returns this specific, typed error and starts no
// session, so an unsupported provider never reaches a session row, a lease, or a
// pseudo-terminal.
export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED =
export const DEVICE_LOGIN_PROVIDER_UNSUPPORTED =
"The sandbox provider does not support the Codex device login.";
export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE =
export const DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE =
"codex_device_login_provider_unsupported";
/**
@ -67,8 +73,18 @@ export const CODEX_DEVICE_LOGIN_PROVIDER_UNSUPPORTED_CODE =
*/
export const LOGIN_LEASE_SESSION_TAG_KEY = "adapterLoginSessionId";
/** The default Codex adapter type for a login session. */
export const CODEX_DEVICE_LOGIN_ADAPTER_TYPE: AgentAdapterType = "codex_local";
/**
* The closed set of displayed-code adapter types. The shared
* `adapter_auth_sessions` table also holds a Claude setup-token row. That row
* uses a different login panel mode. So every store read and every reaper scan
* filters to this set, instead of trusting the raw row's own value. No route
* makes a row of an adapter type outside `codex_local` reachable yet. A later
* phase widens the set of reachable adapters, with no change to this filter.
*/
export const DISPLAYED_CODE_ADAPTER_TYPES: readonly AgentAdapterType[] = [
"codex_local",
"grok_local",
];
/**
* The provider delete result the service observes on a terminal path. The
@ -178,7 +194,7 @@ export interface LoginSessionActivityEvent {
export type LoginSessionActivityRecorder = (event: LoginSessionActivityEvent) => void;
export interface StartCodexDeviceLoginInput {
export interface StartDeviceLoginInput {
companyId: string;
environmentId: string;
adapterType: AgentAdapterType;
@ -193,7 +209,7 @@ export interface StartCodexDeviceLoginInput {
signal?: AbortSignal;
}
export interface CodexDeviceLoginOutcome {
export interface DeviceLoginOutcome {
sessionId: string;
/** The resolved public terminal status. */
status: AdapterAuthSessionStatus;
@ -206,7 +222,7 @@ export interface CodexDeviceLoginOutcome {
sandboxDeleteObserved: boolean;
}
export interface StartCodexDeviceLoginResult {
export interface StartDeviceLoginResult {
/** The initial public response after the insert and the acquisition. */
session: AdapterAuthSessionResponse;
/**
@ -214,7 +230,7 @@ export interface StartCodexDeviceLoginResult {
* readiness check, the promotion write, and the cleanup-state handoff, and
* then it records the terminal status.
*/
completed: Promise<CodexDeviceLoginOutcome>;
completed: Promise<DeviceLoginOutcome>;
}
/** The service throws this when the active company credential slot is taken. */
@ -304,12 +320,18 @@ export interface AdapterAuthSessionStore {
compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise<boolean>;
get(sessionId: string): Promise<AdapterAuthSessionRow | null>;
/**
* Read a session by its public session id, scoped to the company and the Codex
* device-login adapter. The predicate carries the company id, so a query never
* keys on the public session id alone, and a foreign-company caller reads
* nothing. It never accepts the internal primary-key `id`.
* Read a session by its public session id, scoped to the company. The
* predicate carries the company id, so a query never keys on the public
* session id alone, and a foreign-company caller reads nothing. It never
* accepts the internal primary-key `id`. `adapterType` scopes the read to one
* requested adapter type. When the caller omits it, the read scopes to every
* displayed-code adapter type.
*/
getByPublicId(publicSessionId: string, companyId: string): Promise<AdapterAuthSessionRow | null>;
getByPublicId(
publicSessionId: string,
companyId: string,
adapterType?: AgentAdapterType,
): Promise<AdapterAuthSessionRow | null>;
/** Run `fn` while the process holds the promotion critical-section lock for the
* company, owner, and adapter slot. The reaper reclaims a stale `promoting`
* row inside this lock, so a reclaim never interleaves with a live credential
@ -531,11 +553,12 @@ export function createDbAdapterAuthSessionStore(
const row = rows[0];
return row ? toRow(row) : null;
},
async getByPublicId(publicSessionId, companyId) {
// The predicate carries the company id and the Codex device-login adapter,
// so a read never keys on the public session id alone. A foreign-company or
// foreign-adapter caller reads nothing. The internal primary-key `id` never
// matches, so a caller cannot address a row by the internal id.
async getByPublicId(publicSessionId, companyId, adapterType) {
// The predicate carries the company id, so a read never keys on the public
// session id alone. A foreign-company caller reads nothing. The internal
// primary-key `id` never matches, so a caller cannot address a row by the
// internal id. The adapter predicate scopes the read to the one requested
// type, or, when the caller omits it, to every displayed-code adapter type.
const rows = await db
.select()
.from(adapterAuthSessions)
@ -543,7 +566,9 @@ export function createDbAdapterAuthSessionStore(
and(
eq(adapterAuthSessions.publicSessionId, publicSessionId),
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
adapterType
? eq(adapterAuthSessions.adapterType, adapterType)
: inArray(adapterAuthSessions.adapterType, DISPLAYED_CODE_ADAPTER_TYPES),
),
)
.limit(1);
@ -563,8 +588,8 @@ export function createDbAdapterAuthSessionStore(
.where(
and(
// The shared table also holds the setup-token rows, so every reaper
// scan filters by the device-login adapter to reach only Codex rows.
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
// scan filters by the closed set of displayed-code adapter types.
inArray(adapterAuthSessions.adapterType, DISPLAYED_CODE_ADAPTER_TYPES),
inArray(adapterAuthSessions.status, [...ADAPTER_AUTH_ACTIVE_STATUSES]),
isNotNull(adapterAuthSessions.expiresAt),
lte(adapterAuthSessions.expiresAt, nowAt),
@ -583,7 +608,7 @@ export function createDbAdapterAuthSessionStore(
.from(adapterAuthSessions)
.where(
and(
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
inArray(adapterAuthSessions.adapterType, DISPLAYED_CODE_ADAPTER_TYPES),
eq(adapterAuthSessions.status, "cleanup_pending"),
),
);
@ -595,7 +620,7 @@ export function createDbAdapterAuthSessionStore(
.from(adapterAuthSessions)
.where(
and(
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
inArray(adapterAuthSessions.adapterType, DISPLAYED_CODE_ADAPTER_TYPES),
isNotNull(adapterAuthSessions.providerLeaseId),
),
);
@ -702,40 +727,138 @@ export function terminalCleanupWrite(
: { status: "cleanup_pending", failureReason: encodePendingTerminal(terminal, reason) };
}
// ---------------------------------------------------------------------------
// The host-owned displayed-code login profile. `runLogin` resolves one profile
// per login from the trusted adapter type, so a host-owned map, not the runner
// package, chooses the command, the home variable name, the prompt parser, and
// the timeout for each displayed-code adapter.
// ---------------------------------------------------------------------------
/**
* The per-adapter values a displayed-code login run needs. `homeEnvVar` names
* the environment variable the sandbox login pseudo-terminal opener sets to the
* server-controlled session home, for example `CODEX_HOME`. The credential file
* name under that home stays the same for every adapter.
*/
export interface DisplayedCodeLoginProfile {
/** The login command the sandbox pseudo-terminal runs. */
command: string;
/** The environment variable name the login command reads its home from. */
homeEnvVar: string;
/** Parses the authorization prompt from the login output. Returns null when
* the output holds no prompt yet. */
parsePrompt(output: string): AdapterLoginPrompt | null;
/** The host timeout for the sandbox login command. */
timeoutMs: number;
/** The mandatory credential promotion for this adapter. */
promotion: CredentialPromotion;
}
/** A promotion placeholder for a profile map entry. The service always resolves
* the real promotion from {@link DeviceLoginServiceDeps.promotion} before it
* runs a login, so this value never runs in production. */
const UNCONFIGURED_PROMOTION: CredentialPromotion = {
promote() {
throw new Error("device login: no credential promotion is configured for this adapter.");
},
};
/**
* The host-owned profile for every displayed-code adapter type. `AgentAdapterType`
* covers every adapter, not only the displayed-code ones. So the map is a partial
* record: a lookup for an adapter type with no entry yields `undefined`. Both the
* `codex_local` entry and the `grok_local` entry are reachable today: the route
* admission gate in `agents.ts` and the `login-command.ts` closed key map now
* cover `grok_local` too.
*
* `homeEnvVar` names the environment variable the sandbox login pseudo-terminal
* opener sets, for documentation only: no code reads this member today. The
* opener (`composeLaunchLine` in the Daytona plugin) holds its own fixed
* `CODEX_HOME` / `GROK_HOME` mapping, keyed off the closed login command key,
* not off this profile. This matches the existing `codex_local` entry, whose
* `homeEnvVar` has been unread the same way since phase 1.
*/
export const DISPLAYED_CODE_PROFILES: Readonly<
Partial<Record<AgentAdapterType, DisplayedCodeLoginProfile>>
> = {
codex_local: {
command: DEFAULT_CODEX_LOGIN_COMMAND,
homeEnvVar: "CODEX_HOME",
parsePrompt: parseDeviceLoginPrompt,
timeoutMs: DEVICE_LOGIN_TIMEOUT_MS,
promotion: UNCONFIGURED_PROMOTION,
},
grok_local: {
command: DEFAULT_GROK_LOGIN_COMMAND,
homeEnvVar: "GROK_HOME",
parsePrompt: parseGrokDeviceLoginPrompt,
timeoutMs: DEVICE_LOGIN_TIMEOUT_MS,
promotion: UNCONFIGURED_PROMOTION,
},
};
// ---------------------------------------------------------------------------
// The service.
// ---------------------------------------------------------------------------
export interface CodexDeviceLoginServiceDeps {
export interface DeviceLoginServiceDeps {
store: AdapterAuthSessionStore;
runtime: LoginSessionRuntime;
/** The mandatory credential promotion. A successful login authenticates only
* after this promotion resolves; a throw fails the session and writes nothing. */
promotion: CredentialPromotion;
/**
* The mandatory credential promotion, keyed by adapter type. A successful
* login authenticates only after the promotion for its own adapter type
* resolves; a throw fails the session and writes nothing. An adapter type
* with no entry falls back to the `codex_local` entry, matching the profile
* map's own fallback below. Keying by adapter type keeps each adapter's
* promotion running only for its own logins: a `grok_local` login never
* runs the Codex promotion, and a `codex_local` login never runs the Grok
* one.
*/
promotionByAdapterType: Partial<Record<AgentAdapterType, CredentialPromotion>>;
recordActivity?: LoginSessionActivityRecorder;
now?: () => Date;
}
export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) {
const { store, runtime, promotion } = deps;
export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
const { store, runtime, promotionByAdapterType } = deps;
const now = deps.now ?? (() => new Date());
const recordActivity = deps.recordActivity ?? (() => {});
/**
* Resolve the displayed-code profile for one login, with this service
* instance's injected, adapter-scoped promotion in place of the map's
* placeholder. Falls back to the `codex_local` profile (and the
* `codex_local` promotion) for an adapter type with no entry of its own, so
* a login for an adapter type outside the map keeps running the same
* command, parser, and promotion it always did before the map existed.
* `codex_local` and `grok_local` are reachable through the route admission
* gate today; the fallback stays in place for a future adapter type with no
* profile entry.
*/
function resolveProfile(adapterType: AgentAdapterType): DisplayedCodeLoginProfile {
const staticProfile = DISPLAYED_CODE_PROFILES[adapterType] ?? DISPLAYED_CODE_PROFILES.codex_local!;
const promotion =
promotionByAdapterType[adapterType] ??
promotionByAdapterType.codex_local ??
UNCONFIGURED_PROMOTION;
return { ...staticProfile, promotion };
}
// The one-time prompt per session. The service holds it in memory only. The
// owner read path returns it; it never reaches the durable row or an activity
// record.
const promptsBySession = new Map<string, DeviceLoginPrompt>();
async function start(
input: StartCodexDeviceLoginInput,
): Promise<StartCodexDeviceLoginResult> {
input: StartDeviceLoginInput,
): Promise<StartDeviceLoginResult> {
const sessionId = randomUUID();
// The public session identifier the API returns and looks up. It is an
// independent CSPRNG value, so it never equals the internal `sessionId` and a
// caller cannot address the row by the internal id.
const publicSessionId = randomUUID();
const startedAt = now();
const ttlSeconds = input.ttlSeconds ?? CODEX_DEVICE_LOGIN_TIMEOUT_MS / 1000;
const ttlSeconds = input.ttlSeconds ?? resolveProfile(input.adapterType).timeoutMs / 1000;
const expiresAt = new Date(startedAt.getTime() + ttlSeconds * 1000);
const base = {
sessionId,
@ -830,13 +953,14 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
}
async function runLogin(ctx: {
input: StartCodexDeviceLoginInput;
input: StartDeviceLoginInput;
sessionId: string;
lease: LoginSessionLease;
base: Omit<LoginSessionActivityEvent, "phase">;
activity: (phase: LoginSessionActivityPhase) => void;
}): Promise<CodexDeviceLoginOutcome> {
}): Promise<DeviceLoginOutcome> {
const { input, sessionId, lease, activity } = ctx;
const profile = resolveProfile(input.adapterType);
// Serialize every status write for this session, so a late write from a
// callback never overwrites a later transition. Each write runs after the
@ -870,11 +994,20 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
}
let authBytes: Buffer | null = null;
let outcome: DeviceLoginOutcome;
let outcome: RunnerDeviceLoginOutcome;
try {
const result = await runDeviceLogin(lease.driver, {
command: CODEX_DEVICE_LOGIN_COMMAND,
timeoutMs: CODEX_DEVICE_LOGIN_TIMEOUT_MS,
command: profile.command,
timeoutMs: profile.timeoutMs,
// `RunDeviceLoginOptions.parsePrompt` keeps the runner's own required-code
// prompt shape. The profile parser returns the wider adapter-neutral
// shape, so this adapts one call's result without widening the runner's
// own `onPrompt` contract. Every `codex_local` prompt carries a code, so
// this is a no-op today.
parsePrompt: (output) => {
const prompt = profile.parsePrompt(output);
return prompt && prompt.code !== undefined ? { url: prompt.url, code: prompt.code } : null;
},
signal: input.signal,
authPath: lease.authPath,
onPrompt: (prompt) => {
@ -926,7 +1059,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
if (!credential || credential.length === 0) {
throw new Error("missing_credential");
}
await promotion.promote(credential, {
await profile.promotion.promote(credential, {
sessionId,
companyId: input.companyId,
startedByUserId: input.startedByUserId,
@ -988,7 +1121,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
sessionId: string;
lease: LoginSessionLease;
activity: (phase: LoginSessionActivityPhase) => void;
}): Promise<CodexDeviceLoginOutcome> {
}): Promise<DeviceLoginOutcome> {
const { sessionId, lease, activity } = ctx;
const observation = await observeSandboxDelete(() => lease.deleteSandbox());
if (observation.confirmed) {
@ -1020,7 +1153,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
},
) => Promise<boolean>;
activity: (phase: LoginSessionActivityPhase) => void;
}): Promise<CodexDeviceLoginOutcome> {
}): Promise<DeviceLoginOutcome> {
const { sessionId, lease, terminal, reason, expectedStatuses, conditionalTransition, activity } =
ctx;
@ -1133,7 +1266,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
return { start, readOwnerSession, cancelOwnerSession };
}
export type CodexDeviceLoginService = ReturnType<typeof createCodexDeviceLoginService>;
export type DeviceLoginService = ReturnType<typeof createDeviceLoginService>;
/** Resolve the public status of a row. A `cleanup_pending` row resolves the
* retained terminal status; every other status maps through the shared helper. */
@ -1162,13 +1295,13 @@ function buildFailure(
/** The fixed, session-specific Codex home template. The session identifier is
* server-generated, so no caller controls this path. */
export function sessionCodexHomePath(sessionId: string): string {
export function sessionLoginHomePath(sessionId: string): string {
return `/tmp/paperclip-adapter-login/${sessionId}`;
}
/** The fixed, session-specific credential path. No caller controls it. */
export function sessionCredentialPath(sessionId: string): string {
return `${sessionCodexHomePath(sessionId)}/auth.json`;
return `${sessionLoginHomePath(sessionId)}/auth.json`;
}
/**
@ -1266,7 +1399,7 @@ const CODEX_LOGIN_PTY_BIND_FAILED =
"device login failed: the sandbox pseudo-terminal transport is not bound.";
/** The dependencies the worker-bound Codex live pseudo-terminal opener needs. */
export interface CodexWorkerBoundLoginPtyOpenerDeps {
export interface WorkerBoundLoginPtyOpenerDeps {
/** The plugin worker manager that owns the host route gate. */
workerManager: LoginPtyWorkerManagerLike;
/** A non-leaking status sink. It receives only fixed status lines. */
@ -1292,8 +1425,8 @@ function readLeaseMetaString(value: unknown): string | null {
* never from the caller. It validates the session home shape before the worker
* RPC. It fails closed when the lease carries no sandbox worker binding.
*/
export function createCodexWorkerBoundLoginPtyOpener(
deps: CodexWorkerBoundLoginPtyOpenerDeps,
export function createWorkerBoundLoginPtyOpener(
deps: WorkerBoundLoginPtyOpenerDeps,
): OpenLoginPtySession {
const log = deps.log ?? (() => {});
return async (binding) => {
@ -1398,7 +1531,7 @@ export function createProductionLoginSessionRuntime(
...(record.lease.metadata ?? {}),
[LOGIN_LEASE_SESSION_TAG_KEY]: input.sessionId,
});
const sessionHome = sessionCodexHomePath(input.sessionId);
const sessionHome = sessionLoginHomePath(input.sessionId);
const authPath = sessionCredentialPath(input.sessionId);
const providerLeaseId = record.lease.providerLeaseId ?? record.lease.id;
// Resolve the live pseudo-terminal opener for this lease. When no live
@ -1422,7 +1555,7 @@ export function createProductionLoginSessionRuntime(
environment: record.environment,
lease: record.lease,
sessionHome,
timeoutMs: CODEX_DEVICE_LOGIN_TIMEOUT_MS,
timeoutMs: DEVICE_LOGIN_TIMEOUT_MS,
});
const driverKey = record.environment.driver;
return {

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { requireServerAdapter } from "../adapters/registry.js";
import {
deriveLoginSessionHome,
isLoginCommandKey,
@ -14,6 +15,7 @@ describe("resolveLoginCommandKey", () => {
it("resolves the trusted adapter type to the closed key", () => {
expect(resolveLoginCommandKey("claude_local")).toBe("claude");
expect(resolveLoginCommandKey("codex_local")).toBe("codex");
expect(resolveLoginCommandKey("grok_local")).toBe("grok");
});
it("fails closed for an unmapped adapter type", () => {
@ -22,12 +24,30 @@ describe("resolveLoginCommandKey", () => {
expect(() => resolveLoginCommandKey("gemini_local")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
expect(() => resolveLoginCommandKey("")).toThrow("LOGIN_PTY_UNSUPPORTED_ADAPTER");
});
it("stays grok when the registered adapter's own getCommand member changes", () => {
// Condition 4: the login path selects the command key from this closed map
// only, never from the adapter's own login capability. Mutating the
// registered `grok_local` capability's `getCommand` member must not change
// the key this function returns for the same adapter type.
const capability = requireServerAdapter("grok_local").loginCapability;
expect(capability).toBeDefined();
if (!capability) return;
const original = capability.getCommand;
capability.getCommand = () => "rm -rf /";
try {
expect(resolveLoginCommandKey("grok_local")).toBe("grok");
} finally {
capability.getCommand = original;
}
});
});
describe("isLoginCommandKey", () => {
it("accepts only the closed key set", () => {
expect(isLoginCommandKey("claude")).toBe(true);
expect(isLoginCommandKey("codex")).toBe(true);
expect(isLoginCommandKey("grok")).toBe(true);
expect(isLoginCommandKey("gemini")).toBe(false);
expect(isLoginCommandKey("rm -rf /")).toBe(false);
expect(isLoginCommandKey(undefined)).toBe(false);
@ -40,6 +60,7 @@ describe("isLoginCommandSupportedAdapterType", () => {
// the opener resolves. The mapped types pass; an unmapped type fails closed.
expect(isLoginCommandSupportedAdapterType("claude_local")).toBe(true);
expect(isLoginCommandSupportedAdapterType("codex_local")).toBe(true);
expect(isLoginCommandSupportedAdapterType("grok_local")).toBe(true);
expect(isLoginCommandSupportedAdapterType("gemini_local")).toBe(false);
expect(isLoginCommandSupportedAdapterType("daytona")).toBe(false);
expect(isLoginCommandSupportedAdapterType("")).toBe(false);

View File

@ -14,7 +14,7 @@
* trusted adapter type. The worker maps the key to a compile-time command. The
* union is exhaustive: a value outside it fails closed before the worker RPC.
*/
export type LoginCommandKey = "claude" | "codex";
export type LoginCommandKey = "claude" | "codex" | "grok";
/**
* The exhaustive map from the trusted adapter type to the login command key. The
@ -24,6 +24,7 @@ export type LoginCommandKey = "claude" | "codex";
const ADAPTER_TYPE_TO_LOGIN_COMMAND_KEY: Readonly<Record<string, LoginCommandKey>> = {
claude_local: "claude",
codex_local: "codex",
grok_local: "grok",
};
/** The fixed non-secret error an unsupported adapter type returns. */
@ -47,7 +48,7 @@ export function resolveLoginCommandKey(adapterType: string): LoginCommandKey {
/** Reports whether a value is a member of the closed login command key set. */
export function isLoginCommandKey(value: unknown): value is LoginCommandKey {
return value === "claude" || value === "codex";
return value === "claude" || value === "codex" || value === "grok";
}
/**

View File

@ -16,9 +16,9 @@ const ALL_FALSE: AdapterCapabilities = {
* Synchronous fallback for known built-in adapter types so capability checks
* return correct values on first render before the /api/adapters call resolves.
*
* The `login` value for `claude_local` and `codex_local` mirrors the server's
* login capability declaration in `server/src/adapters/registry.ts`. Reconcile
* the two together if either adapter's login flow changes.
* The `login` value for `claude_local`, `codex_local`, and `grok_local` mirrors
* the server's login capability declaration in `server/src/adapters/registry.ts`.
* Reconcile the two together if any adapter's login flow changes.
*/
const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true, login: { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" } },
@ -26,7 +26,7 @@ const KNOWN_DEFAULTS: Record<string, AdapterCapabilities> = {
paperclip_runner: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false, supportsAcp: false },
cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false },
gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: true },
grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false },
grok_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false, login: { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" } },
kimi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: true },
opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true, supportsAcp: false },
pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false, supportsAcp: false },

View File

@ -123,6 +123,7 @@ const mockLoginProjections = vi.hoisted(
() =>
new Map<string, { panelMode: string; timeoutPolicy: string }>([
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
["grok_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
// A third adapter, not a built-in, with a projected displayed-code login.
["vendor_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
@ -365,6 +366,24 @@ const VENDOR_AUTH_MISSING_RESULT = {
testedAt: new Date(0).toISOString(),
};
const GROK_AUTH_MISSING_RESULT = {
adapterType: "grok_local",
status: "warn",
checks: [
{
code: "grok_hello_probe_auth_required",
level: "warn",
message: "Grok CLI could not answer the hello probe because authentication is missing.",
},
{
code: "adapter_auth_missing",
level: "warn",
message: "This environment has no ready authentication for this adapter.",
},
],
testedAt: new Date(0).toISOString(),
};
const PTY_VENDOR_AUTH_MISSING_RESULT = {
adapterType: "pty_vendor_local",
status: "fail",
@ -451,6 +470,26 @@ async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
);
}
// A Grok agent in a sandbox environment. Its projected login capability
// drives the login affordance and the displayed-code panel, the same as
// Codex. The provider advertises the login pseudo-terminal capability the
// login needs.
async function renderGrokSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
{ adapterType: "grok_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
async function renderClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
@ -1107,6 +1146,26 @@ describe("AgentConfigForm environment selector", () => {
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
});
it("shows the login affordance and the displayed-code panel for a Grok sandbox with a projected login capability", async () => {
// The panel dispatcher reads the projected `displayed_code` mode from the
// capability, the same way it does for Codex. It shows the Grok code and
// URL.
mockAgentsApi.testEnvironment.mockResolvedValue(GROK_AUTH_MISSING_RESULT);
const result = await renderGrokSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
await startLogin(result.container);
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
});
it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Claude sandbox", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();

View File

@ -19,6 +19,7 @@ export default defineConfig({
"packages/adapters/pi-local",
"packages/plugins/sdk",
"packages/plugins/create-paperclip-plugin",
"packages/plugins/sandbox-providers/daytona",
"server",
"ui",
"cli",