Add sandbox device-login for the Codex adapter (#11237)

## Thinking Path

> - Paperclip helps people manage AI agents for work.
> - Agent adapters connect Paperclip to tools such as the Codex command
line tool.
> - A sandboxed Codex agent may start without a credential.
> - The operator needs a safe sign-in flow that does not expose
credentials to the shared package or the sandbox.
> - This pull request adds a company-scoped device-login flow with a
temporary Daytona sandbox.
> - The flow promotes the credential only after readiness checks pass
and removes the temporary sandbox after use.
> - The result lets an operator sign in to a sandboxed Codex agent from
the agent form.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (multiple of the above)

**Problem or motivation**

A Codex adapter that runs in a sandbox cannot authenticate when the
company has no pre-provisioned Codex credential.

**Proposed solution**

Add a company-scoped device-login session. Start a temporary sandbox,
run `codex login --device-auth`, stream the code and URL, verify
readiness, promote the credential, and delete the sandbox.

**Alternatives considered**

Pre-provisioning a credential does not support first-time sandbox login.
Keeping the credential in the login sandbox does not provide a durable
company credential.

**Roadmap alignment**

This supports the roadmap item for cloud and sandbox agents.

**Additional context**

The flow uses a five-minute cleanup reaper, compare-and-set status
changes, and a PostgreSQL advisory lock to protect promotion and
cleanup.

## What Changed

- Add the adapter login-session contract, database table, and migration.
- Add company-scoped server routes and a service for sandbox device
login.
- Add credential promotion, readiness checks, and cleanup after login.
- Add restart-safe cleanup for abandoned login sandboxes.
- Add sandbox login controls to the agent creation and edit forms.
- Keep device-login and vendor identifiers out of public shared and
adapter UI symbols.

## Verification

- `pnpm --filter @paperclipai/adapter-codex-local exec vitest run`
passed with 310 tests at the submitted commit.
- The server login route, service, and reaper tests passed with 45 tests
at the submitted commit.
- The agent form render tests passed with 26 tests at the submitted
commit.
- The public-symbol leak check passed at the submitted commit.
- A live Daytona sign-in flow still requires confirmation by a user with
a live sandbox.

## Risks

The migration adds a new company-scoped table. A promotion or cleanup
race could remove a credential or leave a sandbox active, so the service
uses claims, compare-and-set transitions, and an advisory lock. The live
Daytona flow needs operator confirmation because local tests do not
provide a real browser sign-in.

## Model Used

OpenAI Codex, GPT-5, tool use and code execution, extended reasoning.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-08-12 08:58:25 -07:00 committed by GitHub
parent 9c941169a6
commit e5a7fd7038
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 84665 additions and 133 deletions

View File

@ -617,6 +617,65 @@ describe("codex_local ACP lane", () => {
);
});
it("emits the canonical adapter_auth_missing check for a missing-auth sandbox target", async () => {
const root = await makeTempRoot("paperclip-codex-acp-sandbox-missing-auth-");
const sharedCodexHome = path.join(root, "shared-codex-home");
const managedAgentHome = path.join(
root,
"paperclip-home",
"instances",
"test",
"companies",
"company-1",
"agents",
"agent-1",
"codex-home",
);
await fs.mkdir(sharedCodexHome, { recursive: true });
setNodeVersion("v22.13.0");
process.env.CODEX_HOME = sharedCodexHome;
delete process.env.OPENAI_API_KEY;
const result = await testCodexAcpEnvironment({
adapterType: "codex_local",
companyId: "company-1",
config: {
engine: "acp",
cwd: root,
// A shell-style command resolves without a real binary in the sandbox.
agentCommand: "node ./fake-acp.js",
env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" },
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fake-plugin",
remoteCwd: "/work",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
} as never,
});
// A missing-auth sandbox is a warning, not a failure, and it carries the
// neutral canonical check code for the user interface.
expect(result.status).toBe("warn");
expect(result.checks).toContainEqual(
expect.objectContaining({
code: "adapter_auth_missing",
level: "warn",
}),
);
});
it("executes through ACPX with Codex session config and ephemeral skills", async () => {
const root = await makeTempRoot("paperclip-codex-acp-exec-");
const skill = await createRuntimeSkill(root);

View File

@ -44,6 +44,7 @@ import {
resolveSharedCodexHomeDir,
stageCodexHomeForSync,
} from "./codex-home.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const packageRootDir = path.resolve(moduleDir, "../..");
@ -499,6 +500,7 @@ export async function testCodexAcpEnvironment(
const config = parseObject(ctx.config);
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
checks.push({
code: "codex_engine_selected",
@ -607,6 +609,29 @@ export async function testCodexAcpEnvironment(
hint: "Set OPENAI_API_KEY in the agent adapter env, set it in the Paperclip server environment, or run `codex login` for the same OS user that runs the Paperclip server before starting a Codex ACP agent. A `/login` in a separate Codex/chat session does not authenticate the server.",
});
}
} else if (targetIsSandbox) {
// The ACP Test does not probe the sandbox, so it predicts readiness from the
// credentials the Paperclip server can seed into the sandbox. The host
// environment is not seeded, so only the adapter config key counts here.
const configApiKey = isNonEmpty(envConfig.OPENAI_API_KEY) ? envConfig.OPENAI_API_KEY : null;
const configuredCodexHome = isNonEmpty(envConfig.CODEX_HOME) ? envConfig.CODEX_HOME : null;
const credentialReadiness = await evaluateCodexCredentialReadiness({
env: process.env,
companyId: ctx.companyId,
configuredCodexHome,
configuredApiKey: configApiKey,
});
if (!credentialReadiness.ready) {
// 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: "The sandbox has no ready authentication for this adapter.",
hint: "Provide credentials for this adapter, or start login in the sandbox.",
});
}
}
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;

View File

@ -0,0 +1,474 @@
import { chmod, lstat, mkdir, mkdtemp, 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 {
checkStagedCredentialReadiness,
DeviceLoginReadinessError,
promoteDeviceLoginCredential,
type CredentialReadinessResult,
} from "./adapter-auth-promotion.js";
import { MAX_AUTH_JSON_BYTES } from "./device-login-export.js";
import { resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexAuthCacheDir, resolveCodexAuthCacheEntryPath } from "./codex-auth-cache.js";
const COMPANY_A = "company-a";
const COMPANY_B = "company-b";
const NEWER = "2026-07-09T02:00:00Z";
const OLDER = "2026-07-09T01:00:00Z";
const ACCOUNT = "acct-42";
const OTHER_ACCOUNT = "acct-99";
const TOKEN_SENTINEL = "SENTINEL_TOKEN_XYZ";
// This suite proves the device-login credential promotion helper. The helper
// runs an independent readiness check on the exact staged credential first, then
// validates it with the export rules, then writes only the company-scoped
// credential home and the company-scoped cache. It writes only while the session
// holds the sole active claim on the slot (the conditional check). It never
// writes the instance-global host, and it never logs secret bytes.
describe("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-codex-promotion-"));
cleanupDirs.push(dir);
return dir;
}
function envFor(instanceHome: string, extra: Record<string, string> = {}): NodeJS.ProcessEnv {
return {
PAPERCLIP_HOME: instanceHome,
PAPERCLIP_INSTANCE_ID: "default",
// A fixed shared host home, so a test can assert the helper never writes it.
CODEX_HOME: path.join(instanceHome, "shared-codex"),
...extra,
};
}
function subscriptionAuth(input: {
accountId: string;
lastRefresh?: string;
marker?: string;
}): Buffer {
const suffix = input.marker ?? input.accountId;
return Buffer.from(
JSON.stringify({
tokens: {
id_token: `id-token-${suffix}`,
access_token: `access-token-${suffix}`,
refresh_token: `${TOKEN_SENTINEL}-${suffix}`,
account_id: input.accountId,
},
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
}),
);
}
const ready = (): CredentialReadinessResult => ({ ready: true });
const notReady = (): CredentialReadinessResult => ({ ready: false, reason: "auth_unusable" });
const soleOwner = () => true;
const noopLog = (_line: string): void => {};
function companyHomeAuthPath(env: NodeJS.ProcessEnv, companyId: string): string {
return path.join(resolveManagedCodexHomeDir(env, companyId), "auth.json");
}
async function readIfPresent(target: string): Promise<string | null> {
return readFile(target, "utf8").catch(() => null);
}
it("a failed readiness check rejects and writes neither the company home nor the cache", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const logs: string[] = [];
await expect(
promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: notReady,
isSoleActiveOwner: soleOwner,
env,
log: (line) => {
logs.push(line);
},
}),
).rejects.toBeInstanceOf(DeviceLoginReadinessError);
// Neither company target was written.
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
await expect(
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
).rejects.toThrow();
// The instance-global host was never touched.
await expect(
lstat(path.join(resolveSharedCodexHomeDir(env), "auth.json")),
).rejects.toThrow();
// No secret bytes reached the log.
expect(logs.join("\n")).not.toContain(TOKEN_SENTINEL);
expect(logs.join("\n")).not.toContain(ACCOUNT);
});
it("a user login seeds an empty company home and cache slot for the new identity", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
const homeAuth = await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8");
expect(JSON.parse(homeAuth).tokens.account_id).toBe(ACCOUNT);
const cacheAuth = await readFile(
resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A),
"utf8",
);
expect(JSON.parse(cacheAuth).tokens.account_id).toBe(ACCOUNT);
// The instance-global host was never seeded.
await expect(
lstat(path.join(resolveSharedCodexHomeDir(env), "auth.json")),
).rejects.toThrow();
});
it("a strictly-newer same-identity login updates the company home", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "old" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "new" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
expect(homeAuth.last_refresh).toBe(NEWER);
expect(homeAuth.tokens.refresh_token).toContain("new");
});
it("an older same-identity login keeps the company home", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "keep" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "older" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("kept");
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
expect(homeAuth.tokens.refresh_token).toContain("keep");
});
it("a different-identity login keeps the home and reports a foreign-identity outcome", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "first" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: OTHER_ACCOUNT, lastRefresh: NEWER, marker: "other" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
// The home keeps the first identity. The login did not install the other
// account, so the outcome is `kept_foreign_identity`, not a plain `kept`: the
// caller must fail the session instead of a report of `authenticated`. The
// other identity still lands in its own per-identity cache slot.
expect(outcome).toBe("kept_foreign_identity");
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
expect(homeAuth.tokens.account_id).toBe(ACCOUNT);
const otherCache = JSON.parse(
await readFile(resolveCodexAuthCacheEntryPath(env, OTHER_ACCOUNT, COMPANY_A), "utf8"),
);
expect(otherCache.tokens.account_id).toBe(OTHER_ACCOUNT);
});
it("the cache off-switch skips the cache slot but still seeds the company home", async () => {
const home = await makeInstanceRoot();
const env = envFor(home, { PAPERCLIP_CODEX_AUTH_CACHE: "off" });
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("promoted");
// The company home was seeded.
expect(await readIfPresent(companyHomeAuthPath(env, COMPANY_A))).toContain(ACCOUNT);
// The cache slot was not written.
await expect(
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
).rejects.toThrow();
});
it("a cache write failure keeps the promotion successful and the company home durable", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
// Force the per-identity cache write to fail. Plant a regular file where the
// cache expects the identity directory, so the private-directory guard throws
// before the cache slot is written. The company home write runs first, so the
// credential is already durable when the cache write fails.
const cacheDir = resolveCodexAuthCacheDir(env, COMPANY_A);
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
const entryPath = resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A);
await writeFile(path.dirname(entryPath), "not-a-directory");
const logs: string[] = [];
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: (line) => {
logs.push(line);
},
});
// The company home holds a usable credential, so the promotion still reports
// success rather than a failed login.
expect(outcome).toBe("promoted");
const homeAuth = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
expect(homeAuth.tokens.account_id).toBe(ACCOUNT);
// The cache failure is observable and carries no secret bytes.
const haystack = logs.join("\n");
expect(haystack).toContain("the per-identity cache write failed");
expect(haystack).not.toContain(TOKEN_SENTINEL);
expect(haystack).not.toContain(ACCOUNT);
});
it("rejects malformed, API-key, non-subscription, and oversized credentials and writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const malformed = Buffer.from("this is not json {");
const apiKey = Buffer.from(JSON.stringify({ OPENAI_API_KEY: "sk-secret-key" }));
const nonSubscription = Buffer.from(JSON.stringify({ tokens: { account_id: "" } }));
const oversized = Buffer.concat([
subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
Buffer.alloc(MAX_AUTH_JSON_BYTES + 10, 0x20),
]);
for (const bytes of [malformed, apiKey, nonSubscription, oversized]) {
await expect(
promoteDeviceLoginCredential({
authBytes: bytes,
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
}),
).rejects.toThrow();
}
// No company target and no instance-global host was written.
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
await expect(
lstat(path.join(resolveSharedCodexHomeDir(env), "auth.json")),
).rejects.toThrow();
});
it("a promotion whose session is no longer the sole active owner writes nothing", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
// The session lost the active claim on the slot.
isSoleActiveOwner: () => false,
env,
log: noopLog,
});
expect(outcome).toBe("not_sole_owner");
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
await expect(
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
).rejects.toThrow();
});
it("an automatic background login never seeds an empty company slot", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const outcome = await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
// Not a user-initiated login.
userInitiated: false,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(outcome).toBe("background_skipped");
await expect(lstat(companyHomeAuthPath(env, COMPANY_A))).rejects.toThrow();
await expect(
lstat(resolveCodexAuthCacheEntryPath(env, ACCOUNT, COMPANY_A)),
).rejects.toThrow();
});
it("a Company A login never changes a Company B credential", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
// Seed Company B first.
await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "b-cred" }),
companyId: COMPANY_B,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
const before = await readFile(companyHomeAuthPath(env, COMPANY_B), "utf8");
// A Company A login for the same identity, even newer, must not touch B.
await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "a-cred" }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
});
expect(await readFile(companyHomeAuthPath(env, COMPANY_B), "utf8")).toBe(before);
const aHome = JSON.parse(await readFile(companyHomeAuthPath(env, COMPANY_A), "utf8"));
expect(aHome.tokens.refresh_token).toContain("a-cred");
});
it("the promotion log never contains token bytes or a raw account_id", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
const logs: string[] = [];
await promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: COMPANY_A,
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: (line) => {
logs.push(line);
},
});
const haystack = logs.join("\n");
expect(haystack).not.toContain(TOKEN_SENTINEL);
expect(haystack).not.toContain(ACCOUNT);
});
it("rejects an empty companyId, so a promotion can never reach the instance-global home", async () => {
const home = await makeInstanceRoot();
const env = envFor(home);
await expect(
promoteDeviceLoginCredential({
authBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
companyId: "",
userInitiated: true,
checkReadiness: ready,
isSoleActiveOwner: soleOwner,
env,
log: noopLog,
}),
).rejects.toThrow();
// The instance-global codex-home was never seeded.
await expect(
lstat(path.join(resolveManagedCodexHomeDir(env), "auth.json")),
).rejects.toThrow();
});
});
// This suite proves the independent readiness check for a staged credential. It
// answers whether a run launched now with the exact staged bytes would
// authenticate. It writes the bytes to a throwaway home only, and it never reads
// or writes any company scope.
describe("staged credential readiness", () => {
it("returns ready for a usable subscription credential", async () => {
const bytes = Buffer.from(
JSON.stringify({
tokens: {
id_token: "id-token",
access_token: "access-token",
refresh_token: "refresh-token",
account_id: "acct-1",
},
}),
);
const result = await checkStagedCredentialReadiness(bytes);
expect(result.ready).toBe(true);
});
it("returns not ready for empty bytes", async () => {
const result = await checkStagedCredentialReadiness(Buffer.alloc(0));
expect(result.ready).toBe(false);
expect(result.reason).toBe("empty_credential");
});
it("returns not ready for a credential with no usable auth payload", async () => {
const result = await checkStagedCredentialReadiness(
Buffer.from(JSON.stringify({ tokens: { account_id: "acct-1" } })),
);
expect(result.ready).toBe(false);
expect(result.reason).toBe("no_usable_auth");
});
it("returns not ready for malformed bytes", async () => {
const result = await checkStagedCredentialReadiness(Buffer.from("not-json"));
expect(result.ready).toBe(false);
expect(result.reason).toBe("no_usable_auth");
});
});

View File

