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:
parent
cec675ffca
commit
a20a4944ec
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
[90mOnly continue with a code you requested. Don't share it with anyone.[0m
|
||||
|
||||
Waiting for authorization...
|
||||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue