mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
## Thinking Path > - Paperclip manages AI agents and their access to saved connections. > - The Permissions page loads account details and permitted agent choices. > - Company-wide AI accounts repeat retained installation checks for every candidate agent. > - This makes target checks grow with the square of the roster size. > - The browser waits without a deadline, and connection names do not identify the provider login. > - This pull request batches direct AI checks and bounds loading with recovery controls. > - Account details load separately and show a provider email or an explicit profile-permission limit. ## Linked Issues or Issue Description **What happened?** Opening a saved AI account can leave Permissions on “Loading AI account…” for minutes. The account itself can be connected. The existing capability batch excludes AI connections, so a company-wide install repeats checks for every installed agent for every candidate. Saved names and Paperclip ownership also do not establish which provider login a credential uses. **Expected behavior** Direct AI accounts reuse retained-target checks within one descriptive request. The page shows loaded account controls or a bounded failure with Retry. Permissions shows the provider email and organization when the selected credential permits profile access. An unavailable profile must not block maintenance or disconnect a working inference credential. Writes still authorize current permissions. **Steps to reproduce** 1. Save an AI account in a company with several agents. 2. Install the account company-wide. 3. Open its Permissions page. 4. Delay the account-list or grants response to exercise the loading failure. The maintained benchmark reproduces database work with synthetic local data. Refs #17945, which added batching for non-AI connections. Searches found no open PR for this AI-account loading fix. ## What Changed - Include direct AI accounts in the existing board capability batch. Referenced pool members, delegated actors, complex selectors, and mutation authorization keep their existing evaluator. - Add opt-in per-caller GET deadlines to the API client. A timed-out caller detaches the stuck coalesced request so Retry starts a fresh fetch. Other callers retain their own cancellation and results. - Apply a 15-second deadline to AI account lists and grants reads. The permissions view retries transient failures once and reports other failures immediately. - Share the parent Permissions grants query with AI account details. Keep loaded controls visible after a background error and offer Retry. - Add a grant-scoped identity GET and OpenAPI contract. The service checks current membership and credential audience before it reads the selected credential. It projects only the provider email and organization; provider IDs, tokens, and raw errors stay private. - Show account details independently of maintenance controls. Anthropic profile denial has an explicit message and preserves connected status. Transient profile failures have a separate Retry action. Observations are cached for five minutes under the company, connection, grant, and grant revision. - Extend real PostgreSQL and authenticated HTTP coverage, client/component regressions, map guidance, and the existing benchmark's AI mode. - Address the Apex file-size finding by moving gallery identity lookup into the existing app display helper module. `AppDetail.tsx` is 984 lines; lookup behavior is unchanged. ## Verification Local source checkout on macOS, Node 25.6.1, pnpm 9.15.4, and disposable PostgreSQL 18.1. Provider calls use fixtures. No hosted deployment or provider qualification is claimed. - Passed `pnpm -r typecheck` and `pnpm build`. - Passed all 87 tests in `connection-management.integration.test.ts` and all 33 tests in `connection-install-capabilities.test.ts`. - Passed 196 AI connection, identity-provider, and OpenAPI tests: `ai-connections.test.ts`, `ai-account-identity.test.ts`, and `openapi-routes.test.ts`. - Passed 107 focused UI/client tests: `client.test.ts`, `ManagedAiConnectionDetails.test.tsx`, `AiConnectionAccountDetails.test.tsx`, and `AppDetail.test.tsx`. - After the Apex extraction, passed UI typecheck/build and 106 component tests across `AppDetail.test.tsx`, `AppNotConnected.test.tsx`, `ManagedAiConnectionDetails.test.tsx`, and `AiConnectionAccountDetails.test.tsx`. - The authenticated HTTP tests compare complete grants responses across human roles, for tool and AI connections. They verify a real install mutation succeeds before revocation and fails afterward. - A company-wide personal AI fixture checks 20 targets instead of 400. Pool-member permission loss still denies the complete pool description. - UI states covered: shared cached grants, stuck account/grants fetches, one automatic retry, terminal failure, manual recovery, authorization failure, background failure with retained controls, and unmount cancellation. Client tests also cover stalled response bodies and independent coalesced callers. - Identity coverage includes actual bearer-key/JWT reads, private-user and cross-company denial before provider access, revoked credentials, provider 401/403/429/500, and grant-change isolation. A real local HTTP fixture stalls the response body, verifies the shared 15-second deadline, and keeps unrelated HTTP requests responsive. The test shortens the native deadline to 100 ms. Profile permission denial leaves the saved account connected. - Passed `pnpm check:token-gates`, `node scripts/check-maps.mjs`, `node scripts/check-module-boundaries.mjs`, and `git diff --check`. - The full local `pnpm test:run` started with the loading-only change. All six chat shards passed. It recorded one failure in the unchanged `heartbeat-retry-scheduling.test.ts` quota-rejection case. That exact case passed a focused rerun on the final head. The initial failure cause remains unconfirmed and its local log is retained; this is not a full local-suite pass. The redundant local run was stopped with SIGINT after final-head CI completed successfully. - [Final-head CI](https://github.com/paperclipai/paperclip/actions/runs/41515556725) passed on `ead8d276d4a2023614fff54b3e9a858f28c3178b`: typecheck, build, all 12 server shards, all 3 chat shards, workspace suites, all 9 serialized suites, all 8 end-to-end shards, Runner checks, and the aggregate verify gate. Security checks passed. Optional Storybook checks were skipped by workflow policy. GitHub reports the branch mergeable without conflicts. - Apex reviewed head `ead8d276d4a2023614fff54b3e9a858f28c3178b` at 5/5, confirmed the extraction resolves its earlier file-size finding, and has no unresolved review threads. The existing benchmark ran for the loading-only change now recorded in commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce`, with `--purpose=ai --baseline-ref=026fd9e3452`. The identity follow-up leaves that batch source unchanged. Two warmups and five alternating samples exercise each of eight fixture shapes. Complete positional capabilities, reasons, and operations match the baseline in every sample. | AI fixture | SQL calls before → after | Target reads before → after | | --- | ---: | ---: | | 1 agent, company install | 9 → 9 | 1 → 1 | | 32 agents, company install | 5,248 → 164 | 1,024 → 32 | | 100 agents, company install | 50,400 → 504 | 10,000 → 100 | | 100 agents, partial install | 13,175 → 503 | 2,575 → 100 | These counts measure the real service over local PostgreSQL. They do not predict hosted page latency. To reproduce the standalone benchmark, use a checkout of commit `5f725b36fa52002545241a5a8b7a10602f0cc0ce` and run the command below. Its isolation check intentionally rejects the later identity follow-up's additional runtime changes. ```sh node --import ./server/node_modules/tsx/dist/esm/index.mjs scripts/benchmark-connection-install-capabilities.ts --purpose=ai --baseline-ref=026fd9e3452 ``` Run the final-head regression suites from the repository root: ```sh cd server pnpm exec vitest run src/__tests__/connection-management.integration.test.ts src/__tests__/connection-install-capabilities.test.ts src/__tests__/ai-connections.test.ts src/services/ai-account-identity.test.ts src/__tests__/openapi-routes.test.ts cd ../ui pnpm exec vitest run src/api/client.test.ts src/components/ai-connections/ManagedAiConnectionDetails.test.tsx src/components/ai-connections/AiConnectionAccountDetails.test.tsx src/pages/apps/AppDetail.test.tsx ``` ## Risks Descriptive permissions reuse retained facts within one request. A concurrent change can leave a displayed choice stale; the next request and every write still authorize fresh. Pools with referenced members retain their existing cost. The account and grants clients now bound each caller's wait. A browser abort does not cancel an authorization read already admitted by the server. The independent profile request uses a fixed Anthropic origin, a bounded body, no redirects, and a 15-second deadline. Inference-only credentials can return no email; that is reported without guessing from a Paperclip owner or changing connection health. Profiles are fetched only for the caller's authorized selected grant. No ambient CLI identity, persistent identity cache, background polling, or billing-identity change is added. Other API reads keep their existing behavior. No migration is required. Staging has not been redeployed. ## Model Used OpenAI `gpt-6.1-sol` through the Codex harness in T3 Code, with xhigh reasoning, code editing, execution, and test tools. The host does not expose the context-window size. ## 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 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>
2799 lines
232 KiB
TypeScript
2799 lines
232 KiB
TypeScript
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
|
|
import { connectionIntentService } from "../services/connection-intents.js";
|
|
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
|
|
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
|
|
import { localAiLoginService } from "../services/local-ai-login.js";
|
|
// Background plan observations have their own provider-fixture coverage. Runtime
|
|
// auth tests must not send the selected fixture credentials to a real provider.
|
|
vi.mock("../services/subscription-refresh.js", () => ({ refreshSubscriptionConnection: vi.fn().mockResolvedValue(undefined) }));
|
|
import * as localCredentials from "../services/local-ai-credentials.js";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
|
import { promises as fs } from "node:fs";
|
|
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import { createDb, companies, agents, agentTaskSessions, heartbeatRuns, companyMemberships, connectionGrants, toolApplications, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, aiProviderDefaults, adapterAuthSessions, environments, plugins, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests, companySecrets, companySecretVersions, userSecretDefinitions, principalPermissionGrants } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
|
|
import { fetchCompanyQuotaWindows } from "../services/quota-windows.js";
|
|
import { quotaCredentialRecoveryPath, quotaCredentialRecovery, quotaCredentialHash } from "../services/quota-credential-recovery.js";
|
|
import { reconcileRetainedAiCleanup, retainAiCredentialCleanup } from "../services/retained-ai-credential-cleanup.js";
|
|
import { readAiConnectionConfigurationFailure } from "../services/ai-connection-configuration-failure.js";
|
|
import { aiConnectionService } from "../services/ai-connections.js";
|
|
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../services/delivery-work-notifications.js";
|
|
import { syncConnectionCredentialBindings } from "../services/connection-credential-bindings.js";
|
|
import * as codexAdapter from "@paperclipai/adapter-codex-local/server";
|
|
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
|
|
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
|
|
import * as runnerProbe from "../vendor/paperclip-runner/index.js";
|
|
import * as nativeCodexRunner from "../services/native-runtime/native-codex-runner.js";
|
|
import { testNativeRunnerAuthentication } from "../services/native-runtime/setup-readiness.js";
|
|
import { prepareManagedAiRuntime, withManagedAiProbe, assertManagedAiProjectAuth, isAiConnectionBusy, managedAiSessionFingerprintConfig } from "../services/ai-connection-runtime.js";
|
|
import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server";
|
|
import { toolAccessService } from "../services/tool-access.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.js";
|
|
import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
|
|
import { openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution";
|
|
import { createServer } from "node:http";
|
|
import { once } from "node:events";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
|
|
import { validateAiApiKey } from "../routes/ai-connections.js";
|
|
import * as copilotProbe from "../services/copilot-connection-probe.js";
|
|
import * as settingsModule from "../services/instance-settings.js";
|
|
import { agentRoutes } from "../routes/agents.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|
vi.mock("../services/local-ai-browser-login.js", () => ({
|
|
startLocalBrowserLogin: () => ({ authorizationUrl: "https://auth.openai.com/codex/device", code: "ABCD-EFGHJ", abort: () => {} }),
|
|
}));
|
|
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
let home: string;
|
|
const companyId = randomUUID();
|
|
const otherCompanyId = randomUUID();
|
|
const agentId = randomUUID();
|
|
let service: ReturnType<typeof aiConnectionService>;
|
|
const binding = { provider: "anthropic", method: "api_key", mode: "responsible_user" } as const;
|
|
const input = { companyId, agentId, adapterType: "claude_local", binding };
|
|
const create = (userId: string, name: string, ownership: "personal" | "shared" = "personal") => service.save(companyId, userId, { provider: "anthropic", method: "api_key", ownership, name, apiKey: "fixture", agentIds: [], allAgents: true }, `fixture-${name}`);
|
|
|
|
beforeAll(async () => {
|
|
home = await realpath(await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-")));
|
|
vi.stubEnv("PAPERCLIP_HOME", home);
|
|
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "ai-connection-fixture");
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-ai-db-");
|
|
db = createDb(database.connectionString);
|
|
service = aiConnectionService(db);
|
|
await db.insert(companies).values([{ id: companyId, name: "AI connection tests", issuePrefix: "AIT" }, { id: otherCompanyId, name: "Other", issuePrefix: "AIO" }]);
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Nova", adapterType: "claude_local" });
|
|
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
|
}, 90000);
|
|
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
|
|
|
|
describe("managed AI connections", () => {
|
|
it.each([
|
|
{ provider: "openrouter", envKey: "OPENROUTER_API_KEY", routed: false },
|
|
{ provider: "openrouter", envKey: "OPENROUTER_API_KEY", routed: true },
|
|
{ provider: "openai", envKey: "OPENAI_API_KEY", routed: false },
|
|
{ provider: "anthropic", envKey: "ANTHROPIC_API_KEY", routed: false },
|
|
{ provider: "google", envKey: "GEMINI_API_KEY", routed: false },
|
|
] as const)("prepares native Pi with isolated $provider credentials (routing=$routed)", async ({ provider, envKey, routed }) => {
|
|
const owner = `pi-managed-${provider}-${routed}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const token = "fixture-pi-provider-key";
|
|
const account = await service.save(companyId, owner, { provider, method: "api_key", ownership: "personal", name: owner, apiKey: token, allAgents: true, agentIds: [],
|
|
...(routed ? { routing: { kind: "openrouter" as const, protocol: "chat" as const, auth: "bearer" as const, models: [] } } : {}) }, token);
|
|
if (!routed) await service.setDefault(companyId, owner, account.grantId);
|
|
const selected = routed
|
|
? { provider, method: "api_key" as const, mode: "delegated" as const, ...account }
|
|
: { provider, method: "api_key" as const, mode: "responsible_user" as const };
|
|
const piInput = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selected,
|
|
config: { provider: "acpx", acpxAgent: "pi", model: `${provider}/provider-model`, piThinkingLevel: "low", env: { OPENAI_API_KEY: "stale-openai", ANTHROPIC_API_KEY: "stale-anthropic" } } };
|
|
const runtime = await prepareManagedAiRuntime(db, piInput);
|
|
try {
|
|
expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: piInput.config.model, env: { OPENCODE_CONFIG_CONTENT: "", PAPERCLIP_PI_PROVIDERS: "" } });
|
|
for (const key of ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "CODEX_API_KEY"]) expect(runtime.config.env[key]).toBe(key === envKey ? token : "");
|
|
expect(runtime.attribution).toMatchObject({ connectionId: account.connectionId, grantId: account.grantId, provider });
|
|
} finally { await runtime.cleanup(); }
|
|
await expect(prepareManagedAiRuntime(db, { ...piInput, config: { ...piInput.config, env: { PAPERCLIP_PI_PROVIDERS: "{}" } } })).rejects.toThrow("routing is incompatible");
|
|
});
|
|
|
|
it.each([200, 401])("reverifies native Pi OpenRouter keys without requiring the Legacy Pi CLI (HTTP %s)", async status => {
|
|
const owner = `pi-adoption-${status}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const token = `fixture-pi-adoption-${status}`;
|
|
const account = await service.save(companyId, owner, { provider: "openrouter", method: "api_key", ownership: "personal", name: owner, apiKey: token, allAgents: true, agentIds: [], routing: { kind: "openrouter", protocol: "chat", auth: "bearer", models: [] } }, token);
|
|
const selected = { provider: "openrouter", method: "api_key", mode: "delegated", ...account } as const;
|
|
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selected, config: { provider: "acpx", acpxAgent: "pi", model: "openrouter/anthropic/claude-sonnet-4.5" } });
|
|
const { requireServerAdapter } = await import("../adapters/index.js");
|
|
const { agentEnvironmentTestService } = await import("../services/agent-environment-test.js");
|
|
const native = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment").mockResolvedValue({ adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: "acpx_runtime_ready", level: "info", message: "Pinned Pi runtime verified" }] });
|
|
const legacy = vi.spyOn(requireServerAdapter("pi_local"), "testEnvironment").mockRejectedValue(new Error("Legacy Pi CLI must not be required"));
|
|
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ data: {} }), { status }));
|
|
try {
|
|
const result = await agentEnvironmentTestService(db).testManagedEnvironment("paperclip_runner", { companyId, adapterType: "paperclip_runner", config: runtime.config }, selected, runtime, agentId);
|
|
expect(result.status).toBe(status === 200 ? "pass" : "fail");
|
|
expect(result.checks).toContainEqual(expect.objectContaining({ code: status === 200 ? "ai_connection_api_key_reverified" : "ai_connection_api_key_rejected" }));
|
|
expect(legacy).not.toHaveBeenCalled();
|
|
expect(request).toHaveBeenCalled();
|
|
} finally { native.mockRestore(); legacy.mockRestore(); request.mockRestore(); await runtime.cleanup(); }
|
|
});
|
|
|
|
it("defers a new plugin agent's Copilot check until its saved-agent lifecycle can acquire the computer", async () => {
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const owner = "copilot-plugin-bootstrap", pluginId = randomUUID();
|
|
const pluginKey = `test.bootstrap.${pluginId}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey, packageName: "@test/bootstrap", version: "1.0.0", apiVersion: 1,
|
|
categories: ["automation"], status: "ready", manifestJson: { id: pluginKey, apiVersion: 1, version: "1.0.0",
|
|
displayName: "Bootstrap", description: "Bootstrap fixture", author: "Test", categories: ["automation"],
|
|
capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" },
|
|
environmentDrivers: [{ driverKey: "tasks", displayName: "Tasks", supportsTasks: true, configSchema: { type: "object" } }] } });
|
|
const [environment] = await db.insert(environments).values({ companyId, name: "Agent computer", driver: "plugin",
|
|
config: { pluginKey, driverKey: "tasks", driverConfig: {} } }).returning();
|
|
const token = "github_pat_fixture_plugin_bootstrap";
|
|
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Bootstrap", apiKey: token, agentIds: [], allAgents: false }, token);
|
|
await service.setDefault(companyId, owner, account.grantId);
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockRejectedValue(new Error("No computer exists before the agent is saved"));
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
|
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
|
try {
|
|
const response = await request(app).post(`/api/companies/${companyId}/agents`).send({ name: "Plugin bootstrap", role: "general",
|
|
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" },
|
|
defaultEnvironmentId: environment.id, runtimeConfig: { aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } } });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
|
expect(response.body.defaultEnvironmentId).toBe(environment.id);
|
|
expect(response.body.lifecycleState).toBe("preparing");
|
|
expect(probe).not.toHaveBeenCalled();
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
|
|
it.each(["create", "hire", "edit"])("refuses Copilot %s before probing or persisting when forced Kubernetes is missing", async action => {
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const owner = `copilot-kubernetes-${action}`, id = randomUUID();
|
|
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
|
|
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id, companyId, name: "Kubernetes fixture", adapterType: "paperclip_runner", adapterConfig: config, runtimeConfig: { aiConnection } });
|
|
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Kubernetes fixture", apiKey: "github_pat_fixture_kubernetes", agentIds: [id], allAgents: false }, "github_pat_fixture_kubernetes");
|
|
await service.setDefault(companyId, owner, account.grantId);
|
|
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
|
|
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
|
|
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
|
|
const originalSettings = settingsModule.instanceSettingsService;
|
|
const settings = vi.spyOn(settingsModule, "instanceSettingsService").mockImplementation(database => {
|
|
const service = originalSettings(database);
|
|
return { ...service, getGeneral: async () => ({ ...await service.getGeneral(), executionMode: "kubernetes" as const }) };
|
|
});
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget");
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
|
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
|
try {
|
|
const changedConfig = { ...config, model: "prospective-model" };
|
|
const response = action === "edit"
|
|
? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: changedConfig })
|
|
: await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: "Forced Kubernetes", role: "general", adapterType: "paperclip_runner", adapterConfig: changedConfig, runtimeConfig: { aiConnection } });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
|
expect(response.body.details.code).toBe("copilot_environment_unavailable");
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(config);
|
|
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
|
|
} finally {
|
|
probe.mockRestore(); settings.mockRestore();
|
|
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
|
|
}
|
|
});
|
|
it.each(["create", "hire", "edit"].flatMap(action => [true, false].map(connectionProvided => ({ action, connectionProvided }))))(
|
|
"refuses unavailable Copilot models before $action persists configuration (connection binding: $connectionProvided)", async ({ action, connectionProvided }) => {
|
|
const { unprocessable } = await import("../errors.js");
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const owner = `copilot-model-${action}-${connectionProvided}`, id = randomUUID();
|
|
const savedConfig = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
|
|
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id, companyId, name: `Copilot ${action} fixture`, adapterType: "paperclip_runner", adapterConfig: savedConfig, runtimeConfig: connectionProvided ? { aiConnection } : {} });
|
|
const token = `github_pat_fixture_model_${action}`;
|
|
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: `Model ${action}`, apiKey: token, agentIds: [id], allAgents: false }, token);
|
|
await service.setDefault(companyId, owner, account.grantId);
|
|
const [company] = await db.select().from(companies).where(eq(companies.id, companyId));
|
|
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
|
|
const before = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId));
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockRejectedValue(unprocessable("The selected Copilot model is unavailable for this account.", { code: "COPILOT_MODEL_UNAVAILABLE" }));
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
|
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
|
const config = { ...savedConfig, model: "unavailable-copilot-model" };
|
|
try {
|
|
const response = action === "edit"
|
|
? await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config })
|
|
: await request(app).post(`/api/companies/${companyId}/${action === "hire" ? "agent-hires" : "agents"}`).send({ name: `Unavailable ${action}`, role: "general", adapterType: "paperclip_runner", adapterConfig: config, ...(connectionProvided ? { runtimeConfig: { aiConnection } } : {}) });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
|
expect(response.body).toMatchObject({ error: "The selected Copilot model is unavailable for this account." });
|
|
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, "unavailable-copilot-model");
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toEqual(savedConfig);
|
|
expect(await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, companyId))).toHaveLength(before.length);
|
|
expect(JSON.stringify(response.body)).not.toContain(token);
|
|
} finally {
|
|
probe.mockRestore();
|
|
await db.update(companies).set({ requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents }).where(eq(companies.id, companyId));
|
|
}
|
|
});
|
|
|
|
it("verifies the exact available Copilot model before a model-only edit with an unchanged connection", async () => {
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const owner = "copilot-model-valid", id = randomUUID();
|
|
const aiConnection = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
|
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" };
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id, companyId, name: "Copilot available model", adapterType: "paperclip_runner", adapterConfig: { ...config, model: "previous-model" }, runtimeConfig: { aiConnection } });
|
|
const token = "github_pat_fixture_model_valid";
|
|
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Valid model", apiKey: token, agentIds: [id], allAgents: false }, token);
|
|
await service.setDefault(companyId, owner, account.grantId);
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotExecutionTarget").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: config.model, label: "GPT" }] });
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: owner, companyIds: [companyId], isInstanceAdmin: true }; next(); });
|
|
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
|
try {
|
|
const response = await request(app).patch(`/api/agents/${id}`).send({ adapterConfig: config });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(probe).toHaveBeenCalledExactlyOnceWith(token, null, config.model);
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].adapterConfig).toMatchObject(config);
|
|
expect(response.body.runtimeConfig.aiConnection).toEqual(aiConnection);
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
it("passes the live sandbox worker manager to Copilot connection verification", async () => {
|
|
const pluginWorkerManager = {} as PluginWorkerManager;
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({
|
|
status: "verified", version: "1.0.88", profileDigest: "fixture-profile",
|
|
promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }],
|
|
});
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db, { pluginWorkerManager }));
|
|
try {
|
|
const response = await request(app).post(`/api/companies/${companyId}/ai-connections`).send({
|
|
provider: "github", method: "api_key", name: "Copilot remote route", ownership: "personal",
|
|
apiKey: "github_pat_fixture_remote", agentIds: [], allAgents: false,
|
|
});
|
|
expect(response.status).toBe(201);
|
|
expect(probe).toHaveBeenCalledWith(db, companyId, "github_pat_fixture_remote", undefined, undefined, { pluginWorkerManager });
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
|
|
it("discovers Copilot models in the selected environment with its sandbox worker", async () => {
|
|
const pluginWorkerManager = {} as PluginWorkerManager;
|
|
const [environment] = await db.insert(environments).values({ name: "Copilot model discovery", driver: "sandbox", config: { provider: "daytona" } }).returning();
|
|
const token = "github_pat_fixture_model_discovery";
|
|
const account = await service.save(companyId, "alice", { provider: "github", method: "api_key", ownership: "personal", name: "Model discovery account", apiKey: token, agentIds: [agentId], allAgents: false }, token);
|
|
await service.setDefault(companyId, "alice", account.grantId);
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
|
app.use("/api", agentRoutes(db, { pluginWorkerManager }));
|
|
try {
|
|
const response = await request(app).get(`/api/companies/${companyId}/adapters/paperclip_runner/models`).query({ provider: "acpx", acpxAgent: "copilot", environmentId: environment.id, agentId, aiConnection: JSON.stringify({ provider: "github", method: "api_key", mode: "responsible_user" }) });
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers["cache-control"]).toBe("no-store");
|
|
expect(response.body).toEqual([{ id: "gpt-5.6-luna", label: "GPT" }]);
|
|
expect(probe).toHaveBeenCalledWith(db, companyId, token, environment.id, undefined, { pluginWorkerManager, targetAgentId: agentId });
|
|
expect(JSON.stringify(response.body)).not.toContain(token);
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
|
|
it("keeps Copilot model discovery behind shared installation and agent configuration authority", async () => {
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const owner = "copilot-discovery-owner", audience = "copilot-discovery-audience";
|
|
await db.insert(companyMemberships).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
|
await db.insert(principalPermissionGrants).values([owner, audience].map(principalId => ({ companyId, principalId, principalType: "user", permissionKey: "agents:create" })));
|
|
const token = "github_pat_fixture_shared_discovery";
|
|
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "shared", name: "Shared discovery", apiKey: token, agentIds: [], allAgents: false }, token);
|
|
await db.insert(connectionGrantMembers).values([owner, audience].map(subjectId => ({ companyId, grantId: account.grantId, subjectType: "user", subjectId })));
|
|
const binding = { provider: "github", method: "api_key", mode: "shared", ...account };
|
|
const probe = vi.spyOn(copilotProbe, "probeCopilotConnection").mockResolvedValue({ status: "verified", version: "1.0.88", profileDigest: "fixture-profile", promptSent: false, models: [{ id: "gpt-5.6-luna", label: "GPT" }] });
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? audience), companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); });
|
|
app.use("/api", agentRoutes(db)); app.use(errorHandler);
|
|
const url = `/api/companies/${companyId}/adapters/paperclip_runner/models`;
|
|
const query = { provider: "acpx", acpxAgent: "copilot", aiConnection: JSON.stringify(binding) };
|
|
try {
|
|
const denied = await request(app).get(url).query(query);
|
|
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
|
|
expect(denied.body.error).toContain("not permitted for this agent");
|
|
expect(probe).not.toHaveBeenCalled();
|
|
const allowed = await request(app).get(url).set("x-test-user", owner).query(query);
|
|
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
|
|
expect(probe).toHaveBeenCalledExactlyOnceWith(db, companyId, token, null, undefined, { pluginWorkerManager: undefined });
|
|
probe.mockClear();
|
|
const foreign = await request(app).get(url).set("x-test-user", owner).query({ ...query, agentId: randomUUID() });
|
|
expect(foreign.status).toBe(404);
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect(JSON.stringify([denied.body, allowed.body, foreign.body])).not.toContain(token);
|
|
} finally { probe.mockRestore(); }
|
|
});
|
|
|
|
it("preserves repository GitHub credentials for managed non-Copilot execution", async () => {
|
|
const owner = "repo-github-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
await create(owner, owner);
|
|
for (const configured of [{ GH_TOKEN: "repo-gh-fixture", GITHUB_TOKEN: "repo-github-fixture" }, {}]) {
|
|
const runtime = await prepareManagedAiRuntime(db, { ...input, responsibleUserId: owner, config: { env: configured } });
|
|
try {
|
|
expect(runtime.config.env).toMatchObject({ COPILOT_GITHUB_TOKEN: "", ANTHROPIC_API_KEY: `fixture-${owner}` });
|
|
for (const key of ["GH_TOKEN", "GITHUB_TOKEN"] as const) {
|
|
if (key in configured) expect(runtime.config.env?.[key]).toBe(configured[key as keyof typeof configured]);
|
|
else expect(runtime.config.env).not.toHaveProperty(key);
|
|
}
|
|
} finally { await runtime.cleanup(); }
|
|
}
|
|
});
|
|
|
|
it("uses a vaulted Copilot token with GitHub attribution and refuses revoked or foreign grants", async () => {
|
|
const owner = "copilot-owner";
|
|
const token = "github_pat_fixture_copilot";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const account = await service.save(companyId, owner, { provider: "github", method: "api_key", ownership: "personal", name: "Copilot", apiKey: token, agentIds: [agentId], allAgents: false }, token);
|
|
const selectedBinding = { provider: "github", method: "api_key", mode: "responsible_user" } as const;
|
|
const config = { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna", env: { COPILOT_GITHUB_TOKEN: "ambient-never-use", GH_TOKEN: "project-never-use" } };
|
|
const runInput = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner", binding: selectedBinding, config };
|
|
const selected = await service.select({ ...runInput, userId: owner, runnerProvider: "acpx", acpxAgent: "copilot", model: config.model });
|
|
expect(await service.credential(selected)).toBe(token);
|
|
expect(JSON.stringify(await service.list(companyId, owner))).not.toContain(token);
|
|
const runtime = await prepareManagedAiRuntime(db, runInput);
|
|
try {
|
|
expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "copilot", model: config.model, env: { COPILOT_GITHUB_TOKEN: token, GH_TOKEN: "", GITHUB_TOKEN: "", OPENAI_API_KEY: "", ANTHROPIC_API_KEY: "" } });
|
|
expect(runtime.attribution).toMatchObject({ provider: "github", method: "api_key", connectionId: account.connectionId, grantId: account.grantId });
|
|
expect(config.env.COPILOT_GITHUB_TOKEN).toBe("ambient-never-use");
|
|
} finally { await runtime.cleanup(); }
|
|
await expect(access(runtime.home!)).rejects.toThrow();
|
|
await expect(service.select({ ...runInput, userId: "bob", runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
|
|
await expect(service.select({ ...runInput, companyId: otherCompanyId, userId: owner, binding: { ...selectedBinding, mode: "shared", ...account }, runnerProvider: "acpx", acpxAgent: "copilot" })).rejects.toThrow();
|
|
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("usage must not send credentials"));
|
|
try {
|
|
expect(await service.probeUsage(companyId, owner, account.connectionId, account.grantId)).toMatchObject({ provider: "github", status: "unsupported", limits: [] });
|
|
expect(network).not.toHaveBeenCalled();
|
|
} finally { network.mockRestore(); }
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
|
await expect(prepareManagedAiRuntime(db, runInput)).rejects.toThrow();
|
|
});
|
|
|
|
it("marks explicit AI selection rejections without changing their HTTP contract", async () => {
|
|
const owner = "selection-blocker-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const expectSelectionFailure = async (promise: Promise<unknown>, reason: string) => {
|
|
const error = await promise.catch((error: unknown) => error);
|
|
expect(error).toMatchObject({ status: 422, details: { code: reason } });
|
|
expect(readAiConnectionConfigurationFailure(error)).toBe(reason);
|
|
};
|
|
await expectSelectionFailure(service.select({ ...input, userId: null }), "ai_connection_responsible_user_missing");
|
|
await expectSelectionFailure(service.select({ ...input, userId: owner, adapterType: "codex_local" }), "ai_connection_incompatible");
|
|
await expectSelectionFailure(service.select({ ...input, userId: owner }), "ai_connection_default_missing");
|
|
await expectSelectionFailure(service.select({ ...input, userId: owner, binding: {
|
|
...binding, mode: "delegated", connectionId: randomUUID(), grantId: randomUUID(),
|
|
} }), "ai_connection_missing");
|
|
const account = await create(owner, "Selection blocker account");
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
|
await expectSelectionFailure(service.select({ ...input, userId: owner }), "ai_connection_unavailable");
|
|
});
|
|
|
|
it("commits one exchanged token without writable credential recovery storage", async () => {
|
|
const owner = "quota-database-first";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { account_id: owner, access_token: "expired", refresh_token: "single-use" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const file = quotaCredentialRecoveryPath(companyId, row.grant.id);
|
|
const recoveryRoot = path.dirname(path.dirname(file));
|
|
await expect(access(recoveryRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const writes: string[] = [];
|
|
const rejectRecoveryWrite = (target: unknown) => {
|
|
if (String(target).startsWith(recoveryRoot)) {
|
|
writes.push(String(target));
|
|
throw Object.assign(new Error("Fixture recovery storage is read-only"), { code: "EROFS" });
|
|
}
|
|
};
|
|
const realOpen = fs.open.bind(fs), realMkdir = fs.mkdir.bind(fs);
|
|
const open = vi.spyOn(fs, "open").mockImplementation(async (...args: Parameters<typeof fs.open>) => {
|
|
rejectRecoveryWrite(args[0]);
|
|
return realOpen(...args);
|
|
});
|
|
const mkdir = vi.spyOn(fs, "mkdir").mockImplementation(async (...args: Parameters<typeof fs.mkdir>) => {
|
|
rejectRecoveryWrite(args[0]);
|
|
return realMkdir(...args);
|
|
});
|
|
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access_token: "fresh-direct", refresh_token: "replacement-direct" }));
|
|
try {
|
|
const refreshed = await service.refreshQuotaCredential(row, original, new AbortController().signal);
|
|
expect(JSON.parse(refreshed)).toMatchObject({ tokens: { access_token: "fresh-direct", refresh_token: "replacement-direct" } });
|
|
expect(await service.credential(row)).toBe(refreshed);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(writes).toEqual([]);
|
|
await expect(access(file)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(access(recoveryRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { open.mockRestore(); mkdir.mockRestore(); request.mockRestore(); }
|
|
});
|
|
|
|
it.each(["timeout", "unexpected"] as const)("classifies a manual probe's %s lock failure before starting the provider", async (kind) => {
|
|
const owner = `manual-probe-lock-${kind}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = JSON.stringify({ tokens: { account_id: owner, access_token: "valid-access", refresh_token: "valid-refresh" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const probe = vi.fn().mockResolvedValue("ok");
|
|
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: {} };
|
|
const failure = Object.assign(new Error("injected private lock diagnostic"), { code: kind === "timeout" ? WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE : "EACCES" });
|
|
const lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock").mockRejectedValueOnce(failure);
|
|
try {
|
|
const error = await withManagedAiProbe(db, input, probe).then(() => { throw new Error("Probe must not start"); }, error => error);
|
|
if (kind === "timeout") {
|
|
expect(isAiConnectionBusy(error)).toBe(true);
|
|
expect(error).toMatchObject({ status: 422, message: "AI credentials are being updated. Retry shortly.", details: { code: "ai_connection_busy" } });
|
|
} else expect(error).toBe(failure);
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)))[0].status).toBe("active");
|
|
} finally { lock.mockRestore(); }
|
|
expect(await withManagedAiProbe(db, input, probe)).toBe("ok");
|
|
expect(probe).toHaveBeenCalledTimes(1);
|
|
expect(await service.credential(row)).toBe(auth);
|
|
});
|
|
|
|
it("fences retained OpenAI credentials before reads, including callers holding the mutation lock", async () => {
|
|
const owner = "retained-openai-read-fence";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = JSON.stringify({ tokens: { account_id: owner, access_token: "valid-access", refresh_token: "valid-refresh" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const scope = { companyId, accountKey: row.grant.id }, retired = deferredSignal(), cleanup = vi.fn(async () => {});
|
|
retainAiCredentialCleanup({ scope, retired: retired.promise, cleanup });
|
|
try {
|
|
await expect(service.runtimeCredential(row)).rejects.toMatchObject({ details: { code: "ai_connection_busy" } });
|
|
await codexAdapter.withAccountHomeSecretMutationLock(undefined, companyId, async () => {
|
|
await expect(service.runtimeCredential(row, { credentialLockHeld: true })).rejects.toMatchObject({ details: { code: "ai_connection_busy" } });
|
|
});
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
retired.resolve(); await reconcileRetainedAiCleanup(scope);
|
|
expect(cleanup).toHaveBeenCalledOnce();
|
|
await expect(service.runtimeCredential(row)).resolves.toBe(auth);
|
|
} finally { retired.resolve(); await reconcileRetainedAiCleanup(scope).catch(() => {}); }
|
|
});
|
|
|
|
it("retries retired Codex probe cleanup before the same account can launch again", async () => {
|
|
const owner = "retired-probe-retry", otherOwner = "retired-probe-other";
|
|
const auth = (user: string, hour: string) => JSON.stringify({ tokens: { account_id: user, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
for (const user of [owner, otherOwner]) {
|
|
await db.insert(companyMemberships).values({ companyId, principalId: user, principalType: "user", status: "active", membershipRole: "member" });
|
|
await service.save(companyId, user, { provider: "openai", method: "subscription", ownership: "personal", name: user, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth(user, "10"));
|
|
}
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: {} };
|
|
const stopped = deferredSignal();
|
|
const provider = vi.fn(async runtime => readFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), "utf8"));
|
|
const merge = vi.spyOn(codexAdapter, "decideCodexAuthMerge").mockRejectedValue(new Error("fixture cleanup storage unavailable"));
|
|
let retained: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
|
|
try {
|
|
await expect(withManagedAiProbe(db, input, async runtime => {
|
|
retained = runtime;
|
|
await writeFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), auth(owner, "11"));
|
|
throw new runnerProbe.NativeEnvironmentProbeCleanupError(stopped.promise, new Error("fixture probe interruption"), []);
|
|
})).rejects.toMatchObject({ details: { code: "native_probe_cleanup_pending" } });
|
|
await expect(withManagedAiProbe(db, input, provider)).rejects.toMatchObject({ details: { code: "ai_connection_busy" } });
|
|
expect(provider).not.toHaveBeenCalled(); expect(merge).not.toHaveBeenCalled();
|
|
stopped.resolve();
|
|
await vi.waitFor(() => expect(merge).toHaveBeenCalledOnce());
|
|
await expect(withManagedAiProbe(db, input, provider)).rejects.toMatchObject({ details: { code: "ai_connection_busy" } });
|
|
expect(provider).not.toHaveBeenCalled(); expect(merge).toHaveBeenCalledTimes(2);
|
|
await expect(withManagedAiProbe(db, { ...input, responsibleUserId: otherOwner }, provider)).resolves.toBe(auth(otherOwner, "10"));
|
|
expect(await readFile(path.join(String(retained!.config.env.CODEX_HOME), "auth.json"), "utf8")).toBe(auth(owner, "11"));
|
|
merge.mockRestore();
|
|
await expect(withManagedAiProbe(db, input, provider)).resolves.toBe(auth(owner, "11"));
|
|
expect(await service.credential(row)).toBe(auth(owner, "11"));
|
|
await expect(access(retained!.home!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { stopped.resolve(); merge.mockRestore(); await retained?.cleanup().catch(() => {}); }
|
|
});
|
|
|
|
it("keeps quota refresh outside a manual probe until its rotated credential is saved", async () => {
|
|
const owner = "manual-probe-race";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const entered = deferredSignal(), release = deferredSignal();
|
|
const request = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Quota must not exchange the probe's token"));
|
|
const probing = withManagedAiProbe(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} }, async runtime => {
|
|
entered.resolve(); await release.promise;
|
|
await writeFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), auth("11"));
|
|
});
|
|
let refreshing: Promise<string> | undefined;
|
|
let lock: ReturnType<typeof vi.spyOn> | undefined;
|
|
try {
|
|
await entered.promise;
|
|
lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock");
|
|
// Start outside the probe's async scope; it is a separate HTTP request.
|
|
refreshing = service.refreshQuotaCredential(row, auth("10"), new AbortController().signal);
|
|
await vi.waitFor(() => expect(lock).toHaveBeenCalled());
|
|
expect(request).not.toHaveBeenCalled();
|
|
release.resolve(); await probing;
|
|
expect(await refreshing).toBe(auth("11"));
|
|
expect(await service.credential(row)).toBe(auth("11"));
|
|
expect(request).not.toHaveBeenCalled();
|
|
} finally { release.resolve(); await Promise.allSettled([probing, refreshing]); lock?.mockRestore(); request.mockRestore(); }
|
|
});
|
|
|
|
it("persists native Codex setup's refreshed selected login without changing existing agents", async () => {
|
|
const owner = "native-codex-setup-refresh";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const [before] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId));
|
|
const runnerBinary = path.join(home, "fixture-runnerd");
|
|
const resolveBinary = vi.spyOn(nativeCodexRunner, "resolvePaperclipRunnerBinary").mockReturnValue(runnerBinary);
|
|
const probe = vi.spyOn(runnerProbe, "probeNativeRunnerEnvironment").mockImplementation(async input => {
|
|
expect(input.transportOptions?.runnerBinary).toBe(runnerBinary);
|
|
expect(input.transportOptions?.sourceCodexHome).toContain("/provider");
|
|
expect(await readFile(path.join(input.transportOptions!.sourceCodexHome!, "auth.json"), "utf8")).toBe(auth("10"));
|
|
const providerHome = path.join(input.runtimeDirectory, "codex-home");
|
|
await fs.mkdir(providerHome, { mode: 0o700 });
|
|
const refreshedFile = path.join(providerHome, "auth.json");
|
|
await writeFile(refreshedFile, auth("11"), { mode: 0o600 });
|
|
await input.onCodexCredentialRefresh!(refreshedFile);
|
|
await input.onCleanupConfirmed!();
|
|
return { provider: "codex", providerDriver: "codex_app_server", effectiveModel: "gpt-5.6-sol", helloProbePassed: true };
|
|
});
|
|
let managedHome: string | undefined;
|
|
try {
|
|
const result = await withManagedAiProbe(db, { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { provider: "codex" } }, async runtime => {
|
|
managedHome = runtime.home;
|
|
return testNativeRunnerAuthentication({ companyId, adapterType: "paperclip_runner", config: runtime.config, managedAiCredentialHome: path.join(runtime.home!, "provider") }, "codex", "gpt-5.6-sol");
|
|
});
|
|
expect(result.status, JSON.stringify(result)).toBe("pass"); expect(probe).toHaveBeenCalledOnce();
|
|
expect(await service.credential(row)).toBe(auth("11"));
|
|
await expect(access(managedHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
const [after] = await db.select({ adapterType: agents.adapterType, adapterConfig: agents.adapterConfig }).from(agents).where(eq(agents.id, agentId));
|
|
expect(after).toEqual(before);
|
|
} finally { probe.mockRestore(); resolveBinary.mockRestore(); }
|
|
});
|
|
|
|
it("keeps the real Codex setup cleanup error and retries its original owner before another probe", async () => {
|
|
const owner = "native-codex-setup-retirement-retry";
|
|
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { provider: "codex" } };
|
|
let rejectRetirement!: (error: Error) => void, physical = false;
|
|
const retired = new Promise<void>((_resolve, reject) => { rejectRetirement = reject; });
|
|
void retired.catch(() => undefined);
|
|
let original: Parameters<typeof runnerProbe.probeNativeRunnerEnvironment>[0], retainedHome: string | undefined;
|
|
const directories: string[] = [];
|
|
const retry = vi.fn(async () => {
|
|
if (!physical) throw new Error("fixture original owner remains live");
|
|
await original.onCodexCredentialRefresh!(path.join(original.runtimeDirectory, "codex-home", "auth.json"));
|
|
await original.onCleanupConfirmed!();
|
|
});
|
|
const binary = vi.spyOn(nativeCodexRunner, "resolvePaperclipRunnerBinary").mockReturnValue(path.join(home, "fixture-runnerd"));
|
|
const probe = vi.spyOn(runnerProbe, "probeNativeRunnerEnvironment").mockImplementationOnce(async options => {
|
|
original = options; directories.push(options.runtimeDirectory);
|
|
await fs.mkdir(path.join(options.runtimeDirectory, "codex-home"), { mode: 0o700 });
|
|
await writeFile(path.join(options.runtimeDirectory, "codex-home", "auth.json"), auth("11"), { mode: 0o600 });
|
|
throw new runnerProbe.NativeEnvironmentProbeCleanupError(retired, new Error("fixture interrupted probe"), [], retry);
|
|
}).mockImplementation(async options => {
|
|
directories.push(options.runtimeDirectory);
|
|
expect(await readFile(path.join(options.transportOptions!.sourceCodexHome!, "auth.json"), "utf8")).toBe(auth("11"));
|
|
await options.onCleanupConfirmed!();
|
|
return { provider: "codex", providerDriver: "codex_app_server", effectiveModel: "gpt-5.6-sol", helloProbePassed: true };
|
|
});
|
|
const invoke = () => withManagedAiProbe(db, input, runtime => {
|
|
retainedHome ??= runtime.home;
|
|
return testNativeRunnerAuthentication({ companyId, adapterType: "paperclip_runner", config: runtime.config,
|
|
managedAiCredentialHome: path.join(runtime.home!, "provider") }, "codex", "gpt-5.6-sol");
|
|
});
|
|
try {
|
|
await expect(invoke()).rejects.toMatchObject({ details: { code: "native_probe_cleanup_pending" } });
|
|
await expect(access(retainedHome!)).resolves.toBeUndefined();
|
|
rejectRetirement(new Error("fixture initial retirement proof failed"));
|
|
await Promise.resolve();
|
|
await expect(invoke()).rejects.toMatchObject({ details: { code: "ai_connection_busy" } });
|
|
expect(probe).toHaveBeenCalledOnce(); expect(retry).toHaveBeenCalledOnce();
|
|
await expect(access(retainedHome!)).resolves.toBeUndefined();
|
|
physical = true;
|
|
expect((await invoke()).status).toBe("pass");
|
|
expect(retry).toHaveBeenCalledTimes(2); expect(probe).toHaveBeenCalledTimes(2);
|
|
expect(await service.credential(row)).toBe(auth("11"));
|
|
await expect(access(retainedHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally {
|
|
physical = true; rejectRetirement(new Error("fixture teardown"));
|
|
await reconcileRetainedAiCleanup({ companyId, accountKey: row.grant.id }).catch(() => {});
|
|
probe.mockRestore(); binary.mockRestore();
|
|
await Promise.all(directories.map(directory => rm(directory, { recursive: true, force: true })));
|
|
}
|
|
});
|
|
|
|
it("rejects preparation when reconnect replaces the selected credential reference", async () => {
|
|
const owner = "replaced-ref-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (id: string) => JSON.stringify({ tokens: { account_id: id, access_token: id, refresh_token: id } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("old-account"));
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const definition = await secretService(db).createUserSecretDefinition(companyId, { key: "replacement_credential", name: "Replacement", provider: "local_encrypted" }, { userId: owner });
|
|
const replacement = await secretService(db).createCurrentUserSecretValue(companyId, owner, { definitionId: definition.id, value: auth("new-account") }, { userId: owner });
|
|
const prepare = () => prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
const original = codexAdapter.withAccountHomeSecretMutationLock;
|
|
const lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock").mockImplementationOnce(async (env, company, callback) => {
|
|
await db.update(connectionGrants).set({ credentialSecretRefs: row.grant.credentialSecretRefs.map(ref => ref.configPath === "ai.credential" ? { ...ref, secretId: replacement.id } : ref) }).where(eq(connectionGrants.id, row.grant.id));
|
|
await syncConnectionCredentialBindings(db, row.connection);
|
|
return original(env, company, callback);
|
|
});
|
|
try { await expect(prepare()).rejects.toThrow("AI credential changed during preparation"); }
|
|
finally { lock.mockRestore(); }
|
|
const runtime = await prepare();
|
|
try {
|
|
expect(runtime.sessionIdentity).toContain(replacement.id);
|
|
expect(await readFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), "utf8")).toBe(auth("new-account"));
|
|
} finally { await runtime.cleanup(); }
|
|
});
|
|
|
|
it("caches quota after a real secret read but invalidates credential rotation and revocation", async () => {
|
|
const owner = "quota-cache-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const account = await service.save(companyId, owner, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Quota cache account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "quota-cache-original");
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
|
|
const secretId = grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!.secretId;
|
|
const [before] = await db.select().from(companySecrets).where(eq(companySecrets.id, secretId));
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 42 } })));
|
|
try {
|
|
const first = await fetchCompanyQuotaWindows(db, companyId, owner);
|
|
const [after] = await db.select().from(companySecrets).where(eq(companySecrets.id, secretId));
|
|
expect(after.lastResolvedAt).not.toBeNull();
|
|
expect(after.updatedAt.getTime()).toBeGreaterThan(before.updatedAt.getTime());
|
|
expect(first[0].ok).toBe(true);
|
|
expect(await fetchCompanyQuotaWindows(db, companyId, owner)).toEqual(first);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
await secretService(db).rotate(secretId, { value: "quota-cache-rotated" });
|
|
const rotated = await fetchCompanyQuotaWindows(db, companyId, owner);
|
|
expect(rotated[0].ok).toBe(true);
|
|
expect(rotated[0].accountKey).not.toBe(first[0].accountKey);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
expect(fetchSpy).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer quota-cache-rotated" }) }));
|
|
await db.update(userSecretDefinitions).set({ status: "disabled" }).where(eq(userSecretDefinitions.id, before.userSecretDefinitionId!));
|
|
const disabled = await fetchCompanyQuotaWindows(db, companyId, owner);
|
|
expect(disabled[0]).toMatchObject({ ok: false, errorFamily: "credentials_unavailable", windows: [] });
|
|
expect(disabled[0].accountKey).not.toBe(rotated[0].accountKey);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
await db.update(userSecretDefinitions).set({ status: "active" }).where(eq(userSecretDefinitions.id, before.userSecretDefinitionId!));
|
|
expect((await fetchCompanyQuotaWindows(db, companyId, owner))[0].ok).toBe(true);
|
|
await db.update(companySecretVersions).set({ status: "disabled", revokedAt: new Date() }).where(and(eq(companySecretVersions.secretId, secretId), eq(companySecretVersions.version, 2)));
|
|
const revoked = await fetchCompanyQuotaWindows(db, companyId, owner);
|
|
expect(revoked[0].ok).toBe(false);
|
|
expect(revoked[0].windows).toEqual([]);
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
} finally { fetchSpy.mockRestore(); }
|
|
});
|
|
|
|
it("caches refreshed quota under the saved grant and secret revision", async () => {
|
|
const owner = "quota-refreshed-cache-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired-cache", refresh_token: "single-use", account_id: "cache-account" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: "Refreshed cache", loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => {
|
|
if (String(url).endsWith("/oauth/token")) return Response.json({ access_token: "fresh-cache", refresh_token: "replacement" });
|
|
if (new Headers(options?.headers).get("authorization") === "Bearer expired-cache") return new Response("Expired", { status: 401 });
|
|
return Response.json({ rate_limit: { primary_window: { used_percent: 42 } } });
|
|
});
|
|
try {
|
|
const first = await fetchCompanyQuotaWindows(db, companyId, owner);
|
|
expect(first[0]).toMatchObject({ ok: true, windows: [{ usedPercent: 42 }] });
|
|
expect(await fetchCompanyQuotaWindows(db, companyId, owner)).toEqual(first);
|
|
expect(request).toHaveBeenCalledTimes(3); // rejected quota, OAuth, refreshed quota
|
|
} finally { request.mockRestore(); }
|
|
});
|
|
|
|
it.each(["rotation", "secret_metadata", "activity", "commit"] as const)("fails truthfully after %s persistence failure and permits sign-in recovery without a journal", async (failure) => {
|
|
const owner = `quota-rollback-${failure}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: `expired-${failure}`, refresh_token: "single-use", account_id: owner } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const table = failure === "secret_metadata" ? "company_secrets" : failure === "activity" ? "activity_log" : "company_secret_versions";
|
|
const condition = failure === "secret_metadata" ? `new.id = '${ref.secretId}'::uuid and new.latest_version > old.latest_version` : failure === "activity" ? `new.action = 'ai_connection.credential_refreshed'` : `new.secret_id = '${ref.secretId}'::uuid`;
|
|
await db.execute(sql.raw(`create function quota_save_failure() returns trigger language plpgsql as $$ begin
|
|
if ${condition} then raise exception 'injected quota save failure'; end if; return new; end $$;
|
|
create ${failure === "commit" ? "constraint " : ""}trigger quota_save_failure ${failure === "commit" ? "after" : "before"} ${failure === "secret_metadata" ? "update" : "insert"} on ${table}
|
|
${failure === "commit" ? "deferrable initially deferred" : ""} for each row execute function quota_save_failure();`));
|
|
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access_token: `fresh-${failure}`, refresh_token: `replacement-${failure}` }));
|
|
const file = quotaCredentialRecoveryPath(companyId, row.grant.id);
|
|
let triggerDropped = false;
|
|
try {
|
|
await expect(service.refreshQuotaCredential(row, original, new AbortController().signal)).rejects.toThrow();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(await service.credential(row)).toBe(original);
|
|
await expect(access(file)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await db.execute(sql.raw(`drop trigger quota_save_failure on ${table}; drop function quota_save_failure();`));
|
|
triggerDropped = true;
|
|
// A new receiver has no replacement to recover. A failed use routes to the
|
|
// existing sign-in flow, rather than claiming the failed save succeeded.
|
|
const receiver = aiConnectionService(db);
|
|
const generation = createHash("sha256").update(original).digest("hex").slice(0, 16);
|
|
await receiver.markAuthenticationFailed({ companyId, runStartedAt: new Date(), attribution: {
|
|
provider: "openai", method: "subscription", connectionId: row.connection.id, grantId: row.grant.id,
|
|
mode: "responsible_user", responsibleUserId: owner, identity: `${row.grant.id}:${owner}:${generation}`,
|
|
} });
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)))[0].status).toBe("needs_reauthorization");
|
|
const signedIn = JSON.stringify({ tokens: { access_token: "signed-in", refresh_token: "new-login" } });
|
|
await receiver.save(companyId, owner, { connectionId: row.connection.id, provider: "openai", method: "subscription",
|
|
ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, signedIn);
|
|
const [current] = await receiver.quotaAccounts(companyId, owner);
|
|
expect(current.grant.status).toBe("active");
|
|
expect(await receiver.runtimeCredential(current)).toBe(signedIn);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect((await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, ref.secretId))).map(version => version.version).sort()).toEqual([1, 2]);
|
|
} finally {
|
|
request.mockRestore();
|
|
if (!triggerDropped) await db.execute(sql.raw(`drop trigger quota_save_failure on ${table}; drop function quota_save_failure();`));
|
|
}
|
|
});
|
|
|
|
it.each(["company_file", "grant_row", "secret_row"] as const)("defers runtime preparation on a contended %s lock and succeeds once it clears", async (kind) => {
|
|
const owner = `runtime-lock-${kind}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = JSON.stringify({ tokens: { access_token: "valid-access", refresh_token: "valid-refresh", account_id: owner } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const prepare = () => prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
const held = deferredSignal(), release = deferredSignal();
|
|
const lock = kind === "company_file" ? vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock")
|
|
.mockRejectedValueOnce(Object.assign(new Error("company lock held by another account"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE })) : undefined;
|
|
const holding = kind === "company_file" ? Promise.resolve() : db.transaction(async tx => {
|
|
if (kind === "grant_row") await tx.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)).for("update");
|
|
else await tx.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId)).for("update");
|
|
held.resolve(); await release.promise;
|
|
});
|
|
try {
|
|
if (kind !== "company_file") await held.promise;
|
|
const error = await prepare().then(() => { throw new Error("Runtime must defer"); }, error => error);
|
|
expect(isAiConnectionBusy(error)).toBe(true);
|
|
expect(error).toMatchObject({ status: 422, details: { code: "ai_connection_busy" } });
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)))[0].status).toBe("active");
|
|
} finally { release.resolve(); await holding; lock?.mockRestore(); }
|
|
const run = await prepare();
|
|
try { expect(await readFile(path.join(String(run.config.env.CODEX_HOME), "auth.json"), "utf8")).toBe(auth); }
|
|
finally { await run.cleanup(); }
|
|
});
|
|
|
|
it.each([
|
|
["runtime", "rotation"], ["runtime", "commit"], ["failed_run", "rotation"], ["failed_run", "commit"],
|
|
] as const)("recovers pending quota credentials before %s can reuse or invalidate the consumed token after %s failure", async (consumer, failure) => {
|
|
const owner = `quota-consumer-${consumer}-${failure}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (marker: string) => JSON.stringify({ tokens: { access_token: `${marker}-access`, refresh_token: `${marker}-refresh`, account_id: owner } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("old"));
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const prepare = () => prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local",
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
const oldRun = await prepare();
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const journal = quotaCredentialRecovery(companyId, row.grant.id);
|
|
await journal.write({ companyId, grantId: row.grant.id, connectionId: row.connection.id, secretId: ref.secretId,
|
|
baseHash: quotaCredentialHash(auth("old")), value: auth("new") });
|
|
const fail = () => service.markAuthenticationFailed({ companyId, agentId, runStartedAt: new Date(),
|
|
attribution: { ...oldRun.attribution, identity: oldRun.identity } });
|
|
await db.execute(sql.raw(`create function reject_consumer_recovery() returns trigger language plpgsql as $$ begin
|
|
if new.secret_id = '${ref.secretId}'::uuid then raise exception 'recovery unavailable'; end if; return new; end $$;
|
|
create ${failure === "commit" ? "constraint " : ""}trigger reject_consumer_recovery ${failure === "commit" ? "after" : "before"} insert on company_secret_versions
|
|
${failure === "commit" ? "deferrable initially deferred" : ""} for each row execute function reject_consumer_recovery();`));
|
|
try {
|
|
if (consumer === "runtime") {
|
|
const error = await prepare().then(() => { throw new Error("Runtime must defer"); }, error => error);
|
|
expect(isAiConnectionBusy(error)).toBe(true);
|
|
expect(error).toMatchObject({ status: 422, details: { code: "ai_connection_busy" } });
|
|
} else await expect(fail()).rejects.toThrow();
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)))[0].status).toBe("active");
|
|
expect(await service.credential(row)).toBe(auth("old"));
|
|
expect(await journal.read()).not.toBeNull();
|
|
} finally {
|
|
await db.execute(sql`drop trigger reject_consumer_recovery on company_secret_versions; drop function reject_consumer_recovery()`);
|
|
}
|
|
const request = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Recovery must not exchange again"));
|
|
let nextRun: Awaited<ReturnType<typeof prepare>> | undefined;
|
|
try {
|
|
if (consumer === "failed_run") await fail();
|
|
nextRun = await prepare();
|
|
expect(await readFile(path.join(String(nextRun.config.env.CODEX_HOME), "auth.json"), "utf8")).toBe(auth("new"));
|
|
expect(nextRun.sessionIdentity).toBe(oldRun.sessionIdentity);
|
|
await fail(); // A late failure of the old run cannot invalidate the recovered identity.
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)))[0].status).toBe("active");
|
|
expect(await service.credential(row)).toBe(auth("new"));
|
|
expect(await journal.read()).toBeNull();
|
|
expect(request).not.toHaveBeenCalled();
|
|
expect(await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, ref.secretId))).toHaveLength(2);
|
|
} finally { request.mockRestore(); await oldRun.cleanup(); await nextRun?.cleanup(); await journal.clear(); }
|
|
});
|
|
|
|
it("requires sign-in after the receiver process dies with an exchanged but unsaved token", async () => {
|
|
const owner = "quota-receiver-process-loss";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired-process", refresh_token: "single-use-process" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const held = deferredSignal(), release = deferredSignal();
|
|
const lockKey = 7192031;
|
|
const holder = db.transaction(async tx => {
|
|
await tx.execute(sql`select pg_advisory_xact_lock(${lockKey})`);
|
|
held.resolve(); await release.promise;
|
|
});
|
|
await held.promise;
|
|
await db.execute(sql.raw(`create function hold_receiver_save() returns trigger language plpgsql as $$ begin
|
|
if new.secret_id = '${ref.secretId}'::uuid then perform pg_advisory_xact_lock(${lockKey}); end if; return new; end $$;
|
|
create trigger hold_receiver_save before insert on company_secret_versions for each row execute function hold_receiver_save();`));
|
|
let exchanges = 0;
|
|
const provider = createServer((req, res) => {
|
|
exchanges++; req.resume();
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.end(JSON.stringify({ access_token: "unsaved-process", refresh_token: "replacement-process" }));
|
|
});
|
|
provider.listen(0, "127.0.0.1"); await once(provider, "listening");
|
|
const address = provider.address() as { port: number };
|
|
const child = spawn(process.execPath, ["--import", createRequire(import.meta.url).resolve("tsx"), new URL("./helpers/quota-credential-process.ts", import.meta.url).pathname], {
|
|
env: { ...process.env, CREDENTIAL_TEST_DATABASE_URL: database.connectionString,
|
|
CREDENTIAL_TEST_PROVIDER_URL: `http://127.0.0.1:${address.port}`, CREDENTIAL_TEST_COMPANY_ID: companyId, CREDENTIAL_TEST_OWNER: owner },
|
|
stdio: "ignore",
|
|
});
|
|
const exited = once(child, "exit");
|
|
try {
|
|
let blocked = false;
|
|
const deadline = Date.now() + 25_000;
|
|
while (!blocked && Date.now() < deadline && child.exitCode === null) {
|
|
const rows = await db.execute(sql`select pid from pg_stat_activity where datname = current_database()
|
|
and wait_event_type = 'Lock' and wait_event = 'advisory'`);
|
|
blocked = exchanges === 1 && rows.length > 0;
|
|
if (!blocked) await new Promise(resolve => setTimeout(resolve, 25));
|
|
}
|
|
expect(blocked).toBe(true);
|
|
child.kill("SIGKILL"); await exited;
|
|
} finally {
|
|
if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await exited; }
|
|
release.resolve(); await holder;
|
|
await db.execute(sql`drop trigger hold_receiver_save on company_secret_versions; drop function hold_receiver_save()`);
|
|
await new Promise<void>(resolve => provider.close(() => resolve()));
|
|
}
|
|
expect(exchanges).toBe(1);
|
|
expect(await service.credential(row)).toBe(original);
|
|
await expect(access(quotaCredentialRecoveryPath(companyId, row.grant.id))).rejects.toMatchObject({ code: "ENOENT" });
|
|
const receiver = aiConnectionService(db);
|
|
const generation = createHash("sha256").update(original).digest("hex").slice(0, 16);
|
|
await receiver.markAuthenticationFailed({ companyId, runStartedAt: new Date(), attribution: {
|
|
provider: "openai", method: "subscription", connectionId: row.connection.id, grantId: row.grant.id,
|
|
mode: "responsible_user", responsibleUserId: owner, identity: `${row.grant.id}:${owner}:${generation}`,
|
|
} });
|
|
expect((await db.select().from(connectionGrants).where(eq(connectionGrants.id, row.grant.id)))[0].status).toBe("needs_reauthorization");
|
|
const signedIn = JSON.stringify({ tokens: { access_token: "process-sign-in", refresh_token: "process-new-login" } });
|
|
await receiver.save(companyId, owner, { connectionId: row.connection.id, provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, signedIn);
|
|
const [current] = await receiver.quotaAccounts(companyId, owner);
|
|
expect(await receiver.runtimeCredential(current)).toBe(signedIn);
|
|
}, 40_000);
|
|
|
|
it("does not exchange a token when a required database transaction fails", async () => {
|
|
const owner = "quota-db-prerequisite";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired", refresh_token: "single-use" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const request = vi.spyOn(globalThis, "fetch");
|
|
const unavailableUrl = new URL(database.connectionString);
|
|
unavailableUrl.port = "1";
|
|
const unavailable = createDb(unavailableUrl.toString());
|
|
try {
|
|
await expect(aiConnectionService(unavailable).refreshQuotaCredential(row, original, new AbortController().signal)).rejects.toThrow();
|
|
expect(request).not.toHaveBeenCalled();
|
|
await expect(access(quotaCredentialRecoveryPath(companyId, row.grant.id))).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { await unavailable.$client.end(); request.mockRestore(); }
|
|
});
|
|
|
|
it.each(["reconnect", "same_value_new_epoch", "revoked"] as const)("lets %s win after a returned token's save rolls back", async change => {
|
|
const owner = `quota-delayed-save-${change}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired", refresh_token: "single-use" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const transaction = db.transaction.bind(db);
|
|
const interrupted = vi.spyOn(db, "transaction").mockImplementationOnce(async (work, config) => {
|
|
await transaction(async tx => { await work(tx); throw new Error("fixture rollback"); }, config).catch(() => {});
|
|
if (change === "revoked") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, row.grant.id));
|
|
else await secretService(db).rotate(ref.secretId, { value: change === "reconnect" ? "new-login" : original });
|
|
throw new Error("fixture lost response");
|
|
});
|
|
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access_token: "late", refresh_token: "late-refresh" }));
|
|
try {
|
|
const refresh = service.refreshQuotaCredential(row, original, new AbortController().signal);
|
|
if (change === "revoked") await expect(refresh).rejects.toThrow("credentials_unavailable");
|
|
else expect(await refresh).toBe(change === "reconnect" ? "new-login" : original);
|
|
expect(await service.credential(row)).toBe(change === "reconnect" ? "new-login" : original);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
await expect(access(quotaCredentialRecoveryPath(companyId, row.grant.id))).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { interrupted.mockRestore(); request.mockRestore(); }
|
|
});
|
|
|
|
it.each(["rollback", "lost_commit_reply"] as const)("automatically saves one exchanged token across %s", async (failure) => {
|
|
const owner = `quota-once-${failure}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired", refresh_token: "single-use", account_id: owner } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const transaction = db.transaction.bind(db);
|
|
const interrupted = vi.spyOn(db, "transaction").mockImplementationOnce(async (work, config) => {
|
|
await transaction(async (tx) => {
|
|
await work(tx);
|
|
if (failure === "rollback") throw new Error("Injected rollback after writes");
|
|
}, config);
|
|
throw new Error("Injected lost commit response");
|
|
});
|
|
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access_token: "fresh-once", refresh_token: "replacement-once" }));
|
|
try {
|
|
await service.refreshQuotaCredential(row, original, new AbortController().signal);
|
|
expect(JSON.parse(await service.credential(row))).toMatchObject({ tokens: { access_token: "fresh-once", refresh_token: "replacement-once" } });
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
await expect(access(quotaCredentialRecoveryPath(companyId, row.grant.id))).rejects.toThrow();
|
|
} finally { interrupted.mockRestore(); request.mockRestore(); }
|
|
});
|
|
|
|
it("recovers the current credential's pending replacement before answering a delayed older poll", async () => {
|
|
const owner = "delayed-quota-poll";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "A", refresh_token: "refresh-A", account_id: owner } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const journal = quotaCredentialRecovery(companyId, row.grant.id);
|
|
const request = vi.spyOn(globalThis, "fetch")
|
|
.mockResolvedValueOnce(Response.json({ access_token: "B", refresh_token: "refresh-B" }))
|
|
.mockResolvedValueOnce(Response.json({ access_token: "C", refresh_token: "refresh-C" }));
|
|
let triggerInstalled = false;
|
|
try {
|
|
const current = await service.refreshQuotaCredential(row, original, new AbortController().signal);
|
|
await db.execute(sql.raw(`create function delayed_quota_commit_failure() returns trigger language plpgsql as $$ begin
|
|
if new.secret_id = '${ref.secretId}'::uuid then raise exception 'injected delayed quota commit failure'; end if; return new; end $$;
|
|
create constraint trigger delayed_quota_commit_failure after insert on company_secret_versions deferrable initially deferred
|
|
for each row execute function delayed_quota_commit_failure();`));
|
|
triggerInstalled = true;
|
|
await expect(service.refreshQuotaCredential(row, current, new AbortController().signal)).rejects.toThrow();
|
|
expect(await service.credential(row)).toBe(current);
|
|
expect(await journal.read()).toBeNull();
|
|
// Only observed records from the earlier release use the legacy reader.
|
|
await journal.write({ companyId, grantId: row.grant.id, connectionId: row.connection.id, secretId: ref.secretId,
|
|
baseHash: quotaCredentialHash(current), value: JSON.stringify({ tokens: { access_token: "C", refresh_token: "refresh-C" } }) });
|
|
await db.execute(sql`drop trigger delayed_quota_commit_failure on company_secret_versions; drop function delayed_quota_commit_failure()`);
|
|
triggerInstalled = false;
|
|
const recovered = await service.refreshQuotaCredential(row, original, new AbortController().signal);
|
|
expect(JSON.parse(recovered).tokens).toMatchObject({ access_token: "C", refresh_token: "refresh-C" });
|
|
expect(await service.credential(row)).toBe(recovered);
|
|
expect(await journal.read()).toBeNull();
|
|
expect(request).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
if (triggerInstalled) await db.execute(sql`drop trigger delayed_quota_commit_failure on company_secret_versions; drop function delayed_quota_commit_failure()`);
|
|
request.mockRestore(); await journal.clear();
|
|
}
|
|
});
|
|
|
|
it.each(["reconnected", "expired_reconnect", "revoked"] as const)("does not apply a pending refresh over a %s identity", async (change) => {
|
|
const owner = `quota-replay-${change}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired", refresh_token: "single-use", account_id: owner } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const journal = quotaCredentialRecovery(companyId, row.grant.id);
|
|
await journal.write({ companyId, grantId: row.grant.id, connectionId: row.connection.id, secretId: ref.secretId, baseHash: quotaCredentialHash(original), value: "old-pending-replacement" });
|
|
const request = vi.spyOn(globalThis, "fetch");
|
|
try {
|
|
if (change === "reconnected") {
|
|
await secretService(db).rotate(ref.secretId, { value: "new-authorized-identity" });
|
|
expect(await service.refreshQuotaCredential(row, original, new AbortController().signal)).toBe("new-authorized-identity");
|
|
expect(await service.credential(row)).toBe("new-authorized-identity");
|
|
expect(await journal.read()).toBeNull();
|
|
} else if (change === "expired_reconnect") {
|
|
const reconnected = JSON.stringify({ tokens: { access_token: "expired-new-account", refresh_token: "new-account-refresh", account_id: owner } });
|
|
await secretService(db).rotate(ref.secretId, { value: reconnected });
|
|
request.mockResolvedValueOnce(Response.json({ access_token: "new-account-fresh", refresh_token: "new-account-replacement" }));
|
|
await service.refreshQuotaCredential(row, reconnected, new AbortController().signal);
|
|
expect(request).toHaveBeenCalledOnce();
|
|
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toMatchObject({ refresh_token: "new-account-refresh" });
|
|
expect(JSON.parse(await service.credential(row))).toMatchObject({ tokens: { access_token: "new-account-fresh", refresh_token: "new-account-replacement" } });
|
|
expect(await journal.read()).toBeNull();
|
|
} else {
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, row.grant.id));
|
|
await expect(service.refreshQuotaCredential(row, original, new AbortController().signal)).rejects.toThrow("credentials_unavailable");
|
|
expect(await journal.read()).not.toBeNull();
|
|
}
|
|
if (change !== "expired_reconnect") expect(request).not.toHaveBeenCalled();
|
|
} finally { request.mockRestore(); await journal.clear(); }
|
|
});
|
|
|
|
it("saves exchanged tokens when the quota deadline expires during response-body reading", async () => {
|
|
const owner = "quota-body-deadline-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired", refresh_token: "single-use", account_id: "deadline-account" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: "Slow token body", loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const reading = deferredSignal(), releaseBody = deferredSignal(), quotaDeadline = new AbortController();
|
|
let refreshSignal: AbortSignal | null | undefined;
|
|
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, options) => {
|
|
refreshSignal = options?.signal;
|
|
return { ok: true, json: async () => {
|
|
reading.resolve(); await releaseBody.promise;
|
|
refreshSignal?.throwIfAborted();
|
|
return { access_token: "fresh-after-deadline", refresh_token: "replacement" };
|
|
} } as Response;
|
|
});
|
|
const saving = service.refreshQuotaCredential(row, original, quotaDeadline.signal);
|
|
try {
|
|
await reading.promise;
|
|
quotaDeadline.abort();
|
|
expect(refreshSignal?.aborted).toBe(false);
|
|
releaseBody.resolve();
|
|
await saving;
|
|
expect(JSON.parse(await service.credential(row))).toMatchObject({ tokens: { access_token: "fresh-after-deadline", refresh_token: "replacement" } });
|
|
await expect(service.refreshQuotaCredential(row, original, quotaDeadline.signal)).rejects.toThrow();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
} finally { releaseBody.resolve(); await saving.catch(() => {}); request.mockRestore(); }
|
|
});
|
|
|
|
it("retries another account's token write-back while a slow quota exchange holds the company lock", async () => {
|
|
const auth = (account: string, marker: string) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: marker === "old" ? "2026-09-10T10:00:00Z" : "2026-09-10T11:00:00Z" });
|
|
const quotaOwner = "slow-quota-owner", runOwner = "slow-quota-other-run";
|
|
await db.insert(companyMemberships).values([quotaOwner, runOwner].map(principalId => ({ companyId, principalId, principalType: "user" as const, status: "active" as const, membershipRole: "member" as const })));
|
|
for (const owner of [quotaOwner, runOwner]) await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth(owner, "old"));
|
|
const [quotaRow] = await service.quotaAccounts(companyId, quotaOwner);
|
|
// Runtime credential reads now serialize with quota exchanges. Prepare this
|
|
// already-running, different account before testing its contended write-back.
|
|
const preparedRun = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: runOwner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
const started = deferredSignal(), release = deferredSignal();
|
|
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
started.resolve(); await release.promise;
|
|
return Response.json({ access_token: "quota-fresh", refresh_token: "quota-replacement" });
|
|
});
|
|
const refreshing = service.refreshQuotaCredential(quotaRow, auth(quotaOwner, "old"), new AbortController().signal);
|
|
let run: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
|
|
let cleaning: Promise<void> | undefined;
|
|
let lock: ReturnType<typeof vi.spyOn> | undefined;
|
|
try {
|
|
await started.promise;
|
|
run = preparedRun;
|
|
const authFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
|
|
await writeFile(authFile, auth(runOwner, "replacement"));
|
|
// Inject the two 30-second acquisition expirations, then use the real
|
|
// still-held lock and real Postgres write-back for the final attempt.
|
|
lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock")
|
|
.mockRejectedValueOnce(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }))
|
|
.mockRejectedValueOnce(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }));
|
|
cleaning = run.cleanup();
|
|
await vi.waitFor(() => expect(lock).toHaveBeenCalledTimes(3));
|
|
expect(await readFile(authFile, "utf8")).toBe(auth(runOwner, "replacement"));
|
|
release.resolve();
|
|
await Promise.all([refreshing, cleaning]);
|
|
const [runRow] = await service.quotaAccounts(companyId, runOwner);
|
|
expect(await service.credential(runRow)).toBe(auth(runOwner, "replacement"));
|
|
expect(JSON.parse(await service.credential(quotaRow))).toMatchObject({ tokens: { access_token: "quota-fresh", refresh_token: "quota-replacement" } });
|
|
await expect(access(run.home!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { release.resolve(); await Promise.allSettled([refreshing, cleaning]); lock?.mockRestore(); request.mockRestore(); }
|
|
});
|
|
|
|
it.each(["new", "reconnect"])("retains a completed %s sign-in across two lock waits behind a quota refresh", async kind => {
|
|
const owner = `slow-quota-login-${kind}`, loginOwner = kind === "reconnect" ? owner : "contended-login-owner";
|
|
await db.insert(companyMemberships).values([...new Set([owner, loginOwner])].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
|
const original = JSON.stringify({ tokens: { account_id: owner, access_token: "old", refresh_token: "old-refresh" } });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [quotaRow] = await service.quotaAccounts(companyId, owner);
|
|
const [environment] = await db.insert(environments).values({ name: "Contended AI login", driver: "sandbox" }).returning();
|
|
const intent = { provider: "openai", method: "subscription", ownership: "personal", name: "Contended sign-in", agentIds: [], allAgents: true, ...(kind === "reconnect" ? { connectionId: quotaRow.connection.id } : {}) } as const;
|
|
const sessionId = randomUUID();
|
|
await db.insert(adapterAuthSessions).values({ id: sessionId, publicSessionId: randomUUID(), companyId, environmentId: environment.id, adapterType: "codex_local", startedByUserId: loginOwner, status: "promoting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() + 600_000) });
|
|
const started = deferredSignal(), release = deferredSignal();
|
|
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
started.resolve(); await release.promise;
|
|
return Response.json({ access_token: "quota-fresh", refresh_token: "quota-replacement" });
|
|
});
|
|
const refreshing = service.refreshQuotaCredential(quotaRow, original, new AbortController().signal);
|
|
let saving: ReturnType<typeof service.save> | undefined;
|
|
let lock: ReturnType<typeof vi.spyOn> | undefined;
|
|
const credential = JSON.stringify({ tokens: { account_id: "signed-in", access_token: "login-token", refresh_token: "login-refresh" } });
|
|
try {
|
|
await started.promise;
|
|
// Represent two expired 30-second acquisitions, then wait on the real
|
|
// held lock. The credential must survive until that final save commits.
|
|
lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock")
|
|
.mockRejectedValueOnce(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }))
|
|
.mockRejectedValueOnce(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }));
|
|
saving = service.save(companyId, loginOwner, { ...intent, agentIds: [] }, credential, { id: sessionId });
|
|
void saving.catch(() => {});
|
|
await vi.waitFor(() => expect(lock).toHaveBeenCalledTimes(3));
|
|
expect((await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, sessionId)))[0].connectionId).toBeNull();
|
|
release.resolve();
|
|
const [, saved] = await Promise.all([refreshing, saving]);
|
|
const [row] = (await service.quotaAccounts(companyId, loginOwner)).filter(row => row.connection.id === saved.connectionId);
|
|
expect(await service.credential(row)).toBe(credential);
|
|
expect(await service.save(companyId, loginOwner, { ...intent, agentIds: [] }, credential, { id: sessionId })).toEqual(saved);
|
|
if (kind === "new") expect(JSON.parse(await service.credential(quotaRow))).toMatchObject({ tokens: { access_token: "quota-fresh", refresh_token: "quota-replacement" } });
|
|
} finally {
|
|
release.resolve(); await Promise.allSettled([refreshing, saving]); lock?.mockRestore(); request.mockRestore();
|
|
await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, sessionId));
|
|
await db.delete(environments).where(eq(environments.id, environment.id));
|
|
}
|
|
});
|
|
|
|
it.each(["quota", "recovery", "runtime"])("allows an in-flight reconnect after automatic %s credential rotation", async kind => {
|
|
const owner = `reconnect-refresh-${kind}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (label: string, hour: number) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${label}`, refresh_token: `refresh-${label}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
const intent = { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true } as const;
|
|
const account = await service.save(companyId, owner, { ...intent, agentIds: [] }, auth("old", 10));
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!;
|
|
const before = (await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId)))[0];
|
|
const started = new Date();
|
|
if (kind === "quota") {
|
|
const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ access_token: "access-fresh", refresh_token: "refresh-fresh" }));
|
|
try { await service.refreshQuotaCredential(row, auth("old", 10), new AbortController().signal); }
|
|
finally { request.mockRestore(); }
|
|
} else if (kind === "recovery") {
|
|
const recovery = quotaCredentialRecovery(companyId, row.grant.id);
|
|
await recovery.write({ companyId, grantId: row.grant.id, connectionId: row.connection.id, secretId: ref.secretId, baseHash: quotaCredentialHash(auth("old", 10)), value: auth("fresh", 11) });
|
|
expect(await service.runtimeCredential(row)).toBe(auth("fresh", 11));
|
|
} else {
|
|
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
await writeFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), auth("fresh", 11));
|
|
await runtime.cleanup();
|
|
}
|
|
const after = (await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId)))[0];
|
|
expect(after.latestVersion).toBeGreaterThan(before.latestVersion);
|
|
expect(after.aiSessionEpoch).toBe(before.aiSessionEpoch);
|
|
const saved = await service.save(companyId, owner, { ...intent, agentIds: [], connectionId: account.connectionId }, auth("reconnected", 12), undefined, started);
|
|
expect(saved).toEqual(account);
|
|
expect(await service.credential(row)).toBe(auth("reconnected", 12));
|
|
await expect(service.save(companyId, owner, { ...intent, agentIds: [], connectionId: account.connectionId }, auth("older-login", 13), undefined, started)).rejects.toThrow("changed");
|
|
// A real later revocation still fences this same old reconnect attempt.
|
|
await db.update(connectionGrants).set({ status: "revoked", updatedAt: new Date() }).where(eq(connectionGrants.id, account.grantId));
|
|
await expect(service.save(companyId, owner, { ...intent, agentIds: [], connectionId: account.connectionId }, auth("stale", 13), undefined, started)).rejects.toThrow("changed");
|
|
});
|
|
|
|
it.each(["lock", "database"])("preserves replacement tokens after exhausted %s write-back and permits a later retry", async failure => {
|
|
const owner = `retained-writeback-${failure}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (hour: string) => JSON.stringify({ tokens: { account_id: owner, id_token: "identity", access_token: `access-${hour}`, refresh_token: `refresh-${hour}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: owner, loginSessionId: "fixture", agentIds: [], allAgents: true }, auth("10"));
|
|
const run = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
const authFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
|
|
await writeFile(authFile, auth("11"));
|
|
const injected = failure === "lock"
|
|
? vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock").mockRejectedValue(Object.assign(new Error("lock timeout"), { code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE }))
|
|
: vi.spyOn(db, "transaction").mockRejectedValue(new Error("database unavailable"));
|
|
try {
|
|
await expect(run.cleanup()).rejects.toThrow(failure === "lock" ? "lock timeout" : "database unavailable");
|
|
expect(injected).toHaveBeenCalledTimes(failure === "lock" ? 3 : 1);
|
|
expect(await readFile(authFile, "utf8")).toBe(auth("11"));
|
|
} finally { injected.mockRestore(); }
|
|
await run.cleanup();
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
expect(await service.credential(row)).toBe(auth("11"));
|
|
await expect(access(run.home!)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("serializes quota exchange with a concurrent secret edit and runtime credential read", async () => {
|
|
const owner = "quota-race-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "old-access", refresh_token: "single-use", id_token: "identity", account_id: "race-account" } });
|
|
const account = await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: "Racing refresh", loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const secretId = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!.secretId;
|
|
const exchangeStarted = deferredSignal();
|
|
const releaseExchange = deferredSignal();
|
|
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
|
exchangeStarted.resolve(); await releaseExchange.promise;
|
|
return Response.json({ access_token: "new-access", refresh_token: "new-refresh" });
|
|
});
|
|
let runtime: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
|
|
let lock: ReturnType<typeof vi.spyOn> | undefined;
|
|
const refreshing = service.refreshQuotaCredential(row, original, new AbortController().signal);
|
|
try {
|
|
await exchangeStarted.promise;
|
|
const edit = secretService(db).update(secretId, { description: "Changed during refresh" });
|
|
lock = vi.spyOn(codexAdapter, "withAccountHomeSecretMutationLock");
|
|
const preparing = prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: owner, adapterType: "codex_local", binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: {} });
|
|
// Runtime reads now wait at the company file lock before touching the
|
|
// old credential. The returned home must contain the committed rotation.
|
|
let prepared = false;
|
|
void preparing.then(() => { prepared = true; });
|
|
await vi.waitFor(() => expect(lock).toHaveBeenCalled());
|
|
expect(prepared).toBe(false);
|
|
releaseExchange.resolve();
|
|
const results = await Promise.all([refreshing, edit, preparing]);
|
|
runtime = results[2];
|
|
expect(JSON.parse(await readFile(path.join(String(runtime.config.env.CODEX_HOME), "auth.json"), "utf8"))).toMatchObject({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } });
|
|
expect(JSON.parse(await service.credential(row))).toMatchObject({ tokens: { access_token: "new-access", refresh_token: "new-refresh" } });
|
|
expect((await secretService(db).getById(secretId))?.description).toBe("Changed during refresh");
|
|
} finally { releaseExchange.resolve(); await runtime?.cleanup(); request.mockRestore(); lock?.mockRestore(); }
|
|
}, 10_000);
|
|
|
|
it("serializes quota credential refresh, persists rotation, and defers while the provider is active", async () => {
|
|
const owner = "quota-refresh-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const original = JSON.stringify({ tokens: { access_token: "expired-access", refresh_token: "single-use", id_token: "identity", account_id: null } });
|
|
const account = await service.save(companyId, owner, { provider: "openai", method: "subscription", ownership: "personal", name: "Refresh account", loginSessionId: "fixture", agentIds: [], allAgents: true }, original);
|
|
const [row] = await service.quotaAccounts(companyId, owner);
|
|
const request = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ access_token: "fresh-access", refresh_token: "rotated-once" })));
|
|
const signal = new AbortController().signal;
|
|
try {
|
|
// A pre-existing transaction may own a secret row before asking for the
|
|
// company file lock. Refresh must defer before exchanging its token.
|
|
await db.transaction(async tx => {
|
|
const secretId = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential")!.secretId;
|
|
await tx.select().from(companySecrets).where(eq(companySecrets.id, secretId)).for("update");
|
|
await expect(service.refreshQuotaCredential(row, original, signal)).rejects.toThrow();
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
const activeId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: activeId, companyId, agentId, invocationSource: "on_demand", status: "running", contextSnapshot: { aiConnection: { grantId: account.grantId } } });
|
|
await expect(service.refreshQuotaCredential(row, original, signal)).rejects.toThrow("provider_unavailable");
|
|
expect(request).not.toHaveBeenCalled();
|
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, activeId));
|
|
const [first, second] = await Promise.all([
|
|
service.refreshQuotaCredential(row, original, signal),
|
|
service.refreshQuotaCredential(row, original, signal),
|
|
]);
|
|
expect(first).toBe(second);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(await service.credential(row))).toMatchObject({ tokens: { access_token: "fresh-access", refresh_token: "rotated-once", account_id: null } });
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
|
await expect(service.refreshQuotaCredential(row, first, signal)).rejects.toThrow("credentials_unavailable");
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
} finally { request.mockRestore(); }
|
|
});
|
|
|
|
it.each([
|
|
["openai", "codex_local", "responses"],
|
|
["anthropic", "claude_local", "messages"],
|
|
["openai", "opencode_local", "chat"],
|
|
] as const)("prepares a no-auth %s endpoint without a vault secret", async (provider, adapterType, protocol) => {
|
|
const account = await service.save(companyId, "alice", {
|
|
provider, method: "api_key", ownership: "personal", name: `No-auth ${adapterType}`,
|
|
agentIds: [], allAgents: true,
|
|
routing: { kind: "local", protocol, baseUrl: "http://127.0.0.1:9000/v1", auth: "none", models: [] },
|
|
}, "");
|
|
const binding = { provider, method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const;
|
|
const config = { model: "fixture-model", env: { OPENAI_API_KEY: "host-key", ANTHROPIC_API_KEY: "host-key" } };
|
|
const first = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
|
|
const second = await prepareManagedAiRuntime(db, { ...input, adapterType, responsibleUserId: "alice", binding, config });
|
|
try {
|
|
expect(first.sessionIdentity).toBe(second.sessionIdentity);
|
|
expect(first.sessionIdentity).toContain("no-auth");
|
|
expect(JSON.stringify(first.config)).not.toContain("host-key");
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
|
|
expect(grant.credentialSecretRefs).toEqual([]);
|
|
} finally { await Promise.all([first.cleanup(), second.cleanup()]); }
|
|
});
|
|
it("never restores retained Grok transcripts into same-user runtime homes", async () => {
|
|
const account = await service.save(companyId, "alice", {
|
|
provider: "xai", method: "api_key", ownership: "personal",
|
|
name: "Grok history isolation", agentIds: [], allAgents: true,
|
|
}, "fixture-grok-key");
|
|
const taskKey = `grok-history-${randomUUID()}`;
|
|
const runInput = { ...input, taskKey, adapterType: "grok_local", responsibleUserId: "alice", config: {},
|
|
binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
|
|
const first = await prepareManagedAiRuntime(db, runInput);
|
|
const scope = createHash("sha256").update(JSON.stringify([
|
|
companyId, agentId, taskKey, first.sessionIdentity,
|
|
])).digest("hex");
|
|
await first.cleanup();
|
|
// Seed a valid archive from the earlier implementation. Runtime preparation
|
|
// must not decrypt it onto the host, even for its authorized agent/task.
|
|
const retained = await localEncryptedProvider.createVersion({ value: JSON.stringify({
|
|
scope, entries: [{ name: "history.json", bytes: Buffer.from("previous-task-private-transcript").toString("base64") }],
|
|
}) });
|
|
await db.insert(agentTaskSessions).values({ companyId, agentId, adapterType: "grok_local", taskKey,
|
|
sessionParamsJson: { sessionId: "history", paperclipGrokHistory: { scope, material: retained.material } } });
|
|
const resumed = await prepareManagedAiRuntime(db, runInput);
|
|
const peer = await prepareManagedAiRuntime(db, { ...runInput, agentId: randomUUID() });
|
|
try {
|
|
expect(resumed.home).not.toBe(first.home);
|
|
expect(peer.home).not.toBe(resumed.home);
|
|
const transcript = path.join(String(resumed.config.env.GROK_HOME), "sessions", "history.json");
|
|
// A separate same-UID process can bypass 0700. It must find no restored
|
|
// transcript because none was materialized, not because of permissions.
|
|
const crossAgentRead = spawnSync(process.execPath, ["-e", `
|
|
const fs = require("node:fs");
|
|
try { process.stdout.write(fs.readFileSync(process.argv[1], "utf8")); }
|
|
catch (error) { process.stdout.write(error.code); }
|
|
`, transcript], { encoding: "utf8" });
|
|
expect(crossAgentRead.status).toBe(0);
|
|
expect(crossAgentRead.stdout).toBe("ENOENT");
|
|
await expect(access(transcript)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally { await Promise.all([resumed.cleanup(), peer.cleanup()]); }
|
|
});
|
|
it("isolates the selected Google AI credential for pending native Antigravity preparation", async () => {
|
|
const owner = "antigravity-managed-key";
|
|
const token = "saved-antigravity-key";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const saved = await service.save(companyId, owner, { provider: "google", method: "api_key", ownership: "personal",
|
|
name: "Antigravity Google AI", apiKey: token, allAgents: true, agentIds: [] }, token);
|
|
await service.setDefault(companyId, owner, saved.grantId);
|
|
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "paperclip_runner",
|
|
binding: { provider: "google", method: "api_key", mode: "responsible_user" } as const,
|
|
config: { provider: "acpx", acpxAgent: "antigravity", model: "exact-antigravity-model", env: {
|
|
GEMINI_API_KEY: "ambient-key", GOOGLE_API_KEY: "ambient-other-key", GEMINI_HOME: "/ambient/home",
|
|
GOOGLE_APPLICATION_CREDENTIALS: "/ambient/google.json", GOOGLE_CLOUD_ACCESS_TOKEN: "ambient-oauth-token",
|
|
} } };
|
|
const runtime = await prepareManagedAiRuntime(db, input);
|
|
const reopened = await prepareManagedAiRuntime(db, input);
|
|
try {
|
|
expect(runtime.attribution).toMatchObject({ provider: "google", method: "api_key", connectionId: saved.connectionId, grantId: saved.grantId });
|
|
expect(runtime.config).toMatchObject({ provider: "acpx", acpxAgent: "antigravity", model: input.config.model,
|
|
env: { GEMINI_API_KEY: token, GOOGLE_API_KEY: "", GOOGLE_APPLICATION_CREDENTIALS: "", GOOGLE_CLOUD_ACCESS_TOKEN: "" } });
|
|
expect(runtime.config.env.GEMINI_HOME).toBe(path.join(runtime.home!, "provider"));
|
|
const settingsFile = path.join(String(runtime.config.env.GEMINI_HOME), "antigravity-acp", "settings.json");
|
|
expect(JSON.parse(await readFile(settingsFile, "utf8"))).toEqual({ auth: { type: "gemini-api-key" } });
|
|
expect(await readFile(settingsFile, "utf8")).not.toContain(token);
|
|
expect((await stat(settingsFile)).mode & 0o777).toBe(0o600);
|
|
expect(managedAiSessionFingerprintConfig(runtime.config, runtime.home))
|
|
.toEqual(managedAiSessionFingerprintConfig(reopened.config, reopened.home));
|
|
expect(input.config.env.GEMINI_HOME).toBe("/ambient/home");
|
|
await expect(prepareManagedAiRuntime(db, { ...input, responsibleUserId: "bob" })).rejects.toThrow();
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId));
|
|
await expect(prepareManagedAiRuntime(db, input)).rejects.toThrow();
|
|
} finally { await Promise.all([runtime.cleanup(), reopened.cleanup()]); }
|
|
await expect(access(runtime.home!)).rejects.toThrow();
|
|
await expect(access(reopened.home!)).rejects.toThrow();
|
|
});
|
|
|
|
it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => {
|
|
const root = await mkdtemp(path.join(home, "gemini-auth-"));
|
|
const command = path.join(root, "gemini");
|
|
await writeFile(command, `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const settings = JSON.parse(fs.readFileSync(path.join(process.env.HOME, ".gemini/settings.json"), "utf8"));
|
|
if (settings.selectedAuthType !== "gemini-api-key" || settings.security?.auth?.selectedType !== "gemini-api-key" || process.env.GEMINI_API_KEY !== "saved-google-key") {
|
|
console.error("Invalid auth method selected");
|
|
process.exit(1);
|
|
}
|
|
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-managed" }));
|
|
console.log(JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }));
|
|
console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello", session_id: "gemini-managed" }));
|
|
`, { mode: 0o700 });
|
|
const saved = await service.save(companyId, "alice", {
|
|
provider: "google", method: "api_key", ownership: "personal", name: "Saved Google",
|
|
apiKey: "fixture", allAgents: true, agentIds: [],
|
|
}, "saved-google-key");
|
|
const runtime = await prepareManagedAiRuntime(db, {
|
|
companyId, agentId, responsibleUserId: "alice", adapterType: "gemini_local",
|
|
binding: { provider: "google", method: "api_key", mode: "responsible_user" },
|
|
config: { engine: "cli", command, cwd: root, promptTemplate: "Say hello.", paperclipRuntimeSkills: [], env: { GEMINI_API_KEY: "ambient-google-key" } },
|
|
});
|
|
try {
|
|
expect(runtime.attribution.grantId).toBe(saved.grantId);
|
|
expect(runtime.home).not.toBe(os.homedir());
|
|
const settingsFile = path.join(runtime.home!, ".gemini/settings.json");
|
|
expect(await readFile(settingsFile, "utf8")).not.toContain("saved-google-key");
|
|
expect((await stat(settingsFile)).mode & 0o777).toBe(0o600);
|
|
const probe = await testGeminiEnvironment({ companyId, adapterType: "gemini_local", config: runtime.config });
|
|
expect(probe.status).toBe("pass");
|
|
expect(probe.checks).toContainEqual(expect.objectContaining({ code: "gemini_hello_probe_passed" }));
|
|
const result = await executeGemini({
|
|
runId: randomUUID(),
|
|
agent: { id: agentId, companyId, name: "Gemini", adapterType: "gemini_local", adapterConfig: { engine: "cli" } },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: runtime.config, context: {}, onLog: async () => {},
|
|
});
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.sessionId).toBe("gemini-managed");
|
|
} finally {
|
|
await runtime.cleanup();
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
await expect(access(runtime.home!)).rejects.toThrow();
|
|
});
|
|
|
|
it("preserves Google and Copilot account defaults when the current provider constraint migration is reapplied", async () => {
|
|
const saved = await service.save(companyId, "bob", {
|
|
provider: "google", method: "api_key", ownership: "personal", name: "Google migration",
|
|
apiKey: "fixture", allAgents: true, agentIds: [],
|
|
}, "google-migration-key");
|
|
await db.insert(companyMemberships).values({ companyId, principalId: "migration-copilot", principalType: "user", status: "active", membershipRole: "member" });
|
|
const copilot = await service.save(companyId, "migration-copilot", {
|
|
provider: "github", method: "api_key", ownership: "personal", name: "Copilot migration",
|
|
apiKey: "fixture", allAgents: true, agentIds: [],
|
|
}, "github_pat_fixture_migration");
|
|
await service.setDefault(companyId, "migration-copilot", copilot.grantId);
|
|
const migration = await readFile(new URL("../../../packages/db/src/migrations/0361_mighty_dracula.sql", import.meta.url), "utf8");
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
await db.transaction(async (tx) => {
|
|
for (const statement of migration.split("--> statement-breakpoint")) {
|
|
if (statement.trim()) await tx.execute(sql.raw(statement));
|
|
}
|
|
});
|
|
}
|
|
const selected = await service.select({
|
|
companyId, agentId, userId: "bob", adapterType: "gemini_local",
|
|
binding: { provider: "google", method: "api_key", mode: "responsible_user" },
|
|
});
|
|
expect(selected.grant.id).toBe(saved.grantId);
|
|
const selectedCopilot = await service.select({
|
|
companyId, agentId, userId: "migration-copilot", adapterType: "paperclip_runner",
|
|
runnerProvider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna",
|
|
binding: { provider: "github", method: "api_key", mode: "responsible_user" },
|
|
});
|
|
expect(selectedCopilot.grant.id).toBe(copilot.grantId);
|
|
});
|
|
|
|
it.each([false, true])("reports the authoritative connection-manager capability for custom grants (manager: %s)", async (manager) => {
|
|
const userId = `custom-manager-${manager}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
|
if (manager) await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "tools:manage_connections" });
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
const response = await request(app).get(`/api/companies/${companyId}/ai-connections`);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.canManageConnections).toBe(manager);
|
|
});
|
|
|
|
it("probes only the requested authorized grant using its vaulted subscription token", async () => {
|
|
const owner = "usage-probe-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const account = await service.save(companyId, owner, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Usage probe account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "usage-selected-secret");
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 42 }, seven_day: { utilization: 15 } })));
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
req.actor = req.header("x-agent")
|
|
? { type: "agent", agentId, companyId, companyIds: [companyId], onBehalfOfUserId: owner }
|
|
: { type: "board", userId: req.header("x-test-user") ?? owner, companyIds: [companyId] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/usage?grantId=${account.grantId}`;
|
|
try {
|
|
const result = await request(app).get(url);
|
|
expect(result.status).toBe(200);
|
|
expect(result.headers["cache-control"]).toBe("no-store");
|
|
expect(result.body).toMatchObject({ ...account, status: "ok", provider: "anthropic", limits: [{ usedPercent: 42 }, { usedPercent: 15 }] });
|
|
expect(fetchSpy).toHaveBeenLastCalledWith("https://api.anthropic.com/api/oauth/usage", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer usage-selected-secret" }) }));
|
|
expect(JSON.stringify(result.body)).not.toContain("usage-selected-secret");
|
|
fetchSpy.mockClear();
|
|
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
|
|
expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403);
|
|
expect((await request(app).get(url.replace(account.grantId, randomUUID()))).status).toBe(404);
|
|
expect((await request(app).get(url).set("x-agent", "yes")).status).toBe(403);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
|
const revoked = await request(app).get(url);
|
|
expect(revoked.body).toMatchObject({ status: "unavailable", errorCode: "connection_unavailable", limits: [] });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally { fetchSpy.mockRestore(); }
|
|
});
|
|
|
|
it("reads provider identity only for an authorized credential and preserves connected state when profile access is denied", async () => {
|
|
const owner = "identity-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const account = await service.save(companyId, owner, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Identity account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "identity-selected-secret");
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ account: { email: "avery@example.test", uuid: "private-account-id" }, organization: { name: "Example team", uuid: "private-org-id" } }));
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
req.actor = req.header("x-test-anonymous") ? { type: "none" }
|
|
: req.header("x-agent") ? { type: "agent", agentId, companyId, companyIds: [companyId], onBehalfOfUserId: owner }
|
|
: { type: "board", source: "session", userId: req.header("x-test-user") ?? owner, companyIds: [companyId] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/identity?grantId=${account.grantId}`;
|
|
try {
|
|
const result = await request(app).get(url);
|
|
expect(result.status).toBe(200);
|
|
expect(result.headers["cache-control"]).toBe("no-store");
|
|
expect(result.body).toMatchObject({ ...account, status: "available", accountLabel: "avery@example.test", organizationLabel: "Example team" });
|
|
expect(fetchSpy).toHaveBeenCalledWith("https://api.anthropic.com/api/oauth/profile", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer identity-selected-secret" }) }));
|
|
for (const value of ["identity-selected-secret", "private-account-id", "private-org-id"]) expect(JSON.stringify(result.body)).not.toContain(value);
|
|
fetchSpy.mockReset();
|
|
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
|
|
expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403);
|
|
expect((await request(app).get(url.replace(account.grantId, randomUUID()))).status).toBe(404);
|
|
expect((await request(app).get(url).set("x-test-anonymous", "yes")).status).toBe(401);
|
|
expect((await request(app).get(url).set("x-agent", "yes")).status).toBe(403);
|
|
expect((await request(app).get(url.replace(account.connectionId, "invalid"))).status).toBe(404);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
fetchSpy.mockResolvedValue(Response.json({ error: "private-provider-error" }, { status: 403 }));
|
|
expect((await request(app).get(url)).body).toMatchObject({ status: "unavailable", errorCode: "permission_denied", accountLabel: null });
|
|
expect((await service.list(companyId, owner)).find(item => item.id === account.connectionId)?.status).toBe("connected");
|
|
fetchSpy.mockClear();
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, account.grantId));
|
|
expect((await request(app).get(url)).body).toMatchObject({ status: "unavailable", errorCode: "connection_unavailable" });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally { fetchSpy.mockRestore(); }
|
|
});
|
|
|
|
it("uses the same usage probe for shared accounts across legacy and native runner selections", async () => {
|
|
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "subscription", ownership: "shared", name: "Shared usage account", loginSessionId: "fixture", agentIds: [], allAgents: true }, "shared-usage-secret");
|
|
const selected = { provider: "anthropic", method: "subscription", mode: "shared", ...account } as const;
|
|
const tools = toolAccessService(db);
|
|
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["alice"], { userId: "alice" });
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(JSON.stringify({ five_hour: { utilization: 100 }, extra_usage: { is_enabled: false } })));
|
|
try {
|
|
await expect(service.probeUsage(companyId, "bob", account.connectionId)).rejects.toThrow("not found");
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
await tools.replaceConnectionGrantMembers(account.connectionId, account.grantId, ["bob"], { userId: "alice" });
|
|
for (const config of [{ adapterType: "claude_local" }, { adapterType: "paperclip_runner", runnerProvider: "acpx", acpxAgent: "claude" }]) {
|
|
const row = await service.select({ companyId, userId: "bob", agentId, binding: selected, ...config });
|
|
expect(await service.probeUsage(companyId, "bob", row.connection.id, row.grant.id)).toMatchObject({ status: "ok", overage: { available: false }, limits: [{ limitReached: true }, { allowed: false }] });
|
|
}
|
|
// Unsupported methods do not resolve or send secrets.
|
|
const owner = "usage-api-owner";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const apiAccount = await create(owner, "Unsupported usage method");
|
|
fetchSpy.mockClear();
|
|
expect(await service.probeUsage(companyId, owner, apiAccount.connectionId)).toMatchObject({ status: "unsupported" });
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
} finally { fetchSpy.mockRestore(); }
|
|
});
|
|
|
|
it("vaults routed credentials, enforces compatibility and access, and configures both Codex runners", async () => {
|
|
const routing = { kind: "openrouter", protocol: "responses", auth: "bearer", models: [{ id: "openai/gpt-5.4" }] } as const;
|
|
const saved = await service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", allAgents: true, agentIds: [], routing: { ...routing, models: [...routing.models] } }, "fixture-routed-credential");
|
|
const selected = { provider: "openrouter", method: "api_key", mode: "delegated", ...saved } as const;
|
|
expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-routed-credential");
|
|
expect(await service.list(companyId, "alice")).toEqual(expect.arrayContaining([expect.objectContaining({ id: saved.connectionId, routing, isDefault: false })]));
|
|
await expect(service.setDefault(companyId, "alice", saved.grantId)).rejects.toThrow("explicit connection");
|
|
await expect(service.select({ ...input, binding: selected, userId: "bob", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("not shared");
|
|
await expect(service.select({ ...input, companyId: otherCompanyId, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow();
|
|
for (const adapterType of ["codex_local", "paperclip_runner"]) {
|
|
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: "alice", binding: selected, adapterType, config: { provider: "codex", model: "openai/gpt-5.4", env: { OPENAI_API_KEY: "ambient" } } });
|
|
try {
|
|
expect(runtime.config.env.OPENAI_API_KEY).toBe("");
|
|
expect(runtime.config.env.PAPERCLIP_AI_PROVIDER_KEY).toBe("fixture-routed-credential");
|
|
const toml = await readFile(path.join(String(runtime.config.env.CODEX_HOME), "config.toml"), "utf8");
|
|
expect(toml).toContain('base_url = "https://openrouter.ai/api/v1"');
|
|
expect(toml).toContain('wire_api = "responses"');
|
|
expect(toml).not.toContain("fixture-routed-credential");
|
|
} finally { await runtime.cleanup(); }
|
|
}
|
|
await expect(service.save(companyId, "alice", { provider: "openrouter", method: "api_key", name: "Routed test", ownership: "personal", apiKey: "fixture", connectionId: saved.connectionId, allAgents: true, agentIds: [], routing: { ...routing, kind: "gateway", baseUrl: "https://other.example/v1", models: [] } }, "fixture-replacement")).rejects.toThrow("retain");
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId));
|
|
await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("Reconnect");
|
|
});
|
|
it.each(["openrouter", "gateway"] as const)("preserves Claude custom headers for managed %s connections", async (kind) => {
|
|
const provider = kind === "openrouter" ? "openrouter" : "anthropic";
|
|
const routing = { kind, protocol: "messages", auth: "bearer", ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}), models: [] } as const;
|
|
const saved = await service.save(companyId, "alice", { provider, method: "api_key", name: `Headers ${kind}`, ownership: "personal", allAgents: true, agentIds: [], routing: { ...routing, models: [] } }, "fixture-credential");
|
|
const selected = { provider, method: "api_key", mode: "delegated", ...saved } as const;
|
|
const customHeaders = "X-Gateway-Route: account-route\nx-title: Previous harness";
|
|
const reference = { type: "secret_ref", secretId: randomUUID(), version: "latest" };
|
|
const inherited = process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
process.env.ANTHROPIC_CUSTOM_HEADERS = "X-Gateway-Route: inherited-route";
|
|
try {
|
|
for (const adapterType of ["claude_local", "paperclip_runner"]) {
|
|
for (const configured of [customHeaders, reference, undefined]) {
|
|
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: "alice", binding: selected, adapterType,
|
|
config: { provider: "acpx", acpxAgent: "claude", env: configured === undefined ? {} : { ANTHROPIC_CUSTOM_HEADERS: configured } } });
|
|
try {
|
|
const effective = configured ?? process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
const expected = kind === "openrouter" && typeof effective === "string"
|
|
? openRouterClaudeHeaders(effective)
|
|
: configured;
|
|
expect(runtime.config.env.ANTHROPIC_CUSTOM_HEADERS).toEqual(expected);
|
|
} finally { await runtime.cleanup(); }
|
|
}
|
|
}
|
|
} finally {
|
|
if (inherited === undefined) delete process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
else process.env.ANTHROPIC_CUSTOM_HEADERS = inherited;
|
|
}
|
|
});
|
|
|
|
it("reconnects JSONB routing without changing its identity or access", async () => {
|
|
const routing = { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example/v1", models: [{ id: "gateway-model" }] } as const;
|
|
const input = { provider: "openai", method: "api_key", name: "Reconnect gateway", ownership: "personal", allAgents: false, agentIds: [agentId], routing: { ...routing, models: [...routing.models] } } as const;
|
|
const saved = await service.save(companyId, "alice", { ...input, agentIds: [...input.agentIds] }, "old-gateway-credential");
|
|
const [stored] = await db.select().from(toolConnections).where(eq(toolConnections.id, saved.connectionId));
|
|
expect(stored!.config.ai).toMatchObject({ routing });
|
|
expect(stored!.config.sourceTemplateKey).toBe("responses-api");
|
|
const [application] = await db.select().from(toolApplications).where(eq(toolApplications.id, stored!.applicationId));
|
|
expect(application).toMatchObject({ applicationKey: "app-gallery:responses-api", metadata: { sourceTemplateKey: "responses-api" } });
|
|
// PostgreSQL JSONB reorders object keys; compare values, not serialization.
|
|
const reordered = { models: [...routing.models], auth: routing.auth, baseUrl: routing.baseUrl, protocol: routing.protocol, kind: routing.kind };
|
|
expect(await service.save(companyId, "alice", { ...input, agentIds: [], allAgents: true, connectionId: saved.connectionId, routing: reordered }, "new-gateway-credential")).toEqual(saved);
|
|
const selection = { companyId, agentId, userId: "alice", adapterType: "codex_local", binding: { provider: "openai", method: "api_key", mode: "delegated", ...saved } } as const;
|
|
const selected = await service.select(selection);
|
|
expect(await service.credential(selected)).toBe("new-gateway-credential");
|
|
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, saved.connectionId));
|
|
expect(installs).toEqual([expect.objectContaining({ targetType: "agent", targetId: agentId })]);
|
|
await expect(service.save(companyId, "alice", { ...input, agentIds: [agentId], connectionId: saved.connectionId, routing: { ...reordered, baseUrl: "https://different.example/v1" } }, "rejected-credential")).rejects.toThrow("retain");
|
|
expect(await service.credential(await service.select(selection))).toBe("new-gateway-credential");
|
|
});
|
|
|
|
it("saves no-auth endpoints without a secret and refuses protocol mismatches", async () => {
|
|
const routing = { kind: "local", protocol: "chat", auth: "none", baseUrl: "http://localhost:11434/v1", models: [] } as const;
|
|
const saved = await service.save(companyId, "alice", { provider: "openai", method: "api_key", name: "Local", ownership: "personal", allAgents: true, agentIds: [], routing: { ...routing, models: [] } }, "");
|
|
const selected = { provider: "openai", method: "api_key", mode: "delegated", ...saved } as const;
|
|
await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("incompatible");
|
|
const row = await service.select({ ...input, binding: selected, userId: "alice", adapterType: "opencode_local", model: "qwen" });
|
|
expect(row.grant.credentialSecretRefs).toEqual([]);
|
|
expect(await service.credential(row)).toBe("");
|
|
});
|
|
|
|
it.each([
|
|
["anthropic", false], ["openai", false], ["anthropic", true], ["openai", true],
|
|
] as const)("turns a %s auth failure into one card and resumes after repair (switch method: %s)", async (provider, switchMethod) => {
|
|
const userId = `auth-recovery-${provider}-${switchMethod}`;
|
|
const id = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const adapterType = provider === "openai" ? "codex_local" : "claude_local";
|
|
const selectedBinding = { provider, method: "api_key", mode: "responsible_user" } as const;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id, companyId, name: "Auth recovery", adapterType, runtimeConfig: { aiConnection: selectedBinding } });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Fix provider login", status: "in_progress", assigneeAgentId: id });
|
|
const account = await service.save(companyId, userId, { provider, method: "api_key", ownership: "personal", name: "Recovery account", apiKey: "fixture", agentIds: [id], allAgents: false }, "fixture-recovery-key");
|
|
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId: id, responsibleUserId: userId, adapterType, binding: selectedBinding, config: {} });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: userId,
|
|
contextSnapshot: { issueId, aiConnection: { ...runtime.attribution, identity: runtime.identity } } });
|
|
await runtime.cleanup();
|
|
const intents = connectionIntentService(db);
|
|
const card = await intents.requestForRunAuthFailure(runId);
|
|
expect(card).toMatchObject({ state: "needs_user_action", service: provider });
|
|
expect((await intents.requestForRunAuthFailure(runId))?.interactionId).toBe(card!.interactionId);
|
|
const setup = await intents.setupOptions(card!.interactionId!);
|
|
expect(setup.existingConnections).toEqual([]);
|
|
expect(setup.aiRepair).toMatchObject({ canReconnect: true, connection: { id: account.connectionId, status: "needs_attention" } });
|
|
await expect(intents.complete(card!.interactionId!, account.connectionId, userId)).rejects.toThrow();
|
|
// A stopped run's token cannot call the public request path.
|
|
await expect(intents.request({ sub: id, company_id: companyId, run_id: runId, responsible_user_id: userId }, provider, { purpose: "ai" })).rejects.toThrow("no longer active");
|
|
// Reverification can keep the same credential bytes; it still supersedes the failure.
|
|
let repaired = account;
|
|
if (switchMethod) {
|
|
const token = provider === "openai" ? JSON.stringify({ tokens: { access_token: "fixture-subscription", refresh_token: "fixture-refresh", id_token: "fixture-id", account_id: "fixture-account" } }) : "fixture-subscription";
|
|
repaired = await service.save(companyId, userId, { provider, method: "subscription", ownership: "personal", name: "Recovery subscription", loginSessionId: "fixture", agentIds: [id], allAgents: false }, token);
|
|
await service.setDefault(companyId, userId, repaired.grantId);
|
|
const [original] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
|
|
expect(original.status).toBe("needs_reauthorization");
|
|
} else {
|
|
await service.save(companyId, userId, { provider, method: "api_key", ownership: "personal", name: "Recovery account", connectionId: account.connectionId, apiKey: "fixture", agentIds: [id], allAgents: false }, provider === "openai" ? "fixture-recovery-key" : "fixture-repaired-key");
|
|
}
|
|
expect((await intents.setupOptions(card!.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([repaired.connectionId]);
|
|
await intents.complete(card!.interactionId!, repaired.connectionId, userId);
|
|
const wakeup = vi.fn(async (_agentId, opts) => {
|
|
await db.insert(agentWakeupRequests).values({ companyId, agentId: id, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
|
|
return null;
|
|
});
|
|
await connectionIntentDeliveryService(db, { wakeup } as never).deliver(card!.interactionId!);
|
|
expect(wakeup).toHaveBeenCalledTimes(1);
|
|
expect(wakeup).toHaveBeenCalledWith(id, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
|
|
// Reprocessing an old failure cannot invalidate the newly saved credential.
|
|
await intents.requestForRunAuthFailure(runId);
|
|
expect((await service.select({ companyId, agentId: id, userId, adapterType, binding: selectedBinding })).grant.status).toBe("active");
|
|
});
|
|
|
|
it("offers a compatible connection for legacy auth failures without silently adopting it", async () => {
|
|
const id = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
await db.insert(agents).values({ id, companyId, name: "Legacy Codex", adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Legacy auth", status: "in_progress", assigneeAgentId: id });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, nativeIssueId: issueId, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: "alice", contextSnapshot: {} });
|
|
const intents = connectionIntentService(db);
|
|
const card = await intents.requestForRunAuthFailure(runId);
|
|
expect(card).toMatchObject({ service: "openai", state: "needs_user_action" });
|
|
expect(await intents.setupOptions(card!.interactionId!)).toMatchObject({ aiConnectionRequiresAdoption: true, aiConnection: { provider: "openai", mode: "responsible_user" } });
|
|
const [unchanged] = await db.select().from(agents).where(eq(agents.id, id));
|
|
expect(unchanged.runtimeConfig.aiConnection).toBeUndefined();
|
|
await db.insert(heartbeatRuns).values({ companyId, agentId: id, status: "succeeded", responsibleUserId: "alice", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) });
|
|
expect(await intents.requestForRunAuthFailure(runId)).toBeNull();
|
|
});
|
|
|
|
it("lists quotas only for the current member's accessible subscription accounts", async () => {
|
|
const user = `quota-${randomUUID()}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: user, principalType: "user", status: "active", membershipRole: "member" });
|
|
const subscription = await service.save(companyId, user, { provider: "openai", method: "subscription", ownership: "personal", name: "Private quota", loginSessionId: "fixture", allAgents: true, agentIds: [] }, JSON.stringify({ tokens: { access_token: "fixture", account_id: "fixture", refresh_token: "fixture" } }));
|
|
expect((await service.quotaAccounts(companyId, user)).some(row => row.connection.id === subscription.connectionId)).toBe(true);
|
|
expect((await service.quotaAccounts(companyId, "bob")).some(row => row.connection.id === subscription.connectionId)).toBe(false);
|
|
expect(await service.quotaAccounts(otherCompanyId, user)).toEqual([]);
|
|
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, user)));
|
|
expect(await service.quotaAccounts(companyId, user)).toEqual([]);
|
|
});
|
|
|
|
it("repairs a teammate's missing onboarding key with their own inline AI connection", async () => {
|
|
const userId = `new-teammate-${randomUUID()}`;
|
|
const id = randomUUID();
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
const vault = secretService(db);
|
|
const definition = await vault.createUserSecretDefinition(companyId, {
|
|
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding",
|
|
}, { userId: "alice" });
|
|
const ownerSecret = await vault.createCurrentUserSecretValue(companyId, "alice", {
|
|
definitionId: definition.id, value: "fixture-original-owner-key",
|
|
}, { userId: "alice" });
|
|
const adapterConfig = { env: { ANTHROPIC_API_KEY: {
|
|
type: "user_secret_ref", key: definition.key, version: "latest", required: true,
|
|
} } };
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const [agent] = await db.insert(agents).values({ id, companyId, name: "Chief of Staff", adapterType: "claude_local", adapterConfig }).returning();
|
|
await vault.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: id }, adapterConfig.env);
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Chat with Chief of Staff", status: "blocked", assigneeAgentId: id, responsibleUserId: userId });
|
|
const failure = await resolveExecutionRunAdapterConfig({
|
|
companyId, agentId: id, adapterType: "claude_local", issueId,
|
|
responsibleUserId: userId, executionRunConfig: adapterConfig, projectEnv: null, secretsSvc: vault,
|
|
}).catch(error => error);
|
|
expect(failure).toMatchObject({ code: "configuration_incomplete", resultJson: {
|
|
configurationIncomplete: { reason: "secret_binding_missing", missingBindings: [
|
|
expect.objectContaining({ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", errorCode: "user_secret_missing", responsibleUserId: userId }),
|
|
] },
|
|
} });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed",
|
|
errorCode: failure.code, resultJson: failure.resultJson, responsibleUserId: userId, contextSnapshot: { issueId } });
|
|
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId,
|
|
kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `missing-key:${issueId}`,
|
|
nextAction: "Connect your AI account", ownerType: "board", evidence: { latestRunId: runId } });
|
|
const intents = connectionIntentService(db);
|
|
const card = await intents.requestForRunAuthFailure(runId);
|
|
expect(card).toMatchObject({ service: "anthropic", state: "needs_user_action" });
|
|
expect((await intents.setupOptions(card!.interactionId!))).toMatchObject({
|
|
aiConnectionRequiresAdoption: true, aiConnection: { provider: "anthropic", mode: "responsible_user" },
|
|
interaction: { addresseeUserId: userId, sourceRunId: runId },
|
|
});
|
|
expect((await intents.requestForRunAuthFailure(runId))?.interactionId).toBe(card!.interactionId);
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
|
|
|
|
const account = await create(userId, "Teammate's Claude");
|
|
const setup = await intents.setupOptions(card!.interactionId!);
|
|
await intents.complete(card!.interactionId!, account.connectionId, userId, {
|
|
validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection! },
|
|
});
|
|
const wakeup = vi.fn(async (_agentId, opts) => {
|
|
await db.insert(agentWakeupRequests).values({ companyId, agentId: id, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
|
|
return null;
|
|
});
|
|
const delivery = connectionIntentDeliveryService(db, { wakeup } as never);
|
|
await delivery.deliver(card!.interactionId!);
|
|
await delivery.deliver(card!.interactionId!);
|
|
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("in_progress");
|
|
expect(wakeup).toHaveBeenCalledTimes(1);
|
|
expect(wakeup).toHaveBeenCalledWith(id, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
|
|
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
|
|
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, ownerSecret.id)))[0]).toMatchObject({ ownerUserId: "alice", latestVersion: 1, status: "active" });
|
|
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId: id, responsibleUserId: userId, adapterType: "claude_local", binding: setup.aiConnection!, config: adapterConfig });
|
|
try {
|
|
expect(runtime.config.env).toMatchObject({ ANTHROPIC_API_KEY: "fixture-Teammate's Claude" });
|
|
expect(runtime.attribution.grantId).toBe(account.grantId);
|
|
} finally { await runtime.cleanup(); }
|
|
}, 30000);
|
|
it.each([
|
|
["anthropic", "claude_local", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
|
["openai", "codex_local", "CODEX_HOME", "OPENAI_API_KEY"],
|
|
] as const)("runs the same %s agent with each responsible user's subscription or API key", async (provider, adapterType, subscriptionEnv, apiEnv) => {
|
|
const subscriptionUser = `${provider}-subscription-user`;
|
|
const apiUser = `${provider}-api-user`;
|
|
await db.insert(companyMemberships).values([subscriptionUser, apiUser].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
|
|
const token = provider === "openai" ? JSON.stringify({ tokens: { access_token: "fixture-subscription", refresh_token: "fixture-refresh", id_token: "fixture-id", account_id: "fixture-account" } }) : "fixture-subscription";
|
|
const subscription = await service.save(companyId, subscriptionUser, { provider, method: "subscription", ownership: "personal", name: "Subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, token);
|
|
const api = await service.save(companyId, apiUser, { provider, method: "api_key", ownership: "personal", name: "API", apiKey: "fixture", allAgents: true, agentIds: [] }, "fixture-api");
|
|
// This is the exact same saved bot config, including a legacy setup method.
|
|
const bot = { ...input, adapterType, binding: { provider, method: "subscription", mode: "responsible_user" } as const, config: { model: "unchanged-model", env: { [apiEnv]: "ambient", CLAUDE_CODE_OAUTH_TOKEN: "ambient" } } };
|
|
const original = structuredClone(bot);
|
|
const [subRun, apiRun] = await Promise.all([subscriptionUser, apiUser].map(responsibleUserId => prepareManagedAiRuntime(db, { ...bot, responsibleUserId })));
|
|
try {
|
|
expect(subRun.attribution).toMatchObject({ grantId: subscription.grantId, method: "subscription", responsibleUserId: subscriptionUser });
|
|
expect(apiRun.attribution).toMatchObject({ grantId: api.grantId, method: "api_key", responsibleUserId: apiUser });
|
|
const subEnv = subRun.config.env as Record<string, string>;
|
|
const apiEnvValues = apiRun.config.env as Record<string, string>;
|
|
expect(subEnv[apiEnv]).toBe("");
|
|
expect(apiEnvValues[apiEnv]).toBe("fixture-api");
|
|
if (provider === "anthropic") {
|
|
expect(subEnv[subscriptionEnv]).toBe(token);
|
|
expect(apiEnvValues[subscriptionEnv]).toBe("");
|
|
} else {
|
|
expect(await readFile(path.join(subEnv.CODEX_HOME, "auth.json"), "utf8")).toBe(token);
|
|
expect(JSON.parse(await readFile(path.join(apiEnvValues.CODEX_HOME, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "fixture-api" });
|
|
}
|
|
expect(subEnv.HOME).not.toBe(apiEnvValues.HOME);
|
|
expect(subRun.identity).not.toBe(apiRun.identity);
|
|
expect(subRun.config.model).toBe(bot.config.model);
|
|
expect(apiRun.config.model).toBe(bot.config.model);
|
|
expect(bot).toEqual(original);
|
|
expect(aiConnectionBindingSchema.parse(bot.binding)).toEqual(bot.binding);
|
|
} finally { await Promise.all([subRun.cleanup(), apiRun.cleanup()]); }
|
|
});
|
|
|
|
it("has one provider default across methods, retains unavailable defaults and honors explicit account methods", async () => {
|
|
const userId = "provider-default-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const api = await create(userId, "Provider API");
|
|
const subscription = await service.save(companyId, userId, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Provider subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, "fixture-provider-subscription");
|
|
expect((await service.select({ ...input, userId })).grant.id).toBe(api.grantId);
|
|
expect((await service.list(companyId, userId)).filter(account => account.isDefault).map(account => account.grantId)).toEqual([api.grantId]);
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, api.grantId));
|
|
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
|
|
await create(userId, "Another API");
|
|
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
|
|
await service.setDefault(companyId, userId, subscription.grantId);
|
|
expect((await service.select({ ...input, userId })).attribution).toMatchObject({ method: "subscription", grantId: subscription.grantId });
|
|
expect((await service.list(companyId, userId)).filter(account => account.isDefault)).toHaveLength(1);
|
|
await expect(service.select({ ...input, userId, binding: { ...binding, mode: "delegated", ...subscription } })).rejects.toThrow("incompatible");
|
|
});
|
|
|
|
it("backfills provider defaults repeatably without deleting old preferences or replacing an unavailable choice", async () => {
|
|
const userId = "provider-default-migration-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const api = await create(userId, "Migration API");
|
|
const subscription = await service.save(companyId, userId, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Migration subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, "fixture-migration-subscription");
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, api.grantId));
|
|
await db.update(aiConnectionDefaults).set({ updatedAt: new Date("2030-01-01") }).where(eq(aiConnectionDefaults.grantId, api.grantId));
|
|
await db.delete(aiProviderDefaults).where(and(eq(aiProviderDefaults.companyId, companyId), eq(aiProviderDefaults.userId, userId)));
|
|
const legacyRows = await db.select().from(aiConnectionDefaults).where(eq(aiConnectionDefaults.userId, userId));
|
|
const migration = await readFile(new URL("../../../packages/db/src/migrations/0277_uneven_lady_deathstrike.sql", import.meta.url), "utf8");
|
|
for (let pass = 0; pass < 2; pass++) for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
|
|
expect(await db.select().from(aiConnectionDefaults).where(eq(aiConnectionDefaults.userId, userId))).toEqual(legacyRows);
|
|
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
|
|
await service.setDefault(companyId, userId, subscription.grantId);
|
|
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
|
|
expect((await service.select({ ...input, userId })).grant.id).toBe(subscription.grantId);
|
|
});
|
|
it("observes old-server default changes during rolling upgrades without treating new accounts as default changes", async () => {
|
|
const userId = "rolling-upgrade-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const api = await create(userId, "Rolling API");
|
|
const subscription = await service.save(companyId, userId, { provider: "anthropic", method: "subscription", ownership: "personal", name: "Rolling subscription", loginSessionId: "fixture", allAgents: true, agentIds: [] }, "fixture-rolling-subscription");
|
|
expect((await service.select({ ...input, userId })).grant.id).toBe(api.grantId);
|
|
// An older server updates only the legacy per-method row on Make default.
|
|
await db.update(aiConnectionDefaults).set({ grantId: subscription.grantId, updatedAt: new Date() })
|
|
.where(and(eq(aiConnectionDefaults.userId, userId), eq(aiConnectionDefaults.method, "subscription")));
|
|
expect((await service.select({ ...input, userId })).attribution).toMatchObject({ grantId: subscription.grantId, method: "subscription" });
|
|
expect((await service.list(companyId, userId)).filter(account => account.isDefault).map(account => account.grantId)).toEqual([subscription.grantId]);
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, subscription.grantId));
|
|
await create(userId, "Rolling second API");
|
|
await expect(service.select({ ...input, userId })).rejects.toThrow("Reconnect");
|
|
await db.update(aiConnectionDefaults).set({ grantId: api.grantId, updatedAt: new Date() })
|
|
.where(and(eq(aiConnectionDefaults.userId, userId), eq(aiConnectionDefaults.method, "api_key")));
|
|
expect((await service.select({ ...input, userId })).grant.id).toBe(api.grantId);
|
|
});
|
|
it("checks the selected environment for project auth overrides without exposing their contents", async () => {
|
|
const execute = vi.spyOn(executionTarget, "runAdapterExecutionTargetProcess");
|
|
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace/project" } as Parameters<typeof assertManagedAiProjectAuth>[2];
|
|
try {
|
|
execute.mockResolvedValue({ exitCode: 42, stdout: "", stderr: "", signal: null, timedOut: false } as Awaited<ReturnType<typeof executionTarget.runAdapterExecutionTargetProcess>>);
|
|
await expect(assertManagedAiProjectAuth({}, "openai", target)).rejects.toThrow("project authentication settings");
|
|
expect(execute.mock.calls[0][3]).toContain("/workspace/project");
|
|
expect(execute.mock.calls[0][3]).toContain(".codex/config.toml");
|
|
execute.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "", signal: null, timedOut: false } as Awaited<ReturnType<typeof executionTarget.runAdapterExecutionTargetProcess>>);
|
|
await expect(assertManagedAiProjectAuth({}, "openai", target)).resolves.toBeUndefined();
|
|
await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "xai", target)).rejects.toThrow("overrides");
|
|
} finally { execute.mockRestore(); }
|
|
});
|
|
it("requires a typed project authentication capability for task-only environments", async () => {
|
|
const execute = vi.spyOn(executionTarget, "runAdapterExecutionTargetProcess");
|
|
const description = { kind: "description", taskId: "task", projectAuthCheckVersion: 1,
|
|
runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
|
|
const task = vi.fn(async () => description);
|
|
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/unused", runnerTask: task } as Parameters<typeof assertManagedAiProjectAuth>[2];
|
|
try {
|
|
await expect(assertManagedAiProjectAuth({}, "openai", target)).resolves.toBeUndefined();
|
|
await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "openai", target)).rejects.toThrow("overrides");
|
|
expect(task).toHaveBeenCalledWith({ kind: "describe" });
|
|
task.mockResolvedValueOnce({ ...description, projectAuthCheckVersion: undefined } as never);
|
|
await expect(assertManagedAiProjectAuth({}, "openai", target)).rejects.toThrow("cannot validate");
|
|
expect(execute).not.toHaveBeenCalled();
|
|
} finally { execute.mockRestore(); }
|
|
});
|
|
it("keeps personal defaults separate and does not replace the first default", async () => {
|
|
const first = await create("alice", "Alice first");
|
|
await create("alice", "Alice second");
|
|
const bob = await create("bob", "Bob first");
|
|
const [a,b] = await Promise.all([service.select({ ...input, userId: "alice" }), service.select({ ...input, userId: "bob" })]);
|
|
expect(a.grant.id).toBe(first.grantId); expect(b.grant.id).toBe(bob.grantId);
|
|
expect(await service.credential(a)).toBe("fixture-Alice first");
|
|
expect(await service.credential(b)).toBe("fixture-Bob first");
|
|
expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-");
|
|
expect(await service.list(otherCompanyId, "alice")).toEqual([]);
|
|
});
|
|
it("retains a revoked default without automatic fallback", async () => {
|
|
const selected = await service.select({ ...input, userId: "alice" });
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.grant.id));
|
|
await create("alice", "Alice third");
|
|
expect(await toolAccessService(db).getConnection(selected.connection.id, companyId)).toMatchObject({ healthStatus: "missing_secret", requiresReauthorization: true });
|
|
expect((await toolAccessService(db).listConnections(companyId)).find(connection => connection.id === selected.connection.id)?.healthStatus).toBe("missing_secret");
|
|
await expect(service.select({ ...input, userId: "alice" })).rejects.toThrow("Reconnect");
|
|
const second = (await service.list(companyId, "alice")).find(a => a.name === "Alice second")!;
|
|
await service.setDefault(companyId, "alice", second.grantId);
|
|
expect((await service.select({ ...input, userId: "alice" })).grant.id).toBe(second.grantId);
|
|
await expect(service.setDefault(companyId, "bob", second.grantId)).rejects.toThrow("owner");
|
|
});
|
|
it("uses human access for every selection; an agent delegation cannot override Just me", async () => {
|
|
const personal = await service.select({ ...input, userId: "alice" });
|
|
const delegated = { ...binding, mode: "delegated" as const, connectionId: personal.connection.id, grantId: personal.grant.id };
|
|
const rejection = await service.select({ ...input, userId: "bob", binding: delegated }).catch(error => error);
|
|
expect(rejection).toMatchObject({ status: 403, message: "This credential is not shared with the responsible user" });
|
|
expect(rejection.details).toBeUndefined();
|
|
expect(readAiConnectionConfigurationFailure(rejection)).toBe("ai_connection_credential_not_shared");
|
|
// Existing delegation records no longer confer an independent AI permission.
|
|
await db.insert(connectionGrantDelegations).values({ companyId, grantId: personal.grant.id, agentId, createdByUserId: "alice" });
|
|
await expect(service.select({ ...input, userId: "bob", binding: delegated })).rejects.toThrow("not shared");
|
|
expect((await service.select({ ...input, userId: "alice", binding: delegated })).grant.id).toBe(personal.grant.id);
|
|
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === personal.connection.id)).toBe(false);
|
|
await expect(toolAccessService(db).createConnectionGrantDelegation(personal.connection.id, personal.grant.id, agentId, "alice")).rejects.toThrow("human access settings");
|
|
});
|
|
it("applies the existing human audience editor to AI listing and execution without a second authorization", async () => {
|
|
const shared = await create("alice", "Engineering", "shared");
|
|
const sharedBinding = { ...binding, mode: "shared" as const, ...shared };
|
|
const tools = toolAccessService(db);
|
|
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { userId: "alice" });
|
|
const rejection = await service.select({ ...input, userId: "bob", binding: sharedBinding }).catch(error => error);
|
|
expect(rejection).toMatchObject({ status: 403, message: "This credential is not shared with the responsible user" });
|
|
expect(readAiConnectionConfigurationFailure(rejection)).toBe("ai_connection_credential_not_shared");
|
|
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(false);
|
|
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { userId: "alice" });
|
|
expect((await service.select({ ...input, userId: "bob", binding: sharedBinding })).grant.id).toBe(shared.grantId);
|
|
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(true);
|
|
await expect(service.select({ ...input, userId: "alice", binding: sharedBinding })).rejects.toThrow("not shared");
|
|
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { userId: "alice" });
|
|
for (const userId of ["alice", "bob"]) {
|
|
expect((await service.select({ ...input, userId, binding: sharedBinding })).grant.id).toBe(shared.grantId);
|
|
}
|
|
const missingIdentity = await service.select({ ...input, userId: null, binding: sharedBinding }).catch(error => error);
|
|
expect(missingIdentity).toMatchObject({ status: 403, message: "This credential is not shared with the responsible user" });
|
|
expect(readAiConnectionConfigurationFailure(missingIdentity)).toBeNull();
|
|
const inactiveMember = await service.select({ ...input, userId: "inactive-user", binding: sharedBinding }).catch(error => error);
|
|
expect(inactiveMember).toMatchObject({ status: 403, message: "The responsible user is not an active company member" });
|
|
expect(readAiConnectionConfigurationFailure(inactiveMember)).toBeNull();
|
|
// Human permission still cannot bypass the separate agent-access setting.
|
|
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, shared.connectionId));
|
|
const missingAgentPermission = await service.select({ ...input, userId: "bob", binding: sharedBinding }).catch(error => error);
|
|
expect(missingAgentPermission).toMatchObject({ status: 403 });
|
|
expect(missingAgentPermission.message).toContain("not permitted for this agent");
|
|
expect(readAiConnectionConfigurationFailure(missingAgentPermission)).toBeNull();
|
|
expect(sharedBinding).toEqual({ ...binding, mode: "shared", ...shared });
|
|
});
|
|
it.each(["inactive_owner", "wrong_binding_kind", "wrong_company_membership"] as const)(
|
|
"does not classify other credential authorization denials as sharing configuration: %s", async (cause) => {
|
|
const owner = `sharing-${cause}-owner`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const account = await create(owner, "Sharing permission fixture");
|
|
const selected = { ...binding, mode: cause === "wrong_binding_kind" ? "shared" as const : "delegated" as const, ...account };
|
|
let userId = "bob";
|
|
if (cause === "inactive_owner") {
|
|
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalId, owner)));
|
|
}
|
|
if (cause === "wrong_company_membership") {
|
|
userId = "other-company-sharing-user";
|
|
await db.insert(companyMemberships).values({ companyId: otherCompanyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
}
|
|
const error = await service.select({ ...input, userId, binding: selected }).catch(error => error);
|
|
expect(error).toMatchObject({ status: 403, message: cause === "inactive_owner"
|
|
? "The account owner is no longer an active company member"
|
|
: cause === "wrong_binding_kind" ? "Select a company-shared account"
|
|
: "The responsible user is not an active company member" });
|
|
expect(readAiConnectionConfigurationFailure(error)).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("isolates concurrent homes and overrides ambient credentials without changing the model", async () => {
|
|
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-never-use");
|
|
const config = { model: "unchanged-model", env: { ANTHROPIC_API_KEY: "project-never-use" } };
|
|
const [a,b] = await Promise.all(["alice", "bob"].map(responsibleUserId => prepareManagedAiRuntime(db, { ...input, responsibleUserId, config })));
|
|
const ae = a.config.env as Record<string,string>, be = b.config.env as Record<string,string>;
|
|
expect(ae.ANTHROPIC_API_KEY).toBe("fixture-Alice second"); expect(be.ANTHROPIC_API_KEY).toBe("fixture-Bob first");
|
|
expect(ae.HOME).not.toBe(be.HOME); expect(a.identity).not.toBe(b.identity);
|
|
expect(a.config.model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
|
|
await Promise.all([a.cleanup(), b.cleanup()]); await expect(access(ae.HOME)).rejects.toThrow();
|
|
});
|
|
it("blocks missing identity, incompatible providers, and cross-company explicit selections", async () => {
|
|
await expect(service.select({ ...input, userId: null })).rejects.toThrow("responsible user");
|
|
await expect(service.select({ ...input, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("compatible");
|
|
const account = await service.select({ ...input, userId: "alice" });
|
|
await expect(service.select({ ...input, companyId: otherCompanyId, userId: "alice", binding: { ...binding, mode: "shared", connectionId: account.connection.id, grantId: account.grant.id } })).rejects.toThrow();
|
|
});
|
|
it("resolves shared encrypted credentials through the existing secret binding system", async () => {
|
|
const created = await create("alice", "Shared credential proof", "shared");
|
|
const selected = await service.select({ ...input, userId: "bob", binding: { ...binding, mode: "shared", ...created } });
|
|
expect(await service.credential(selected)).toBe("fixture-Shared credential proof");
|
|
});
|
|
it("saves successful login completion once and rejects abandoned attempts", async () => {
|
|
const [environment] = await db.insert(environments).values({ name: "AI login test", driver: "sandbox" }).returning();
|
|
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Claude subscription", agentIds: [], allAgents: true } as const;
|
|
const sessionId = randomUUID();
|
|
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: sessionId, status: "submitting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() + 60000) });
|
|
const first = await completeSetupTokenAiConnection(db, { scope: { companyId, ownerUserId: "alice", environmentId: environment.id, adapterType: "claude_local", aiConnection: { ...intent, agentIds: [] } }, token: "fixture-subscription", sessionId });
|
|
expect(await completeSetupTokenAiConnection(db, { scope: { companyId, ownerUserId: "alice", environmentId: environment.id, adapterType: "claude_local", aiConnection: { ...intent, agentIds: [] } }, token: "fixture-subscription", sessionId })).toEqual(first);
|
|
expect((await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.publicSessionId, sessionId)))[0]).toMatchObject({ status: "stored", connectionId: first.connectionId, connectionGrantId: first.grantId });
|
|
const cancelled = randomUUID();
|
|
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: cancelled, status: "cancelled", expiresAt: new Date(Date.now() + 60000) });
|
|
await expect(completeSetupTokenAiConnection(db, { scope: { companyId, ownerUserId: "alice", environmentId: environment.id, adapterType: "claude_local", aiConnection: { ...intent, agentIds: [] } }, token: "fixture-never-save", sessionId: cancelled })).rejects.toThrow("no longer active");
|
|
});
|
|
it.each(["anthropic", "openai"] as const)("binds %s by internal session ID without choosing its completion state", async provider => {
|
|
const owner = `session-owner-${provider}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const [environment] = await db.insert(environments).values({ name: `Session identity ${provider}`, driver: "sandbox" }).returning();
|
|
const intent = { provider, method: "subscription", ownership: "personal", name: `Session identity ${provider}`, allAgents: false, agentIds: [] } as const;
|
|
const [session] = await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id,
|
|
adapterType: provider === "anthropic" ? "claude_local" : "codex_local", startedByUserId: owner,
|
|
publicSessionId: randomUUID(), status: "promoting", aiConnection: { ...intent, agentIds: [] },
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
}).returning();
|
|
const adapterWake = vi.fn(), setupWake = vi.fn();
|
|
const unsubscribe = [
|
|
subscribeDeliveryWork(db, DELIVERY_QUEUES.adapterLoginCleanup, adapterWake),
|
|
subscribeDeliveryWork(db, DELIVERY_QUEUES.setupTokenCleanup, setupWake),
|
|
];
|
|
const complete = vi.fn(async () => {
|
|
expect(adapterWake).not.toHaveBeenCalled();
|
|
expect(setupWake).not.toHaveBeenCalled();
|
|
});
|
|
try {
|
|
await expect(service.save(companyId, owner, { ...intent, agentIds: [] }, "fixture", { id: session.publicSessionId, complete })).rejects.toThrow("ownership mismatch");
|
|
await expect(service.save(companyId, "bob", { ...intent, agentIds: [] }, "fixture", { id: session.id, complete })).rejects.toThrow("ownership mismatch");
|
|
await expect(service.save(companyId, owner, { ...intent, agentIds: [], ownership: "shared" }, "fixture", { id: session.id, complete })).rejects.toThrow("target mismatch");
|
|
expect(complete).not.toHaveBeenCalled();
|
|
const saved = await service.save(companyId, owner, { ...intent, agentIds: [] }, "fixture", { id: session.id, complete });
|
|
expect((await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, session.id)))[0]).toMatchObject({
|
|
connectionId: saved.connectionId, connectionGrantId: saved.grantId, status: "promoting",
|
|
});
|
|
expect(await service.save(companyId, owner, { ...intent, agentIds: [] }, "replacement", { id: session.id, complete })).toEqual(saved);
|
|
expect(complete).toHaveBeenCalledTimes(1);
|
|
expect(adapterWake).toHaveBeenCalledTimes(1);
|
|
expect(setupWake).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
unsubscribe.forEach(stop => stop());
|
|
await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, session.id));
|
|
}
|
|
});
|
|
it("rolls back credential and connection writes if setup-token completion loses its claim", async () => {
|
|
const owner = "lost-setup-token-claim";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const [environment] = await db.insert(environments).values({ name: "Lost setup claim", driver: "sandbox" }).returning();
|
|
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Lost setup claim", allAgents: false, agentIds: [] } as const;
|
|
const [session] = await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id,
|
|
adapterType: "claude_local", startedByUserId: owner, publicSessionId: randomUUID(), status: "submitting",
|
|
aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() + 60_000), boundAt: new Date(),
|
|
}).returning();
|
|
try {
|
|
await expect(completeSetupTokenAiConnection(db, { scope: { companyId, ownerUserId: owner,
|
|
environmentId: environment.id, adapterType: "claude_local", aiConnection: { ...intent, agentIds: [] } },
|
|
sessionId: session.publicSessionId, token: "must-rollback",
|
|
})).rejects.toThrow();
|
|
expect((await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, session.id)))[0]).toMatchObject({
|
|
connectionId: null, connectionGrantId: null, status: "submitting",
|
|
});
|
|
expect(await db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.name, intent.name)))).toEqual([]);
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.ownerUserId, owner))).toEqual([]);
|
|
} finally { await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, session.id)); }
|
|
});
|
|
it("preserves connection identity and defaults through reconnect; revocation wins over older attempts", async () => {
|
|
const current = await service.select({ ...input, userId: "bob" });
|
|
const reconnect = { ...binding, ownership: "personal" as const, name: current.connection.name, apiKey: "fixture", agentIds: [], allAgents: true, connectionId: current.connection.id };
|
|
const result = await service.save(companyId, "bob", reconnect, "fixture-reconnected");
|
|
expect(result.grantId).toBe(current.grant.id);
|
|
expect(await service.credential(await service.select({ ...input, userId: "bob" }))).toBe("fixture-reconnected");
|
|
const beforeRevocation = new Date(Date.now() - 1000);
|
|
await db.update(connectionGrants).set({ status: "revoked", updatedAt: new Date() }).where(eq(connectionGrants.id, current.grant.id));
|
|
await expect(service.save(companyId, "bob", reconnect, "fixture-stale", undefined, beforeRevocation)).rejects.toThrow("changed");
|
|
await expect(service.select({ ...input, userId: "bob" })).rejects.toThrow("Reconnect");
|
|
});
|
|
it("rejects invalid purpose/transport combinations in the database", async () => {
|
|
const selected = await service.select({ ...input, userId: "alice" });
|
|
await expect(db.update(toolConnections).set({ transport: "mcp_remote" }).where(eq(toolConnections.id, selected.connection.id))).rejects.toThrow();
|
|
await expect(db.update(toolConnections).set({ connectionPurpose: "tool" }).where(eq(toolConnections.id, selected.connection.id))).rejects.toThrow();
|
|
});
|
|
it("indexes only known user credentials, retains references, and is repeatable without adopting agents", async () => {
|
|
const selected = await service.select({ ...input, userId: "alice" });
|
|
const [emailConnection] = await db.insert(toolConnections).values({
|
|
companyId,
|
|
applicationId: selected.connection.applicationId,
|
|
name: "Existing AgentMail inbox",
|
|
uid: `agentmail-migration-${randomUUID()}`,
|
|
connectionPurpose: "channel",
|
|
transport: "rest_api",
|
|
authKind: "api_key",
|
|
config: { provider: "agentmail" },
|
|
}).returning();
|
|
const vault = secretService(db);
|
|
const definition = await vault.createUserSecretDefinition(companyId, { key: "legacy_claude", name: "Existing owned Claude key", provider: "local_encrypted" }, { userId: "alice" });
|
|
const secret = await vault.createCurrentUserSecretValue(companyId, "alice", { definitionId: definition.id, value: "fixture-legacy" }, { userId: "alice" });
|
|
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");
|
|
const adoption = migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")));
|
|
await db.execute(sql.raw(adoption));
|
|
const before = await service.list(companyId, "alice");
|
|
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
|
|
expect(await service.list(companyId, "alice")).toEqual(before);
|
|
const [preservedEmail] = await db.select().from(toolConnections).where(eq(toolConnections.id, emailConnection.id));
|
|
expect(preservedEmail).toEqual(emailConnection);
|
|
const indexed = before.find(account => account.name === secret.name)!;
|
|
expect(indexed.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, indexed.grantId));
|
|
expect(grant.credentialSecretRefs[0].secretId).toBe(secret.id);
|
|
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
|
|
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
|
|
});
|
|
it("runs two Claude subscription executions for the same grant at the same time", async () => {
|
|
// Claude writes no auth file back to the grant, so two runs share no
|
|
// mutable state and must not wait for each other.
|
|
const subscription = { ...input, binding: { ...binding, method: "subscription" as const }, responsibleUserId: "alice", config: { model: "same-model" } };
|
|
const account = (await service.list(companyId, "alice")).find(account => account.provider === "anthropic" && account.method === "subscription")!;
|
|
await service.setDefault(companyId, "alice", account.grantId);
|
|
const [first, second] = await Promise.all([prepareManagedAiRuntime(db, subscription), prepareManagedAiRuntime(db, subscription)]);
|
|
try {
|
|
expect(second.identity).toBe(first.identity);
|
|
} finally {
|
|
await Promise.all([first.cleanup(), second.cleanup()]);
|
|
}
|
|
});
|
|
it("runs a same-agent OpenAI subscription child alongside a still-open parent", async () => {
|
|
const userId = "subscription-contention-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const credential = JSON.stringify({ tokens: { access_token: "fixture-access", refresh_token: "fixture-refresh", id_token: "fixture-id", account_id: "fixture-account" } });
|
|
const account = await service.save(companyId, userId, { provider: "openai", method: "subscription", ownership: "personal", name: "Contention fixture", loginSessionId: "fixture", allAgents: true, agentIds: [] }, credential);
|
|
const runInput = {
|
|
companyId,
|
|
agentId,
|
|
adapterType: "codex_local",
|
|
responsibleUserId: userId,
|
|
binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const,
|
|
config: {},
|
|
};
|
|
// A parent can create and assign a child before its own execution ends.
|
|
// Both runs select the same personal subscription, even on the same agent.
|
|
const parent = await prepareManagedAiRuntime(db, runInput);
|
|
const child = await prepareManagedAiRuntime(db, runInput);
|
|
try {
|
|
expect(child.identity).toBe(parent.identity);
|
|
expect(child.attribution.grantId).toBe(account.grantId);
|
|
expect(parent.attribution.subscriptionId).toEqual(expect.any(String));
|
|
expect(child.attribution.subscriptionId).toBe(parent.attribution.subscriptionId);
|
|
expect(child.config.managedAiConnection).toMatchObject({ subscriptionId: parent.attribution.subscriptionId });
|
|
} finally {
|
|
await Promise.all([parent.cleanup(), child.cleanup()]);
|
|
}
|
|
});
|
|
it("persists the freshest refreshed credential to its original grant across a same-account reconnect", async () => {
|
|
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
const intent = { provider: "openai" as const, method: "subscription" as const, name: "Refresh test", ownership: "personal" as const, agentIds: [], allAgents: true, loginSessionId: "fixture" };
|
|
const saved = await service.save(companyId, "alice", intent, auth("first", 10));
|
|
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: "alice", binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
|
|
const first = await prepareManagedAiRuntime(db, runInput);
|
|
await writeFile(path.join(String(first.config.env.CODEX_HOME), "auth.json"), auth("refreshed", 11));
|
|
await first.cleanup();
|
|
const selected = await service.select({ ...runInput, userId: "alice" });
|
|
expect(await service.credential(selected)).toBe(auth("refreshed", 11));
|
|
const second = await prepareManagedAiRuntime(db, runInput);
|
|
expect(second.identity).not.toBe(first.identity);
|
|
// A same-account reconnect writes an older last_refresh than the run
|
|
// that is still open.
|
|
await service.save(companyId, "alice", { ...intent, connectionId: saved.connectionId }, auth("reconnect", 12));
|
|
await writeFile(path.join(String(second.config.env.CODEX_HOME), "auth.json"), auth("later-refresh", 13));
|
|
await second.cleanup();
|
|
// The newer refresh persists to the grant it started from.
|
|
expect(await service.credential(await service.select({ ...runInput, userId: "alice" }))).toBe(auth("later-refresh", 13));
|
|
});
|
|
it("resolves two concurrent OpenAI subscription write-backs by freshness, not by order", async () => {
|
|
const userId = "concurrent-freshness-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
await service.save(companyId, userId, { provider: "openai", method: "subscription", ownership: "personal", name: "Freshness fixture", loginSessionId: "fixture", allAgents: true, agentIds: [] }, auth("start", 10));
|
|
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: userId, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
|
|
// Two runs use the same OpenAI subscription grant at the same time.
|
|
// Neither call below throws ai_connection_busy.
|
|
const older = await prepareManagedAiRuntime(db, runInput);
|
|
const newer = await prepareManagedAiRuntime(db, runInput);
|
|
await writeFile(path.join(String(older.config.env.CODEX_HOME), "auth.json"), auth("older", 11));
|
|
await writeFile(path.join(String(newer.config.env.CODEX_HOME), "auth.json"), auth("newer", 12));
|
|
// The run with the newer last_refresh writes back first. The run with
|
|
// the older last_refresh writes back last and must not overwrite it.
|
|
await newer.cleanup();
|
|
await older.cleanup();
|
|
const stored = await service.credential(await service.select({ ...runInput, userId }));
|
|
expect(stored).toBe(auth("newer", 12));
|
|
});
|
|
it("resolves two concurrent OpenAI subscription write-backs by freshness in reverse arrival order", async () => {
|
|
const userId = "concurrent-freshness-reverse-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
await service.save(companyId, userId, { provider: "openai", method: "subscription", ownership: "personal", name: "Reverse freshness fixture", loginSessionId: "fixture", allAgents: true, agentIds: [] }, auth("start", 10));
|
|
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: userId, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
|
|
// Two runs use the same OpenAI subscription grant at the same time.
|
|
// Neither call below throws ai_connection_busy.
|
|
const older = await prepareManagedAiRuntime(db, runInput);
|
|
const newer = await prepareManagedAiRuntime(db, runInput);
|
|
await writeFile(path.join(String(older.config.env.CODEX_HOME), "auth.json"), auth("older", 11));
|
|
await writeFile(path.join(String(newer.config.env.CODEX_HOME), "auth.json"), auth("newer", 12));
|
|
// The run with the older last_refresh writes back first. The run with
|
|
// the newer last_refresh writes back last and must win.
|
|
await older.cleanup();
|
|
await newer.cleanup();
|
|
const stored = await service.credential(await service.select({ ...runInput, userId }));
|
|
expect(stored).toBe(auth("newer", 12));
|
|
});
|
|
it("resolves two concurrent xAI subscription write-backs by freshness, not by order", async () => {
|
|
const userId = "concurrent-freshness-xai-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const identityKey = "https://auth.x.ai::33333333-3333-3333-3333-333333333333";
|
|
const auth = (marker: string, expiresAtMs: number) => JSON.stringify({ [identityKey]: { key: `key-${marker}`, refresh_token: `refresh-${marker}`, expires_at: new Date(expiresAtMs).toISOString() } });
|
|
const now = Date.now();
|
|
await service.save(companyId, userId, { provider: "xai", method: "subscription", ownership: "personal", name: "Grok freshness fixture", loginSessionId: "fixture", allAgents: true, agentIds: [] }, auth("start", now));
|
|
const runInput = { ...input, adapterType: "grok_local", responsibleUserId: userId, binding: { provider: "xai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
|
|
// Two runs use the same xAI subscription grant at the same time.
|
|
// Neither call below throws ai_connection_busy.
|
|
const older = await prepareManagedAiRuntime(db, runInput);
|
|
const newer = await prepareManagedAiRuntime(db, runInput);
|
|
await writeFile(path.join(String(older.config.env.GROK_HOME), "auth.json"), auth("older", now + 60 * 60 * 1000));
|
|
await writeFile(path.join(String(newer.config.env.GROK_HOME), "auth.json"), auth("newer", now + 2 * 60 * 60 * 1000));
|
|
// The run with the older expiry writes back first. The run with the
|
|
// newer expiry writes back last and must win.
|
|
await older.cleanup();
|
|
await newer.cleanup();
|
|
const stored = await service.credential(await service.select({ ...runInput, userId }));
|
|
expect(stored).toBe(auth("newer", now + 2 * 60 * 60 * 1000));
|
|
});
|
|
it("discards a credential write-back when the grant is revoked while the run is open", async () => {
|
|
const userId = "revoked-write-back-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
const saved = await service.save(companyId, userId, { provider: "openai", method: "subscription", ownership: "personal", name: "Revocation fixture", loginSessionId: "fixture", allAgents: true, agentIds: [] }, auth("start", 10));
|
|
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: userId, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
|
|
const run = await prepareManagedAiRuntime(db, runInput);
|
|
const [grantBeforeCleanup] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, saved.grantId));
|
|
const ref = grantBeforeCleanup.credentialSecretRefs.find(r => r.configPath === "ai.credential")!;
|
|
const [secretBefore] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
|
|
// A newer last_refresh would win the freshness merge if the grant stayed
|
|
// active. The revoked grant must discard the write-back before that merge
|
|
// decides anything.
|
|
await writeFile(path.join(String(run.config.env.CODEX_HOME), "auth.json"), auth("revoked-run", 11));
|
|
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId));
|
|
await run.cleanup();
|
|
const [secretAfter] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId));
|
|
// service.select rejects a revoked grant, so it cannot read the stored
|
|
// credential here. Compare the stored secret version directly instead.
|
|
expect(secretAfter.latestVersion).toBe(secretBefore.latestVersion);
|
|
});
|
|
it("does not let a stale write-back overwrite an authorized secret rotation that commits while cleanup waits on the credential lock", async () => {
|
|
const userId = "credential-lock-race-user";
|
|
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
|
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
|
|
const saved = await service.save(companyId, userId, { provider: "openai", method: "subscription", ownership: "personal", name: "Credential lock race fixture", loginSessionId: "fixture", allAgents: true, agentIds: [] }, auth("start", 10));
|
|
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: userId, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
|
|
const run = await prepareManagedAiRuntime(db, runInput);
|
|
// The run's own refresh looks newer than the value it started with, but
|
|
// it must lose to a company-authorized rotation that commits while
|
|
// cleanup is still waiting on the credential secret's row lock.
|
|
await writeFile(path.join(String(run.config.env.CODEX_HOME), "auth.json"), auth("run-refresh", 11));
|
|
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, saved.grantId));
|
|
const ref = grant.credentialSecretRefs.find(r => r.configPath === "ai.credential")!;
|
|
let holdAcquired!: () => void;
|
|
const holdAcquiredPromise = new Promise<void>(resolve => { holdAcquired = resolve; });
|
|
let releaseHold!: () => void;
|
|
const holdReleased = new Promise<void>(resolve => { releaseHold = resolve; });
|
|
// An authorized rotation writes the new credential inside its own open
|
|
// transaction, so it still holds the secret row's lock when signaled.
|
|
const holder = db.transaction(async tx => {
|
|
await secretService(tx).rotate(ref.secretId, { value: auth("authorized-rotation", 12) }, { userId });
|
|
holdAcquired();
|
|
await holdReleased;
|
|
});
|
|
await holdAcquiredPromise;
|
|
const cleanupPromise = run.cleanup();
|
|
releaseHold();
|
|
await holder;
|
|
await cleanupPromise;
|
|
const stored = await service.credential(await service.select({ ...runInput, userId }));
|
|
expect(stored).toBe(auth("authorized-rotation", 12));
|
|
});
|
|
it("enforces the shared transport discriminator and existing harness compatibility", () => {
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "ai", transport: "mcp_remote" }).success).toBe(false);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "tool", transport: "runtime_auth" }).success).toBe(false);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "agentmail" } }).success).toBe(true);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "slack" } }).success).toBe(false);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "runtime_auth", config: { provider: "agentmail" } }).success).toBe(false);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "voice", config: { provider: "speko" } }).success).toBe(true);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "voice", config: { provider: "slack" } }).success).toBe(false);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "chat_sdk", config: { provider: "speko" } }).success).toBe(false);
|
|
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "tool", transport: "voice", config: { provider: "speko" } }).success).toBe(false);
|
|
expect(aiConnectionBindingSchema.safeParse({ provider: "anthropic", mode: "responsible_user" }).success).toBe(false);
|
|
expect(aiConnectionBindingSchema.safeParse({ provider: "anthropic", mode: "shared", connectionId: randomUUID(), grantId: randomUUID() }).success).toBe(false);
|
|
expect(isAiConnectionCompatible({ provider: "anthropic", method: "api_key", mode: "responsible_user" }, "paperclip_runner", "same-model", "acpx", "claude")).toBe(true);
|
|
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "claude")).toBe(true);
|
|
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "codex")).toBe(false);
|
|
expect(isAiConnectionCompatible({ provider: "openrouter", method: "api_key" }, "opencode_local", "anthropic/model")).toBe(false);
|
|
});
|
|
it("does not let a forged delegation bypass human access or accept an expired subscription attempt", async () => {
|
|
const selected = await service.select({ ...input, userId: "alice" });
|
|
const otherAgent = randomUUID();
|
|
await db.insert(agents).values({ id: otherAgent, companyId, name: "Other" });
|
|
await db.insert(connectionGrantDelegations).values({ companyId, grantId: selected.grant.id, agentId: otherAgent, createdByUserId: "bob" });
|
|
await expect(service.select({ ...input, agentId: otherAgent, userId: "bob", binding: { ...binding, method: selected.attribution.method, mode: "delegated", connectionId: selected.connection.id, grantId: selected.grant.id } })).rejects.toThrow("not shared");
|
|
const [environment] = await db.select().from(environments).limit(1);
|
|
const sessionId = randomUUID();
|
|
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Expired", agentIds: [], allAgents: true } as const;
|
|
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: sessionId, status: "submitting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() - 1000) });
|
|
await expect(completeSetupTokenAiConnection(db, { scope: { companyId, ownerUserId: "alice", environmentId: environment.id, adapterType: "claude_local", aiConnection: { ...intent, agentIds: [] } }, token: "fixture-never-save", sessionId })).rejects.toThrow("no longer active");
|
|
expect((await service.list(companyId, "alice")).some(account => account.name === "Expired")).toBe(false);
|
|
});
|
|
it("authorizes account creation, reconnect and defaults at the HTTP boundary before provider calls", async () => {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
const userId = String(req.headers["x-test-user"] ?? "alice");
|
|
const role = req.headers["x-test-role"] === "viewer" ? "viewer" : "member";
|
|
req.actor = { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, membershipRole: role, status: "active" }] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const personal = await service.select({ ...input, userId: "alice" });
|
|
const base = `/api/companies/${companyId}/ai-connections`;
|
|
expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connections`)).status).toBe(403);
|
|
expect((await request(app).put(`${base}/default`).set("x-test-user", "bob").send({ grantId: personal.grant.id })).status).toBe(403);
|
|
expect((await request(app).put(`${base}/default`).set("x-test-role", "viewer").send({ grantId: personal.grant.id })).status).toBe(403);
|
|
const payload = { provider: "anthropic", method: "api_key", name: "Fixture", ownership: "personal", apiKey: "fixture", allAgents: false, agentIds: [] };
|
|
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("must not reach provider"));
|
|
try {
|
|
expect((await request(app).post(base).set("x-test-role", "viewer").send(payload)).status).toBe(403);
|
|
expect((await request(app).post(base).send({ ...payload, ownership: "shared" })).status).toBe(403);
|
|
expect((await request(app).post(base).set("x-test-user", "bob").send({ ...payload, connectionId: personal.connection.id })).status).toBe(403);
|
|
expect(network).not.toHaveBeenCalled();
|
|
} finally { network.mockRestore(); }
|
|
});
|
|
it.each(["anthropic", "openai"] as const)("reports a missing %s browser process after a server restart", async provider => {
|
|
const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")).limit(1);
|
|
const id = randomUUID();
|
|
const owner = `restart-${provider}`;
|
|
const intent = { provider, method: "subscription", ownership: "personal", name: "Interrupted sign-in", allAgents: false, agentIds: [] } as const;
|
|
await db.insert(adapterAuthSessions).values({ id, publicSessionId: id, companyId, environmentId: environment.id, startedByUserId: owner, adapterType: provider === "anthropic" ? "claude_local" : "codex_local", aiConnection: { ...intent, agentIds: [] }, connectionMethod: "local_subscription", status: "waiting_for_user", expiresAt: new Date(Date.now() + 60_000) });
|
|
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredentialDetails").mockRejectedValue(new Error("No credential yet"));
|
|
try {
|
|
const login = localAiLoginService(db);
|
|
await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "sign_in_required", error: "The server restarted during sign-in. Start sign-in again." });
|
|
await expect(login.check(companyId, "bob", { ...intent, agentIds: [] }, id)).rejects.toThrow("not found");
|
|
reader.mockResolvedValue({ credential: "completed-before-restart", accountLabel: undefined });
|
|
await expect(login.check(companyId, owner, { ...intent, agentIds: [] }, id)).resolves.toEqual({ status: "ready" });
|
|
} finally { reader.mockRestore(); await db.delete(adapterAuthSessions).where(eq(adapterAuthSessions.id, id)); }
|
|
});
|
|
it("requires an owned browser sign-in attempt for local subscriptions", async () => {
|
|
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredentialDetails").mockResolvedValue({ credential: "fixture-local-token", accountLabel: undefined });
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: req.headers["x-local"] === "yes" ? "local_implicit" : "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const url = `/api/companies/${companyId}/ai-connections/local`;
|
|
const payload = { provider: "anthropic", method: "subscription", name: "Local account test", ownership: "personal", agentIds: [agentId], allAgents: false };
|
|
try {
|
|
expect((await request(app).post(url).send(payload)).status).toBe(403);
|
|
expect(reader).not.toHaveBeenCalled();
|
|
expect((await request(app).post(`${url}/check`).send(payload)).status).toBe(403);
|
|
expect(reader).not.toHaveBeenCalled();
|
|
const checked = await request(app).post(`${url}/check`).set("x-local", "yes").send(payload);
|
|
expect(checked.status).toBe(422);
|
|
expect((await service.list(companyId, "alice")).some(c => c.name === payload.name)).toBe(false);
|
|
const connected = await request(app).post(url).set("x-local", "yes").send(payload);
|
|
expect(connected.status).toBe(422);
|
|
expect(reader).not.toHaveBeenCalled();
|
|
const codex = { ...payload, provider: "openai", name: "Isolated terminal login" };
|
|
const attempts = `${url}/attempts`;
|
|
expect((await request(app).post(attempts).send(codex)).status).toBe(403); // This member cannot authorize agentId.
|
|
expect((await request(app).post(url).set("x-local", "yes").send(codex)).status).toBe(422);
|
|
const prepared = await request(app).post(attempts).set("x-local", "yes").send(codex);
|
|
expect(prepared.status).toBe(201);
|
|
expect(prepared.body.command).toBeUndefined();
|
|
expect((await request(app).post(attempts).set("x-local", "yes").send(codex)).body).toEqual(prepared.body);
|
|
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-test-user", "bob").send()).status).toBe(404);
|
|
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-local", "yes").send()).status).toBe(200);
|
|
expect((await request(app).post(url).set("x-local", "yes").send({ ...codex, localSessionId: prepared.body.sessionId })).status).toBe(422);
|
|
} finally { reader.mockRestore(); }
|
|
});
|
|
it.each(["anthropic", "openai"] as const)("blocks server-host %s login on a public deployment without a trusted host", async provider => {
|
|
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredentialDetails");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "session", userId: "alice", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "" }));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const base = `/api/companies/${companyId}/ai-connections/local`;
|
|
const intent = { provider, method: "subscription", ownership: "personal", name: "Hosted account", allAgents: false, agentIds: [] };
|
|
try {
|
|
for (const endpoint of [base, `${base}/attempts`, `${base}/check`]) {
|
|
const result = await request(app).post(endpoint).send(intent);
|
|
expect(result.status).toBe(422);
|
|
expect(result.body.error).toContain("unavailable on this hosted instance");
|
|
}
|
|
expect(reader).not.toHaveBeenCalled();
|
|
} finally { reader.mockRestore(); }
|
|
});
|
|
it.each(["anthropic", "openai"] as const)("lets authenticated users connect only their own isolated %s login", async provider => {
|
|
const owner = `self-hosted-${provider}`;
|
|
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
|
|
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredentialDetails").mockResolvedValue({ credential: "isolated-fixture-token", accountLabel: undefined });
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? owner), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: req.headers["x-viewer"] ? "viewer" : "member" }] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const base = `/api/companies/${companyId}/ai-connections/local`;
|
|
const intent = { provider, method: "subscription", ownership: "personal", name: `Self-hosted ${provider}`, allAgents: false, agentIds: [] };
|
|
try {
|
|
expect((await request(app).post(`${base}/attempts`).set("x-viewer", "yes").send(intent)).status).toBe(403);
|
|
const started = await request(app).post(`${base}/attempts`).send(intent);
|
|
expect(started.status).toBe(201);
|
|
expect(started.headers["cache-control"]).toBe("no-store");
|
|
expect(started.body.command).toBeUndefined();
|
|
expect((await request(app).post(`${base}/attempts`).send(intent)).body).toEqual(started.body);
|
|
await db.update(adapterAuthSessions).set({ publicSessionId: randomUUID() }).where(eq(adapterAuthSessions.id, started.body.sessionId));
|
|
const input = { ...intent, localSessionId: started.body.sessionId };
|
|
for (const endpoint of [base, `${base}/check`]) {
|
|
expect((await request(app).post(endpoint).set("x-test-user", "bob").send(input)).status).toBe(404);
|
|
expect((await request(app).post(endpoint.replace(companyId, otherCompanyId)).send(input)).status).toBe(403);
|
|
}
|
|
if (provider === "anthropic") {
|
|
const codeUrl = `${base}/attempts/${started.body.sessionId}/code`;
|
|
expect((await request(app).post(codeUrl).set("x-test-user", "bob").send({ browserCode: "fixture-code" })).status).toBe(404);
|
|
expect((await request(app).post(codeUrl).send({ browserCode: "fixture-code" })).status).toBe(422);
|
|
}
|
|
expect(reader).not.toHaveBeenCalled();
|
|
const checked = await request(app).post(`${base}/check`).send(input);
|
|
expect(checked.body).toEqual({ status: "ready" });
|
|
expect(reader).toHaveBeenLastCalledWith(provider, path.join(home, "instances/ai-connection-fixture/ai-local-logins", started.body.sessionId));
|
|
const saved = await request(app).post(base).send(input);
|
|
expect(saved.status).toBe(201);
|
|
expect((await request(app).post(base).send(input)).body).toEqual(saved.body);
|
|
expect(JSON.stringify(saved.body)).not.toContain("isolated-fixture-token");
|
|
expect((await service.list(companyId, owner)).filter(c => c.name === intent.name)).toHaveLength(1);
|
|
} finally { reader.mockRestore(); }
|
|
});
|
|
it("rejects invalid credentials without exposing the provider response", async () => {
|
|
const request = vi.fn().mockResolvedValue(new Response("secret-provider-body", { status: 401 }));
|
|
await expect(validateAiApiKey("anthropic", "fixture", request)).rejects.toThrow("rejected");
|
|
expect(request.mock.calls[0][1].redirect).toBe("error");
|
|
});
|
|
it("uses the authenticated responsible user for agent-originated configuration and tests", async () => {
|
|
const req = { actor: { type: "agent", agentId, onBehalfOfUserId: "alice" } } as express.Request;
|
|
const selected = await service.select({ ...input, userId: responsibleUserForAiRequest(req) });
|
|
expect(selected.grant.subjectUserId).toBe("alice");
|
|
req.actor.onBehalfOfUserId = undefined;
|
|
expect(responsibleUserForAiRequest(req)).toBeNull();
|
|
await expect(service.select({ ...input, userId: responsibleUserForAiRequest(req) })).rejects.toThrow();
|
|
});
|
|
|
|
it("protects active-run attribution with the connection human audience", async () => {
|
|
const account = await create("alice", "Private run attribution");
|
|
const runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { aiConnection: { connectionId: account.connectionId, grantId: account.grantId } } });
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId] };
|
|
next();
|
|
});
|
|
app.use("/api", aiConnectionRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/active-runs`;
|
|
const own = await request(app).get(url);
|
|
expect(own.status).toBe(200);
|
|
expect(own.headers["cache-control"]).toBe("no-store");
|
|
expect(own.body).toEqual([expect.objectContaining({ id: runId, agentId })]);
|
|
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
|
|
await db.update(connectionGrants).set({ kind: "organization", subjectUserId: null }).where(eq(connectionGrants.id, account.grantId));
|
|
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "alice" });
|
|
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
|
|
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
|
|
expect((await request(app).get(url).set("x-test-user", "bob")).body).toEqual(own.body);
|
|
expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403);
|
|
});
|
|
it("permits new-agent shared installation only for a connection configurator, without bypassing audience", async () => {
|
|
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Restricted shared", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-restricted");
|
|
const selected = { provider: "anthropic", method: "api_key", mode: "shared", ...account } as const;
|
|
const futureAgentId = randomUUID();
|
|
const req = (userId: string, role = "member") => ({ actor: { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, membershipRole: role, status: "active" }] } }) as express.Request;
|
|
expect(await canInstallSharedAiConnectionForNewAgent(db, req("alice"), companyId, selected)).toBe(true);
|
|
expect(await canInstallSharedAiConnectionForNewAgent(db, req("bob"), companyId, selected)).toBe(false);
|
|
expect(await canInstallSharedAiConnectionForNewAgent(db, req("alice", "viewer"), companyId, selected)).toBe(false);
|
|
expect(await canInstallSharedAiConnectionForNewAgent(db, { actor: { type: "agent", onBehalfOfUserId: "alice" } } as express.Request, companyId, selected)).toBe(false);
|
|
const selectionInput = { ...input, agentId: futureAgentId, userId: "alice", binding: selected };
|
|
await expect(service.select(selectionInput)).rejects.toThrow("not permitted for this agent");
|
|
const run = await prepareManagedAiRuntime(db, { companyId, agentId: futureAgentId, responsibleUserId: "alice", adapterType: "claude_local", binding: selected, config: { cwd: home, model: "same-model" }, allowUninstalledShared: true });
|
|
expect(run.config.model).toBe("same-model");
|
|
await run.cleanup();
|
|
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
|
|
await expect(service.select({ ...selectionInput, allowUninstalledShared: true })).rejects.toThrow("not shared with the responsible user");
|
|
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, account.grantId));
|
|
await db.insert(agents).values({ id: futureAgentId, companyId, name: "New shared agent", adapterType: "claude_local" });
|
|
await db.insert(toolConnectionInstalls).values({ companyId, connectionId: account.connectionId, targetType: "agent", targetId: futureAgentId, createdByUserId: "alice" });
|
|
expect((await service.select(selectionInput)).grant.id).toBe(account.grantId);
|
|
});
|
|
|
|
it.each([false, true])("validates runner account adoption in the inherited sandbox (inline: %s)", async (inline) => {
|
|
const { agentRoutes } = await import("../routes/agents.js");
|
|
const { instanceSettingsService } = await import("../services/instance-settings.js");
|
|
const targetModule = await import("../services/environment-execution-target.js");
|
|
const runtimeModule = await import("../services/environment-runtime.js");
|
|
const { requireServerAdapter } = await import("../adapters/index.js");
|
|
const settings = instanceSettingsService(db);
|
|
const previous = await settings.get();
|
|
const [environment] = await db.insert(environments).values({ name: `Adoption sandbox ${inline}`, driver: "sandbox", config: { provider: "daytona" } }).returning();
|
|
await settings.update({ defaultEnvironmentId: environment.id });
|
|
const id = randomUUID();
|
|
await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } });
|
|
const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: inline ? [] : [id], allAgents: false }, "fixture-adoption-key");
|
|
await service.setDefault(companyId, "alice", account.grantId);
|
|
const tryDeliver = vi.fn(async (interactionId: string) => {
|
|
// Dispatch observes committed adoption, never a partially saved choice.
|
|
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("accepted");
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toMatchObject({ mode: "responsible_user" });
|
|
});
|
|
const deliveryModule = await import("../services/connection-intent-delivery.js");
|
|
const delivery = vi.spyOn(deliveryModule, "connectionIntentDeliveryService").mockReturnValue({ tryDeliver } as never);
|
|
let interactionId: string | undefined;
|
|
if (inline) {
|
|
const issueId = randomUUID();
|
|
const runId = randomUUID();
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Atomic adoption", status: "in_progress", assigneeAgentId: id });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed", errorCode: "acpx_auth_required", responsibleUserId: "alice", contextSnapshot: { issueId } });
|
|
interactionId = (await connectionIntentService(db).requestForRunAuthFailure(runId))!.interactionId!;
|
|
}
|
|
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace", providerKey: "daytona", runner: { execute: vi.fn(async () => ({ exitCode: 0, stdout: "", stderr: "", timedOut: false })) } } as const;
|
|
const resolveTarget = vi.spyOn(targetModule, "resolveEnvironmentExecutionTarget").mockResolvedValue(target);
|
|
const release = vi.fn(async () => undefined);
|
|
const acquire = vi.fn(async () => ({ lease: { id: randomUUID(), provider: "daytona", providerLeaseId: "test-sandbox", metadata: {} }, leaseContext: {} }));
|
|
const runtime = vi.spyOn(runtimeModule, "environmentRuntimeService").mockReturnValue({ acquireRunLease: acquire, realizeWorkspace: vi.fn(async () => ({ cwd: "/workspace" })), getDriver: () => ({ releaseRunLease: release }) } as any);
|
|
const probe = vi.spyOn(requireServerAdapter("paperclip_runner"), "testEnvironment").mockImplementation(async context => ({ adapterType: "paperclip_runner", status: context.executionTarget ? "pass" : "fail", testedAt: new Date().toISOString(), checks: [{ code: context.executionTarget ? "codex_hello_probe_passed" : "host_probe_failed", level: context.executionTarget ? "info" : "error", message: "fixture" }] }));
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
|
app.use("/api", agentRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const selected = { provider: "openai", method: "api_key", mode: "responsible_user" } as const;
|
|
const providerRequest = vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 200 }));
|
|
const adopt = () => inline
|
|
? request(app).post(`/api/agents/${id}/connection-intents/${interactionId}/adopt`).send({ connectionId: account.connectionId })
|
|
: request(app).patch(`/api/agents/${id}`).send({ runtimeConfig: { aiConnection: selected } });
|
|
try {
|
|
// Target resolution can return only warning checks. Adoption still must
|
|
// fail closed, rather than quietly running the probe on the server host.
|
|
resolveTarget.mockResolvedValueOnce(null);
|
|
const unavailable = await adopt();
|
|
expect(unavailable.status, JSON.stringify(unavailable.body)).toBe(422);
|
|
expect(tryDeliver).not.toHaveBeenCalled();
|
|
expect(probe).not.toHaveBeenCalled();
|
|
expect(providerRequest).not.toHaveBeenCalled();
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
|
|
if (inline) {
|
|
const assertUnchanged = async () => {
|
|
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
|
|
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId))).toEqual([]);
|
|
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId!)))[0].status).toBe("pending");
|
|
};
|
|
await assertUnchanged();
|
|
// Force the last write to fail after binding and install changes.
|
|
await db.execute(sql.raw(`CREATE FUNCTION reject_atomic_adoption() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.id = '${interactionId}'::uuid THEN RAISE EXCEPTION 'injected resolution failure'; END IF; RETURN NEW; END $$`));
|
|
await db.execute(sql.raw("CREATE TRIGGER reject_atomic_adoption BEFORE UPDATE ON issue_thread_interactions FOR EACH ROW EXECUTE FUNCTION reject_atomic_adoption()"));
|
|
try {
|
|
const failed = await adopt();
|
|
expect(failed.status).toBe(500);
|
|
expect(tryDeliver).not.toHaveBeenCalled();
|
|
await assertUnchanged();
|
|
} finally {
|
|
await db.execute(sql.raw("DROP TRIGGER reject_atomic_adoption ON issue_thread_interactions"));
|
|
await db.execute(sql.raw("DROP FUNCTION reject_atomic_adoption()"));
|
|
}
|
|
}
|
|
const saved = await adopt();
|
|
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
|
const savedAgent = (await db.select().from(agents).where(eq(agents.id, id)))[0];
|
|
expect(savedAgent.defaultEnvironmentId).toBeNull();
|
|
expect(savedAgent.runtimeConfig.aiConnection).toEqual(inline ? { ...selected, method: "subscription" } : selected);
|
|
if (inline) {
|
|
expect(saved.body.status).toBe("accepted");
|
|
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId))).toHaveLength(1);
|
|
expect((await adopt()).status).toBe(200);
|
|
expect(tryDeliver).toHaveBeenCalledTimes(2);
|
|
expect(tryDeliver).toHaveBeenCalledWith(interactionId);
|
|
}
|
|
expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ companyId, environment: expect.objectContaining({ id: environment.id }) }));
|
|
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target, config: expect.objectContaining({ provider: "codex", model: "gpt-5.6-sol", managedAiConnection: expect.any(Object) }) }));
|
|
expect(providerRequest).toHaveBeenCalledWith("https://api.openai.com/v1/models", expect.objectContaining({
|
|
headers: { Authorization: "Bearer fixture-adoption-key" },
|
|
redirect: "error",
|
|
}));
|
|
expect(release).toHaveBeenCalledTimes(inline ? 3 : 2);
|
|
expect(JSON.stringify(saved.body)).not.toContain("fixture-adoption-key");
|
|
} finally {
|
|
delivery.mockRestore(); providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore();
|
|
await settings.update({ defaultEnvironmentId: previous.defaultEnvironmentId });
|
|
}
|
|
});
|
|
|
|
it("saves a restricted shared connection during setup and installs it only when setup finishes", async () => {
|
|
const { requireServerAdapter } = await import("../adapters/index.js");
|
|
const setupId = randomUUID();
|
|
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Shared setup", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-setup-key");
|
|
const selected = { provider: "anthropic", method: "api_key", mode: "shared", ...account } as const;
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
|
app.use("/api", agentRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const initial = await request(app).post(`/api/companies/${companyId}/agents`).send({ setupId, name: "Shared setup agent", adapterType: "claude_local", runner: "legacy" });
|
|
expect(initial.status, JSON.stringify(initial.body)).toBe(201);
|
|
const probe = vi.spyOn(requireServerAdapter("claude_local"), "testEnvironment");
|
|
const providerRequest = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
|
|
try {
|
|
const saved = await request(app).patch(`/api/agents/${setupId}`).send({ runtimeConfig: { aiConnection: selected } });
|
|
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
|
expect(saved.body).toMatchObject({ pauseReason: "setup", runtimeConfig: { aiConnection: selected } });
|
|
expect(probe).not.toHaveBeenCalled();
|
|
probe.mockResolvedValue({ adapterType: "claude_local", status: "pass", checks: [], testedAt: new Date().toISOString() });
|
|
const tested = await request(app).post(`/api/companies/${companyId}/adapters/claude_local/test-environment`).send({
|
|
agentId: setupId, runner: "legacy", adapterConfig: {}, aiConnection: selected,
|
|
});
|
|
expect(tested.status, JSON.stringify(tested.body)).toBe(200);
|
|
expect(probe).toHaveBeenCalledTimes(1);
|
|
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.targetId, setupId))).toHaveLength(0);
|
|
const finished = await request(app).post(`/api/companies/${companyId}/agent-hires`).send({
|
|
setupAgentId: setupId, name: "Shared setup agent", adapterType: "claude_local", runner: "legacy", runtimeConfig: { aiConnection: selected },
|
|
});
|
|
expect(finished.status, JSON.stringify(finished.body)).toBe(201);
|
|
expect(finished.body.agent.id).toBe(setupId);
|
|
expect(await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.connectionId, account.connectionId), eq(toolConnectionInstalls.targetId, setupId)))).toHaveLength(1);
|
|
} finally { probe.mockRestore(); providerRequest.mockRestore(); }
|
|
});
|
|
|
|
it.each([["responsible_user", "codex_local"], ["shared", "codex_local"], ["responsible_user", "claude_local"]] as const)("drops a superseded saved %s connection when %s setup changes authentication", async (mode, adapterType) => {
|
|
const setupId = randomUUID();
|
|
await db.insert(agents).values({ id: setupId, companyId, name: "Switch provider", createdByUserId: "alice",
|
|
adapterType, status: "paused", lifecycleState: "paused", pauseReason: "setup", lifecycleHolds: ["setup"],
|
|
runtimeConfig: { aiConnection: mode === "shared"
|
|
? { provider: "openai", method: "api_key", mode, connectionId: randomUUID(), grantId: randomUUID() }
|
|
: { provider: adapterType === "claude_local" ? "anthropic" : "openai", method: "subscription", mode } },
|
|
});
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] }; next(); });
|
|
app.use("/api", agentRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
const saved = await request(app).patch(`/api/agents/${setupId}`).send({ adapterType: "claude_local", runner: "legacy",
|
|
adapterConfig: {}, runtimeConfig: { heartbeat: { enabled: false } },
|
|
});
|
|
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
|
expect(saved.body.runtimeConfig).not.toHaveProperty("aiConnection");
|
|
const [row] = await db.select().from(agents).where(eq(agents.id, setupId));
|
|
expect(row.runtimeConfig).not.toHaveProperty("aiConnection");
|
|
});
|
|
|
|
it("creates and hires agents with an authorized restricted shared connection", async () => {
|
|
const { agentRoutes } = await import("../routes/agents.js");
|
|
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
|
|
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Shared creation routes", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-create-routes");
|
|
const selected = { ...binding, mode: "shared", ...account } as const;
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => {
|
|
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
|
|
next();
|
|
});
|
|
app.use("/api", agentRoutes(db));
|
|
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
|
|
for (const endpoint of ["agents", "agent-hires"]) {
|
|
const response = await request(app).post(`/api/companies/${companyId}/${endpoint}`).send({ name: `Shared ${endpoint}`, role: "general", adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4-6" }, runtimeConfig: { aiConnection: selected } });
|
|
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
|
const agent = endpoint === "agents" ? response.body : response.body.agent;
|
|
expect(agent.adapterConfig.model).toBe("claude-sonnet-4-6");
|
|
expect(agent.runtimeConfig.aiConnection).toEqual(selected);
|
|
const installs = await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.connectionId, account.connectionId), eq(toolConnectionInstalls.targetId, agent.id)));
|
|
expect(installs).toHaveLength(1);
|
|
expect((await service.select({ ...input, agentId: agent.id, userId: "alice", binding: selected })).grant.id).toBe(account.grantId);
|
|
}
|
|
// A database failure between the two inserts must roll back the agent too.
|
|
await db.execute(sql`CREATE FUNCTION reject_test_ai_install() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture install failure'; END $$`);
|
|
await db.execute(sql`CREATE TRIGGER reject_test_ai_install BEFORE INSERT ON tool_connection_installs FOR EACH ROW EXECUTE FUNCTION reject_test_ai_install()`);
|
|
try {
|
|
for (const endpoint of ["agents", "agent-hires"]) {
|
|
const name = `Rollback ${endpoint}`;
|
|
const response = await request(app).post(`/api/companies/${companyId}/${endpoint}`).send({ name, role: "general", adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4-6" }, runtimeConfig: { aiConnection: selected } });
|
|
expect(response.status).toBe(500);
|
|
expect(await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.name, name)))).toEqual([]);
|
|
}
|
|
} finally {
|
|
await db.execute(sql`DROP TRIGGER reject_test_ai_install ON tool_connection_installs`);
|
|
await db.execute(sql`DROP FUNCTION reject_test_ai_install()`);
|
|
}
|
|
}, 30000);
|
|
|
|
});
|
|
|
|
|
|
describe("AI connection recovery delivery", () => {
|
|
it.each(["ai_connection_unavailable", "secret_binding_missing"].flatMap(reason =>
|
|
["restored", "newer failure", "different blocker", "revoked again", "closed task"].map(scenario => [reason, scenario]),
|
|
))(
|
|
"continues only the repaired %s source failure: %s", async (reason, scenario) => {
|
|
const userId = `recovery-${randomUUID()}`;
|
|
const recoveringAgentId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const failedRunId = randomUUID();
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id: recoveringAgentId, companyId, name: "Recovery agent", status: "active", adapterType: "claude_local", runtimeConfig: { aiConnection: binding } });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Restore selected account", status: "in_progress", assigneeAgentId: recoveringAgentId });
|
|
await db.insert(heartbeatRuns).values({ id: failedRunId, companyId, agentId: recoveringAgentId, status: "running", responsibleUserId: userId, contextSnapshot: { issueId } });
|
|
const intents = connectionIntentService(db);
|
|
const pending = await intents.request({ sub: recoveringAgentId, company_id: companyId, run_id: failedRunId, responsible_user_id: userId }, "anthropic", { purpose: "ai" });
|
|
const account = await create(userId, `Recovered ${scenario}`);
|
|
expect((await intents.setupOptions(pending.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([account.connectionId]);
|
|
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason,
|
|
...(reason === "secret_binding_missing" ? { agentId: recoveringAgentId, missingBindings: [{ bindingType: "user_secret_ref", consumerType: "agent", consumerId: recoveringAgentId, envKey: "ANTHROPIC_API_KEY", configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing" }] } : {}),
|
|
} } }).where(eq(heartbeatRuns.id, failedRunId));
|
|
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
|
|
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `ai:${issueId}`, nextAction: "Reconnect", ownerType: "board", evidence: { latestRunId: failedRunId } });
|
|
await intents.complete(pending.interactionId!, account.connectionId, userId);
|
|
if (scenario === "newer failure") await db.insert(heartbeatRuns).values({ companyId, agentId: recoveringAgentId, status: "failed", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) });
|
|
if (scenario === "different blocker") await db.update(issueRecoveryActions).set({ cause: "workspace_validation_failed" }).where(eq(issueRecoveryActions.sourceIssueId, issueId));
|
|
if (scenario === "closed task") await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
if (scenario === "revoked again") {
|
|
await toolAccessService(db).revokeConnectionGrant(account.connectionId, account.grantId, { actorType: "user", actorId: userId });
|
|
const repairOptions = await intents.setupOptions(pending.interactionId!);
|
|
expect(repairOptions.existingConnections).toEqual([]);
|
|
expect(repairOptions.aiRepair).toMatchObject({ canReconnect: true, connection: { id: account.connectionId, grantId: account.grantId, isDefault: true, status: "revoked" } });
|
|
}
|
|
const wakeup = vi.fn(async (_agentId, opts) => {
|
|
await db.insert(agentWakeupRequests).values({ companyId, agentId: recoveringAgentId, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
|
|
return null;
|
|
});
|
|
const delivery = connectionIntentDeliveryService(db, { wakeup } as never);
|
|
await delivery.deliver(pending.interactionId!);
|
|
await delivery.deliver(pending.interactionId!);
|
|
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
if (scenario === "restored") {
|
|
expect(issue.status).toBe("in_progress");
|
|
expect(wakeup).toHaveBeenCalledTimes(1);
|
|
expect(wakeup).toHaveBeenCalledWith(recoveringAgentId, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
|
|
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
|
|
const [receipt] = await db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, pending.interactionId!));
|
|
expect(receipt.deliveredAt).not.toBeNull();
|
|
} else {
|
|
expect(wakeup).not.toHaveBeenCalled();
|
|
expect(issue.status).toBe(scenario === "closed task" ? "done" : "blocked");
|
|
}
|
|
}, 30000,
|
|
);
|
|
});
|
|
|
|
function deferredSignal() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>(done => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|