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:
parent
9c941169a6
commit
e5a7fd7038
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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-");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue