From 7eef3d459f7e702ff8290d73bfe8f67f5eae5754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 15:02:12 -0400 Subject: [PATCH] Q88: a sign-in is saved only if a run can use and renew it Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 9 -- src/main/services/oauth/stored-oauth.ts | 41 +++++ src/main/services/provider-adapters.ts | 5 +- src/main/services/provider-auth-profiles.ts | 29 +--- src/main/services/provider-service.ts | 151 +----------------- src/main/services/provider-sign-in-window.ts | 43 ----- .../credential-resolver.ts | 34 +--- src/preload/index.ts | 1 - .../src/pages/settings/ProvidersPanel.tsx | 1 - src/shared/commands/provider.ts | 7 - src/shared/desktop-api.ts | 2 - src/shared/ipc.ts | 1 - tests/contract/provider-contracts.test.ts | 10 +- tests/integration/agent-model.test.ts | 6 +- tests/integration/agent-reply.test.ts | 2 +- tests/integration/autonomous-run.test.ts | 4 +- tests/integration/ceo-founder-chat.test.ts | 4 +- tests/integration/channel-anchor.test.ts | 2 +- tests/integration/chat-context.test.ts | 4 +- tests/integration/goal-changes.test.ts | 2 +- tests/integration/interactive-lane.test.ts | 6 +- tests/integration/run-checkpoint.test.ts | 2 +- tests/integration/run-journal.test.ts | 2 +- tests/integration/run-memory.test.ts | 2 +- tests/integration/run-stop.test.ts | 6 +- tests/integration/spend-budget.test.ts | 2 +- tests/integration/stale-queued-runs.test.ts | 2 +- tests/integration/task-context-scope.test.ts | 6 +- tests/integration/task-dependencies.test.ts | 2 +- tests/unit/agent-instructions.test.ts | 4 +- tests/unit/char-accounting.test.ts | 2 +- tests/unit/model-check.test.ts | 2 +- tests/unit/provider-error-codes.test.ts | 2 +- tests/unit/provider-oauth.test.ts | 26 +-- tests/unit/provider-service.test.ts | 87 +--------- tests/unit/quota-reservation.test.ts | 2 +- tests/unit/rate-limit-observation.test.ts | 2 +- tests/unit/task-execution-service.test.ts | 10 +- 38 files changed, 127 insertions(+), 398 deletions(-) create mode 100644 src/main/services/oauth/stored-oauth.ts delete mode 100644 src/main/services/provider-sign-in-window.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 883a08e5..fc7cb4f6 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -235,14 +235,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("provider_model_check_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => { - try { - providers.launchSignInWindow(input); - return ok(null); - } catch (error) { - return fail("provider_sign_in_failed", error instanceof Error ? error.message : String(error)); - } - }); ipcMain.handle(IPC_CHANNELS.startProviderOAuth, async (_event, input) => { const emit = (event: Record) => deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-oauth", providerKey: input.providerKey, ...event }); @@ -264,7 +256,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { providerId: input.providerId, secret, model: input.model ?? null, - employeeId: input.employeeId, }); emit({ phase: "active" }); notifyDataChanged(); diff --git a/src/main/services/oauth/stored-oauth.ts b/src/main/services/oauth/stored-oauth.ts new file mode 100644 index 00000000..600e377f --- /dev/null +++ b/src/main/services/oauth/stored-oauth.ts @@ -0,0 +1,41 @@ +import { PROVIDER_CATALOG } from "../../../shared/provider-catalog"; + +/** A provider sign-in as stored: the token, the means to renew it, its expiry, and the extras its backend needs. */ +export interface StoredOAuth { + access: string; + refresh: string; + expires: number; + accountId?: string; + projectId?: string; + enterpriseUrl?: string | null; +} + +/** Extras a provider's turns cannot run without, with the words used when one is missing. */ +const REQUIRED_EXTRAS: Record = { + "gpt-account": { field: "accountId", label: "ChatGPT account id" }, + "google-antigravity": { field: "projectId", label: "Google Cloud project id" }, +}; + +/** The one check of a stored sign-in, used both when it is saved and when a run uses it. */ +export function parseStoredOAuth(providerKey: string, secret: string): StoredOAuth { + const entry = PROVIDER_CATALOG.find((candidate) => candidate.key === providerKey); + if (!entry) throw new Error(`${providerKey} is not a known provider.`); + const label = entry.label; + const value = secret.trim(); + if (!value.startsWith("{")) throw new Error(`Sign in to ${label}; a pasted token cannot be renewed when it expires.`); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`The stored ${label} sign-in is not valid JSON; sign in again.`); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`The stored ${label} sign-in is not an object; sign in again.`); + const creds = parsed as Record; + const missing = (what: string) => new Error(`The ${label} sign-in is missing its ${what}; sign in again.`); + if (typeof creds.access !== "string" || !creds.access.trim()) throw missing("access token"); + if (typeof creds.refresh !== "string" || !creds.refresh.trim()) throw missing("refresh token"); + if (typeof creds.expires !== "number" || !Number.isFinite(creds.expires)) throw missing("expiry"); + const extra = REQUIRED_EXTRAS[providerKey]; + if (extra && (typeof creds[extra.field] !== "string" || !(creds[extra.field] as string).trim())) throw missing(extra.label); + return creds as unknown as StoredOAuth; +} diff --git a/src/main/services/provider-adapters.ts b/src/main/services/provider-adapters.ts index d8903d65..6fabc0e5 100644 --- a/src/main/services/provider-adapters.ts +++ b/src/main/services/provider-adapters.ts @@ -1,8 +1,7 @@ import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain"; import { listProviderModels, type ProviderModelEntry } from "../../shared/provider-models"; import { - parseAccessToken, - parseAccountToken, + oauthProfileParser, parseApiKey, parseAnthropicCompatProfile, parseAzureProfile, @@ -408,7 +407,7 @@ function responseMessages(instructions: string | null, prompt: string): Array<{ } function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter { - const parseAuthProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? parseAccountToken : parseAccessToken; + const parseAuthProfile = oauthProfileParser(providerKey, authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? "account_token" : "access_token"); const assertProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? requireAccountTokenProfile : requireAccessTokenProfile; return { providerKey, diff --git a/src/main/services/provider-auth-profiles.ts b/src/main/services/provider-auth-profiles.ts index f523f8b8..6fa92d99 100644 --- a/src/main/services/provider-auth-profiles.ts +++ b/src/main/services/provider-auth-profiles.ts @@ -1,5 +1,6 @@ import type { JsonRecord } from "./provider-http"; import { isRecord } from "./provider-http"; +import { parseStoredOAuth } from "./oauth/stored-oauth"; export type ProviderAuthProfile = | { type: "api_key"; apiKey: string } @@ -15,31 +16,9 @@ export function parseApiKey(secret: string): ProviderAuthProfile { return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") }; } -export function parseAccessToken(secret: string): ProviderAuthProfile { - return { type: "access_token", accessToken: oauthAccessToken(secret, "Access token") }; -} - -export function parseAccountToken(secret: string): ProviderAuthProfile { - return { type: "account_token", accessToken: oauthAccessToken(secret, "Account token") }; -} - -/** - * Accepts either a raw token string or the JSON credential object produced by - * the native OAuth login flow, returning the usable access token. - */ -function oauthAccessToken(secret: string, label: string): string { - const value = nonEmptySecret(secret, label); - if (!value.startsWith("{")) return value; - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - throw new Error(`${label} credential must be a token or JSON.`); - } - if (isRecord(parsed) && typeof parsed.access === "string" && parsed.access.trim()) { - return parsed.access.trim(); - } - throw new Error(`${label} credential is missing an access token.`); +/** A sign-in provider's parser: it accepts only what a run can use and renew (see parseStoredOAuth). */ +export function oauthProfileParser(providerKey: string, type: "access_token" | "account_token"): (secret: string) => ProviderAuthProfile { + return (secret) => ({ type, accessToken: parseStoredOAuth(providerKey, secret).access }); } export function parseAzureProfile(secret: string): ProviderAuthProfile { diff --git a/src/main/services/provider-service.ts b/src/main/services/provider-service.ts index 3f341b55..2a008c66 100644 --- a/src/main/services/provider-service.ts +++ b/src/main/services/provider-service.ts @@ -1,6 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; -import { createHash, randomBytes } from "node:crypto"; -import type { CancelProviderResponseInput, LaunchProviderSignInInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands"; +import type { CancelProviderResponseInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands"; import type { ProviderModelEntry } from "../../shared/provider-models"; import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain"; import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain"; @@ -15,7 +14,6 @@ import { ProviderRateLimitRegistry } from "./provider-rate-limit-registry"; import { ProviderResponseObserver } from "./provider-response-observer"; import { ProviderRequestStore } from "./provider-request-store"; import { ProviderResponseRunner, type ProviderResponseCreateInput } from "./provider-response-runner"; -import { launchProviderSignInWindow, type LaunchProviderSignInWindowInput } from "./provider-sign-in-window"; import type { ToolTurnFn } from "./provider-tool-turn"; import { stopRun } from "./run-control"; import type { ModelCheckResult } from "../../shared/desktop-api"; @@ -35,8 +33,6 @@ import { export type { ProviderResponseCreateInput }; -const AUTH_SESSION_TTL_MS = 15 * 60 * 1000; - /** Room for instructions, the output reserve, and some history. */ const MIN_CONTEXT_WINDOW_TOKENS = 24_000; @@ -55,7 +51,6 @@ export class ProviderService { private readonly db: DatabaseSync, private readonly adapters: ProviderAdapterRegistry = providerAdapters, vault: ProviderSecretVault = new SecretVault(), - private readonly launchSignIn: (input: LaunchProviderSignInWindowInput) => void = launchProviderSignInWindow, toolTurn?: ToolTurnFn, ) { this.audit = new AuditService(db); @@ -106,14 +101,7 @@ export class ProviderService { const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type); adapter.parseAuthProfile(secret); const catalogEntry = requireCatalogEntry(provider.provider_key); - const source = this.resolveCredentialSource({ - orgId: input.orgId, - providerId: input.providerId, - providerKey: provider.provider_key, - authType: provider.auth_type, - employeeId: input.employeeId ?? null, - secret, - }); + const source = this.resolveCredentialSource(provider.auth_type); const authProfile = this.authBroker.createProviderProfile({ orgId: input.orgId, providerId: input.providerId, @@ -223,44 +211,6 @@ export class ProviderService { } - launchSignInWindow(input: LaunchProviderSignInInput): void { - this.assertEmployee(input.orgId, input.employeeId); - const provider = this.db.prepare(` - select id, provider_key, label, auth_type - from providers - where id = ? and org_id = ? - `).get(input.providerId, input.orgId) as { id: string; provider_key: string; label: string; auth_type: string } | undefined; - if (!provider) throw new Error("Provider was not found."); - const catalogEntry = requireCatalogEntry(provider.provider_key); - if (!catalogEntry.signInUrl) throw new Error(`${provider.label} does not support browser sign-in.`); - const session = this.createAuthSession({ - orgId: input.orgId, - providerId: provider.id, - providerKey: provider.provider_key, - employeeId: input.employeeId, - signInUrl: catalogEntry.signInUrl, - }); - this.launchSignIn({ providerId: provider.id, label: provider.label, signInUrl: catalogEntry.signInUrl, partition: session.partition }); - - this.audit.record({ - orgId: input.orgId, - entityType: "provider", - entityId: provider.id, - actorType: "employee", - actorId: input.employeeId, - action: "sign_in_window_opened", - payload: { - providerKey: provider.provider_key, - authType: provider.auth_type, - authSessionId: session.id, - state: session.state, - nonce: session.nonce, - codeVerifierSha256: session.codeVerifierSha256, - redirectUri: session.redirectUri, - partition: session.partition, - }, - }); - } /** Sets the per-million-token prices that turn recorded usage into spend. */ setPrices(input: SetProviderPricesInput): void { @@ -322,101 +272,12 @@ export class ProviderService { }); } - private assertEmployee(orgId: string, employeeId: string): void { - const employee = this.db.prepare("select id from employees where id = ? and org_id = ?") - .get(employeeId, orgId) as { id: string } | undefined; - if (!employee) throw new Error("Provider sign-in employee was not found."); - } - - private resolveCredentialSource(input: { - orgId: string; - providerId: string; - providerKey: string; - authType: ProviderAuthType; - employeeId: string | null; - secret: string; - }): { sourceType: string; sourceRef: string | null } { - if (input.authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && input.authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) { + private resolveCredentialSource(authType: ProviderAuthType): { sourceType: string; sourceRef: null } { + if (authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) { return { sourceType: "manual_secret", sourceRef: null }; } - // Credentials obtained through the native OAuth login arrive as a JSON - // object; they are authenticated by the flow itself and need no paste session. - if (input.secret.trim().startsWith("{")) { - return { sourceType: "provider_oauth_login", sourceRef: null }; - } - if (!input.employeeId) throw new Error("Provider sign-in employee is required."); - const session = this.db.prepare(` - select id - from provider_auth_sessions - where org_id = ? - and provider_id = ? - and provider_key = ? - and employee_id = ? - and completed_at is null - and expires_at > ? - order by created_at desc - limit 1 - `).get(input.orgId, input.providerId, input.providerKey, input.employeeId, new Date().toISOString()) as { id: string } | undefined; - if (!session) throw new Error("Provider sign-in session is required."); - this.db.prepare("update provider_auth_sessions set completed_at = ? where id = ? and org_id = ?") - .run(new Date().toISOString(), session.id, input.orgId); - return { sourceType: "provider_sign_in_session", sourceRef: session.id }; + // A sign-in credential can only come from the native OAuth login: parseStoredOAuth refuses anything else. + return { sourceType: "provider_oauth_login", sourceRef: null }; } - private createAuthSession(input: { - orgId: string; - providerId: string; - providerKey: string; - employeeId: string; - signInUrl: string; - }): ProviderAuthSessionDraft { - const id = createProviderAuthSessionId(); - const state = randomToken(); - const nonce = randomToken(); - const codeVerifier = randomToken(); - const codeVerifierSha256 = createHash("sha256").update(codeVerifier).digest("hex"); - const redirectUri = `agentcompany://provider-auth/${input.providerId}`; - const partition = `persist:agentcompany-provider-${input.providerId}`; - const createdAt = new Date(); - const expiresAt = new Date(createdAt.getTime() + AUTH_SESSION_TTL_MS); - this.db.prepare(` - insert into provider_auth_sessions( - id, org_id, provider_id, provider_key, employee_id, state, nonce, - code_verifier_sha256, redirect_uri, sign_in_url, partition, - created_at, expires_at, completed_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null) - `).run( - id, - input.orgId, - input.providerId, - input.providerKey, - input.employeeId, - state, - nonce, - codeVerifierSha256, - redirectUri, - input.signInUrl, - partition, - createdAt.toISOString(), - expiresAt.toISOString(), - ); - return { id, state, nonce, codeVerifierSha256, redirectUri, partition }; - } -} - -interface ProviderAuthSessionDraft { - id: string; - state: string; - nonce: string; - codeVerifierSha256: string; - redirectUri: string; - partition: string; -} - -function createProviderAuthSessionId(): string { - return `auths_${randomToken()}`; -} - -function randomToken(): string { - return randomBytes(24).toString("base64url"); } diff --git a/src/main/services/provider-sign-in-window.ts b/src/main/services/provider-sign-in-window.ts deleted file mode 100644 index 20edbdf8..00000000 --- a/src/main/services/provider-sign-in-window.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { BrowserWindow, session } from "electron"; - -export interface LaunchProviderSignInWindowInput { - providerId: string; - label: string; - signInUrl: string; - partition: string; -} - -export function launchProviderSignInWindow(input: LaunchProviderSignInWindowInput): void { - const providerSession = session.fromPartition(input.partition); - providerSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); - providerSession.setPermissionCheckHandler(() => false); - - const win = new BrowserWindow({ - width: 1100, - height: 760, - title: input.label, - webPreferences: { - session: providerSession, - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - webSecurity: true, - }, - }); - win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); - attachNavigationPolicy(win, input.signInUrl); - win.loadURL(input.signInUrl); -} - -function attachNavigationPolicy(win: BrowserWindow, signInUrl: string): void { - const allowedHost = new URL(signInUrl).hostname.toLowerCase(); - const allow = (url: string) => { - const host = new URL(url).hostname.toLowerCase(); - return host === allowedHost || host.endsWith(`.${allowedHost}`); - }; - const blockOffHost = (event: Electron.Event, url: string) => { - if (!allow(url)) event.preventDefault(); - }; - win.webContents.on("will-navigate", blockOffHost); - win.webContents.on("will-redirect", blockOffHost); -} diff --git a/src/main/services/provider-tool-turns/credential-resolver.ts b/src/main/services/provider-tool-turns/credential-resolver.ts index db1a0e41..4cc6e58d 100644 --- a/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/src/main/services/provider-tool-turns/credential-resolver.ts @@ -30,29 +30,7 @@ import { refreshAntigravity, requireAntigravityClientSecret } from "../oauth/goo import type { IntegrationKeyStore } from "../integration-key-store"; import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow"; import { refreshKimiCode } from "../oauth/kimi-code-flow"; - -interface StoredOAuth { - access: string; - refresh: string; - expires: number; - accountId?: string; - projectId?: string; - enterpriseUrl?: string | null; -} - -function parseStoredOAuth(secret: string): StoredOAuth { - let parsed: unknown; - try { - parsed = JSON.parse(secret); - } catch { - throw new Error("Stored OAuth credential is not valid JSON; reconnect the provider."); - } - const creds = parsed as StoredOAuth; - if (typeof creds.access !== "string" || typeof creds.refresh !== "string" || typeof creds.expires !== "number") { - throw new Error("Stored OAuth credential is incomplete; reconnect the provider."); - } - return creds; -} +import { parseStoredOAuth, type StoredOAuth } from "../oauth/stored-oauth"; function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise { switch (providerKey) { @@ -61,8 +39,7 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: Integration case "claude-pro": return refreshClaudePro(creds.refresh); case "google-antigravity": - if (!creds.projectId) throw new Error("Antigravity credential is missing its project id; reconnect the provider."); - return refreshAntigravity(creds.refresh, creds.projectId, requireAntigravityClientSecret(keys.read("google_antigravity_client_secret"))); + return refreshAntigravity(creds.refresh, requireProjectId(creds), requireAntigravityClientSecret(keys.read("google_antigravity_client_secret"))); case "github-copilot": return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined); case "kimi-code": @@ -122,7 +99,7 @@ export async function resolveTurnCredential(deps: { if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.CLOUD) { return resolveCloudCredential(deps.provider.provider_key, secret); } - const creds = parseStoredOAuth(secret); + const creds = parseStoredOAuth(deps.provider.provider_key, secret); if (Date.now() < creds.expires) return toTurnCredential(creds); if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider."); const refreshed = await refreshOAuth(deps.provider.provider_key, creds, deps.integrationKeys); @@ -134,3 +111,8 @@ export async function resolveTurnCredential(deps: { }); return toTurnCredential(refreshed); } + +function requireProjectId(creds: StoredOAuth): string { + if (!creds.projectId) throw new Error("The Google Antigravity sign-in is missing its Google Cloud project id; sign in again."); + return creds.projectId; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 2b935f25..9213cc28 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -55,7 +55,6 @@ const api: DesktopApi = { markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input), saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input), revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input), - launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input), startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input), cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input), checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input), diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index 72a1f3a6..5f975d64 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -52,7 +52,6 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat providerId: active.id, secret, model: String(form.get("model") ?? "").trim() || null, - employeeId, })).then((result) => { if (result) close(); }); }; diff --git a/src/shared/commands/provider.ts b/src/shared/commands/provider.ts index a128a967..24fa2daf 100644 --- a/src/shared/commands/provider.ts +++ b/src/shared/commands/provider.ts @@ -5,7 +5,6 @@ export interface SaveProviderCredentialInput { providerId: string; secret: string; model: string | null; - employeeId?: string | null; } export interface RevokeProviderCredentialInput { @@ -13,12 +12,6 @@ export interface RevokeProviderCredentialInput { providerId: string; } -export interface LaunchProviderSignInInput { - orgId: string; - providerId: string; - employeeId: string; -} - export interface StartProviderOAuthInput { providerKey: string; enterpriseDomain?: string | null; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 42c7bbb3..e4685d03 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -61,7 +61,6 @@ import type { ImportFileInput, OpenFileInput, GrantRoleInput, - LaunchProviderSignInInput, LaunchSocialLoginInput, ResolveMigrationReconciliationInput, RunChaosDrillInput, @@ -162,7 +161,6 @@ export interface DesktopApi { markChatSpaceRead(input: MarkChatSpaceReadInput): Promise>; saveProviderCredential(input: SaveProviderCredentialInput): Promise>; revokeProviderCredential(input: RevokeProviderCredentialInput): Promise>; - launchProviderSignIn(input: LaunchProviderSignInInput): Promise>; startProviderOAuth(input: StartProviderOAuthInput): Promise>; cancelProviderOAuth(input: CancelProviderOAuthInput): Promise>; checkProviderHealth(input: CheckProviderHealthInput): Promise>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3aec746d..cacb2f35 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -28,7 +28,6 @@ export const IPC_CHANNELS = { markChatSpaceRead: "agentcompany:mark-chat-space-read", saveProviderCredential: "agentcompany:save-provider-credential", revokeProviderCredential: "agentcompany:revoke-provider-credential", - launchProviderSignIn: "agentcompany:launch-provider-sign-in", startProviderOAuth: "agentcompany:start-provider-oauth", cancelProviderOAuth: "agentcompany:cancel-provider-oauth", checkProviderHealth: "agentcompany:check-provider-health", diff --git a/tests/contract/provider-contracts.test.ts b/tests/contract/provider-contracts.test.ts index b2c6fb4a..2b00b497 100644 --- a/tests/contract/provider-contracts.test.ts +++ b/tests/contract/provider-contracts.test.ts @@ -12,7 +12,7 @@ import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog"; const PROVIDER_SECRET_SAMPLES: Record = { openai: "sk-live-provider", anthropic: "sk-live-provider", - "claude-pro": "claude-session-token", + "claude-pro": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }), xai: "sk-live-provider", groq: "sk-live-provider", mistral: "sk-live-provider", @@ -20,10 +20,10 @@ const PROVIDER_SECRET_SAMPLES: Record = { openrouter: "sk-or-live-provider", kimi: "sk-live-provider", "kimi-cn": "sk-live-provider", - "kimi-code": "kimi-session-token", - "github-copilot": "ghu_live_provider", - "google-antigravity": "antigravity-session", - "gpt-account": "chatgpt-session", + "kimi-code": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }), + "github-copilot": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }), + "google-antigravity": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000, projectId: "acme-project" }), + "gpt-account": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000, accountId: "acct_1" }), "azure-openai": JSON.stringify({ baseUrl: "https://acme.openai.azure.com", deployment: "gpt-5.4", diff --git a/tests/integration/agent-model.test.ts b/tests/integration/agent-model.test.ts index 88ed0943..5d89d8cf 100644 --- a/tests/integration/agent-model.test.ts +++ b/tests/integration/agent-model.test.ts @@ -28,7 +28,7 @@ describe("agent model", () => { }; const connection = new DatabaseConnection(join(tmpdir(), `ac-model-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; const tasks: Record = {}; @@ -54,7 +54,7 @@ describe("agent model", () => { const turn = async (): Promise => ({ text: "done", toolCalls: [], rawResponseJson: "{}" }); const connection = new DatabaseConnection(join(tmpdir(), `ac-model-az-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let taskId = ""; @@ -84,7 +84,7 @@ describe("agent model", () => { }; const connection = new DatabaseConnection(join(tmpdir(), `ac-model-pin-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let taskId = ""; diff --git a/tests/integration/agent-reply.test.ts b/tests/integration/agent-reply.test.ts index 133ff795..0e82acc2 100644 --- a/tests/integration/agent-reply.test.ts +++ b/tests/integration/agent-reply.test.ts @@ -60,7 +60,7 @@ function scriptedTeam(): (input: ProviderToolTurnInput) => Promise { test("an @mention wakes the teammate, who replies in the same thread", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-reply-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(scriptedTeam())); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(scriptedTeam())); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const taskExecution = new TaskExecutionService(connection.db, providers); const snapshot = new SnapshotService(connection.db); diff --git a/tests/integration/autonomous-run.test.ts b/tests/integration/autonomous-run.test.ts index 8ffdfdfd..c00a817c 100644 --- a/tests/integration/autonomous-run.test.ts +++ b/tests/integration/autonomous-run.test.ts @@ -90,7 +90,7 @@ describe("autonomous bootstrap run", () => { const workspacePath = join(tmpdir(), `ac-auto-ws-${randomUUID()}`); mkdirSync(workspacePath, { recursive: true }); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(scriptedCeo())); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(scriptedCeo())); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const snapshot = new SnapshotService(connection.db); @@ -184,7 +184,7 @@ describe("autonomous bootstrap run", () => { test("a goal created after onboarding reaches the CEO and runs autonomously", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-${randomUUID()}.db`)); // A minimal well-behaved agent: does its session, closes its task, then stops. - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(async (input: ProviderToolTurnInput) => { + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(async (input: ProviderToolTurnInput) => { const firstUser = input.messages.find((message) => message.role === "user"); const taskId = firstUser && firstUser.role === "user" ? /Task id: (\S+)/.exec(firstUser.text)?.[1] : undefined; const alreadyClosed = input.messages.some( diff --git a/tests/integration/ceo-founder-chat.test.ts b/tests/integration/ceo-founder-chat.test.ts index f8109fa8..c46fd3eb 100644 --- a/tests/integration/ceo-founder-chat.test.ts +++ b/tests/integration/ceo-founder-chat.test.ts @@ -68,7 +68,7 @@ describe("founder⇄CEO chat", () => { test("a founder DM wakes the CEO, who sees who is asking and replies in the pinned conversation", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-ceo-chat-${randomUUID()}.db`)); const capturedWakePrompts: string[] = []; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(scriptedCeo(capturedWakePrompts))); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(scriptedCeo(capturedWakePrompts))); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const taskExecution = new TaskExecutionService(connection.db, providers); const chat = new ChatService(connection.db); @@ -144,7 +144,7 @@ describe("founder⇄CEO chat", () => { test("a CEO who answers in plain text still has the reply delivered into the founder's thread", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-ceo-plain-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(plainTextCeo())); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(plainTextCeo())); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const taskExecution = new TaskExecutionService(connection.db, providers); const chat = new ChatService(connection.db); diff --git a/tests/integration/channel-anchor.test.ts b/tests/integration/channel-anchor.test.ts index e615041c..6c6d4692 100644 --- a/tests/integration/channel-anchor.test.ts +++ b/tests/integration/channel-anchor.test.ts @@ -38,7 +38,7 @@ function scriptedCeoReply(): (input: ProviderToolTurnInput) => Promise { test("a plain user message in a channel wakes the most senior member, who replies", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-anchor-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(scriptedCeoReply())); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(scriptedCeoReply())); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const taskExecution = new TaskExecutionService(connection.db, providers); const chat = new ChatService(connection.db); diff --git a/tests/integration/chat-context.test.ts b/tests/integration/chat-context.test.ts index db0eb702..0c60849d 100644 --- a/tests/integration/chat-context.test.ts +++ b/tests/integration/chat-context.test.ts @@ -35,7 +35,7 @@ async function founderLine() { return { text: `Reply ${wakePrompts.length}.`, toolCalls: [], rawResponseJson: "{}" }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-chatctx-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); const chat = new ChatService(connection.db); @@ -154,7 +154,7 @@ describe("chat wake context", () => { } return { text: "Switching to Postgres.", toolCalls: [], rawResponseJson: "{}" }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let ceoId = ""; diff --git a/tests/integration/goal-changes.test.ts b/tests/integration/goal-changes.test.ts index aff5affc..a3f403b2 100644 --- a/tests/integration/goal-changes.test.ts +++ b/tests/integration/goal-changes.test.ts @@ -18,7 +18,7 @@ const testVault = { function company(turn: (input: ProviderToolTurnInput, db: DatabaseConnection["db"]) => AgentTurnResult) { const connection = new DatabaseConnection(join(tmpdir(), `ac-goalchg-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(async (input) => turn(input, connection.db))); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(async (input) => turn(input, connection.db))); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; diff --git a/tests/integration/interactive-lane.test.ts b/tests/integration/interactive-lane.test.ts index f332da2e..62bbb5cb 100644 --- a/tests/integration/interactive-lane.test.ts +++ b/tests/integration/interactive-lane.test.ts @@ -77,7 +77,7 @@ describe("interactive execution lane", () => { } return turn("done"); }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(script)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(script)); providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" }); const taskExecution = new TaskExecutionService(connection.db, providers); @@ -164,7 +164,7 @@ describe("interactive execution lane", () => { } return turn("done"); }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(script)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(script)); providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" }); const taskExecution = new TaskExecutionService(connection.db, providers); @@ -229,7 +229,7 @@ describe("interactive execution lane", () => { const alwaysOverloaded = async (): Promise => { throw new ProviderHttpError("The engine is currently overloaded, please try again later", null, 529, false, "overloaded_error"); }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(alwaysOverloaded)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(alwaysOverloaded)); providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" }); const taskExecution = new TaskExecutionService(connection.db, providers); const wakeDana = () => dispatchAgentTool({ db: connection.db, organization, chat }, { diff --git a/tests/integration/run-checkpoint.test.ts b/tests/integration/run-checkpoint.test.ts index 6736e025..5c7888f3 100644 --- a/tests/integration/run-checkpoint.test.ts +++ b/tests/integration/run-checkpoint.test.ts @@ -25,7 +25,7 @@ describe("session checkpoints", () => { ? { text: "Audit done: all clean.", toolCalls: [], rawResponseJson: "{}" } : { text: "Looking into it.", toolCalls: [{ id: `c_${randomUUID()}`, name: "read_messages", arguments: {} }], rawResponseJson: "{}" }; const connection = new DatabaseConnection(join(tmpdir(), `ac-checkpoint-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; diff --git a/tests/integration/run-journal.test.ts b/tests/integration/run-journal.test.ts index 93d94a5c..a9d94f0e 100644 --- a/tests/integration/run-journal.test.ts +++ b/tests/integration/run-journal.test.ts @@ -42,7 +42,7 @@ describe("run journal", () => { throw new Error("Provider dropped the connection mid-run"); }; const connection = new DatabaseConnection(join(tmpdir(), `ac-journal-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; diff --git a/tests/integration/run-memory.test.ts b/tests/integration/run-memory.test.ts index 421a7091..3b8f7693 100644 --- a/tests/integration/run-memory.test.ts +++ b/tests/integration/run-memory.test.ts @@ -28,7 +28,7 @@ async function company(reply: (prompt: string) => AgentTurnResult) { return reply(prompt); }; const connection = new DatabaseConnection(join(tmpdir(), `ac-memory-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; diff --git a/tests/integration/run-stop.test.ts b/tests/integration/run-stop.test.ts index c016fe98..2c15c2ed 100644 --- a/tests/integration/run-stop.test.ts +++ b/tests/integration/run-stop.test.ts @@ -22,7 +22,7 @@ const testVault = { function company(turn: (input: ProviderToolTurnInput) => Promise) { const connection = new DatabaseConnection(join(tmpdir(), `ac-stop-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; @@ -45,7 +45,7 @@ describe("stopping runs", () => { return { text: "Announcing.", toolCalls: [{ id: "c1", name: "send_message", arguments: { channel: "general", body: "Should never be posted." } }], rawResponseJson: "{}" }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-stop-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; @@ -116,7 +116,7 @@ describe("stopping runs", () => { return { text: "done", toolCalls: [], rawResponseJson: "{}" }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-deadline-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers, undefined, undefined, undefined, 200); let orgId = ""; diff --git a/tests/integration/spend-budget.test.ts b/tests/integration/spend-budget.test.ts index 38799af7..aaeb70bd 100644 --- a/tests/integration/spend-budget.test.ts +++ b/tests/integration/spend-budget.test.ts @@ -24,7 +24,7 @@ describe("money budgets", () => { return { text: "Worked.", toolCalls: [], rawResponseJson: JSON.stringify({ usage: { input_tokens: 2_000_000, output_tokens: 1_000_000 } }) }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-spend-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const providers = new ProviderService(connection.db, providerAdapters, testVault, turn); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; diff --git a/tests/integration/stale-queued-runs.test.ts b/tests/integration/stale-queued-runs.test.ts index 24c8be46..09a9dff9 100644 --- a/tests/integration/stale-queued-runs.test.ts +++ b/tests/integration/stale-queued-runs.test.ts @@ -25,7 +25,7 @@ function company() { return { text: "done", toolCalls: [], rawResponseJson: "{}" }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-stale-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; diff --git a/tests/integration/task-context-scope.test.ts b/tests/integration/task-context-scope.test.ts index b3965ba0..e2afe906 100644 --- a/tests/integration/task-context-scope.test.ts +++ b/tests/integration/task-context-scope.test.ts @@ -50,7 +50,7 @@ describe("task prompt scope", () => { return { text: step.text, toolCalls: step.toolCalls, rawResponseJson: "{}" }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-scope-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; @@ -91,7 +91,7 @@ describe("task prompt scope", () => { calls += 1; return { text: "done", toolCalls: [], rawResponseJson: "{}" }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const organization = new OrganizationService(connection.db); const execution = new TaskExecutionService(connection.db, providers); @@ -126,7 +126,7 @@ describe("task prompt scope", () => { test("a manual write run waits while another run is changing the same workspace", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-wsplock-${randomUUID()}.db`)); const turn = async (): Promise => ({ text: "done", toolCalls: [], rawResponseJson: "{}" }); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const organization = new OrganizationService(connection.db); const execution = new TaskExecutionService(connection.db, providers); diff --git a/tests/integration/task-dependencies.test.ts b/tests/integration/task-dependencies.test.ts index 3de159b4..1fe4d099 100644 --- a/tests/integration/task-dependencies.test.ts +++ b/tests/integration/task-dependencies.test.ts @@ -29,7 +29,7 @@ function company() { prompts.push(first.text); return { text: "done", toolCalls: [], rawResponseJson: "{}" }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(turn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn)); const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); const execution = new TaskExecutionService(connection.db, providers); const organization = new OrganizationService(connection.db); diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index d7141b43..96bea4d5 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -135,7 +135,7 @@ describe("agent instruction wiring", () => { capturedInstructions = input.instructions ?? ""; return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-1" }) }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; @@ -190,7 +190,7 @@ describe("agent instruction wiring", () => { capturedPrompt = first.text; return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-1" }) }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let taskId = ""; diff --git a/tests/unit/char-accounting.test.ts b/tests/unit/char-accounting.test.ts index eec661ee..86928e00 100644 --- a/tests/unit/char-accounting.test.ts +++ b/tests/unit/char-accounting.test.ts @@ -19,7 +19,7 @@ describe("character accounting", () => { const toolCalls = [{ id: "c1", name: "send_message", arguments: { channel: "general", body: "Shipping today." } }]; const turn = async (): Promise => ({ text: "Posting.", toolCalls, rawResponseJson: "{}" }); const connection = new DatabaseConnection(join(tmpdir(), `ac-chars-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const providers = new ProviderService(connection.db, providerAdapters, testVault, turn); let orgId = ""; let providerId = ""; connection.transaction(() => { diff --git a/tests/unit/model-check.test.ts b/tests/unit/model-check.test.ts index c4acbe8f..0880e4d5 100644 --- a/tests/unit/model-check.test.ts +++ b/tests/unit/model-check.test.ts @@ -21,7 +21,7 @@ const TEXT_ONLY = async (): Promise => ({ text: "Sure, the code function harness(turn: (input: ProviderToolTurnInput) => Promise) { const connection = new DatabaseConnection(join(tmpdir(), `ac-model-check-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const providers = new ProviderService(connection.db, providerAdapters, testVault, turn); let orgId = ""; let providerId = ""; connection.transaction(() => { diff --git a/tests/unit/provider-error-codes.test.ts b/tests/unit/provider-error-codes.test.ts index 52895cf5..470b3d7c 100644 --- a/tests/unit/provider-error-codes.test.ts +++ b/tests/unit/provider-error-codes.test.ts @@ -109,7 +109,7 @@ describe("provider errors keep their status and machine code", () => { test("our own monthly limit is a typed error, and a failed request keeps its status and code", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-codes-${randomUUID()}.db`)); const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") }; - const providers = new ProviderService(connection.db, providerAdapters, vault, undefined, async () => { throw new ProviderHttpError("Overloaded", null, 529, false, "overloaded_error"); }); + const providers = new ProviderService(connection.db, providerAdapters, vault, async () => { throw new ProviderHttpError("Overloaded", null, 529, false, "overloaded_error"); }); let orgId = ""; let providerId = ""; connection.transaction(() => { diff --git a/tests/unit/provider-oauth.test.ts b/tests/unit/provider-oauth.test.ts index c8d9dea1..24298da7 100644 --- a/tests/unit/provider-oauth.test.ts +++ b/tests/unit/provider-oauth.test.ts @@ -5,7 +5,7 @@ import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-co import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow"; import { readProjectId, requireAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow"; import { generatePkce } from "../../src/main/services/oauth/pkce"; -import { parseAccessToken, parseAccountToken } from "../../src/main/services/provider-auth-profiles"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; function makeJwt(claims: Record): string { const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); @@ -112,18 +112,20 @@ describe("pkce generation", () => { }); describe("oauth credential parsing", () => { - test("accepts a raw token", () => { - expect(parseAccessToken("ghu_raw")).toEqual({ type: "access_token", accessToken: "ghu_raw" }); - expect(parseAccountToken("chatgpt_raw")).toEqual({ type: "account_token", accessToken: "chatgpt_raw" }); + const signIn = (extra: Record = {}) => JSON.stringify({ access: "live_token", refresh: "r", expires: 1_900_000_000_000, ...extra }); + + test("a stored sign-in is accepted only with what a run needs to use and renew it", () => { + expect(providerAdapters["github-copilot"].parseAuthProfile(signIn())).toEqual({ type: "access_token", accessToken: "live_token" }); + expect(providerAdapters["gpt-account"].parseAuthProfile(signIn({ accountId: "acct_1" }))).toEqual({ type: "account_token", accessToken: "live_token" }); + expect(providerAdapters["google-antigravity"].parseAuthProfile(signIn({ projectId: "proj-1" }))).toEqual({ type: "access_token", accessToken: "live_token" }); }); - test("extracts the access token from native OAuth credential JSON", () => { - const secret = JSON.stringify({ access: "live_token", refresh: "r", expires: 123 }); - expect(parseAccessToken(secret)).toEqual({ type: "access_token", accessToken: "live_token" }); - expect(parseAccountToken(secret)).toEqual({ type: "account_token", accessToken: "live_token" }); - }); - - test("rejects credential JSON without an access token", () => { - expect(() => parseAccessToken(JSON.stringify({ refresh: "r" }))).toThrow("missing an access token"); + test("a pasted token, or a sign-in missing its refresh token, expiry or provider extras, is refused at save time", () => { + expect(() => providerAdapters["github-copilot"].parseAuthProfile("ghu_raw")).toThrow("Sign in to GitHub Copilot"); + expect(() => providerAdapters["claude-pro"].parseAuthProfile(JSON.stringify({ access: "a", expires: 1 }))).toThrow("missing its refresh token"); + expect(() => providerAdapters["kimi-code"].parseAuthProfile(JSON.stringify({ access: "a", refresh: "r" }))).toThrow("missing its expiry"); + expect(() => providerAdapters["gpt-account"].parseAuthProfile(signIn())).toThrow("missing its ChatGPT account id"); + expect(() => providerAdapters["google-antigravity"].parseAuthProfile(signIn())).toThrow("missing its Google Cloud project id"); }); }); + diff --git a/tests/unit/provider-service.test.ts b/tests/unit/provider-service.test.ts index 8649d975..417382fb 100644 --- a/tests/unit/provider-service.test.ts +++ b/tests/unit/provider-service.test.ts @@ -141,7 +141,7 @@ describe("provider service", () => { let mode: "capable" | "text" | "down" = "capable"; const seen: string[] = []; const capable: (input: ProviderToolTurnInput) => Promise = answeringModelCheck(async () => ({ text: "", toolCalls: [], rawResponseJson: "{}" })); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, async (input) => { + const providers = new ProviderService(connection.db, providerAdapters, testVault, async (input) => { seen.push(`${input.credential.apiKey}:${input.model}`); if (mode === "down") throw new Error("401 invalid x-api-key"); return mode === "text" ? { text: "I am healthy.", toolCalls: [], rawResponseJson: "{}" } : capable(input); @@ -173,98 +173,27 @@ describe("provider service", () => { connection.close(); }); - test("audits provider sign-in launches with the employee actor", () => { + test("a sign-in provider saves only a native sign-in, recorded as coming from the OAuth login", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); const snapshot = new SnapshotService(connection.db); - let launched: { providerId: string; label: string; signInUrl: string; partition: string } | null = null; - const providers = new ProviderService(connection.db, providerAdapters, testVault, (input) => { launched = input; }); + const providers = new ProviderService(connection.db, providerAdapters, testVault); let orgId = ""; - let employeeId = ""; let copilotProviderId = ""; + const signIn = JSON.stringify({ access: "ghu_live_provider", refresh: "ghr_refresh", expires: 1_900_000_000_000 }); connection.transaction(() => { orgId = createOrganization(organization); - employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; copilotProviderId = providerId(connection, orgId, "github-copilot"); }); - expect(() => providers.launchSignInWindow({ - orgId, - providerId: copilotProviderId, - employeeId: "emp_missing", - })).toThrow("Provider sign-in employee was not found."); - - connection.transaction(() => providers.launchSignInWindow({ orgId, providerId: copilotProviderId, employeeId })); - - const state = snapshot.load(); - expect(launched).toMatchObject({ - providerId: copilotProviderId, - label: "GitHub Copilot", - signInUrl: "https://github.com/login/device", - partition: `persist:agentcompany-provider-${copilotProviderId}`, - }); - expect(state.providerAuthSessions[0]).toMatchObject({ - orgId, - providerId: copilotProviderId, - providerKey: "github-copilot", - employeeId, - partition: `persist:agentcompany-provider-${copilotProviderId}`, - completedAt: null, - }); - expect(state.providerAuthSessions[0]?.state).toHaveLength(32); - expect(state.providerAuthSessions[0]?.nonce).toHaveLength(32); - expect(state.providerAuthSessions[0]?.codeVerifierSha256).toHaveLength(64); - expect(state.auditEvents).toContainEqual(expect.objectContaining({ - entityType: "provider", - entityId: copilotProviderId, - actorType: "employee", - actorId: employeeId, - action: "sign_in_window_opened", - })); - connection.close(); - }); - - test("binds OAuth credentials to a sign-in session source", () => { - const connection = createConnection(); - const organization = new OrganizationService(connection.db); - const snapshot = new SnapshotService(connection.db); - const providers = new ProviderService(connection.db, providerAdapters, testVault, () => {}); - let orgId = ""; - let employeeId = ""; - let copilotProviderId = ""; - - connection.transaction(() => { - orgId = createOrganization(organization); - employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; - copilotProviderId = providerId(connection, orgId, "github-copilot"); - }); - - expect(() => connection.transaction(() => providers.saveCredential({ - orgId, - providerId: copilotProviderId, - secret: "ghu_live_provider", - model: "claude-opus-4-5", - employeeId, - }))).toThrow("Provider sign-in session is required."); - - connection.transaction(() => { - providers.launchSignInWindow({ orgId, providerId: copilotProviderId, employeeId }); - providers.saveCredential({ - orgId, - providerId: copilotProviderId, - secret: "ghu_live_provider", - model: "claude-opus-4-5", - employeeId, - }); - }); + expect(() => connection.transaction(() => providers.saveCredential({ orgId, providerId: copilotProviderId, secret: "ghu_live_provider", model: "claude-opus-4-5" }))) + .toThrow("Sign in to GitHub Copilot; a pasted token cannot be renewed when it expires."); + connection.transaction(() => providers.saveCredential({ orgId, providerId: copilotProviderId, secret: signIn, model: "claude-opus-4-5" })); const state = snapshot.load(); const profile = state.providerAuthProfiles.find((item) => item.providerId === copilotProviderId && item.active); - const session = state.providerAuthSessions.find((item) => item.providerId === copilotProviderId); - if (!profile || !session) throw new Error("Provider auth evidence was not created."); - expect(profile).toMatchObject({ sourceType: "provider_sign_in_session", sourceRef: session.id }); - expect(session.completedAt).toBeTruthy(); + expect(profile).toMatchObject({ sourceType: "provider_oauth_login", sourceRef: null }); expect(state.providers.find((item) => item.id === copilotProviderId)?.enabled).toBe(true); connection.close(); }); diff --git a/tests/unit/quota-reservation.test.ts b/tests/unit/quota-reservation.test.ts index e0c8c6d3..01a19970 100644 --- a/tests/unit/quota-reservation.test.ts +++ b/tests/unit/quota-reservation.test.ts @@ -27,7 +27,7 @@ describe("monthly quota reservation", () => { return { text: "done", toolCalls: [], rawResponseJson: "{}" }; }; const connection = new DatabaseConnection(join(tmpdir(), `ac-quota-${randomUUID()}.db`)); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const providers = new ProviderService(connection.db, providerAdapters, testVault, turn); let orgId = ""; let providerId = ""; connection.transaction(() => { diff --git a/tests/unit/rate-limit-observation.test.ts b/tests/unit/rate-limit-observation.test.ts index abb70801..a87b1f9e 100644 --- a/tests/unit/rate-limit-observation.test.ts +++ b/tests/unit/rate-limit-observation.test.ts @@ -26,7 +26,7 @@ describe("rate limits seen on successful calls", () => { }); const connection = new DatabaseConnection(join(tmpdir(), `ac-ratelimit-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const providers = new ProviderService(connection.db, providerAdapters, testVault, turn); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let providerId = ""; diff --git a/tests/unit/task-execution-service.test.ts b/tests/unit/task-execution-service.test.ts index cd2dd9b5..611b218d 100644 --- a/tests/unit/task-execution-service.test.ts +++ b/tests/unit/task-execution-service.test.ts @@ -42,7 +42,7 @@ describe("task execution service", () => { if (first?.role === "user") capturedPrompt = first.text; return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-1" }) }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); const skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-task-skills-${randomUUID()}`)); const execution = new TaskExecutionService(connection.db, providers); const skillDir = join(tmpdir(), `agentcompany-task-skill-${randomUUID()}`); @@ -154,7 +154,7 @@ describe("task execution service", () => { toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-approved" }), }); - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let employeeId = ""; @@ -293,7 +293,7 @@ describe("task execution service", () => { const fakeToolTurn = async (): Promise => { throw new Error("provider rejected task"); }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let taskId = ""; @@ -351,7 +351,7 @@ describe("task execution service", () => { turns += 1; return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: `resp-${turns}` }) }; }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); const execution = new TaskExecutionService(connection.db, providers); let orgId = ""; let taskId = ""; @@ -850,7 +850,7 @@ describe("task execution service", () => { const fakeToolTurn = async (): Promise => { throw new ProviderHttpError("The usage limit has been reached", null, 429, false, "usage_limit_reached"); }; - const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, answeringModelCheck(fakeToolTurn)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(fakeToolTurn)); let nowMs = Date.now(); const execution = new TaskExecutionService(connection.db, providers, undefined, undefined, () => nowMs); let orgId = "";