@ -0,0 +1,273 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
ensureCodexAuthCacheEntryDir,
isCodexAuthCacheEnabled,
readSubscriptionAccountId,
writeCodexAuthCacheEntry,
} from "./codex-auth-cache.js";
import { writeCredentialSeedOrNewer } from "./codex-auth-seed-write.js";
import { codexHomeHasUsableAuth, resolveManagedCodexHomeDir } from "./codex-home.js";
import { assertUsableSubscriptionShape } from "./device-login-export.js";
// The device-login credential promotion. It runs after a successful device
// login, on the exact credential the login sandbox produced. It runs an
// independent readiness check first, then validates the credential with the
// export rules, then writes the credential into the company scope only.
//
// The helper never writes the instance-global host (`CODEX_HOME` or `~/.codex`),
// because that source has no `companyId`. It writes two company-scoped targets:
// the company credential home and the company per-identity cache slot. The
// company home write is the first-login host rule: the cache vend never seeds an
// empty host, so a first login must seed the company home directly.
//
// Two decisions gate the write:
// - Decision C: only a user-initiated login seeds the company slot. An
// automatic background path never seeds an empty slot.
// - Decision H: the helper writes only while the session still holds the sole
// active claim on `(company_id, adapter_type)`. This is defense in depth over
// the partial unique index, so a second session cannot race the same slot.
//
// The helper preserves company scope, private modes, atomic rename, the
// directory lock, and redacted logs. It never logs token bytes or a raw
// `account_id`.
const AUTH_FILE_NAME = "auth.json";
// A private directory (owner rwx only). 0o700 has no group or other bits.
const PRIVATE_DIR_MODE = 0o700;
/** 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(`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 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.
*
* A ready result means a run launched now with this exact credential would
* authenticate. A non-ready result rejects the promotion, and the caller writes
* nothing. The function reads and writes no company scope and logs no bytes.
*/
export async function checkStagedCredentialReadiness(
authBytes: Buffer,
): Promise<CredentialReadinessResult> {
if (authBytes.length === 0) {
return { ready: false, reason: "empty_credential" };
}
const scratchHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-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 codexHomeHasUsableAuth(scratchHome);
return ready ? { ready: true } : { ready: false, reason: "no_usable_auth" };
} finally {
await rm(scratchHome, { recursive: true, force: true }).catch(() => {});
}
}
/**
* The promotion outcome.
*
* - `promoted`: the helper wrote the company home (a seed or a strictly-newer
* same-identity update). The helper then tries the per-identity cache slot when
* the cache is on. The cache write is best-effort: a cache failure keeps the
* `promoted` outcome, because the company home is already durable.
* - `kept`: the login carried the SAME identity as the company home, and the home
* already held a newer same-identity credential, so the home was kept. This is a
* successful authentication: a later run vends and uses the same account. The
* helper still tries the best-effort cache write.
* - `kept_foreign_identity`: the login carried a DIFFERENT identity than the
* company home. The helper never clobbers an occupied home, so it kept the other
* account and installed nothing durable for this login. The identity-anchored
* vend reads the home identity first, so a later run can never select this
* login. This is NOT a successful authentication; the caller must fail the
* session instead of a report of `authenticated`.
* - `not_sole_owner`: Decision H rejected the write. Nothing was written.
* - `background_skipped`: Decision C rejected the write (an automatic background
* path never seeds a company slot). Nothing was written.
*/
export type PromoteDeviceLoginCredentialOutcome =
| "promoted"
| "kept"
| "kept_foreign_identity"
| "not_sole_owner"
| "background_skipped";
export interface PromoteDeviceLoginCredentialInput {
/** 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. Decision C: 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 with the normal execution precedence, before any write. A
* non-ready result rejects the promotion (the session fails), and the helper
* writes nothing.
*/
checkReadiness: (
authBytes: Buffer,
) => Promise<CredentialReadinessResult> | CredentialReadinessResult;
/**
* Decision H: resolves true only while this session still holds the sole active
* claim on `(company_id, adapter_type)` (the internal `promoting` state). 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 (`resolveManagedCodexHomeDir` 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("device-login promotion: companyId is empty");
}
if (trimmed === "." || trimmed === "..") {
throw new Error("device-login promotion: companyId is a relative path segment");
}
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
throw new Error("device-login promotion: companyId contains a path separator");
}
return trimmed;
}
/**
* Promotes a device-login credential into the company scope. The order is fixed:
* readiness check, credential validation, Decision C, Decision H, then the writes.
* The readiness check and the writes run while the caller still holds the active
* claim, so a second session cannot race the same slot.
*/
export async function promoteDeviceLoginCredential(
input: PromoteDeviceLoginCredentialInput,
): Promise<PromoteDeviceLoginCredentialOutcome> {
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 with the export rules. This rejects an empty, an
// oversized, an API-key, a non-subscription, and a malformed payload.
assertUsableSubscriptionShape(authBytes);
const accountId = readSubscriptionAccountId(authBytes);
if (!accountId) {
// The shape gate above already guarantees a subscription identity; this guard
// keeps the account_id non-null for the cache key without a non-null cast.
throw new Error("device-login promotion: the credential has no subscription identity");
}
// 3. Decision C: only a user-initiated login seeds the company slot.
if (!userInitiated) {
await log("[paperclip] Codex device-login promotion: skipped (an automatic background login never seeds a company slot).");
return "background_skipped";
}
// 4. Decision H: write only while the session still owns the active slot.
const soleOwner = await isSoleActiveOwner();
if (!soleOwner) {
await log("[paperclip] Codex device-login promotion: skipped (the session no longer holds the sole active claim on the slot).");
return "not_sole_owner";
}
// 5a. First-login host rule: seed or update the company credential home. The
// shared writer seeds an empty home and applies a strictly-newer
// same-identity update; it keeps a newer same-identity or a different
// identity. It never touches the instance-global host.
const companyHome = resolveManagedCodexHomeDir(env, companyId);
await mkdir(companyHome, { recursive: true, mode: PRIVATE_DIR_MODE });
const companyHomeAuthPath = path.join(companyHome, AUTH_FILE_NAME);
const homeOutcome = await writeCredentialSeedOrNewer({
sourceBytes: authBytes,
destinationPath: companyHomeAuthPath,
seedIfDestAbsent: true,
log,
writtenLine: "[paperclip] Codex device-login promotion: wrote the company credential home at mode 0600.",
keptLine: "[paperclip] Codex device-login promotion: kept the company credential home (the login is not a seed or a strictly-newer same-identity credential).",
tempPrefix: "auth.json.promotion-home",
errorLabel: "codex device-login promotion",
});
// A kept home has two very different meanings. The writer keeps the home when
// it already holds a newer SAME-identity credential (a genuine success: a later
// run vends and uses the same account), and it also keeps the home when the home
// holds a DIFFERENT identity (the writer never clobbers an occupied home). The
// second case installed nothing durable for this login: the home still holds the
// other account, and the identity-anchored vend reads the home identity first,
// so it can never select this login. Compare the login identity with the kept
// home identity, so the caller can fail the session instead of a report of
// `authenticated`. A home that this helper cannot read as the same identity is
// treated as a foreign identity; the caller fails closed.
let foreignIdentityKeep = false;
if (homeOutcome === "kept") {
const homeBytes = await readFile(companyHomeAuthPath).catch(() => null);
const homeAccountId = homeBytes ? readSubscriptionAccountId(homeBytes) : null;
foreignIdentityKeep = homeAccountId !== accountId;
}
// 5b. Record the credential in its per-identity company cache slot, so a later
// run can vend a strictly-newer copy. The cache write respects the
// off-switch. It is company-scoped and per identity, so it never crosses a
// company boundary and never clobbers a different identity.
//
// The company home write above already made the credential durable, so a
// later run authenticates from the home even when the cache slot is absent.
// The cache is only a vend optimization. So a cache write failure (a
// permission error, a full disk, or a lock timeout) must not fail the
// promotion, or the operator sees a failed login for a credential that is
// already usable. Log the failure and keep the home outcome; a later run
// re-seeds the cache slot from the durable home.
if (isCodexAuthCacheEnabled(env)) {
try {
const cacheEntryPath = await ensureCodexAuthCacheEntryDir(env, accountId, companyId);
await writeCodexAuthCacheEntry({ sandboxAuthBytes: authBytes, cacheEntryPath, log });
} catch {
await log(
"[paperclip] Codex device-login promotion: the per-identity cache write failed; the company credential home is durable, so the login stays successful.",
);
}
}
if (homeOutcome === "written") {
return "promoted";
}
return foreignIdentityKeep ? "kept_foreign_identity" : "kept";
}

View File

@ -0,0 +1,10 @@
/**
* The canonical check code both Codex Test engines emit when a sandbox target
* has no ready authentication. The user interface reads this stable code to
* decide login eligibility. The user interface does not read the message text
* or the top-level status.
*
* The name is neutral. It carries no vendor name and no login-flow word, because
* a Test result can sync to public packages.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";

View File

@ -1,13 +1,10 @@
import { execFile as execFileCallback } from "node:child_process";
import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
const execFile = promisify(execFileCallback);
import { USE_SOURCE_EXIT, decideCodexAuthMerge } from "./codex-auth-merge-decision.js";
import { writeCredentialSeedOrNewer } from "./codex-auth-seed-write.js";
// The identity-keyed host credential cache keeps one usable subscription
// credential per identity (`account_id`) in a SEPARATE host store, outside the
@ -30,19 +27,11 @@ export const CODEX_AUTH_CACHE_OFF_SWITCH_ENV = "PAPERCLIP_CODEX_AUTH_CACHE";
const FALSY_ENV_RE = /^(0|false|no|off)$/i;
// The cache reuses the same direction-agnostic decision predicate the copy-back
// and inbound restore run. The predicate answers one question — "should the
// caller replace `destination` with `source`?" — purely by argument order (first
// = source, second = destination). Exit 10 = use source; exit 20 = keep
// destination. A leading `--seed-if-dest-absent` flag adds one opt-in behaviour:
// fill an ABSENT destination slot from a usable subscription source. The
// predicate only reads the two files and exits with a code; it never prints
// token bytes.
const DECISION_SCRIPT_PATH = fileURLToPath(
new URL("./codex-auth-merge-decision.cjs", import.meta.url),
);
const SEED_IF_DEST_ABSENT_FLAG = "--seed-if-dest-absent";
const USE_SOURCE_EXIT = 10;
const KEEP_DESTINATION_EXIT = 20;
// and inbound restore run, through the shared `decideCodexAuthMerge` entry point.
// The predicate answers one question — "should the caller replace `destination`
// with `source`?" — purely by argument order (first = source, second =
// destination). Exit 10 = use source; exit 20 = keep destination. The opt-in seed
// mode fills an ABSENT destination slot from a usable subscription source.
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@ -218,75 +207,32 @@ export function readSubscriptionAccountId(bytes: Buffer): string | null {
return accountId;
}
async function decideExitCode(
sourcePath: string,
destinationPath: string,
options: { seedIfDestAbsent?: boolean } = {},
): Promise<number> {
const args = options.seedIfDestAbsent
? [DECISION_SCRIPT_PATH, SEED_IF_DEST_ABSENT_FLAG, sourcePath, destinationPath]
: [DECISION_SCRIPT_PATH, sourcePath, destinationPath];
try {
await execFile("node", args);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === USE_SOURCE_EXIT || code === KEEP_DESTINATION_EXIT) {
return code;
}
const detail =
typeof code === "string"
? `node could not be executed (${code})`
: typeof code === "number"
? `unexpected predicate exit code ${code}`
: error instanceof Error
? error.message
: String(error);
throw new Error(`codex auth cache decision predicate failed: ${detail}`);
}
throw new Error("codex auth cache decision predicate exited 0 (expected 10 or 20)");
}
export type WriteCodexAuthCacheEntryOutcome = "written" | "kept-slot";
/**
* Writes `sandboxAuthBytes` into its per-identity cache slot when the slot is
* absent, or when the source is a strictly-newer same-identity subscription
* credential (Phase 2 seed mode). The mutation runs under the merge lock on the
* slot directory. Never logs token bytes or a raw `account_id`.
* credential (seed mode). The mutation runs under the merge lock on the slot
* directory, through the shared {@link writeCredentialSeedOrNewer} writer. Never
* logs token bytes or a raw `account_id`.
*/
export async function writeCodexAuthCacheEntry(input: {
sandboxAuthBytes: Buffer;
cacheEntryPath: string;
log: (line: string) => void | Promise<void>;
}): Promise<WriteCodexAuthCacheEntryOutcome> {
const { sandboxAuthBytes, cacheEntryPath, log } = input;
const cacheEntryDir = path.dirname(cacheEntryPath);
return withDirectoryMergeLock(cacheEntryDir, async () => {
const stagedTempPath = path.join(
cacheEntryDir,
`.auth.json.cache-source-${process.pid}-${randomUUID()}.tmp`,
);
const handle = await open(stagedTempPath, "wx", 0o600);
try {
await handle.writeFile(sandboxAuthBytes);
await handle.close();
const decision = await decideExitCode(stagedTempPath, cacheEntryPath, {
seedIfDestAbsent: true,
});
if (decision === USE_SOURCE_EXIT) {
await rename(stagedTempPath, cacheEntryPath);
await log("[paperclip] Codex auth cache: wrote the per-identity cache slot at mode 0600.");
return "written";
}
await log(
"[paperclip] Codex auth cache: kept the cache slot (source is not a strictly-newer same-identity subscription credential).",
);
return "kept-slot";
} finally {
await handle.close().catch(() => undefined);
await rm(stagedTempPath, { force: true }).catch(() => undefined);
}
const outcome = await writeCredentialSeedOrNewer({
sourceBytes: input.sandboxAuthBytes,
destinationPath: input.cacheEntryPath,
seedIfDestAbsent: true,
log: input.log,
writtenLine: "[paperclip] Codex auth cache: wrote the per-identity cache slot at mode 0600.",
keptLine:
"[paperclip] Codex auth cache: kept the cache slot (source is not a strictly-newer same-identity subscription credential).",
tempPrefix: "auth.json.cache-source",
errorLabel: "codex auth cache",
});
return outcome === "written" ? "written" : "kept-slot";
}
export type VendCodexAuthOutcome = "vended" | "kept-host" | "no-host-identity";
@ -358,7 +304,9 @@ export async function selectVendCredential(
// Default-mode predicate: install the cache copy only when it is strictly
// newer for the SAME identity. Same-identity + strictly-newer keeps the
// change additive and semantics-preserving.
const decision = await decideExitCode(stagedTempPath, sharedHomeAuthPath);
const decision = await decideCodexAuthMerge(stagedTempPath, sharedHomeAuthPath, {
errorLabel: "codex auth cache",
});
if (decision === USE_SOURCE_EXIT) {
await rename(stagedTempPath, sharedHomeAuthPath);
await log(

View File

@ -1,8 +1,5 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdir, open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
import {
@ -10,23 +7,17 @@ import {
readSubscriptionAccountId,
writeCodexAuthCacheEntry,
} from "./codex-auth-cache.js";
const execFile = promisify(execFileCallback);
import { USE_SOURCE_EXIT, decideCodexAuthMerge } from "./codex-auth-merge-decision.js";
// The outbound copy-back reuses the exact same direction-agnostic decision
// predicate the inbound restore runs (`codex-auth-merge-decision.cjs`). The
// predicate answers one question — "should the caller replace `destination`
// with `source`?" — purely by argument order (first = source, second =
// destination). For the copy-back the sandbox credential is the `source` and
// the shared host credential is the `destination`, so exit 10 (use source)
// means "install the sandbox copy onto the host" and exit 20 (keep destination)
// means "leave the host copy untouched". The predicate only ever reads the two
// files and exits with a code; it never prints token bytes.
const DECISION_SCRIPT_PATH = fileURLToPath(
new URL("./codex-auth-merge-decision.cjs", import.meta.url),
);
const USE_SOURCE_EXIT = 10;
const KEEP_DESTINATION_EXIT = 20;
// predicate the inbound restore runs, through the shared `decideCodexAuthMerge`
// entry point. The predicate answers one question — "should the caller replace
// `destination` with `source`?" — purely by argument order (first = source,
// second = destination). For the copy-back the sandbox credential is the
// `source` and the shared host credential is the `destination`, so exit 10 (use
// source) means "install the sandbox copy onto the host" and exit 20 (keep
// destination) means "leave the host copy untouched". The predicate only ever
// reads the two files and exits with a code; it never prints token bytes.
/** Outcome of a copy-back attempt. No token material is ever surfaced. */
export type CopyBackCodexAuthOutcome = "copied" | "kept-host";
@ -61,35 +52,6 @@ export interface CopyBackCodexAuthInput {
env?: NodeJS.ProcessEnv;
}
async function decideExitCode(sourcePath: string, destinationPath: string): Promise<number> {
try {
await execFile("node", [DECISION_SCRIPT_PATH, sourcePath, destinationPath]);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === USE_SOURCE_EXIT || code === KEEP_DESTINATION_EXIT) {
return code;
}
// A non-numeric `code` (e.g. "ENOENT" when node is not on PATH) or any exit
// code other than 10/20 is a hard failure — fail loud so a broken predicate
// is never mistaken for a "keep host" decision.
const detail =
typeof code === "string"
? `node could not be executed (${code})`
: typeof code === "number"
? `unexpected predicate exit code ${code}`
: error instanceof Error
? error.message
: String(error);
throw new Error(`codex auth copy-back decision predicate failed: ${detail}`);
}
// Reached only when `execFile` resolved — i.e. the predicate exited 0. The
// predicate always exits 10 or 20, so a clean exit 0 is unexpected; throw
// directly here, outside the try/catch, so this already self-explanatory
// message is not re-wrapped by the catch's "...failed:" prefix.
throw new Error("codex auth copy-back decision predicate exited 0 (expected 10 or 20)");
}
/**
* Guards, locks, and atomically installs a strictly-newer sandbox Codex
* `auth.json` onto the shared host credential at teardown.
@ -146,7 +108,9 @@ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise<
await handle.writeFile(sandboxAuthBytes);
await handle.close();
const decision = await decideExitCode(stagedTempPath, hostAuthPath);
const decision = await decideCodexAuthMerge(stagedTempPath, hostAuthPath, {
errorLabel: "codex auth copy-back",
});
if (decision === USE_SOURCE_EXIT) {
// Atomic same-directory swap; rename preserves the temp's 0600 mode.
await rename(stagedTempPath, hostAuthPath);

View File

@ -0,0 +1,73 @@
import { execFile as execFileCallback } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFile = promisify(execFileCallback);
// The single identity-and-freshness predicate. Every credential writer that must
// answer "should the caller replace `destination` with `source`?" runs this one
// predicate: the inbound restore, the outbound copy-back, the per-identity cache
// slot, and the device-login promotion. The predicate lives in
// `codex-auth-merge-decision.cjs`. It reads only the two files, and it exits with
// a code; it never prints token bytes. Argument order sets source and
// destination (first = source, second = destination), so the caller frames the
// direction. Exit 10 = use source; exit 20 = keep destination. The leading
// `--seed-if-dest-absent` flag adds one behavior: fill an absent destination slot
// from a usable subscription source. This module gives every caller one shared
// entry point, so the predicate contract can never drift between callers.
const DECISION_SCRIPT_PATH = fileURLToPath(
new URL("./codex-auth-merge-decision.cjs", import.meta.url),
);
const SEED_IF_DEST_ABSENT_FLAG = "--seed-if-dest-absent";
/** Exit code: install the source credential over the destination. */
export const USE_SOURCE_EXIT = 10;
/** Exit code: keep the destination credential. */
export const KEEP_DESTINATION_EXIT = 20;
export interface DecideCodexAuthMergeOptions {
/** Opt in to the cache-slot seed mode: fill an absent destination from a
* usable subscription source. */
seedIfDestAbsent?: boolean;
/** The caller name that prefixes a predicate error, for example
* `codex auth cache`. */
errorLabel: string;
}
/**
* Runs the shared decision predicate and returns its exit code (10 or 20). A
* non-10/20 exit, or a failure to run `node`, is a hard failure: this throws so a
* broken predicate is never mistaken for a "keep destination" decision.
*/
export async function decideCodexAuthMerge(
sourcePath: string,
destinationPath: string,
options: DecideCodexAuthMergeOptions,
): Promise<number> {
const args = options.seedIfDestAbsent
? [DECISION_SCRIPT_PATH, SEED_IF_DEST_ABSENT_FLAG, sourcePath, destinationPath]
: [DECISION_SCRIPT_PATH, sourcePath, destinationPath];
try {
await execFile("node", args);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code === USE_SOURCE_EXIT || code === KEEP_DESTINATION_EXIT) {
return code;
}
const detail =
typeof code === "string"
? `node could not be executed (${code})`
: typeof code === "number"
? `unexpected predicate exit code ${code}`
: error instanceof Error
? error.message
: String(error);
throw new Error(`${options.errorLabel} decision predicate failed: ${detail}`);
}
// `execFile` resolved, so the predicate exited 0. The predicate always exits 10
// or 20, so a clean exit 0 is unexpected; fail loud.
throw new Error(
`${options.errorLabel} decision predicate exited 0 (expected 10 or 20)`,
);
}

View File

@ -0,0 +1,76 @@
import { open, rename, rm } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { USE_SOURCE_EXIT, decideCodexAuthMerge } from "./codex-auth-merge-decision.js";
// The one atomic credential writer. It stages the source bytes into a private
// (0600) temp next to the destination, runs the shared decision predicate, and
// on a use-source decision renames the temp over the destination. The rename is
// an atomic same-directory swap that preserves mode 0600. The whole write runs
// under the directory merge lock, so a concurrent restore, copy-back, or
// promotion can never interleave. The per-identity cache slot and the
// device-login company home both use this writer, so the staged-rename and lock
// logic lives in one place. It never logs token bytes; the caller passes the
// fixed status lines.
export type WriteCredentialSeedOrNewerOutcome = "written" | "kept";
export interface WriteCredentialSeedOrNewerInput {
/** The source credential bytes to (maybe) install. */
sourceBytes: Buffer;
/** The absolute destination path to (maybe) overwrite. */
destinationPath: string;
/** Fill an absent destination from a usable subscription source. */
seedIfDestAbsent: boolean;
/** A non-leaking progress sink. It receives only the two fixed status lines. */
log: (line: string) => void | Promise<void>;
/** The fixed status line for a use-source write. It carries no secret data. */
writtenLine: string;
/** The fixed status line for a keep-destination decision. */
keptLine: string;
/** A safe, non-secret prefix for the staged temp file name. */
tempPrefix: string;
/** The caller name that prefixes a predicate error. */
errorLabel: string;
}
/**
* Writes `sourceBytes` over `destinationPath` when the shared predicate returns a
* use-source decision, else keeps the destination. Seeds an absent destination
* only when `seedIfDestAbsent` is set and the source is a usable subscription
* credential. The staged temp is always removed, so a failure never leaves a
* partial file.
*/
export async function writeCredentialSeedOrNewer(
input: WriteCredentialSeedOrNewerInput,
): Promise<WriteCredentialSeedOrNewerOutcome> {
const destinationDir = path.dirname(input.destinationPath);
return withDirectoryMergeLock(destinationDir, async () => {
const stagedTempPath = path.join(
destinationDir,
`.${input.tempPrefix}-${process.pid}-${randomUUID()}.tmp`,
);
// `wx` + explicit mode create the temp private (0600) and fail if it already
// exists, so the writer never writes through a pre-existing symlink.
const handle = await open(stagedTempPath, "wx", 0o600);
try {
await handle.writeFile(input.sourceBytes);
await handle.close();
const decision = await decideCodexAuthMerge(stagedTempPath, input.destinationPath, {
seedIfDestAbsent: input.seedIfDestAbsent,
errorLabel: input.errorLabel,
});
if (decision === USE_SOURCE_EXIT) {
await rename(stagedTempPath, input.destinationPath);
await input.log(input.writtenLine);
return "written";
}
await input.log(input.keptLine);
return "kept";
} finally {
await handle.close().catch(() => undefined);
await rm(stagedTempPath, { force: true }).catch(() => undefined);
}
});
}

View File

@ -136,11 +136,12 @@ function assertProofHomeIsSafeTarget(
}
/**
* Enforces the bounded-size, subscription-only auth shape. Returns the
* subscription `account_id` on success. Rejects an empty, an oversized, an
* API-key, and a malformed payload. Never puts token bytes into the error.
* Enforces the bounded-size, subscription-only auth shape. Rejects an empty, an
* oversized, an API-key, and a malformed payload. Never puts token bytes into the
* error. The device-login promotion reuses this exact rule, so the export and the
* promotion validate the same way.
*/
function assertUsableSubscriptionShape(bytes: Buffer): void {
export function assertUsableSubscriptionShape(bytes: Buffer): void {
if (bytes.length === 0) {
throw new Error("device-login export: refused an empty auth payload");
}

View File

@ -23,6 +23,24 @@ export {
} from "./codex-home.js";
export { listCodexSkills, syncCodexSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export {
runDeviceLogin,
CODEX_DEVICE_LOGIN_COMMAND,
type SandboxLoginDriver,
type DeviceLoginPromptSink,
type DeviceLoginOutcome,
type DeviceLoginResult,
type RunDeviceLoginOptions,
} from "./device-login-runner.js";
export { DEVICE_LOGIN_URL, type DeviceLoginPrompt } from "./device-login-parse.js";
export {
promoteDeviceLoginCredential,
checkStagedCredentialReadiness,
DeviceLoginReadinessError,
type CredentialReadinessResult,
type PromoteDeviceLoginCredentialInput,
type PromoteDeviceLoginCredentialOutcome,
} from "./adapter-auth-promotion.js";
export { parseCodexJsonl, isCodexHarnessCrash, isCodexProviderQuotaError, isCodexTransientUpstreamError, isCodexUnknownSessionError } from "./parse.js";
export {
getQuotaWindows,

View File

@ -219,6 +219,60 @@ describe("codex remote environment diagnostics", () => {
expect(probeCall?.[3]).toContain("--skip-git-repo-check");
});
it("emits the canonical adapter_auth_missing check when a sandbox hello probe reports missing auth", async () => {
// The sandbox has no seedable credentials, so the hello probe returns an
// authentication-required error. The Test must emit the neutral canonical
// check code. The user interface reads this code to decide login
// eligibility; it does not parse the message text or the top-level status.
prepareManagedCodexHome.mockImplementationOnce(async () => {
const dir = await fs.mkdtemp(`${os.tmpdir()}/paperclip-managed-codex-home-noauth-`);
await fs.writeFile(`${dir}/config.toml`, "model = \"gpt-5\"\n");
return dir;
});
runAdapterExecutionTargetProcess.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Please run `codex login` to authenticate.",
pid: 321,
startedAt: new Date().toISOString(),
});
const remoteTarget: AdapterExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
remoteCwd: "/remote/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
};
const result = await testEnvironment({
companyId: "company-1",
adapterType: "codex_local",
config: { engine: "cli", command: "codex" },
executionTarget: remoteTarget,
environmentName: "QA Daytona",
});
// A missing-auth probe is a warning, not a failure, so the environment stays
// testable and the user interface can offer login.
expect(result.status).toBe("warn");
expect(result.checks.some((check) => check.code === "adapter_auth_missing")).toBe(true);
// The descriptive probe check stays, so existing diagnostics keep working.
expect(result.checks.some((check) => check.code === "codex_hello_probe_auth_required")).toBe(true);
});
it("does not override CODEX_HOME when the host has no credentials to seed", async () => {
// Custom-image flow: the login lives inside the captured snapshot, and the
// host has no Codex auth.json. The probe must not upload an empty home or

View File

@ -26,6 +26,7 @@ import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
import { buildCodexExecArgs } from "./codex-args.js";
import { prepareManagedCodexHome } from "./codex-home.js";
import { resolveCodexExecutionEngineForRun, testCodexAcpEnvironment } from "./acp.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
@ -422,6 +423,18 @@ export async function testEnvironment(
? "OPENAI_API_KEY was provided but Codex still rejected the request. Verify the key is valid for the OpenAI Responses API (e.g. `curl -H \"Authorization: Bearer $OPENAI_API_KEY\" https://api.openai.com/v1/models`), or run `codex login` and seed `~/.codex/auth.json`."
: "Codex CLI does not read OPENAI_API_KEY from the environment; set OPENAI_API_KEY in this adapter's config (so Paperclip writes it to `$CODEX_HOME/auth.json`) or run `codex login` on the host first.",
});
if (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: "The sandbox has no ready authentication for this adapter.",
...(detail ? { detail } : {}),
hint: "Provide credentials for this adapter, or start login in the sandbox.",
});
}
} else {
checks.push({
code: "codex_hello_probe_failed",

View File

@ -1,2 +1,7 @@
export { parseCodexStdoutLine } from "./parse-stdout.js";
export { buildCodexLocalConfig } from "./build-config.js";
// The canonical check code the Test result carries when a sandbox target has no
// ready authentication. The user interface reads this stable code to decide when
// to show the login affordance. The source file has no runtime dependencies, so
// this re-export stays safe for the browser bundle.
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "../server/auth-check.js";

View File

@ -0,0 +1,67 @@
import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { adapterAuthSessions } from "./schema/adapter_auth_sessions.js";
type PgTable = Parameters<typeof getTableConfig>[0];
function findIndex(table: PgTable, indexName: string) {
return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
}
function indexColumns(table: PgTable, indexName: string): string[] {
const index = findIndex(table, indexName);
if (!index) return [];
return index.config.columns.map((column) => (column as { name: string }).name);
}
function columnIsNotNull(table: PgTable, columnName: string): boolean {
const column = getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
return column?.notNull ?? false;
}
// The migration that creates the table. The test locates it by content, so a
// later re-generation with a different name does not break the test.
function activeIndexMigrationSql(): string {
const migrationsDir = new URL("./migrations/", import.meta.url);
const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql"));
for (const name of files) {
const content = fs.readFileSync(new URL(name, migrationsDir), "utf8");
if (content.includes("adapter_auth_sessions_company_adapter_active_uq")) {
return content;
}
}
throw new Error("no migration creates adapter_auth_sessions_company_adapter_active_uq");
}
describe("adapter auth sessions schema", () => {
it("serializes on the company credential slot over the active statuses", () => {
const active = findIndex(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq");
expect(active).toBeDefined();
expect(active?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq")).toEqual([
"company_id",
"adapter_type",
]);
// The active index is partial; the where clause is present.
expect(active?.config.where).toBeDefined();
});
it("keeps the owner principal non-null and out of the uniqueness key", () => {
expect(columnIsNotNull(adapterAuthSessions, "started_by_user_id")).toBe(true);
const activeColumns = indexColumns(
adapterAuthSessions,
"adapter_auth_sessions_company_adapter_active_uq",
);
expect(activeColumns).not.toContain("started_by_user_id");
expect(activeColumns).not.toContain("environment_id");
});
it("generates a partial unique index over the three active statuses", () => {
const sql = activeIndexMigrationSql();
expect(sql).toContain('CREATE UNIQUE INDEX "adapter_auth_sessions_company_adapter_active_uq"');
expect(sql).toContain('("company_id","adapter_type")');
expect(sql).toContain("'starting', 'waiting_for_user', 'promoting'");
expect(sql).toContain('"started_by_user_id" text NOT NULL');
});
});

View File

@ -0,0 +1,22 @@
CREATE TABLE "adapter_auth_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"environment_id" uuid NOT NULL,
"adapter_type" text NOT NULL,
"started_by_user_id" text NOT NULL,
"provider_lease_id" text,
"status" text DEFAULT 'starting' NOT NULL,
"expires_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"failure_reason" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD CONSTRAINT "adapter_auth_sessions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD CONSTRAINT "adapter_auth_sessions_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "adapter_auth_sessions_company_status_idx" ON "adapter_auth_sessions" USING btree ("company_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "adapter_auth_sessions_company_adapter_active_uq" ON "adapter_auth_sessions" USING btree ("company_id","adapter_type") WHERE "adapter_auth_sessions"."status" IN ('starting', 'waiting_for_user', 'promoting');--> statement-breakpoint
CREATE INDEX "adapter_auth_sessions_environment_idx" ON "adapter_auth_sessions" USING btree ("environment_id");--> statement-breakpoint
CREATE INDEX "adapter_auth_sessions_expires_idx" ON "adapter_auth_sessions" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "adapter_auth_sessions_provider_lease_idx" ON "adapter_auth_sessions" USING btree ("provider_lease_id");

View File

@ -0,0 +1 @@
ALTER TABLE "adapter_auth_sessions" ADD COLUMN "promotion_expires_at" timestamp with time zone;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1485,6 +1485,20 @@
"when": 1786467951626,
"tag": "0213_complete_mystique",
"breakpoints": true
},
{
"idx": 214,
"version": "7",
"when": 1786467951627,
"tag": "0214_lively_lord_tyger",
"breakpoints": true
},
{
"idx": 215,
"version": "7",
"when": 1786467951628,
"tag": "0215_flat_daimon_hellstrom",
"breakpoints": true
}
]
}
}

View File

@ -0,0 +1,55 @@
import { sql } from "drizzle-orm";
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import type { AdapterAuthSessionInternalStatus, AgentAdapterType } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
// The durable store for an adapter login session. One row tracks one login
// attempt for one adapter in one environment. The row keeps the owner principal,
// the provider lease reference, the status, and the finish times. The row never
// stores the prompt, a credential byte, or the raw provider secret.
export const adapterAuthSessions = pgTable(
"adapter_auth_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }),
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
// The immutable owner principal. The service sets this column one time at
// create and never updates it. The service returns the prompt only to this
// owner.
startedByUserId: text("started_by_user_id").notNull(),
// The provider lease reference for the sandbox. The reaper reads it to retry
// a failed sandbox delete. It is not a public field.
providerLeaseId: text("provider_lease_id"),
status: text("status").$type<AdapterAuthSessionInternalStatus>().notNull().default("starting"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
// The promotion claim deadline. The service sets this column when it moves the
// row to `promoting`. While the deadline is in the future, the claim is live,
// so the reaper does not terminate the session or release the company slot.
// A null or past deadline means no live claim, so the reaper can reclaim a
// stalled `promoting` row. The service clears the column on every terminal
// transition.
promotionExpiresAt: timestamp("promotion_expires_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
// The fixed, non-secret failure code. The public response reads it.
failureReason: text("failure_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusIdx: index("adapter_auth_sessions_company_status_idx").on(
table.companyId,
table.status,
),
// Serialize on the company credential slot. Only one active session can hold
// the slot per adapter. The index applies to the three active statuses. It
// does not include the owner or the environment; those stay columns only.
companyAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_adapter_active_uq")
.on(table.companyId, table.adapterType)
.where(sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting')`),
environmentIdx: index("adapter_auth_sessions_environment_idx").on(table.environmentId),
expiresIdx: index("adapter_auth_sessions_expires_idx").on(table.expiresAt),
providerLeaseIdx: index("adapter_auth_sessions_provider_lease_idx").on(table.providerLeaseId),
}),
);

View File

@ -32,6 +32,7 @@ export { environments } from "./environments.js";
export { environmentLeases } from "./environment_leases.js";
export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js";
export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js";
export { adapterAuthSessions } from "./adapter_auth_sessions.js";
export { workspaceOperations } from "./workspace_operations.js";
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js";

View File

@ -0,0 +1,56 @@
import {
type AdapterAuthSessionInternalStatus,
type AdapterAuthSessionStatus,
} from "./types/agent.js";
// The status helpers for an adapter login session. The server and the user
// interface import these helpers, so both sides use one source. The helpers map
// the internal status to the public status and name the active statuses.
// The active internal statuses. The company credential slot allows one active
// session at a time, so the concurrency index applies to exactly these three
// statuses. The `promoting` state stays active because the slot still holds the
// company credential until the promotion window ends.
export const ADAPTER_AUTH_SESSION_ACTIVE_STATUSES = [
"starting",
"waiting_for_user",
"promoting",
] as const satisfies readonly AdapterAuthSessionInternalStatus[];
export type AdapterAuthSessionActiveStatus =
(typeof ADAPTER_AUTH_SESSION_ACTIVE_STATUSES)[number];
const ACTIVE_STATUS_SET: ReadonlySet<AdapterAuthSessionInternalStatus> = new Set(
ADAPTER_AUTH_SESSION_ACTIVE_STATUSES,
);
/** Returns true when the status holds the company credential slot. */
export function isActiveAdapterAuthSessionStatus(
status: AdapterAuthSessionInternalStatus,
): status is AdapterAuthSessionActiveStatus {
return ACTIVE_STATUS_SET.has(status);
}
/**
* Maps an internal status to the public status. The map hides the two internal
* states from a public response:
*
* - `promoting` maps to `waiting_for_user`.
* - `cleanup_pending` throws. It is a terminal-cleanup bookkeeping state. The
* caller must resolve the terminal status from the row before it builds a
* public response. The throw stops any accidental leak of the cleanup state.
*/
export function toPublicAdapterAuthSessionStatus(
status: AdapterAuthSessionInternalStatus,
): AdapterAuthSessionStatus {
switch (status) {
case "promoting":
return "waiting_for_user";
case "cleanup_pending":
throw new Error(
"cleanup_pending is an internal cleanup state; resolve the terminal status before you build a public response",
);
default:
return status;
}
}

View File

@ -798,6 +798,13 @@ export type {
AdapterEnvironmentTestStatus,
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
AdapterAuthSessionStatus,
AdapterAuthSessionInternalStatus,
AdapterAuthSessionFailure,
AdapterAuthSessionResponse,
AdapterAuthSessionPrompt,
AdapterAuthSessionOwnerResponse,
StartAdapterAuthSessionRequest,
AssetImage,
Project,
ProjectBudgetSummary,
@ -1411,6 +1418,24 @@ export {
COMPANY_SEARCH_SORTS,
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
} from "./types/index.js";
export {
ADAPTER_AUTH_SESSION_STATUSES,
ADAPTER_AUTH_SESSION_INTERNAL_STATUSES,
} from "./types/index.js";
export {
ADAPTER_AUTH_SESSION_ACTIVE_STATUSES,
isActiveAdapterAuthSessionStatus,
toPublicAdapterAuthSessionStatus,
type AdapterAuthSessionActiveStatus,
} from "./adapter-auth-session.js";
export {
adapterAuthSessionStatusSchema,
adapterAuthSessionFailureSchema,
adapterAuthSessionResponseSchema,
adapterAuthSessionPromptSchema,
adapterAuthSessionOwnerResponseSchema,
startAdapterAuthSessionRequestSchema,
} from "./validators/adapter-auth-session.js";
export {
ISSUE_REFERENCE_IDENTIFIER_RE,
buildIssueReferenceHref,

View File

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import {
ADAPTER_AUTH_SESSION_INTERNAL_STATUSES,
ADAPTER_AUTH_SESSION_STATUSES,
type AdapterAuthSessionOwnerResponse,
type AdapterAuthSessionResponse,
} from "./agent.js";
import {
ADAPTER_AUTH_SESSION_ACTIVE_STATUSES,
isActiveAdapterAuthSessionStatus,
toPublicAdapterAuthSessionStatus,
} from "../adapter-auth-session.js";
import {
adapterAuthSessionResponseSchema,
startAdapterAuthSessionRequestSchema,
} from "../validators/adapter-auth-session.js";
// A key that must never appear on the public response. If the response type ever
// grows one of these keys, `AssertMissingKey` fails the type-check.
type AssertMissingKey<T, K extends string> = K extends keyof T ? never : true;
const sessionId = "11111111-1111-4111-8111-111111111111";
const environmentId = "22222222-2222-4222-8222-222222222222";
describe("adapter auth session contract", () => {
it("keeps the public status union closed and neutral", () => {
expect([...ADAPTER_AUTH_SESSION_STATUSES]).toEqual([
"starting",
"waiting_for_user",
"authenticated",
"failed",
"timed_out",
"cancelled",
]);
// The internal states extend the public union with two server-only states.
expect([...ADAPTER_AUTH_SESSION_INTERNAL_STATUSES]).toEqual([
...ADAPTER_AUTH_SESSION_STATUSES,
"promoting",
"cleanup_pending",
]);
// No vendor or roadmap word in the public status union.
const joined = ADAPTER_AUTH_SESSION_STATUSES.join(" ").toLowerCase();
expect(joined).not.toContain("codex");
expect(joined).not.toContain("device");
});
it("accepts a valid public response and rejects an unknown status", () => {
const valid: AdapterAuthSessionResponse = {
sessionId,
environmentId,
status: "waiting_for_user",
expiresAt: "2026-08-11T12:00:00.000Z",
failure: null,
};
expect(adapterAuthSessionResponseSchema.parse(valid)).toEqual(valid);
expect(() => adapterAuthSessionResponseSchema.parse({ ...valid, status: "unknown" })).toThrow();
// The internal states never validate as a public response status.
expect(() => adapterAuthSessionResponseSchema.parse({ ...valid, status: "promoting" })).toThrow();
expect(() => adapterAuthSessionResponseSchema.parse({ ...valid, status: "cleanup_pending" })).toThrow();
});
it("rejects a prompt, token, or lease field on the public response", () => {
const base = {
sessionId,
environmentId,
status: "waiting_for_user" as const,
expiresAt: null,
failure: null,
};
expect(() => adapterAuthSessionResponseSchema.parse({ ...base, prompt: { url: "x", code: "y" } })).toThrow();
expect(() => adapterAuthSessionResponseSchema.parse({ ...base, token: "secret" })).toThrow();
expect(() => adapterAuthSessionResponseSchema.parse({ ...base, providerLeaseId: "lease-1" })).toThrow();
expect(() => adapterAuthSessionResponseSchema.parse({ ...base, accountId: "acct-1" })).toThrow();
// Compile-time guard: the public response type carries none of these keys.
const _noPrompt: AssertMissingKey<AdapterAuthSessionResponse, "prompt"> = true;
const _noToken: AssertMissingKey<AdapterAuthSessionResponse, "token"> = true;
const _noLease: AssertMissingKey<AdapterAuthSessionResponse, "providerLeaseId"> = true;
const _noAccount: AssertMissingKey<AdapterAuthSessionResponse, "accountId"> = true;
void _noPrompt;
void _noToken;
void _noLease;
void _noAccount;
});
it("returns the prompt only through the owner read type", () => {
const owner: AdapterAuthSessionOwnerResponse = {
sessionId,
environmentId,
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.openai.com/codex/device", code: "ABCD-EFGHI" },
};
expect(owner.prompt?.code).toBe("ABCD-EFGHI");
});
it("maps the internal promoting state to the public waiting_for_user state", () => {
expect(toPublicAdapterAuthSessionStatus("promoting")).toBe("waiting_for_user");
expect(toPublicAdapterAuthSessionStatus("starting")).toBe("starting");
expect(toPublicAdapterAuthSessionStatus("authenticated")).toBe("authenticated");
});
it("never projects cleanup_pending to a public status", () => {
expect(() => toPublicAdapterAuthSessionStatus("cleanup_pending")).toThrow();
});
it("treats starting, waiting_for_user, and promoting as the active statuses", () => {
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATUSES]).toEqual([
"starting",
"waiting_for_user",
"promoting",
]);
expect(isActiveAdapterAuthSessionStatus("starting")).toBe(true);
expect(isActiveAdapterAuthSessionStatus("promoting")).toBe(true);
expect(isActiveAdapterAuthSessionStatus("authenticated")).toBe(false);
expect(isActiveAdapterAuthSessionStatus("cleanup_pending")).toBe(false);
});
it("validates a start request and rejects an unknown adapter", () => {
const request = startAdapterAuthSessionRequestSchema.parse({
environmentId,
adapterType: "codex_local",
});
expect(request.environmentId).toBe(environmentId);
expect(() => startAdapterAuthSessionRequestSchema.parse({ environmentId })).toThrow();
});
});

View File

@ -136,6 +136,74 @@ export interface AgentConfigRevision {
createdAt: Date;
}
// The public status union for an adapter login session. The name is neutral: it
// carries no vendor word and no roadmap word. The union is closed. A public
// response returns only one of these six values.
export const ADAPTER_AUTH_SESSION_STATUSES = [
"starting",
"waiting_for_user",
"authenticated",
"failed",
"timed_out",
"cancelled",
] as const;
export type AdapterAuthSessionStatus = (typeof ADAPTER_AUTH_SESSION_STATUSES)[number];
// The internal status union. It extends the public union with two server-only
// states. The server never returns these two states in a public response.
//
// - `promoting`: the readiness-and-promotion window. The server maps this state
// to the public `waiting_for_user` state.
// - `cleanup_pending`: a terminal outcome whose sandbox delete failed. A reaper
// retries the delete. The server never projects this state to a public status;
// it resolves the terminal status first.
export const ADAPTER_AUTH_SESSION_INTERNAL_STATUSES = [
...ADAPTER_AUTH_SESSION_STATUSES,
"promoting",
"cleanup_pending",
] as const;
export type AdapterAuthSessionInternalStatus =
(typeof ADAPTER_AUTH_SESSION_INTERNAL_STATUSES)[number];
// Fixed, non-secret failure information. The `reason` is a stable code. The
// `message` is a short, non-secret sentence. Neither field carries a prompt, a
// credential byte, an account identifier, or a provider lease identifier.
export interface AdapterAuthSessionFailure {
reason: string;
message: string | null;
}
// The public login-session response. It carries only these five fields. It never
// carries the prompt, a credential byte, an account identifier, or the provider
// lease identifier. The `status` is always a public status.
export interface AdapterAuthSessionResponse {
sessionId: string;
environmentId: string;
status: AdapterAuthSessionStatus;
expiresAt: string | null;
failure: AdapterAuthSessionFailure | null;
}
// The one-time login prompt. The server returns it only through an owner read.
export interface AdapterAuthSessionPrompt {
url: string;
code: string;
}
// The owner read of a login session. It adds the one-time prompt to the public
// response. Only the owner principal that started the session reads this shape.
export interface AdapterAuthSessionOwnerResponse extends AdapterAuthSessionResponse {
prompt: AdapterAuthSessionPrompt | null;
}
// The request that starts a login session for one adapter in one environment.
// The owner principal comes from the authenticated caller, not from this body.
export interface StartAdapterAuthSessionRequest {
environmentId: string;
adapterType: AgentAdapterType;
ttlSeconds?: number;
}
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";

View File

@ -277,6 +277,17 @@ export type {
AdapterEnvironmentTestStatus,
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
AdapterAuthSessionStatus,
AdapterAuthSessionInternalStatus,
AdapterAuthSessionFailure,
AdapterAuthSessionResponse,
AdapterAuthSessionPrompt,
AdapterAuthSessionOwnerResponse,
StartAdapterAuthSessionRequest,
} from "./agent.js";
export {
ADAPTER_AUTH_SESSION_STATUSES,
ADAPTER_AUTH_SESSION_INTERNAL_STATUSES,
} from "./agent.js";
export type {
AgentEligibilityAgent,

View File

@ -0,0 +1,48 @@
import { z } from "zod";
import { AGENT_ADAPTER_TYPES } from "../constants.js";
import { ADAPTER_AUTH_SESSION_STATUSES } from "../types/agent.js";
const isoDateTime = z.union([z.date(), z.string().datetime()]);
// The public status schema. It accepts only the six public statuses. It rejects
// an unknown status and both internal states (`promoting`, `cleanup_pending`).
export const adapterAuthSessionStatusSchema = z.enum(ADAPTER_AUTH_SESSION_STATUSES);
export type AdapterAuthSessionStatus = z.infer<typeof adapterAuthSessionStatusSchema>;
export const adapterAuthSessionFailureSchema = z.object({
reason: z.string().min(1).max(200),
message: z.string().min(1).max(1000).nullable(),
}).strict();
export type AdapterAuthSessionFailure = z.infer<typeof adapterAuthSessionFailureSchema>;
// The public response schema. `.strict()` rejects an extra field, so a prompt, a
// token, an account identifier, or a provider lease identifier never validates.
export const adapterAuthSessionResponseSchema = z.object({
sessionId: z.string().uuid(),
environmentId: z.string().uuid(),
status: adapterAuthSessionStatusSchema,
expiresAt: isoDateTime.nullable(),
failure: adapterAuthSessionFailureSchema.nullable(),
}).strict();
export type AdapterAuthSessionResponse = z.infer<typeof adapterAuthSessionResponseSchema>;
export const adapterAuthSessionPromptSchema = z.object({
url: z.string().min(1),
code: z.string().min(1),
}).strict();
export type AdapterAuthSessionPrompt = z.infer<typeof adapterAuthSessionPromptSchema>;
// The owner read schema. It adds the one-time prompt to the public response.
export const adapterAuthSessionOwnerResponseSchema = adapterAuthSessionResponseSchema.extend({
prompt: adapterAuthSessionPromptSchema.nullable(),
}).strict();
export type AdapterAuthSessionOwnerResponse =
z.infer<typeof adapterAuthSessionOwnerResponseSchema>;
export const startAdapterAuthSessionRequestSchema = z.object({
environmentId: z.string().uuid(),
adapterType: z.enum(AGENT_ADAPTER_TYPES),
ttlSeconds: z.number().int().min(60).max(24 * 60 * 60).optional(),
}).strict();
export type StartAdapterAuthSessionRequest =
z.infer<typeof startAdapterAuthSessionRequestSchema>;

View File

@ -0,0 +1,630 @@
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 type {
AdapterAuthSessionRow,
AdapterAuthSessionStore,
AcquireLoginLeaseInput,
LoginSessionLease,
LoginSessionRuntime,
} from "../services/codex-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
// the active company-adapter slot, and a fake runtime models the sandbox, so the
// tests run with no database and no provider. The tests assert the owner
// authorization contract, the environment guard, the concurrency conflict, and
// the redaction of logs and activity.
const COMPANY_1 = "company-1";
const COMPANY_2 = "company-2";
const OWNER_A = "user-a";
const OWNER_B = "user-b";
const SANDBOX_ENV_1 = "11111111-1111-4111-8111-111111111111";
const SANDBOX_ENV_2 = "22222222-2222-4222-8222-222222222222";
// The device-login URL and the one-time code the fake sandbox streams. The
// runner's parser accepts the exact URL and a code of four characters, a hyphen,
// and five characters on a dedicated line after the "one-time code" preamble.
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`;
// 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"}}';
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
getChainOfCommand: vi.fn(async () => []),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
getMembership: vi.fn(async () => null),
listPrincipalGrants: vi.fn(async () => []),
}));
const mockSecretService = vi.hoisted(() => ({
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
collectMissingRuntimeBindings: vi.fn(async () => [] as Array<Record<string, unknown>>),
resolveEnvBindings: vi.fn(async () => ({
env: {} as Record<string, string>,
secretKeys: new Set<string>(),
manifest: [],
})),
}));
const mockEnvironmentService = vi.hoisted(() => ({
getById: vi.fn(),
releaseLease: vi.fn(),
}));
const mockEnvironmentRuntime = vi.hoisted(() => ({
acquireRunLease: vi.fn(),
realizeWorkspace: vi.fn(),
getDriver: vi.fn(() => ({ releaseRunLease: vi.fn(async () => undefined) })),
}));
const mockResolveEnvironmentExecutionTarget = vi.hoisted(() => vi.fn());
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })),
}));
const mockDeviceLoginPromotion = vi.hoisted(() => vi.fn());
// Capture every logger call, so the redaction test can assert the logs and the
// activity omit the URL, the code, the credential bytes, and the lease id.
const mockLogger = vi.hoisted(() => {
const logger: Record<string, unknown> = {};
for (const level of ["info", "warn", "error", "debug", "trace", "fatal"]) {
logger[level] = vi.fn();
}
logger.child = vi.fn(() => logger);
return logger;
});
// The harness holds the fake store and the fake runtime the route service binds
// to. The service module mock returns these through the store and runtime
// factories. A gate keeps the fake login run active during a test.
const harness = vi.hoisted(() => ({
store: null as unknown as AdapterAuthSessionStore,
runtime: null as unknown as LoginSessionRuntime,
acquisitions: [] as AcquireLoginLeaseInput[],
gate: Promise.resolve<void>(undefined),
releaseGate: (() => {}) as () => void,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => ({}),
accessService: () => mockAccessService,
approvalService: () => ({}),
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
companySkillService: () => ({
listRuntimeSkillEntries: vi.fn(async () => []),
resolveRequestedSkillKeys: vi.fn(async () => []),
}),
budgetService: () => ({}),
heartbeatService: () => ({
wakeup: vi.fn(),
cancelActiveForAgent: vi.fn(),
}),
ISSUE_LIST_DEFAULT_LIMIT: 50,
issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({}),
issueService: () => ({}),
logActivity: vi.fn(),
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
vi.mock("../services/secrets.js", () => ({
secretService: () => mockSecretService,
}));
vi.mock("../services/environment-runtime.js", () => ({
environmentRuntimeService: () => mockEnvironmentRuntime,
}));
vi.mock("../services/environment-execution-target.js", () => ({
resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget,
}));
vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
vi.mock("../middleware/logger.js", () => ({
logger: mockLogger,
httpLogger: (_req: unknown, _res: unknown, next: () => void) => next(),
}));
// Retain the production readiness helper while making the promotion decision
// observable. This lets the route test prove that a resolved but rejected
// Decision H outcome becomes a failed terminal rather than authenticated.
vi.mock("@paperclipai/adapter-codex-local/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("@paperclipai/adapter-codex-local/server")>();
return {
...actual,
promoteDeviceLoginCredential: mockDeviceLoginPromotion,
};
});
// 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")>();
return {
...actual,
createDbAdapterAuthSessionStore: () => harness.store,
createProductionLoginSessionRuntime: () => harness.runtime,
};
});
// An in-memory session store. It mimics the active company-adapter slot: the
// partial unique index allows one active session per company and adapter, so a
// second active start throws the conflict error.
function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, AdapterAuthSessionRow> } {
const rows = new Map<string, AdapterAuthSessionRow>();
const activeSlots = new Set<string>();
const slotKey = (companyId: string, adapterType: string) => `${companyId}|${adapterType}`;
const isActive = (status: AdapterAuthSessionRow["status"]) =>
status === "starting" || status === "waiting_for_user" || status === "promoting";
return {
rows,
async insert(input) {
const key = slotKey(input.companyId, input.adapterType);
if (activeSlots.has(key)) {
throw new AdapterAuthSessionConflictError();
}
activeSlots.add(key);
rows.set(input.id, {
id: input.id,
companyId: input.companyId,
environmentId: input.environmentId,
adapterType: input.adapterType,
startedByUserId: input.startedByUserId,
providerLeaseId: null,
status: "starting",
expiresAt: input.expiresAt,
promotionExpiresAt: null,
finishedAt: null,
failureReason: null,
});
},
async recordLeaseAcquired(input) {
const row = rows.get(input.sessionId);
if (row) row.providerLeaseId = input.providerLeaseId;
},
async setStatus(input) {
const row = rows.get(input.sessionId);
if (!row) return;
row.status = input.status;
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType));
},
async compareAndSetStatus(input) {
const row = rows.get(input.sessionId);
if (!row || !input.expectedStatuses.includes(row.status)) return false;
row.status = input.status;
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType));
return true;
},
async get(sessionId) {
const row = rows.get(sessionId);
return row ? { ...row } : null;
},
async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) {
// The route test runs on a single event loop, so it needs no real lock. The
// pass-through keeps the promotion contract satisfied.
return fn();
},
};
}
// 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 {
return {
async acquireLoginLease(input) {
harness.acquisitions.push(input);
const lease: LoginSessionLease = {
providerLeaseId: `provider-lease-${input.sessionId}`,
authPath: `/tmp/paperclip-adapter-login/${input.sessionId}/auth.json`,
driver: {
async execStreaming(_command, onStdout) {
onStdout(PROMPT_OUTPUT);
await harness.gate;
return { exitCode: 0 };
},
async readFile() {
return Buffer.from(CREDENTIAL_BYTES, "utf8");
},
async dispose() {},
},
async deleteSandbox() {
return { outcome: "deleted" };
},
async release() {},
};
return lease;
},
};
}
let currentActor: Record<string, unknown>;
function boardActor(userId: string, companyIds: string[] = [COMPANY_1, COMPANY_2]): Record<string, unknown> {
return {
type: "board",
userId,
companyIds,
source: "local_implicit",
isInstanceAdmin: false,
};
}
async function createApp() {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as unknown as { actor: unknown }).actor = currentActor;
next();
});
app.use("/api", agentRoutes({} as never));
app.use(errorHandler);
return app;
}
const loginPath = (companyId: string, type = "codex_local") =>
`/api/companies/${companyId}/adapters/${type}/login-sessions`;
describe("adapter device-login routes", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockDeviceLoginPromotion.mockResolvedValue("promoted");
harness.store = createMemoryStore();
harness.runtime = createFakeRuntime();
harness.acquisitions = [];
harness.gate = new Promise<void>((resolve) => {
harness.releaseGate = resolve;
});
currentActor = boardActor(OWNER_A);
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
explanation: "Allowed by test grant",
});
mockEnvironmentService.getById.mockImplementation(async (id: string) => ({
id,
companyId: COMPANY_1,
name: "Sandbox QA",
driver: "sandbox",
status: "active",
config: { provider: "daytona" },
envVars: {},
}));
});
afterEach(() => {
// Release the gate, so every in-flight login run ends and clears its timer.
harness.releaseGate();
});
it("requires a board actor and rejects an agent-token start", async () => {
currentActor = {
type: "agent",
agentId: "agent-1",
companyId: COMPANY_1,
source: "agent_key",
};
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(harness.acquisitions).toHaveLength(0);
});
it("starts a login for a board actor with the configuration permission and acquires a fresh lease", async () => {
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body).toMatchObject({
environmentId: SANDBOX_ENV_1,
status: "starting",
});
expect(typeof res.body.sessionId).toBe("string");
// A fresh lease is acquired for the owner.
expect(harness.acquisitions).toHaveLength(1);
expect(harness.acquisitions[0]).toMatchObject({
companyId: COMPANY_1,
environmentId: SANDBOX_ENV_1,
adapterType: "codex_local",
startedByUserId: OWNER_A,
});
// The row persists the immutable owner from the actor.
const store = harness.store as ReturnType<typeof createMemoryStore>;
const row = store.rows.get(res.body.sessionId);
expect(row?.startedByUserId).toBe(OWNER_A);
});
it("rejects a non-codex adapter", async () => {
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1, "claude_local"))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(harness.acquisitions).toHaveLength(0);
});
it("rejects a local environment", async () => {
mockEnvironmentService.getById.mockResolvedValueOnce({
id: SANDBOX_ENV_1,
companyId: COMPANY_1,
name: "Local",
driver: "local",
status: "active",
config: {},
envVars: {},
});
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(harness.acquisitions).toHaveLength(0);
});
it("rejects an archived (inactive) sandbox environment", async () => {
mockEnvironmentService.getById.mockResolvedValueOnce({
id: SANDBOX_ENV_1,
companyId: COMPANY_1,
name: "Sandbox QA",
driver: "sandbox",
status: "archived",
config: { provider: "daytona" },
envVars: {},
});
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(harness.acquisitions).toHaveLength(0);
});
it("rejects a missing environment", async () => {
mockEnvironmentService.getById.mockResolvedValueOnce(null);
const app = await createApp();
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(harness.acquisitions).toHaveLength(0);
});
it("delivers the one-time prompt to the owner on the first read only", async () => {
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
// The first authorized owner read receives the one-time prompt.
const first = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
// A second authorized owner read no longer carries the prompt. The status
// stays available, so the owner still tracks the session.
const second = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(second.status, JSON.stringify(second.body)).toBe(200);
expect(second.body.prompt).toBeNull();
expect(second.body.status).toBe(first.body.status);
});
it("returns 404 for a wrong-user status, prompt, and cancel", async () => {
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
// A different board user in the same company is not the owner.
currentActor = boardActor(OWNER_B);
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.status, JSON.stringify(status.body)).toBe(404);
expect(status.body.prompt).toBeUndefined();
expect(status.body.environmentId).toBeUndefined();
const cancel = await request(app).post(`${loginPath(COMPANY_1)}/${sessionId}/cancel`);
expect(cancel.status, JSON.stringify(cancel.body)).toBe(404);
});
it("returns 404 for a cross-company status", async () => {
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
// The same owner reads the session under a different company scope.
const status = await request(app).get(`${loginPath(COMPANY_2)}/${sessionId}`);
expect(status.status, JSON.stringify(status.body)).toBe(404);
expect(status.body.prompt).toBeUndefined();
});
it("durably cancels a login for the owner and releases the company slot", async () => {
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
const cancel = await request(app).post(`${loginPath(COMPANY_1)}/${sessionId}/cancel`);
expect(cancel.status, JSON.stringify(cancel.body)).toBe(200);
expect(cancel.body.sessionId).toBe(sessionId);
// The cancel resolves the public terminal status at once.
expect(cancel.body.status).toBe("cancelled");
// The durable write released the company slot, so a fresh start for the same
// company and adapter succeeds without a wait for the in-flight run or the
// reaper. This proves the cancel does not depend on the process-local abort.
const restart = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(restart.status, JSON.stringify(restart.body)).toBe(201);
expect(restart.body.sessionId).not.toBe(sessionId);
});
it("returns 409 for a second active start by a different owner", async () => {
const app = await createApp();
const first = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(first.status, JSON.stringify(first.body)).toBe(201);
// A different owner starts a second login for the same company and adapter.
currentActor = boardActor(OWNER_B);
const second = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(second.status, JSON.stringify(second.body)).toBe(409);
// The second start never acquires a lease.
expect(harness.acquisitions).toHaveLength(1);
});
it("returns 409 for a second active start in a different environment", async () => {
const app = await createApp();
const first = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(first.status, JSON.stringify(first.body)).toBe(201);
// The same owner starts a second login in a different environment for the
// same company and adapter.
const second = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_2 });
expect(second.status, JSON.stringify(second.body)).toBe(409);
expect(harness.acquisitions).toHaveLength(1);
});
it("fails closed when promotion loses the sole-owner claim", async () => {
// This is the expiry/reaper-race result from Decision H. It is a normal
// resolved adapter outcome, but it must never be accepted as authentication.
mockDeviceLoginPromotion.mockResolvedValueOnce("not_sole_owner");
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
const row = (harness.store as ReturnType<typeof createMemoryStore>).rows.get(sessionId);
expect(row?.status).toBe("failed");
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
});
it("fails closed when the login is a different account than the company home", async () => {
// The promotion keeps the occupied company home and installs nothing durable
// for a different-identity login. The identity-anchored vend can never select
// this login, so a later run keeps the existing account. The route must fail
// the session instead of a report of `authenticated`.
mockDeviceLoginPromotion.mockResolvedValueOnce("kept_foreign_identity");
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
harness.releaseGate();
await vi.waitFor(async () => {
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.body.status).toBe("failed");
expect(status.body.failure?.reason).toBe("promotion_failed");
});
const row = (harness.store as ReturnType<typeof createMemoryStore>).rows.get(sessionId);
expect(row?.status).toBe("failed");
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
});
it("omits the URL, code, credential bytes, and lease id from logs and activity", async () => {
const app = await createApp();
const start = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(start.status, JSON.stringify(start.body)).toBe(201);
const sessionId = start.body.sessionId as string;
// Read the owner status, so the prompt passes through the owner read path.
const status = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
expect(status.status).toBe(200);
// Serialize every logged argument and assert none carries a secret.
const loggedText = (["info", "warn", "error", "debug"] as const)
.flatMap((level) => (mockLogger[level] as ReturnType<typeof vi.fn>).mock.calls)
.map((args) => JSON.stringify(args))
.join("\n");
expect(loggedText).not.toContain("auth.openai.com");
expect(loggedText).not.toContain(PROMPT_CODE);
expect(loggedText).not.toContain("SECRET-ACCESS-TOKEN");
expect(loggedText).not.toContain("provider-lease-");
});
});

View File

@ -0,0 +1,329 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { AgentAdapterType } from "@paperclipai/shared";
import {
createCodexDeviceLoginReaper,
type LoginLeaseRef,
type LoginSessionCleanupRuntime,
type TaggedLease,
} from "../services/codex-device-login-reaper.ts";
import {
ADAPTER_AUTH_ACTIVE_STATUSES,
type AdapterAuthReaperStore,
type AdapterAuthSessionRow,
type SandboxDeleteResult,
} from "../services/codex-device-login-service.ts";
const ADAPTER_TYPE: AgentAdapterType = "codex_local";
const NOW = new Date("2026-02-01T00:05:00.000Z");
const PAST = new Date("2026-02-01T00:00:00.000Z");
const FUTURE = new Date("2026-02-01T01:00:00.000Z");
// An in-memory reaper store. The test seeds rows directly, so no database runs.
function createMemoryReaperStore(): AdapterAuthReaperStore & {
seed(row: Partial<AdapterAuthSessionRow> & Pick<AdapterAuthSessionRow, "id">): AdapterAuthSessionRow;
} {
const rows = new Map<string, AdapterAuthSessionRow>();
const isActive = (status: AdapterAuthSessionRow["status"]) =>
ADAPTER_AUTH_ACTIVE_STATUSES.includes(status);
return {
seed(input) {
const row: AdapterAuthSessionRow = {
id: input.id,
companyId: input.companyId ?? randomUUID(),
environmentId: input.environmentId ?? randomUUID(),
adapterType: input.adapterType ?? ADAPTER_TYPE,
startedByUserId: input.startedByUserId ?? "user-a",
providerLeaseId: input.providerLeaseId ?? null,
status: input.status ?? "starting",
expiresAt: input.expiresAt ?? null,
promotionExpiresAt: input.promotionExpiresAt ?? null,
finishedAt: input.finishedAt ?? null,
failureReason: input.failureReason ?? null,
};
rows.set(row.id, row);
return row;
},
async listExpiredActiveSessions(now) {
// Mirror the database scan: skip a `promoting` row that still holds a live
// promotion claim, so the reaper never terminates a live credential write.
return [...rows.values()].filter(
(row) =>
isActive(row.status) &&
row.expiresAt != null &&
row.expiresAt <= now &&
(row.status !== "promoting" ||
row.promotionExpiresAt == null ||
row.promotionExpiresAt <= now),
);
},
async listCleanupPendingSessions() {
return [...rows.values()].filter((row) => row.status === "cleanup_pending");
},
async listLeaseReferences() {
return [...rows.values()]
.map((row) => row.providerLeaseId)
.filter((value): value is string => value != null);
},
async setStatus(input) {
const row = rows.get(input.sessionId);
if (!row) return;
row.status = input.status;
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
},
async compareAndSetStatus(input) {
const row = rows.get(input.sessionId);
if (!row || !input.expectedStatuses.includes(row.status)) return false;
row.status = input.status;
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
return true;
},
async get(sessionId) {
const row = rows.get(sessionId);
return row ? { ...row } : null;
},
async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) {
// The in-memory store runs on a single event loop, so it needs no real
// lock. The pass-through keeps the reaper contract satisfied.
return fn();
},
};
}
interface FakeRuntimeOptions {
tagged?: TaggedLease[];
deleteImpl?: (ref: LoginLeaseRef) => Promise<SandboxDeleteResult>;
}
function createFakeRuntime(opts: FakeRuntimeOptions = {}) {
const deleteCalls: LoginLeaseRef[] = [];
const runtime: LoginSessionCleanupRuntime = {
async deleteSandbox(ref) {
deleteCalls.push(ref);
if (opts.deleteImpl) return await opts.deleteImpl(ref);
return { outcome: "deleted" };
},
async listTaggedLeases() {
return opts.tagged ?? [];
},
};
return { runtime, deleteCalls };
}
describe("codex device login reaper", () => {
it("deletes the sandbox and marks a persisted expired non-terminal session timed_out", async () => {
const store = createMemoryReaperStore();
const environmentId = randomUUID();
const seeded = store.seed({
id: randomUUID(),
environmentId,
status: "waiting_for_user",
providerLeaseId: "lease-1",
expiresAt: PAST,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
expect(deleteCalls).toEqual([{ environmentId, providerLeaseId: "lease-1" }]);
expect(result.expiredTimedOut).toBe(1);
const row = await store.get(seeded.id);
expect(row?.status).toBe("timed_out");
expect(row?.finishedAt).not.toBeNull();
});
it("holds an unexpired active session and never deletes its sandbox", async () => {
const store = createMemoryReaperStore();
const seeded = store.seed({
id: randomUUID(),
status: "waiting_for_user",
providerLeaseId: "lease-1",
expiresAt: FUTURE,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
expect(deleteCalls).toHaveLength(0);
expect(result.expiredTimedOut).toBe(0);
expect((await store.get(seeded.id))?.status).toBe("waiting_for_user");
});
it("never terminates an expired promoting row that still holds a live claim", async () => {
const store = createMemoryReaperStore();
const seeded = store.seed({
id: randomUUID(),
status: "promoting",
providerLeaseId: "lease-1",
// The login window expired, but the promotion claim is still live.
expiresAt: PAST,
promotionExpiresAt: FUTURE,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
// The reaper leaves the live claim alone: no delete, no slot release.
expect(deleteCalls).toHaveLength(0);
expect(result.expiredTimedOut).toBe(0);
expect((await store.get(seeded.id))?.status).toBe("promoting");
});
it("reclaims an expired promoting row after its promotion claim goes stale", async () => {
const store = createMemoryReaperStore();
const environmentId = randomUUID();
const seeded = store.seed({
id: randomUUID(),
environmentId,
status: "promoting",
providerLeaseId: "lease-1",
expiresAt: PAST,
// The promotion claim deadline passed, so the claim is stale.
promotionExpiresAt: PAST,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
// The stale claim is reclaimed: the reaper deletes the sandbox and times out.
expect(deleteCalls).toEqual([{ environmentId, providerLeaseId: "lease-1" }]);
expect(result.expiredTimedOut).toBe(1);
expect((await store.get(seeded.id))?.status).toBe("timed_out");
});
it("marks an expired session with no lease reference timed_out without a delete", async () => {
const store = createMemoryReaperStore();
const seeded = store.seed({
id: randomUUID(),
status: "starting",
providerLeaseId: null,
expiresAt: PAST,
});
const { runtime, deleteCalls } = createFakeRuntime();
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
expect(deleteCalls).toHaveLength(0);
expect(result.expiredTimedOut).toBe(1);
expect((await store.get(seeded.id))?.status).toBe("timed_out");
});
it("retries a cleanup_pending terminal session and clears it only on a provider not_found confirmation", async () => {
const store = createMemoryReaperStore();
const seeded = store.seed({
id: randomUUID(),
status: "cleanup_pending",
providerLeaseId: "lease-1",
// The encoding keeps the resolved terminal and the failure code.
failureReason: "failed|login_command_failed",
finishedAt: PAST,
});
let attempt = 0;
const { runtime, deleteCalls } = createFakeRuntime({
deleteImpl: async () => {
attempt += 1;
if (attempt === 1) throw new Error("provider delete rejected");
return { outcome: "not_found" };
},
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
// First sweep: the delete rejects, so the row stays cleanup_pending.
const first = await reaper.sweep();
expect(first.cleanupRetried).toBe(1);
expect(first.cleanupCleared).toBe(0);
expect(first.cleanupPendingRemaining).toBe(1);
expect((await store.get(seeded.id))?.status).toBe("cleanup_pending");
// Second sweep: the provider confirms with not_found, so the row clears to
// the retained terminal public status.
const second = await reaper.sweep();
expect(second.cleanupRetried).toBe(1);
expect(second.cleanupCleared).toBe(1);
expect(second.cleanupPendingRemaining).toBe(0);
expect(deleteCalls).toHaveLength(2);
const row = await store.get(seeded.id);
expect(row?.status).toBe("failed");
expect(row?.failureReason).toBe("login_command_failed");
});
it("deletes a tagged lease that no live session references", async () => {
const store = createMemoryReaperStore();
const environmentId = randomUUID();
// A live session references its own lease. The reaper never deletes it.
store.seed({
id: randomUUID(),
environmentId,
status: "waiting_for_user",
providerLeaseId: "linked-1",
expiresAt: FUTURE,
});
const { runtime, deleteCalls } = createFakeRuntime({
tagged: [
{ environmentId, providerLeaseId: "linked-1", sessionId: "linked-session" },
{ environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" },
],
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const result = await reaper.sweep();
// Only the orphan lease is deleted; the referenced lease is untouched.
expect(deleteCalls).toEqual([{ environmentId, providerLeaseId: "orphan-1" }]);
expect(result.orphanLeasesDeleted).toBe(1);
});
it("keeps retrying an orphan tagged lease when its delete fails", async () => {
const store = createMemoryReaperStore();
const environmentId = randomUUID();
let attempt = 0;
const { runtime, deleteCalls } = createFakeRuntime({
tagged: [{ environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" }],
deleteImpl: async () => {
attempt += 1;
if (attempt === 1) throw new Error("provider delete rejected");
return { outcome: "deleted" };
},
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
const first = await reaper.sweep();
expect(first.orphanLeasesDeleted).toBe(0);
const second = await reaper.sweep();
expect(second.orphanLeasesDeleted).toBe(1);
expect(deleteCalls).toHaveLength(2);
});
it("does not throw on a second sweep over an already-deleted sandbox", async () => {
const store = createMemoryReaperStore();
const environmentId = randomUUID();
store.seed({
id: randomUUID(),
environmentId,
status: "waiting_for_user",
providerLeaseId: "lease-1",
expiresAt: PAST,
});
// The delete is idempotent: an already-gone sandbox returns not_found.
const { runtime } = createFakeRuntime({
tagged: [{ environmentId, providerLeaseId: "orphan-1", sessionId: "orphan-session" }],
deleteImpl: async () => ({ outcome: "not_found" }),
});
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => NOW });
await expect(reaper.sweep()).resolves.toBeDefined();
// The second sweep runs over the now-terminal session and the same orphan
// lease. A repeated delete does not throw.
await expect(reaper.sweep()).resolves.toBeDefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,12 @@ import {
} from "./services/index.js";
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 {
createCodexDeviceLoginReaper,
createProductionLoginSessionReaperRuntime,
} from "./services/codex-device-login-reaper.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
@ -1002,6 +1008,41 @@ export async function startServer(): Promise<StartedServer> {
logger.error({ err }, "terminal issue workspace reaper failed");
}));
};
// The restart-safe cleanup backstop for adapter login sessions. The
// in-process five-minute timer stays the primary control. This reaper runs
// on startup and on the scheduler interval. It deletes the login sandbox for
// 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({
store: createDbAdapterAuthSessionStore(db as any),
runtime: createProductionLoginSessionReaperRuntime({
db: db as any,
environmentRuntime: environmentRuntimeService(db as any, { pluginWorkerManager }),
}),
});
const logAdapterLoginReaperResult = (
result: Awaited<ReturnType<typeof adapterLoginReaper.sweep>>,
) => {
if (
result.expiredTimedOut > 0 ||
result.cleanupCleared > 0 ||
result.orphanLeasesDeleted > 0 ||
result.cleanupPendingRemaining > 0
) {
logger.info(result, "adapter login reaper swept login sessions");
}
};
const scheduleAdapterLoginReaperSweep = () => {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(adapterLoginReaper
.sweep()
.then(logAdapterLoginReaperResult)
.catch((err) => {
logger.error({ err }, "adapter login reaper sweep failed");
}));
};
const tools = toolAccessService(db as any, {
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
@ -1129,6 +1170,16 @@ export async function startServer(): Promise<StartedServer> {
logger.warn({ ...toolHealthSweep }, "startup tool connection health sweep found failing connections");
}
await decisionExecutor.sweepExpired();
// Run the adapter login reaper once at startup, so a login sandbox that
// outlived a server restart is deleted before timer ticks start.
await adapterLoginReaper
.sweep()
.then(logAdapterLoginReaperResult)
.catch((err) => {
logger.error({ err }, "startup adapter login reaper sweep failed");
});
const runRetentionSweep = async () => {
const activeCompanies = await db.select({ id: companies.id }).from(companies).where(eq(companies.status, "active"));
let archived = 0;
@ -1187,6 +1238,7 @@ export async function startServer(): Promise<StartedServer> {
if (heartbeatSchedulerStopped) return;
scheduleMergedPullRequestConfirmationSweep();
scheduleTerminalWorkspaceSweep();
scheduleAdapterLoginReaperSweep();
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(routines

View File

@ -95,6 +95,18 @@ import {
} from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
import {
checkStagedCredentialReadiness,
promoteDeviceLoginCredential,
} from "@paperclipai/adapter-codex-local/server";
import {
AdapterAuthSessionConflictError,
CODEX_DEVICE_LOGIN_ADAPTER_TYPE,
createCodexDeviceLoginService,
createDbAdapterAuthSessionStore,
createProductionLoginSessionRuntime,
} from "../services/codex-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";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
@ -235,6 +247,83 @@ export function agentRoutes(
const instanceSettings = instanceSettingsService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
// The company-scoped adapter login-session service. It runs the device-login
// flow in a fresh trusted sandbox and holds the one-time prompt in memory. The
// process owns one instance, so the in-memory prompt and the cancellation
// controllers persist across requests.
const adapterLoginStore = createDbAdapterAuthSessionStore(db);
const adapterLoginService = createCodexDeviceLoginService({
store: adapterLoginStore,
runtime: createProductionLoginSessionRuntime({ db, environmentRuntime }),
// 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.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",
);
}
if (outcome !== "promoted" && outcome !== "kept") {
throw new Error(`device-login credential promotion rejected: ${outcome}`);
}
},
},
recordActivity: (event) => {
// The event carries no URL, no code, no credential, no account identifier,
// and no lease identifier, so it is safe to log.
logger.info(event, "adapter login session lifecycle");
},
});
// The cancellation controllers for the in-flight login runs this process owns.
const adapterLoginAbortControllers = new Map<string, AbortController>();
async function assertAgentEnvironmentSelection(
companyId: string,
adapterType: string,
@ -813,6 +902,79 @@ export function agentRoutes(
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
// The single owner-authorization helper for the three adapter login routes. It
// requires a board actor, company access, and the same configuration
// permission as the adapter Test route (`agents:create`). It returns the
// immutable owner identifier: the board user that starts, reads, or cancels the
// session. The start route persists this identifier; the status and cancel
// routes compare it to the session owner and return 404 on a mismatch, so a
// non-owner cannot enumerate a session.
async function assertCanManageAdapterLogin(
req: Request,
companyId: string,
): Promise<string> {
assertBoard(req);
assertCompanyAccess(req, companyId);
const decision = await access.decide({
actor: req.actor,
action: "agents:create",
resource: { type: "company", companyId },
});
if (!decision.allowed) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
const userId = req.actor.userId;
if (!userId) {
throw forbidden(
"A board user identity is required to manage an adapter login session.",
);
}
return userId;
}
// The device-login flow supports only the Codex adapter. Reject any other type.
function assertCodexLoginAdapter(type: string): void {
if (type !== CODEX_DEVICE_LOGIN_ADAPTER_TYPE) {
throw badRequest(`Adapter "${type}" does not support a device login.`);
}
}
// The environment-eligibility guard for an adapter login. A device login runs
// only in an active sandbox environment. This reuses the shared environment
// selection guard, so it rejects a missing, archived (inactive), local, SSH, or
// plugin environment the same way the agent configuration routes do.
async function assertSandboxLoginEnvironment(
companyId: string,
environmentId: string,
): Promise<void> {
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
allowedDrivers: ["sandbox"],
});
}
// Read a login session for its owner. The durable row is the authority for the
// company and the owner. This returns null when the row is absent, when it
// belongs to another company or adapter, or when the requesting user is not the
// owner. So a non-owner and a cross-company caller both receive a 404 and cannot
// enumerate a session. Only the owner path reads the one-time prompt.
async function readOwnerLoginSession(
companyId: string,
adapterType: string,
sessionId: string,
requestingUserId: string,
): Promise<AdapterAuthSessionOwnerResponse | null> {
const row = await adapterLoginStore.get(sessionId);
if (
!row ||
row.companyId !== companyId ||
row.adapterType !== adapterType ||
row.startedByUserId !== requestingUserId
) {
return null;
}
return adapterLoginService.readOwnerSession(sessionId, requestingUserId);
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
// Reading agent configurations, skills, and config revisions is a
// read-only operation available to any board (human) member of the
@ -2001,6 +2163,119 @@ export function agentRoutes(
},
);
// Start a company-scoped adapter device login. The create form has no agent
// identifier, so the route keys on the company and the adapter. The owner
// helper requires a board actor with the configuration permission, and it
// returns the immutable owner identifier that the service persists on the row.
router.post(
"/companies/:companyId/adapters/:type/login-sessions",
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
const startedByUserId = await assertCanManageAdapterLogin(req, companyId);
assertCodexLoginAdapter(type);
const environmentId =
typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0
? (req.body.environmentId as string)
: null;
if (!environmentId) {
throw badRequest("A sandbox environment is required to start a device login.");
}
const ttlSeconds =
typeof req.body?.ttlSeconds === "number" && Number.isFinite(req.body.ttlSeconds)
? (req.body.ttlSeconds as number)
: undefined;
// Reject a non-sandbox or inactive environment before the service starts.
await assertSandboxLoginEnvironment(companyId, environmentId);
const controller = new AbortController();
let result: Awaited<ReturnType<typeof adapterLoginService.start>>;
try {
result = await adapterLoginService.start({
companyId,
environmentId,
adapterType: type,
startedByUserId,
ttlSeconds,
signal: controller.signal,
});
} catch (error) {
// A second active login for the same company and adapter loses the
// credential slot. Map the service conflict to a 409 response.
if (error instanceof AdapterAuthSessionConflictError) {
throw conflict(error.message);
}
throw error;
}
// Keep the controller so the cancel route can abort the in-flight run.
// Drop it when the run ends. The completion runs the terminal handling in
// the background; the response returns the initial session at once.
const startedSessionId = result.session.sessionId;
adapterLoginAbortControllers.set(startedSessionId, controller);
void result.completed
.catch(() => {})
.finally(() => {
adapterLoginAbortControllers.delete(startedSessionId);
});
res.status(201).json(result.session);
},
);
// Read a login session. The owner receives the status and the one-time prompt.
// A non-owner or a cross-company caller receives a 404.
router.get(
"/companies/:companyId/adapters/:type/login-sessions/:sessionId",
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
const sessionId = req.params.sessionId as string;
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
assertCodexLoginAdapter(type);
const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId);
if (!owner) {
res.status(404).json({ error: "Adapter login session not found" });
return;
}
res.json(owner);
},
);
// Cancel a login session. The owner aborts the in-flight run. A non-owner or a
// cross-company caller receives a 404.
router.post(
"/companies/:companyId/adapters/:type/login-sessions/:sessionId/cancel",
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
const sessionId = req.params.sessionId as string;
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
assertCodexLoginAdapter(type);
// Scope the cancel to this company, adapter, and owner. A non-owner and a
// cross-company caller both receive a 404 and cannot cancel a session.
const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId);
if (!owner) {
res.status(404).json({ error: "Adapter login session not found" });
return;
}
// Durably release the company slot. The durable write terminates the row
// even when this process does not own the in-flight run, so a cross-process
// cancel or a cancel after a restart does not leave the slot held until the
// expiry. The reaper deletes the sandbox and finalizes the terminal.
const cancelled = await adapterLoginService.cancelOwnerSession(sessionId, ownerUserId);
// Abort the in-flight run this process owns, so the local login stops at
// once instead of waiting for the reaper. A run in another process, or an
// already-terminal run, has no controller here.
adapterLoginAbortControllers.get(sessionId)?.abort();
res.json(cancelled ?? owner);
},
);
router.get("/agents/:id/skills", async (req, res) => {
const id = req.params.id as string;
const agent = await svc.getById(id);

View File

@ -587,6 +587,13 @@ const refreshExternalObjectsBodySchema = z.object({
objectIds: z.array(z.string().uuid()).max(50).optional(),
}).strict();
// The start route reads the body directly, so document the accepted fields
// here. A sandbox environment is required. The time-to-live is optional.
const startAdapterLoginSessionSchema = z.object({
environmentId: z.string().min(1),
ttlSeconds: z.number().optional(),
});
const environmentCustomImageCompanyQuerySchema = z.object({
companyId: z.string().optional(),
}).strict();
@ -2103,6 +2110,46 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions",
tags: ["adapters"],
summary: "Start a company-scoped adapter device login",
request: {
params: z.object({ companyId: z.string(), type: z.string() }),
body: jsonBody(startAdapterLoginSessionSchema),
},
responses: {
201: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
409: r.conflict,
},
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}",
tags: ["adapters"],
summary: "Read an adapter device login session",
request: {
params: z.object({ companyId: z.string(), type: z.string(), sessionId: z.string() }),
},
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}/cancel",
tags: ["adapters"],
summary: "Cancel an adapter device login session",
request: {
params: z.object({ companyId: z.string(), type: z.string(), sessionId: z.string() }),
},
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
// ─── Issues ──────────────────────────────────────────────────────────────────
registry.registerPath({

View File

@ -0,0 +1,334 @@
import { inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { adapterAuthSessions } from "@paperclipai/db";
import {
ADAPTER_AUTH_ACTIVE_STATUSES,
decodePendingTerminal,
LOGIN_LEASE_SESSION_TAG_KEY,
observeSandboxDelete,
terminalCleanupWrite,
type AdapterAuthReaperStore,
type SandboxDeleteResult,
} from "./codex-device-login-service.js";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { environmentService } from "./environments.js";
// The restart-safe cleanup backstop for adapter login sessions.
//
// The in-process five-minute timer in the login-session service stays the
// primary control. This reaper is the backstop. It runs on server startup and on
// a fixed interval, so sandbox cleanup survives a server restart and a delete
// failure. The reaper sweeps three sets:
//
// 1. The expired non-terminal sessions. It deletes the login sandbox and marks
// the session `timed_out`.
// 2. The terminal sessions left in the internal `cleanup_pending` state. It
// retries the delete and clears `cleanup_pending` only on a provider
// confirmation. A `not_found` result is the idempotent confirmation.
// 3. The tagged provider leases that no live session references. Such a lease is
// an orphan from a crash between lease acquisition and the durable session
// write. It deletes the orphan.
//
// Security: the reaper records no secret data. A cleanup never puts a URL, a
// code, a credential byte, an account identifier, or a lease identifier into a
// public response. The `cleanup_pending` state stays internal; the reaper
// resolves the terminal public status before it clears the state.
/** The provider lease reference the reaper deletes through. */
export interface LoginLeaseRef {
environmentId: string;
providerLeaseId: string;
}
/** A login-tagged provider lease. The tag carries the session identifier, so the
* reaper matches the lease against the live session rows. */
export interface TaggedLease extends LoginLeaseRef {
sessionId: string;
}
/**
* The sandbox side the reaper drives. A production runtime binds it to the
* environment runtime; a test binds it to a fake. The delete is idempotent, so a
* repeated delete of an already-gone sandbox returns `not_found` and never
* throws.
*/
export interface LoginSessionCleanupRuntime {
/** Delete the sandbox behind a provider lease. It returns the provider result
* and throws only on a failed delete. */
deleteSandbox(ref: LoginLeaseRef): Promise<SandboxDeleteResult>;
/** List the login-tagged provider leases. Each carries its session identifier
* from the tag. */
listTaggedLeases(): Promise<TaggedLease[]>;
}
/** The counts one sweep produced. The scheduler logs a non-empty result. */
export interface ReaperSweepResult {
/** The expired non-terminal sessions the sweep marked `timed_out`. */
expiredTimedOut: number;
/** The terminal `cleanup_pending` sessions the sweep retried. */
cleanupRetried: number;
/** The terminal `cleanup_pending` sessions the sweep cleared on a confirmation. */
cleanupCleared: number;
/** The orphan tagged leases the sweep deleted. */
orphanLeasesDeleted: number;
/** The sessions still in `cleanup_pending` after the sweep. A failed delete
* keeps the state for the next sweep. */
cleanupPendingRemaining: number;
}
export interface CodexDeviceLoginReaperDeps {
store: AdapterAuthReaperStore;
runtime: LoginSessionCleanupRuntime;
now?: () => Date;
}
export function createCodexDeviceLoginReaper(deps: CodexDeviceLoginReaperDeps) {
const { store, runtime } = deps;
const now = deps.now ?? (() => new Date());
// Retry the delete for the terminal sessions already in `cleanup_pending`. The
// sweep runs this first, so a delete that fails in the expired scan below does
// not retry at once in the same sweep.
async function sweepCleanupPending(): Promise<{
retried: number;
cleared: number;
remaining: number;
}> {
const pending = await store.listCleanupPendingSessions();
let retried = 0;
let cleared = 0;
let remaining = 0;
for (const row of pending) {
const decoded = decodePendingTerminal(row.failureReason);
const finishedAt = row.finishedAt ?? now();
if (!row.providerLeaseId) {
// No lease reference to retry. Resolve the terminal so the row never
// stays pending forever.
await store.setStatus({
sessionId: row.id,
status: decoded.terminal,
at: now(),
failureReason: decoded.reason,
finishedAt,
});
cleared += 1;
continue;
}
retried += 1;
const observation = await observeSandboxDelete(() =>
runtime.deleteSandbox({
environmentId: row.environmentId,
providerLeaseId: row.providerLeaseId!,
}),
);
if (observation.confirmed) {
// The provider confirmed the delete. Clear `cleanup_pending` and record
// the retained terminal public status.
await store.setStatus({
sessionId: row.id,
status: decoded.terminal,
at: now(),
failureReason: decoded.reason,
finishedAt,
});
cleared += 1;
} else {
// The delete failed again. Keep `cleanup_pending` for the next sweep.
remaining += 1;
}
}
return { retried, cleared, remaining };
}
// Terminate every expired non-terminal session and mark it `timed_out`. A
// failed delete records `cleanup_pending`, so the next sweep retries it. The
// scan already excludes a session that still holds a live promotion claim, so
// the reaper never releases a slot whose credential write is in progress.
async function sweepExpiredSessions(at: Date): Promise<{
timedOut: number;
newlyPending: number;
}> {
const expired = await store.listExpiredActiveSessions(at);
let timedOut = 0;
let newlyPending = 0;
for (const row of expired) {
// Claim the terminalization with a conditional write from the row's current
// status. The claim reserves `cleanup_pending` and releases the company
// slot. A lost claim means the session owner reached a terminal first, so
// the reaper deletes nothing and leaves the row alone. This closes the
// lost-update race on the slot.
//
// Take the promotion critical-section lock around the claim. The credential
// promotion holds the same lock across its ownership check and its
// credential write, so the reclaim of a stale `promoting` row never
// interleaves with a live write. A crashed owner drops the lock, so the
// reaper still reclaims the stalled row on a later sweep.
const pendingWrite = terminalCleanupWrite(false, "timed_out", null);
const claimed = await store.withCompanyAdapterPromotionLock(
row.companyId,
row.adapterType,
() =>
store.compareAndSetStatus({
sessionId: row.id,
expectedStatuses: [row.status],
status: pendingWrite.status,
at: now(),
failureReason: pendingWrite.failureReason,
finishedAt: now(),
promotionExpiresAt: null,
}),
);
if (!claimed) continue;
// The reaper owns the row now. Delete the sandbox, then finalize the
// terminal only from the reserved `cleanup_pending` state.
const observation = row.providerLeaseId
? await observeSandboxDelete(() =>
runtime.deleteSandbox({
environmentId: row.environmentId,
providerLeaseId: row.providerLeaseId!,
}),
)
: // No lease reference on the row. There is no sandbox to delete through
// it. Any orphan provider lease is caught by the tagged-lease scan.
{ observed: false, confirmed: true };
if (observation.confirmed) {
await store.compareAndSetStatus({
sessionId: row.id,
expectedStatuses: ["cleanup_pending"],
status: "timed_out",
at: now(),
failureReason: null,
finishedAt: now(),
});
timedOut += 1;
} else {
// The delete failed. Keep `cleanup_pending` for the next sweep to retry.
newlyPending += 1;
}
}
return { timedOut, newlyPending };
}
// Delete the tagged provider leases that no live session references. Such a
// lease is an orphan from a crash between lease acquisition and the durable
// session write. The delete is idempotent, so a repeated sweep does not fail.
async function sweepOrphanLeases(): Promise<number> {
const [referenced, tagged] = await Promise.all([
store.listLeaseReferences(),
runtime.listTaggedLeases(),
]);
const referencedSet = new Set(referenced);
let deleted = 0;
for (const lease of tagged) {
if (referencedSet.has(lease.providerLeaseId)) continue;
const observation = await observeSandboxDelete(() =>
runtime.deleteSandbox({
environmentId: lease.environmentId,
providerLeaseId: lease.providerLeaseId,
}),
);
// A confirmed delete removes the orphan. A failed delete leaves it for the
// next sweep.
if (observation.confirmed) deleted += 1;
}
return deleted;
}
async function sweep(): Promise<ReaperSweepResult> {
const at = now();
const cleanup = await sweepCleanupPending();
const expired = await sweepExpiredSessions(at);
const orphanLeasesDeleted = await sweepOrphanLeases();
return {
expiredTimedOut: expired.timedOut,
cleanupRetried: cleanup.retried,
cleanupCleared: cleanup.cleared,
orphanLeasesDeleted,
cleanupPendingRemaining: cleanup.remaining + expired.newlyPending,
};
}
return { sweep };
}
export type CodexDeviceLoginReaper = ReturnType<typeof createCodexDeviceLoginReaper>;
// ---------------------------------------------------------------------------
// The production runtime binding.
// ---------------------------------------------------------------------------
export interface ProductionLoginSessionReaperRuntimeDeps {
db: Db;
environmentRuntime: EnvironmentRuntimeService;
}
/**
* Build the production cleanup runtime. It deletes a login sandbox through the
* environment runtime and lists the login-tagged provider leases from the lease
* store. The delete is idempotent: an already-gone lease returns `not_found`.
*/
export function createProductionLoginSessionReaperRuntime(
deps: ProductionLoginSessionReaperRuntimeDeps,
): LoginSessionCleanupRuntime {
const environmentsSvc = environmentService(deps.db);
return {
async deleteSandbox(ref) {
const environment = await environmentsSvc.getById(ref.environmentId);
if (!environment) {
// The environment is gone, so the sandbox is gone. Treat it as an
// idempotent confirmed delete.
return { outcome: "not_found" };
}
const leases = await environmentsSvc.listLeases(ref.environmentId);
const lease = leases.find((candidate) => candidate.providerLeaseId === ref.providerLeaseId);
if (!lease) {
// No live lease row holds this provider lease. The sandbox is already
// released, so the delete is an idempotent confirmation.
return { outcome: "not_found" };
}
const driverKey =
typeof lease.metadata?.driver === "string" ? lease.metadata.driver : environment.driver;
const runtimeDriver = deps.environmentRuntime.getDriver(driverKey);
if (!runtimeDriver) {
throw new Error(`Environment driver "${driverKey}" is not registered.`);
}
const released = await runtimeDriver.releaseRunLease({
environment,
lease,
status: "released",
});
// A failed provider cleanup is not a confirmed delete. The reaper keeps
// `cleanup_pending` and retries on the next sweep.
if (released?.cleanupStatus === "failed") {
throw new Error("The sandbox delete did not confirm.");
}
return { outcome: "deleted" };
},
async listTaggedLeases() {
// The candidate environments are those with a login session that still
// owns a lease. An orphan session row keeps its environment, so this set
// covers every orphan lease.
const environmentRows = await deps.db
.selectDistinct({ environmentId: adapterAuthSessions.environmentId })
.from(adapterAuthSessions)
.where(
inArray(adapterAuthSessions.status, [
...ADAPTER_AUTH_ACTIVE_STATUSES,
"cleanup_pending",
]),
);
const tagged: TaggedLease[] = [];
for (const { environmentId } of environmentRows) {
const leases = await environmentsSvc.listLeases(environmentId, { status: "active" });
for (const lease of leases) {
const sessionId = lease.metadata?.[LOGIN_LEASE_SESSION_TAG_KEY];
if (typeof sessionId === "string" && lease.providerLeaseId) {
tagged.push({ environmentId, providerLeaseId: lease.providerLeaseId, sessionId });
}
}
}
return tagged;
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,8 @@ import type {
AgentInstructionsFileDetail,
AgentSkillSnapshot,
AdapterEnvironmentTestResult,
AdapterAuthSessionResponse,
AdapterAuthSessionOwnerResponse,
AgentKeyCreated,
AgentRuntimeState,
AgentTaskSession,
@ -237,6 +239,24 @@ export const agentsApi = {
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
loginWithClaude: (id: string, companyId?: string) =>
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
startAdapterAuthLogin: (
companyId: string,
type: string,
data: { environmentId: string; ttlSeconds?: number },
) =>
api.post<AdapterAuthSessionResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
data,
),
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
api.get<AdapterAuthSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
),
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
api.post<AdapterAuthSessionOwnerResponse>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}/cancel`,
{},
),
availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
};

View File

@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -7,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, Environment } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { AgentConfigForm } from "./AgentConfigForm";
import { AgentConfigForm, AdapterLoginPanel, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { defaultCreateValues } from "./agent-config-defaults";
const mockAgentsApi = vi.hoisted(() => ({
@ -16,6 +17,13 @@ const mockAgentsApi = vi.hoisted(() => ({
detectModel: vi.fn(),
list: vi.fn(),
testEnvironment: vi.fn(),
startAdapterAuthLogin: vi.fn(),
getAdapterAuthLoginStatus: vi.fn(),
cancelAdapterAuthLogin: vi.fn(),
}));
const mockClipboard = vi.hoisted(() => ({
copyTextToClipboard: vi.fn(),
}));
const mockEnvironmentsApi = vi.hoisted(() => ({
@ -49,6 +57,10 @@ vi.mock("../api/secrets", () => ({
secretsApi: mockSecretsApi,
}));
vi.mock("../lib/clipboard", () => ({
copyTextToClipboard: mockClipboard.copyTextToClipboard,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip" }],
@ -277,6 +289,69 @@ async function renderCreateForm(
return { container, root, onChange };
}
const AUTH_MISSING_RESULT = {
adapterType: "codex_local",
status: "fail",
checks: [
{
code: "adapter_auth_missing",
level: "error",
message: "The sandbox has no ready authentication.",
},
],
testedAt: new Date(0).toISOString(),
};
function findButton(container: HTMLElement, label: string) {
return Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === label,
);
}
function findByAriaLabel(container: HTMLElement, label: string) {
return container.querySelector<HTMLElement>(`[aria-label="${label}"]`);
}
async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
{ defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
async function clickByText(container: HTMLElement, label: string) {
const button = findButton(container, label);
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
async function clickElement(element: Element | null | undefined) {
await act(async () => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
async function runTest(container: HTMLElement) {
await clickByText(container, "Test");
}
async function startLogin(container: HTMLElement) {
await clickByText(container, "Log in");
await flushReact();
}
describe("AgentConfigForm environment selector", () => {
let roots: Root[] = [];
@ -296,6 +371,30 @@ describe("AgentConfigForm environment selector", () => {
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/device", code: "WXYZ-1234" },
});
mockAgentsApi.cancelAdapterAuthLogin.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "cancelled",
expiresAt: null,
failure: null,
prompt: null,
});
mockClipboard.copyTextToClipboard.mockResolvedValue(undefined);
});
afterEach(async () => {
@ -609,4 +708,349 @@ describe("AgentConfigForm environment selector", () => {
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(result.container.textContent).toContain("Network unavailable");
});
it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Codex sandbox", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
});
it("shows the Login button when a parent lifts the test feedback and renders the panel from the descriptor", async () => {
// The create page hides the inline feedback branch and renders the test
// result and the login panel itself. This harness mirrors that parent: it
// lifts the feedback and renders `AdapterLoginPanel` from the lifted login
// descriptor. Without the descriptor the Login button never appears.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockEnvironmentsApi.list.mockResolvedValue([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
]);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
function LiftedFeedbackHarness() {
const [login, setLogin] = useState<AdapterLoginDescriptor | null>(null);
return (
<>
<AgentConfigForm
mode="create"
values={{
...defaultCreateValues,
adapterType: "codex_local",
defaultEnvironmentId: "sandbox-1",
}}
onChange={() => {}}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
onTestFeedbackChange={(feedback) => setLogin(feedback.login)}
/>
{login && (
<AdapterLoginPanel
companyId={login.companyId}
adapterType={login.adapterType}
environmentId={login.environmentId}
/>
)}
</>
);
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>
<LiftedFeedbackHarness />
</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
expect(findButton(container, "Log in")).toBeFalsy();
await runTest(container);
expect(findButton(container, "Log in")).toBeTruthy();
});
it("does not show the Login button when the Test result has no adapter_auth_missing check", async () => {
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
});
it("does not show the Login button when the effective environment is Local", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{},
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
});
it("starts a login session for the effective sandbox and shows the code and the authentication URL", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", {
environmentId: "sandbox-1",
});
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.textContent).toContain("https://auth.example.test/device");
});
it("shows the loading state while the session starts and the prompt is not ready", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Preparing the login");
expect(result.container.textContent).not.toContain("https://");
});
it("copies the login code and the authentication URL", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await clickElement(findByAriaLabel(result.container, "Copy code"));
await clickElement(findByAriaLabel(result.container, "Copy URL"));
expect(mockClipboard.copyTextToClipboard).toHaveBeenCalledWith("WXYZ-1234");
expect(mockClipboard.copyTextToClipboard).toHaveBeenCalledWith("https://auth.example.test/device");
});
it("keeps the code and URL visible after a later poll returns no prompt", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
// The server delivers the one-time prompt on the first owner read only. The
// first status poll carries the prompt; every later poll carries a null one.
mockAgentsApi.getAdapterAuthLoginStatus
.mockResolvedValueOnce({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/device", code: "WXYZ-1234" },
})
.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("WXYZ-1234");
// Wait for the next status poll, which returns no prompt.
const start = Date.now();
while (mockAgentsApi.getAdapterAuthLoginStatus.mock.calls.length < 2) {
if (Date.now() - start > 6000) throw new Error("the status poll did not run a second time");
await flushReact();
await new Promise((resolve) => setTimeout(resolve, 50));
}
await flushReact();
// The panel latched the prompt, so the code and the URL stay visible.
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.textContent).toContain("https://auth.example.test/device");
});
it("shows a Cancel affordance while a login is active and cancels the session", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
// The Cancel button appears while the session is active.
expect(findButton(result.container, "Cancel")).toBeTruthy();
await clickByText(result.container, "Cancel");
await flushReact();
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledWith(
"company-1",
"codex_local",
"session-1",
);
// The panel resets: the Log in button is available again and the code is gone.
const login = findButton(result.container, "Log in");
expect(login?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("WXYZ-1234");
});
it("announces the login state through a polite live region", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
const live = result.container.querySelector('[role="status"][aria-live="polite"]');
expect(live).toBeTruthy();
expect(live?.textContent).toContain("WXYZ-1234");
});
it("opens the authentication URL in a new tab with a safe rel", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
const link = result.container.querySelector('a[href="https://auth.example.test/device"]');
expect(link).toBeTruthy();
expect(link?.getAttribute("target")).toBe("_blank");
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
});
it("disables a second login start while a session is active", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
const startButton = findButton(result.container, "Log in");
expect(startButton).toBeTruthy();
expect(startButton?.disabled).toBe(true);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
});
it("renders the authenticated terminal state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Authenticated");
});
it("renders the failed terminal state with the non-secret message", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "failed",
expiresAt: null,
failure: { reason: "device_rejected", message: "The device rejected the code." },
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Login failed");
expect(result.container.textContent).toContain("The device rejected the code.");
});
it("renders the timed-out terminal state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "timed_out",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Login timed out");
});
it("hides the Login button when the effective environment changes after a Test", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
const select = result.container.querySelector("select");
await act(async () => {
if (select) {
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
setter?.call(select, "");
select.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await flushReact();
expect(findButton(result.container, "Log in")).toBeFalsy();
});
});

