mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use a harness, a model, and a credential to run tasks. > - Connections already store credentials and control who can use them. > - Custom providers also need an endpoint and a supported API format. > - A per-agent endpoint would duplicate credentials and access rules. > - This pull request stores routing on the connection and projects it into the harness. > - Isolated credentials and protocol checks keep the selected connection authoritative. ## Linked Issues or Issue Description Refs #37, #13083, #14104, #14565, #12692. #14016 is a reference only. This PR has its own schema, vault persistence, routing validation, runtime projection, and tests. None of the five commits in #14016 is an ancestor of this branch. We do not depend on or plan to merge it. #14967 addresses task-pinned account pools. #14422 addresses another provider integration. This is the first of two linked PRs. Merge this connection change before #15341, which refines agent setup and adds the qualification harness. The split keeps each review below 100 changed files. Provider catalog entries have usable setup forms in this PR. Local browser subscription sign-in is included. ## What Changed - Store non-secret routing metadata on AI connections. Vault provider API keys, including Bedrock bearer API keys. Reject general AWS access keys. - Enforce company, owner, human audience, agent access, connection status, and protocol checks before resolving credentials. Keep reconnect destinations immutable and retain connection identity during key rotation. - Project OpenRouter and compatible custom endpoints into Codex, Claude, OpenCode, and local Hermes. Carry these settings through both legacy and native runner transports. Clear conflicting host credentials and redact keys from diagnostics. - Preserve older OpenRouter accounts and native personal defaults. Add Google API-key accounts and migration `0306` for the two provider-default constraints. - Run local Claude and Codex subscription sign-in behind the existing browser sign-in card. Use private attempt homes and owner-bound completion instead of a copied terminal command. - Seed isolated Gemini authentication and preserve OpenCode workspace permissions. Keep the selected connection authoritative. The independent Gemini and Grok workflow fixes are in #15341. - Keep native OpenCode custom gateway keys in a runner-owned selected-model proxy; the harness config contains only a session-scoped capability. Honor runtime outgoing proxy and certificate settings. Preserve streamed responses and revoke the proxy on close or startup failure. - Allow ordinary members to connect native personal accounts before an agent exists. - Repair routed accounts from task cards using the saved provider destination, protocol, model aliases, and connection identity. - Add provider catalog definitions, model discovery, pinned logos, and complete native and routed setup forms. Allow a personal routed connection before a new agent exists. Keep endpoint authentication keys out of Hermes terminal children. - Recover cancelled or restarted browser sign-in with a clear restart action. Support no-auth endpoints without a vault credential. Add isolation and recovery regressions and runtime documentation. ## Verification - Updated with `origin/master` at `22a3ea341`. Migration `0306` follows the new master migration and passes migration and snapshot checks. - The integrated connection regressions passed 152 tests and 50 native OpenCode driver tests, including key-free child-shell configuration reads, authenticated/no-auth forwarding, streaming, model/path restrictions, cancellation, outgoing proxy routing, and NO_PROXY bypass. Provider setup has 14 passing tests. The pinned real OpenCode 1.18.34 executable also completed a turn through the proxy against a local synthetic provider; the reusable key was absent from its config. A second real-executable smoke passed with an HTTPS CONNECT proxy and runtime-specific synthetic certificate trust. Certificate-file and certificate-directory regressions pass. - Task-card repair passed 48 tests, including OpenRouter, Bedrock, and custom gateway reconnect cases. UI typecheck and token gates passed. - The prior core regression set passed 133 tests across new-agent setup, provider forms, browser sign-in, routing projection, and connection authorization. Token gates and UI typecheck passed. - Full workspace typecheck and production build passed again after the latest integration and credential-proxy fix. The merged deterministic runner E2E suite passed 1,400 Vitest tests and 128 Node tests. - Full workspace typecheck passed on the prior linked combined implementation. Production build, Storybook build, 1,316 browser-harness Vitest tests, and 128 Node tests passed. Head `9d964c8d9` includes the latest master integration and regenerated migration. This exact head passed 54 remote checks with four expected skips and Greptile 5/5; no review threads remain open. An unchanged server fixture had a random six-character issue-prefix collision on its first attempt. All 245 tests passed locally and the single CI retry passed. - A provider-free terminal check used the cited supported Hermes source and dummy keys. Gateway and OpenRouter terminal children could not read the selected key. - The broad local Vitest attempt passed 15,442 tests but was not green. It had an embedded-Postgres startup failure, an HTTP logger timeout, an origin socket error, and a browser cancellation wait timeout. The cancellation wait was corrected. The relevant connection tests and the full origin test file passed separately. Latest-head CI must pass before merge. - Prior credential-backed acceptance exercised task creation, tool use, artifact delivery, completion, and context-dependent follow-up. Claude legacy and native runners passed Bedrock with `us-east-1` and `us.anthropic.claude-sonnet-4-6`. - Historical local qualification retained 43 passing API/gateway cells out of 46. Those attempts span earlier builds. They do not qualify this exact commit or staging. All subscription combinations and staging remain unqualified. - Verify native subscription and API-key setup. Connect a regular provider catalog row. Verify an incompatible harness and a changed reconnect URL are rejected. Use #15341 for the complete browser campaign. ## Risks - Migration `0306` changes two check constraints. It preserves rows and is safe to reapply. It takes normal constraint-change locks. - Credential projection touches several harnesses. CLI upgrades can change provider configuration and session behavior. - The native OpenCode proxy adds a loopback hop, pins requests to the selected model, limits request bodies to 16 MiB, rejects redirects, and expires at session close. It prevents reusable keys in the child configuration; it is not an OS isolation boundary against a process debugger running as the same user. - Custom endpoints must be reachable from the agent environment. Saving a connection does not prove connectivity. Bedrock keys require rotation before expiry. - Gemini CLI 0.58.0 has an upstream ACP new-file error conversion defect. Provider overloads and an unresolved follow-up timeout also affect live Gemini qualification. We have not patched the installed CLI or marked those cases as passing. - OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore, Process, HTTP, and legacy ACPX local are excluded. Vertex, ambient AWS identity, arbitrary auth headers, and custom routing for other harnesses are excluded. - These PRs do not establish production or staging qualification for every provider and login method. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact deployment model ID and context window size were not exposed in this session. ## 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>
230 lines
18 KiB
TypeScript
230 lines
18 KiB
TypeScript
import { beforeAll, afterAll, it, expect, vi } from "vitest";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog, companySecrets } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
|
import { secretService } from "../services/secrets.js";
|
|
import { aiConnectionService } from "../services/ai-connections.js";
|
|
import { prepareManagedAiRuntime, managedAiSessionFingerprintConfig } from "../services/ai-connection-runtime.js";
|
|
import { localAiLoginService } from "../services/local-ai-login.js";
|
|
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
|
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
|
|
|
const browserLogin = vi.hoisted(() => ({ submitCode: vi.fn(), abort: vi.fn() }));
|
|
vi.mock("../services/local-ai-browser-login.js", () => ({
|
|
startLocalBrowserLogin: () => ({
|
|
authorizationUrl: "https://auth.openai.com/codex/device",
|
|
code: "TEST-CODE",
|
|
submitCode: browserLogin.submitCode,
|
|
abort: browserLogin.abort,
|
|
}),
|
|
}));
|
|
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let home: string;
|
|
const companyId = randomUUID(), agentId = randomUUID(), owner = "audit-owner";
|
|
beforeAll(async () => {
|
|
home = await mkdtemp(path.join(os.tmpdir(), "ai-legacy-audit-"));
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
database = await startEmbeddedPostgresTestDatabase("ai-legacy-audit-db-");
|
|
db = createDb(database.connectionString);
|
|
await db.insert(companies).values({ id: companyId, name: "Audit", issuePrefix: "AUD" });
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "owner" });
|
|
await db.insert(environments).values({ name: "Local audit", driver: "local" }).onConflictDoNothing();
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Unadopted legacy agent", adapterType: "claude_local" });
|
|
}, 90000);
|
|
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); if (home) await rm(home, { recursive: true, force: true }); });
|
|
|
|
it("reconnect detaches an indexed credential without changing unadopted legacy agents", async () => {
|
|
const vault = secretService(db), service = aiConnectionService(db);
|
|
const definition = await vault.createUserSecretDefinition(companyId, { key: "audit_legacy_claude", name: "Legacy key", provider: "local_encrypted" }, { userId: owner });
|
|
const secret = await vault.createCurrentUserSecretValue(companyId, owner, { definitionId: definition.id, value: "fixture-original" }, { userId: owner });
|
|
const legacyConfig = { env: { ANTHROPIC_API_KEY: { type: "user_secret_ref", key: definition.key, required: true } } };
|
|
await db.update(agents).set({ adapterConfig: legacyConfig }).where(eq(agents.id, agentId));
|
|
await vault.syncUserSecretDeclarationsForTarget(companyId, { targetType: "agent", targetId: agentId }, [{ definitionKey: definition.key, configPath: "env.ANTHROPIC_API_KEY", envKey: "ANTHROPIC_API_KEY", required: true }]);
|
|
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
|
|
await db.execute(sql.raw(migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")))));
|
|
const connection = (await service.list(companyId, owner)).find(c => c.name === secret.name)!;
|
|
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "delegated", connectionId: connection.id, grantId: connection.grantId } as const, config: {}, allowLegacyValidation: true };
|
|
const before = await prepareManagedAiRuntime(db, input);
|
|
await before.cleanup();
|
|
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
|
|
expect((await resolve())?.value).toBe("fixture-original");
|
|
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
|
|
expect((await resolve())?.value).toBe("fixture-original");
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, connection.grantId));
|
|
expect(grant.credentialSecretRefs[0].secretId).not.toBe(secret.id);
|
|
const [oldSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id));
|
|
const [newSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId));
|
|
expect(oldSecret.aiSessionEpoch).toBe(0);
|
|
expect(newSecret.aiSessionEpoch).toBe(0);
|
|
const after = await prepareManagedAiRuntime(db, input);
|
|
try {
|
|
expect(after.attribution.grantId).toBe(before.attribution.grantId);
|
|
expect(after.sessionIdentity).not.toBe(before.sessionIdentity);
|
|
expect(managedAiSessionFingerprintConfig(after.config, after.home)).not.toEqual(managedAiSessionFingerprintConfig(before.config, before.home));
|
|
} finally { await after.cleanup(); }
|
|
const selected = await service.select({ companyId, agentId, userId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
|
|
expect(await service.credential(selected)).toBe("fixture-new-account");
|
|
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-second" }, "fixture-second");
|
|
expect((await resolve())?.value).toBe("fixture-original");
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
|
|
expect(agent.adapterConfig).toEqual(legacyConfig);
|
|
});
|
|
|
|
const intent = { provider: "openai", method: "subscription", name: "Isolated Codex", ownership: "personal", agentIds: [], allAgents: true } as const;
|
|
const loginIntent = () => ({ ...intent, agentIds: [] });
|
|
const auth = (mark: string, hour = 10) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${mark}`, access_token: `access-${mark}`, refresh_token: `refresh-${mark}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
const directoryFor = (id: string) => path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
|
|
|
|
it("accepts a Claude code once, only from its owner, and audits without the code", async () => {
|
|
const login = localAiLoginService(db);
|
|
const intent = { ...loginIntent(), provider: "anthropic" as const };
|
|
const attempt = await login.start(companyId, owner, intent);
|
|
browserLogin.submitCode.mockClear();
|
|
await expect(login.submitCode(companyId, "another-owner", attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
|
|
await expect(login.submitCode(randomUUID(), owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("not found");
|
|
expect(browserLogin.submitCode).not.toHaveBeenCalled();
|
|
await login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code");
|
|
expect(browserLogin.submitCode).toHaveBeenCalledExactlyOnceWith("fixture-secret-code");
|
|
await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-secret-code")).rejects.toThrow("cannot accept a code");
|
|
const events = await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId));
|
|
expect(events.filter(event => event.action === "ai_connection.local_login_code_submitted")).toHaveLength(1);
|
|
expect(JSON.stringify(events)).not.toContain("fixture-secret-code");
|
|
await login.cancel(companyId, owner, attempt.sessionId);
|
|
});
|
|
|
|
it.each(["cancelled", "expired"])("rejects code submission to a %s Claude attempt", async state => {
|
|
const login = localAiLoginService(db);
|
|
const intent = { ...loginIntent(), provider: "anthropic" as const };
|
|
const attempt = await login.start(companyId, owner, intent);
|
|
browserLogin.submitCode.mockClear();
|
|
if (state === "cancelled") await login.cancel(companyId, owner, attempt.sessionId);
|
|
else await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, attempt.sessionId));
|
|
await expect(login.submitCode(companyId, owner, attempt.sessionId, "fixture-code")).rejects.toThrow("expired or was cancelled");
|
|
expect(browserLogin.submitCode).not.toHaveBeenCalled();
|
|
await login.reapExpired();
|
|
});
|
|
|
|
it("isolates sign-in and refresh from the host, survives restart, and completes only once", async () => {
|
|
const hostHome = path.join(home, "host-codex");
|
|
await mkdir(hostHome);
|
|
await writeFile(path.join(hostHome, "auth.json"), auth("legacy"));
|
|
vi.stubEnv("CODEX_HOME", hostHome);
|
|
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}")));
|
|
const login = localAiLoginService(db);
|
|
const attempt = await login.start(companyId, owner, loginIntent());
|
|
const directory = directoryFor(attempt.sessionId);
|
|
expect(await localAiLoginService(db).start(companyId, owner, loginIntent())).toEqual(attempt);
|
|
expect(attempt.command).toBeUndefined();
|
|
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
|
|
expect(await localAiLoginService(db).check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({
|
|
status: "sign_in_required", authorizationUrl: "https://auth.openai.com/codex/device", code: "TEST-CODE",
|
|
});
|
|
// Resuming a valid attempt also repairs a missing directory without changing its ID.
|
|
await rm(directory, { recursive: true });
|
|
expect(await login.start(companyId, owner, loginIntent())).toEqual(attempt);
|
|
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
|
|
// A valid host login cannot satisfy an unfinished connection-specific login.
|
|
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("Finish browser sign-in");
|
|
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
|
|
await writeFile(path.join(directory, "auth.json"), auth("independent-login"));
|
|
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "ready" });
|
|
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
|
|
await expect(login.check(companyId, "another-owner", loginIntent(), attempt.sessionId)).rejects.toThrow("not found");
|
|
await expect(login.check(randomUUID(), owner, loginIntent(), attempt.sessionId)).rejects.toThrow("not found");
|
|
await expect(login.check(companyId, owner, { ...loginIntent(), ownership: "shared" }, attempt.sessionId)).rejects.toThrow("not found");
|
|
// New service instance simulates process restart: all intent is durable.
|
|
const results = await Promise.all([
|
|
localAiLoginService(db).complete(companyId, owner, attempt.sessionId, loginIntent()),
|
|
localAiLoginService(db).complete(companyId, owner, attempt.sessionId, loginIntent()),
|
|
]);
|
|
expect(results[0]).toEqual(results[1]);
|
|
await expect(readFile(path.join(directory, "auth.json"))).rejects.toHaveProperty("code", "ENOENT");
|
|
const input = { companyId, agentId, adapterType: "codex_local", responsibleUserId: owner, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { cwd: home, model: "unchanged-model" } };
|
|
const run = await prepareManagedAiRuntime(db, input);
|
|
const credentialFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
|
|
expect(JSON.parse(await readFile(credentialFile, "utf8")).tokens.refresh_token).toBe("refresh-independent-login");
|
|
await writeFile(credentialFile, auth("rotated-independent", 11));
|
|
await run.cleanup();
|
|
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
|
|
const next = await prepareManagedAiRuntime(db, input);
|
|
expect(JSON.parse(await readFile(path.join(String(next.config.env.CODEX_HOME), "auth.json"), "utf8")).tokens.refresh_token).toBe("refresh-rotated-independent");
|
|
expect(next.config.model).toBe(input.config.model);
|
|
await next.cleanup();
|
|
// Cancellation after successful completion must not revoke the saved account.
|
|
await login.cancel(companyId, owner, attempt.sessionId);
|
|
expect((await aiConnectionService(db).list(companyId, owner)).find(c => c.id === results[0].connectionId)?.status).toBe("connected");
|
|
expect((await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId)))
|
|
.filter(event => event.action === "ai_connection.local_login_cancelled")).toHaveLength(0);
|
|
const reconnectIntent = { ...loginIntent(), connectionId: results[0].connectionId };
|
|
const reconnect = await login.start(companyId, owner, reconnectIntent);
|
|
expect(reconnect.sessionId).not.toBe(attempt.sessionId);
|
|
await writeFile(path.join(directoryFor(reconnect.sessionId), "auth.json"), auth("new-login"));
|
|
expect(await login.complete(companyId, owner, reconnect.sessionId, reconnectIntent)).toEqual(results[0]);
|
|
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
|
|
});
|
|
|
|
it("enforces local attempt ownership, company, target, cancellation, and expiry", async () => {
|
|
const login = localAiLoginService(db);
|
|
const attempt = await login.start(companyId, owner, loginIntent());
|
|
const cancellations = async () => (await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId)))
|
|
.filter(event => event.action === "ai_connection.local_login_cancelled");
|
|
await expect(login.complete(companyId, "another-owner", attempt.sessionId, loginIntent())).rejects.toThrow("not found");
|
|
await expect(login.cancel(companyId, "another-owner", attempt.sessionId)).rejects.toThrow("not found");
|
|
await expect(login.cancel(randomUUID(), owner, attempt.sessionId)).rejects.toThrow("not found");
|
|
await expect(login.complete(companyId, owner, attempt.sessionId, { ...loginIntent(), ownership: "shared" })).rejects.toThrow("not found");
|
|
expect(await cancellations()).toHaveLength(0);
|
|
await login.cancel(companyId, owner, attempt.sessionId);
|
|
await login.cancel(companyId, owner, attempt.sessionId);
|
|
expect(await cancellations()).toEqual([expect.objectContaining({
|
|
companyId, actorType: "user", actorId: owner, entityType: "adapter_auth_session",
|
|
entityId: attempt.sessionId, details: { provider: "openai" },
|
|
})]);
|
|
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("cancelled");
|
|
const retry = await login.start(companyId, owner, loginIntent());
|
|
await writeFile(path.join(directoryFor(retry.sessionId), "auth.json"), auth("abandoned"));
|
|
await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, retry.sessionId));
|
|
await expect(login.complete(companyId, owner, retry.sessionId, loginIntent())).rejects.toThrow("expired");
|
|
expect(await login.check(companyId, owner, loginIntent(), retry.sessionId)).toEqual({ status: "expired" });
|
|
await localAiLoginService(db).reapExpired();
|
|
await expect(readFile(path.join(directoryFor(retry.sessionId), "auth.json"))).rejects.toHaveProperty("code", "ENOENT");
|
|
const [row] = await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, retry.sessionId));
|
|
expect(row.status).toBe("timed_out");
|
|
});
|
|
|
|
it("explicit retry replaces another owned local attempt while ordinary navigation preserves it", async () => {
|
|
const login = localAiLoginService(db);
|
|
const first = await login.start(companyId, owner, loginIntent());
|
|
const restricted = { ...loginIntent(), allAgents: false, agentIds: [agentId] };
|
|
await expect(login.start(companyId, owner, restricted)).rejects.toThrow("Another sign-in");
|
|
const retry = await login.start(companyId, owner, restricted, true);
|
|
expect(retry.sessionId).not.toBe(first.sessionId);
|
|
const [old] = await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, first.sessionId));
|
|
expect(old.status).toBe("cancelled");
|
|
await expect(readFile(path.join(directoryFor(first.sessionId), "config.toml"))).rejects.toHaveProperty("code", "ENOENT");
|
|
expect(await login.start(companyId, owner, restricted)).toEqual(retry);
|
|
await login.cancel(companyId, owner, retry.sessionId);
|
|
});
|
|
|
|
it("blocks preview-era copied subscriptions until isolated reconnect, leaving legacy config intact", async () => {
|
|
const service = aiConnectionService(db);
|
|
const account = (await service.list(companyId, owner)).find(c => c.provider === "openai")!;
|
|
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.id));
|
|
await db.update(toolConnections).set({ config: { ...connection.config, aiIsolatedSubscription: false } }).where(eq(toolConnections.id, account.id));
|
|
await expect(prepareManagedAiRuntime(db, {
|
|
companyId, agentId, adapterType: "codex_local", responsibleUserId: owner,
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { cwd: home },
|
|
})).rejects.toThrow("separate sign-in");
|
|
expect((await service.list(companyId, owner)).find(c => c.id === account.id)?.status).toBe("needs_attention");
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
|
|
expect(grant.status).toBe("active");
|
|
});
|