View File

@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
AdapterAuthSessionPrompt,
AdapterAuthSessionStatus,
AdapterEnvironmentTestResult,
CompanySecret,
EnvBinding,
@ -19,14 +21,16 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/a
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/adapter-codex-local/ui";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import { resolveAdapterTestEnvironmentId } from "../lib/adapter-test-environment";
import { extractModelName, extractProviderId } from "../lib/model-utils";
import { queryKeys } from "../lib/queryKeys";
@ -84,6 +88,12 @@ type AgentConfigFormProps = {
onTestFeedbackChange?: (feedback: {
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
// The login panel descriptor when the current target is a sandbox with no
// ready authentication, otherwise null. A parent that lifts the test
// feedback must render `AdapterLoginPanel` from this descriptor. The inline
// feedback branch renders the panel itself, so this descriptor is the only
// way the panel reaches a parent that hides the inline branch.
login: AdapterLoginDescriptor | null;
}) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
@ -455,6 +465,23 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
[environments, instanceDefaultEnvironmentId],
);
// The environment a login session runs in. It mirrors the Test resolution: the
// agent's own environment wins, otherwise the instance default. The login
// affordance shows only when this environment is a sandbox, because the
// canonical auth-missing check comes only from a sandbox target.
const effectiveLoginEnvironmentId = useMemo(
() =>
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
instanceDefaultEnvironmentId: instanceSettings?.defaultEnvironmentId ?? null,
}),
[rawCurrentDefaultEnvironmentId, instanceSettings?.defaultEnvironmentId],
);
const effectiveLoginEnvironment = useMemo(
() => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null,
[environments, effectiveLoginEnvironmentId],
);
// When the instance forces Kubernetes execution, new agents must default to the
// managed Kubernetes sandbox environment (never the implicit local default).
// Only applies in create mode and only once the K8s environment is loaded; if
@ -753,6 +780,32 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const testActionLabel = "Test";
const isSavePending = !isCreate && Boolean(props.isSaving);
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId;
// Drop a stale Test result when the adapter type or the effective environment
// changes. A held result would keep the login affordance visible for a target
// the user no longer selected. The reset unmounts the login panel too, so its
// session state clears with it. Hold `reset` in a ref so the effect does not
// re-run on every render (the mutation object has a new identity each render).
const resetTestEnvironmentRef = useRef(testEnvironment.reset);
resetTestEnvironmentRef.current = testEnvironment.reset;
useEffect(() => {
resetTestEnvironmentRef.current();
setTestActionError(null);
}, [adapterType, effectiveLoginEnvironmentId]);
// Show the login affordance only for a current `codex_local` sandbox whose most
// recent Test result carries the canonical auth-missing check. The result keeps
// its own `adapterType`, so a result from another adapter never gates the panel.
const authMissingCheck =
testEnvironment.data?.adapterType === "codex_local"
? testEnvironment.data.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
: null;
const showAdapterLogin =
adapterType === "codex_local" &&
effectiveLoginEnvironment?.driver === "sandbox" &&
Boolean(effectiveLoginEnvironmentId) &&
Boolean(selectedCompanyId) &&
Boolean(authMissingCheck);
const runEnvironmentTest = useCallback(async () => {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
@ -819,11 +872,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? "Environment test failed"
: null),
result: testEnvironment.data ?? null,
// `showAdapterLogin` already requires a selected company and a non-empty
// environment id, so both are present here.
login:
showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId
? { companyId: selectedCompanyId, adapterType, environmentId: effectiveLoginEnvironmentId }
: null,
});
return () => {
props.onTestFeedbackChange?.({ errorMessage: null, result: null });
props.onTestFeedbackChange?.({ errorMessage: null, result: null, login: null });
};
}, [props.onTestFeedbackChange, testActionError, testEnvironment.data, testEnvironment.error]);
}, [
props.onTestFeedbackChange,
testActionError,
testEnvironment.data,
testEnvironment.error,
showAdapterLogin,
selectedCompanyId,
adapterType,
effectiveLoginEnvironmentId,
]);
// Current model for display
const currentModelValue = isCreate
@ -1231,6 +1299,15 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<AdapterEnvironmentResult result={testEnvironment.data} />
)}
{showInlineAdapterTestEnvironmentFeedback && showAdapterLogin && (
<AdapterLoginPanel
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
companyId={selectedCompanyId!}
adapterType={adapterType}
environmentId={effectiveLoginEnvironmentId!}
/>
)}
{/* Working directory */}
{showLegacyWorkingDirectoryField && (
<Field label="Working directory (deprecated)" hint={help.cwd}>
@ -1636,6 +1713,264 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
);
}
// The public session states that end a login. The panel stops the status poll
// and shows a terminal message when the session reaches one of these.
const ADAPTER_LOGIN_TERMINAL_STATUSES = new Set<AdapterAuthSessionStatus>([
"authenticated",
"failed",
"timed_out",
"cancelled",
]);
// The status route poll interval while a session is active (Decision A). The
// poll stops at a terminal state.
const ADAPTER_LOGIN_POLL_INTERVAL_MS = 2000;
// A copy-to-clipboard button. It mirrors the workspace service control bar: a
// short "copied" flash, then it returns to the copy icon.
function AdapterLoginCopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
title={label}
className="text-muted-foreground hover:text-foreground"
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
} catch {
setCopied(false);
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</Button>
);
}
// The terminal message for a finished login. It never shows a secret. It shows
// only the fixed, non-secret failure message the server returns.
function AdapterLoginTerminalState({
status,
message,
}: {
status: AdapterAuthSessionStatus;
message: string | null;
}) {
if (status === "authenticated") {
return (
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
<Check className="size-3 shrink-0" />
<span>Authenticated. The sandbox has credentials now.</span>
</div>
);
}
const label =
status === "timed_out"
? "Login timed out"
: status === "cancelled"
? "Login cancelled"
: "Login failed";
return (
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
<TriangleAlert className="size-3 shrink-0" />
<span>
{label}
{message ? `: ${message}` : "."}
</span>
</div>
);
}
// The login panel for one adapter in one sandbox environment. It starts a login
// session, polls the status route, and shows the one-time code and the
// authentication URL with copy and open actions. It shows the terminal states.
// It never writes the code, the URL, or any credential byte to a log line.
//
// The panel holds its own session state. The parent gives it a stable `key` from
// the adapter type and the environment id, so a change to either remounts the
// panel with a fresh session state.
// The props that identify one login panel: one adapter in one sandbox
// environment for one company. A parent that lifts the test feedback renders
// the panel from this descriptor.
export type AdapterLoginDescriptor = {
companyId: string;
adapterType: string;
environmentId: string;
};
export function AdapterLoginPanel({
companyId,
adapterType,
environmentId,
}: AdapterLoginDescriptor) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
// The server delivers the one-time prompt on the first owner read only. Latch
// it so a later poll that returns a null prompt does not hide the code and the
// URL.
const [latchedPrompt, setLatchedPrompt] = useState<AdapterAuthSessionPrompt | null>(null);
const startLogin = useMutation({
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }),
onSuccess: (session) => {
setStartError(null);
setLatchedPrompt(null);
setSessionId(session.sessionId);
},
onError: (error) => {
setStartError(error instanceof Error ? error.message : "Could not start the login.");
},
});
const cancelLogin = useMutation({
mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!),
onSuccess: () => {
// Reset local state, so the panel returns to its idle start state and the
// Log in button is available again.
setSessionId(null);
setLatchedPrompt(null);
setStartError(null);
},
onError: (error) => {
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
},
});
const statusQuery = useQuery({
queryKey: ["adapter-login-status", companyId, adapterType, sessionId],
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
enabled: Boolean(sessionId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status)
? false
: ADAPTER_LOGIN_POLL_INTERVAL_MS;
},
});
// Latch the first non-null prompt for the current session. A later poll
// returns a null prompt after the one-time delivery, so keep the latched value.
useEffect(() => {
const next = statusQuery.data?.prompt ?? null;
if (next) setLatchedPrompt(next);
}, [statusQuery.data]);
const session = statusQuery.data ?? startLogin.data ?? null;
const status = session?.status ?? null;
const prompt = latchedPrompt;
const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false;
const isActive = Boolean(sessionId) && !isTerminal;
const startDisabled = startLogin.isPending || isActive;
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">Sign in to the sandbox</span>
<div className="flex items-center gap-1.5">
{isActive && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={cancelLogin.isPending}
onClick={() => cancelLogin.mutate()}
>
Cancel
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={startDisabled}
onClick={() => startLogin.mutate()}
>
Log in
</Button>
</div>
</div>
{startError && (
<div role="alert" className="text-(length:--text-micro) text-destructive">
{startError}
</div>
)}
{/* One live region announces the loading, prompt, and terminal states, so a
screen reader reports each transition without a re-navigation. */}
<div role="status" aria-live="polite" className="space-y-2 empty:hidden">
{isActive && !prompt && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="size-3 animate-spin shrink-0" />
<span>Preparing the login…</span>
</div>
)}
{isActive && prompt && (
<div className="space-y-2">
<div className="text-(length:--text-micro) text-muted-foreground">
Open the authentication page and enter the code.
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
Code
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.code}</span>
</div>
<AdapterLoginCopyButton value={prompt.code} label="Copy code" />
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
Authentication URL
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.url}</span>
</div>
<div className="flex items-center">
<AdapterLoginCopyButton value={prompt.url} label="Copy URL" />
<Button
asChild
type="button"
variant="ghost"
size="icon-xs"
aria-label="Open the authentication page"
title="Open the authentication page"
className="text-muted-foreground hover:text-foreground"
>
<a href={prompt.url} target="_blank" rel="noreferrer noopener">
<ExternalLink className="size-3" />
</a>
</Button>
</div>
</div>
</div>
)}
{isTerminal && status && (
<AdapterLoginTerminalState status={status} message={session?.failure?.message ?? null} />
)}
</div>
</div>
);
}
export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
const statusLabel =
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";

View File

@ -23,6 +23,8 @@ import { roleLabels } from "../components/agent-config-primitives";
import {
AgentConfigForm,
AdapterEnvironmentResult,
AdapterLoginPanel,
type AdapterLoginDescriptor,
type CreateConfigValues,
} from "../components/AgentConfigForm";
import { defaultCreateValues } from "../components/agent-config-defaults";
@ -80,9 +82,11 @@ export function NewAgent() {
const [testAgentFeedback, setTestAgentFeedback] = useState<{
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
login: AdapterLoginDescriptor | null;
}>({
errorMessage: null,
result: null,
login: null,
});
const { data: agents } = useQuery({
@ -203,6 +207,7 @@ export function NewAgent() {
const handleTestAgentFeedbackChange = useCallback((feedback: {
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
login: AdapterLoginDescriptor | null;
}) => {
setTestAgentFeedback(feedback);
}, []);
@ -359,6 +364,14 @@ export function NewAgent() {
{testAgentFeedback.result && (
<AdapterEnvironmentResult result={testAgentFeedback.result} />
)}
{testAgentFeedback.login && (
<AdapterLoginPanel
key={`${testAgentFeedback.login.adapterType}:${testAgentFeedback.login.environmentId}`}
companyId={testAgentFeedback.login.companyId}
adapterType={testAgentFeedback.login.adapterType}
environmentId={testAgentFeedback.login.environmentId}
/>
)}
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
Cancel