Q88: a sign-in is saved only if a run can use and renew it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4668396f25
commit
7eef3d459f
38 files changed
+127
-398
No files matched your search
@@ -235,14 +235,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
return fail("provider_model_check_failed", error instanceof Error ? error.message : String(error));
|
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) => {
|
ipcMain.handle(IPC_CHANNELS.startProviderOAuth, async (_event, input) => {
|
||||||
const emit = (event: Record<string, unknown>) =>
|
const emit = (event: Record<string, unknown>) =>
|
||||||
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "provider-oauth", providerKey: input.providerKey, ...event });
|
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,
|
providerId: input.providerId,
|
||||||
secret,
|
secret,
|
||||||
model: input.model ?? null,
|
model: input.model ?? null,
|
||||||
employeeId: input.employeeId,
|
|
||||||
});
|
});
|
||||||
emit({ phase: "active" });
|
emit({ phase: "active" });
|
||||||
notifyDataChanged();
|
notifyDataChanged();
|
||||||
|
|||||||
@@ -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<string, { field: "accountId" | "projectId"; label: string }> = {
|
||||||
|
"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<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain";
|
import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain";
|
||||||
import { listProviderModels, type ProviderModelEntry } from "../../shared/provider-models";
|
import { listProviderModels, type ProviderModelEntry } from "../../shared/provider-models";
|
||||||
import {
|
import {
|
||||||
parseAccessToken,
|
oauthProfileParser,
|
||||||
parseAccountToken,
|
|
||||||
parseApiKey,
|
parseApiKey,
|
||||||
parseAnthropicCompatProfile,
|
parseAnthropicCompatProfile,
|
||||||
parseAzureProfile,
|
parseAzureProfile,
|
||||||
@@ -408,7 +407,7 @@ function responseMessages(instructions: string | null, prompt: string): Array<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter {
|
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;
|
const assertProfile = authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? requireAccountTokenProfile : requireAccessTokenProfile;
|
||||||
return {
|
return {
|
||||||
providerKey,
|
providerKey,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { JsonRecord } from "./provider-http";
|
import type { JsonRecord } from "./provider-http";
|
||||||
import { isRecord } from "./provider-http";
|
import { isRecord } from "./provider-http";
|
||||||
|
import { parseStoredOAuth } from "./oauth/stored-oauth";
|
||||||
|
|
||||||
export type ProviderAuthProfile =
|
export type ProviderAuthProfile =
|
||||||
| { type: "api_key"; apiKey: string }
|
| { type: "api_key"; apiKey: string }
|
||||||
@@ -15,31 +16,9 @@ export function parseApiKey(secret: string): ProviderAuthProfile {
|
|||||||
return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") };
|
return { type: "api_key", apiKey: nonEmptySecret(secret, "API key") };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAccessToken(secret: string): ProviderAuthProfile {
|
/** A sign-in provider's parser: it accepts only what a run can use and renew (see parseStoredOAuth). */
|
||||||
return { type: "access_token", accessToken: oauthAccessToken(secret, "Access token") };
|
export function oauthProfileParser(providerKey: string, type: "access_token" | "account_token"): (secret: string) => ProviderAuthProfile {
|
||||||
}
|
return (secret) => ({ type, accessToken: parseStoredOAuth(providerKey, secret).access });
|
||||||
|
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAzureProfile(secret: string): ProviderAuthProfile {
|
export function parseAzureProfile(secret: string): ProviderAuthProfile {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
import type { CancelProviderResponseInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands";
|
||||||
import type { CancelProviderResponseInput, LaunchProviderSignInInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands";
|
|
||||||
import type { ProviderModelEntry } from "../../shared/provider-models";
|
import type { ProviderModelEntry } from "../../shared/provider-models";
|
||||||
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
|
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
|
||||||
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-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 { ProviderResponseObserver } from "./provider-response-observer";
|
||||||
import { ProviderRequestStore } from "./provider-request-store";
|
import { ProviderRequestStore } from "./provider-request-store";
|
||||||
import { ProviderResponseRunner, type ProviderResponseCreateInput } from "./provider-response-runner";
|
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 type { ToolTurnFn } from "./provider-tool-turn";
|
||||||
import { stopRun } from "./run-control";
|
import { stopRun } from "./run-control";
|
||||||
import type { ModelCheckResult } from "../../shared/desktop-api";
|
import type { ModelCheckResult } from "../../shared/desktop-api";
|
||||||
@@ -35,8 +33,6 @@ import {
|
|||||||
|
|
||||||
export type { ProviderResponseCreateInput };
|
export type { ProviderResponseCreateInput };
|
||||||
|
|
||||||
const AUTH_SESSION_TTL_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Room for instructions, the output reserve, and some history. */
|
/** Room for instructions, the output reserve, and some history. */
|
||||||
const MIN_CONTEXT_WINDOW_TOKENS = 24_000;
|
const MIN_CONTEXT_WINDOW_TOKENS = 24_000;
|
||||||
|
|
||||||
@@ -55,7 +51,6 @@ export class ProviderService {
|
|||||||
private readonly db: DatabaseSync,
|
private readonly db: DatabaseSync,
|
||||||
private readonly adapters: ProviderAdapterRegistry = providerAdapters,
|
private readonly adapters: ProviderAdapterRegistry = providerAdapters,
|
||||||
vault: ProviderSecretVault = new SecretVault(),
|
vault: ProviderSecretVault = new SecretVault(),
|
||||||
private readonly launchSignIn: (input: LaunchProviderSignInWindowInput) => void = launchProviderSignInWindow,
|
|
||||||
toolTurn?: ToolTurnFn,
|
toolTurn?: ToolTurnFn,
|
||||||
) {
|
) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
@@ -106,14 +101,7 @@ export class ProviderService {
|
|||||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||||
adapter.parseAuthProfile(secret);
|
adapter.parseAuthProfile(secret);
|
||||||
const catalogEntry = requireCatalogEntry(provider.provider_key);
|
const catalogEntry = requireCatalogEntry(provider.provider_key);
|
||||||
const source = this.resolveCredentialSource({
|
const source = this.resolveCredentialSource(provider.auth_type);
|
||||||
orgId: input.orgId,
|
|
||||||
providerId: input.providerId,
|
|
||||||
providerKey: provider.provider_key,
|
|
||||||
authType: provider.auth_type,
|
|
||||||
employeeId: input.employeeId ?? null,
|
|
||||||
secret,
|
|
||||||
});
|
|
||||||
const authProfile = this.authBroker.createProviderProfile({
|
const authProfile = this.authBroker.createProviderProfile({
|
||||||
orgId: input.orgId,
|
orgId: input.orgId,
|
||||||
providerId: input.providerId,
|
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. */
|
/** Sets the per-million-token prices that turn recorded usage into spend. */
|
||||||
setPrices(input: SetProviderPricesInput): void {
|
setPrices(input: SetProviderPricesInput): void {
|
||||||
@@ -322,101 +272,12 @@ export class ProviderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertEmployee(orgId: string, employeeId: string): void {
|
private resolveCredentialSource(authType: ProviderAuthType): { sourceType: string; sourceRef: null } {
|
||||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?")
|
if (authType !== PROVIDER_AUTH_TYPE.OAUTH_PKCE && authType !== PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) {
|
||||||
.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) {
|
|
||||||
return { sourceType: "manual_secret", sourceRef: null };
|
return { sourceType: "manual_secret", sourceRef: null };
|
||||||
}
|
}
|
||||||
// Credentials obtained through the native OAuth login arrive as a JSON
|
// A sign-in credential can only come from the native OAuth login: parseStoredOAuth refuses anything else.
|
||||||
// object; they are authenticated by the flow itself and need no paste session.
|
return { sourceType: "provider_oauth_login", sourceRef: null };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -30,29 +30,7 @@ import { refreshAntigravity, requireAntigravityClientSecret } from "../oauth/goo
|
|||||||
import type { IntegrationKeyStore } from "../integration-key-store";
|
import type { IntegrationKeyStore } from "../integration-key-store";
|
||||||
import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow";
|
import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow";
|
||||||
import { refreshKimiCode } from "../oauth/kimi-code-flow";
|
import { refreshKimiCode } from "../oauth/kimi-code-flow";
|
||||||
|
import { parseStoredOAuth, type StoredOAuth } from "../oauth/stored-oauth";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise<OAuthCredentials> {
|
function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: IntegrationKeyStore): Promise<OAuthCredentials> {
|
||||||
switch (providerKey) {
|
switch (providerKey) {
|
||||||
@@ -61,8 +39,7 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth, keys: Integration
|
|||||||
case "claude-pro":
|
case "claude-pro":
|
||||||
return refreshClaudePro(creds.refresh);
|
return refreshClaudePro(creds.refresh);
|
||||||
case "google-antigravity":
|
case "google-antigravity":
|
||||||
if (!creds.projectId) throw new Error("Antigravity credential is missing its project id; reconnect the provider.");
|
return refreshAntigravity(creds.refresh, requireProjectId(creds), requireAntigravityClientSecret(keys.read("google_antigravity_client_secret")));
|
||||||
return refreshAntigravity(creds.refresh, creds.projectId, requireAntigravityClientSecret(keys.read("google_antigravity_client_secret")));
|
|
||||||
case "github-copilot":
|
case "github-copilot":
|
||||||
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
|
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
|
||||||
case "kimi-code":
|
case "kimi-code":
|
||||||
@@ -122,7 +99,7 @@ export async function resolveTurnCredential(deps: {
|
|||||||
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.CLOUD) {
|
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.CLOUD) {
|
||||||
return resolveCloudCredential(deps.provider.provider_key, secret);
|
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 (Date.now() < creds.expires) return toTurnCredential(creds);
|
||||||
if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider.");
|
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);
|
const refreshed = await refreshOAuth(deps.provider.provider_key, creds, deps.integrationKeys);
|
||||||
@@ -134,3 +111,8 @@ export async function resolveTurnCredential(deps: {
|
|||||||
});
|
});
|
||||||
return toTurnCredential(refreshed);
|
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;
|
||||||
|
}
|
||||||
@@ -55,7 +55,6 @@ const api: DesktopApi = {
|
|||||||
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
|
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
|
||||||
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
|
||||||
revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input),
|
revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input),
|
||||||
launchProviderSignIn: (input) => invoke(IPC_CHANNELS.launchProviderSignIn, input),
|
|
||||||
startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input),
|
startProviderOAuth: (input) => invoke(IPC_CHANNELS.startProviderOAuth, input),
|
||||||
cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input),
|
cancelProviderOAuth: (input) => invoke(IPC_CHANNELS.cancelProviderOAuth, input),
|
||||||
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
|||||||
providerId: active.id,
|
providerId: active.id,
|
||||||
secret,
|
secret,
|
||||||
model: String(form.get("model") ?? "").trim() || null,
|
model: String(form.get("model") ?? "").trim() || null,
|
||||||
employeeId,
|
|
||||||
})).then((result) => { if (result) close(); });
|
})).then((result) => { if (result) close(); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface SaveProviderCredentialInput {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
employeeId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RevokeProviderCredentialInput {
|
export interface RevokeProviderCredentialInput {
|
||||||
@@ -13,12 +12,6 @@ export interface RevokeProviderCredentialInput {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaunchProviderSignInInput {
|
|
||||||
orgId: string;
|
|
||||||
providerId: string;
|
|
||||||
employeeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StartProviderOAuthInput {
|
export interface StartProviderOAuthInput {
|
||||||
providerKey: string;
|
providerKey: string;
|
||||||
enterpriseDomain?: string | null;
|
enterpriseDomain?: string | null;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ import type {
|
|||||||
ImportFileInput,
|
ImportFileInput,
|
||||||
OpenFileInput,
|
OpenFileInput,
|
||||||
GrantRoleInput,
|
GrantRoleInput,
|
||||||
LaunchProviderSignInInput,
|
|
||||||
LaunchSocialLoginInput,
|
LaunchSocialLoginInput,
|
||||||
ResolveMigrationReconciliationInput,
|
ResolveMigrationReconciliationInput,
|
||||||
RunChaosDrillInput,
|
RunChaosDrillInput,
|
||||||
@@ -162,7 +161,6 @@ export interface DesktopApi {
|
|||||||
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
launchProviderSignIn(input: LaunchProviderSignInInput): Promise<DesktopResult<null>>;
|
|
||||||
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
startProviderOAuth(input: StartProviderOAuthInput): Promise<DesktopResult<StartProviderOAuthResult>>;
|
||||||
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
cancelProviderOAuth(input: CancelProviderOAuthInput): Promise<DesktopResult<null>>;
|
||||||
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const IPC_CHANNELS = {
|
|||||||
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
markChatSpaceRead: "agentcompany:mark-chat-space-read",
|
||||||
saveProviderCredential: "agentcompany:save-provider-credential",
|
saveProviderCredential: "agentcompany:save-provider-credential",
|
||||||
revokeProviderCredential: "agentcompany:revoke-provider-credential",
|
revokeProviderCredential: "agentcompany:revoke-provider-credential",
|
||||||
launchProviderSignIn: "agentcompany:launch-provider-sign-in",
|
|
||||||
startProviderOAuth: "agentcompany:start-provider-oauth",
|
startProviderOAuth: "agentcompany:start-provider-oauth",
|
||||||
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
|
cancelProviderOAuth: "agentcompany:cancel-provider-oauth",
|
||||||
checkProviderHealth: "agentcompany:check-provider-health",
|
checkProviderHealth: "agentcompany:check-provider-health",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog";
|
|||||||
const PROVIDER_SECRET_SAMPLES: Record<string, string> = {
|
const PROVIDER_SECRET_SAMPLES: Record<string, string> = {
|
||||||
openai: "sk-live-provider",
|
openai: "sk-live-provider",
|
||||||
anthropic: "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",
|
xai: "sk-live-provider",
|
||||||
groq: "sk-live-provider",
|
groq: "sk-live-provider",
|
||||||
mistral: "sk-live-provider",
|
mistral: "sk-live-provider",
|
||||||
@@ -20,10 +20,10 @@ const PROVIDER_SECRET_SAMPLES: Record<string, string> = {
|
|||||||
openrouter: "sk-or-live-provider",
|
openrouter: "sk-or-live-provider",
|
||||||
kimi: "sk-live-provider",
|
kimi: "sk-live-provider",
|
||||||
"kimi-cn": "sk-live-provider",
|
"kimi-cn": "sk-live-provider",
|
||||||
"kimi-code": "kimi-session-token",
|
"kimi-code": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }),
|
||||||
"github-copilot": "ghu_live_provider",
|
"github-copilot": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000 }),
|
||||||
"google-antigravity": "antigravity-session",
|
"google-antigravity": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000, projectId: "acme-project" }),
|
||||||
"gpt-account": "chatgpt-session",
|
"gpt-account": JSON.stringify({ access: "live-access", refresh: "live-refresh", expires: 1_900_000_000_000, accountId: "acct_1" }),
|
||||||
"azure-openai": JSON.stringify({
|
"azure-openai": JSON.stringify({
|
||||||
baseUrl: "https://acme.openai.azure.com",
|
baseUrl: "https://acme.openai.azure.com",
|
||||||
deployment: "gpt-5.4",
|
deployment: "gpt-5.4",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("agent model", () => {
|
|||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-${randomUUID()}.db`));
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-${randomUUID()}.db`));
|
||||||
const organization = new OrganizationService(connection.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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
const tasks: Record<string, string> = {};
|
const tasks: Record<string, string> = {};
|
||||||
@@ -54,7 +54,7 @@ describe("agent model", () => {
|
|||||||
const turn = async (): Promise<AgentTurnResult> => ({ text: "done", toolCalls: [], rawResponseJson: "{}" });
|
const turn = async (): Promise<AgentTurnResult> => ({ text: "done", toolCalls: [], rawResponseJson: "{}" });
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-az-${randomUUID()}.db`));
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-az-${randomUUID()}.db`));
|
||||||
const organization = new OrganizationService(connection.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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let taskId = "";
|
let taskId = "";
|
||||||
@@ -84,7 +84,7 @@ describe("agent model", () => {
|
|||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-pin-${randomUUID()}.db`));
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-pin-${randomUUID()}.db`));
|
||||||
const organization = new OrganizationService(connection.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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let taskId = "";
|
let taskId = "";
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function scriptedTeam(): (input: ProviderToolTurnInput) => Promise<AgentTurnResu
|
|||||||
describe("inter-agent reply", () => {
|
describe("inter-agent reply", () => {
|
||||||
test("an @mention wakes the teammate, who replies in the same thread", async () => {
|
test("an @mention wakes the teammate, who replies in the same thread", async () => {
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-reply-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
const snapshot = new SnapshotService(connection.db);
|
const snapshot = new SnapshotService(connection.db);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe("autonomous bootstrap run", () => {
|
|||||||
const workspacePath = join(tmpdir(), `ac-auto-ws-${randomUUID()}`);
|
const workspacePath = join(tmpdir(), `ac-auto-ws-${randomUUID()}`);
|
||||||
mkdirSync(workspacePath, { recursive: true });
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const snapshot = new SnapshotService(connection.db);
|
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 () => {
|
test("a goal created after onboarding reaches the CEO and runs autonomously", async () => {
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-${randomUUID()}.db`));
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-${randomUUID()}.db`));
|
||||||
// A minimal well-behaved agent: does its session, closes its task, then stops.
|
// 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 firstUser = input.messages.find((message) => message.role === "user");
|
||||||
const taskId = firstUser && firstUser.role === "user" ? /Task id: (\S+)/.exec(firstUser.text)?.[1] : undefined;
|
const taskId = firstUser && firstUser.role === "user" ? /Task id: (\S+)/.exec(firstUser.text)?.[1] : undefined;
|
||||||
const alreadyClosed = input.messages.some(
|
const alreadyClosed = input.messages.some(
|
||||||
|
|||||||
@@ -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 () => {
|
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 connection = new DatabaseConnection(join(tmpdir(), `ac-ceo-chat-${randomUUID()}.db`));
|
||||||
const capturedWakePrompts: string[] = [];
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
const chat = new ChatService(connection.db);
|
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 () => {
|
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 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
const chat = new ChatService(connection.db);
|
const chat = new ChatService(connection.db);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function scriptedCeoReply(): (input: ProviderToolTurnInput) => Promise<AgentTurn
|
|||||||
describe("channel anchor wake", () => {
|
describe("channel anchor wake", () => {
|
||||||
test("a plain user message in a channel wakes the most senior member, who replies", async () => {
|
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 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
const chat = new ChatService(connection.db);
|
const chat = new ChatService(connection.db);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async function founderLine() {
|
|||||||
return { text: `Reply ${wakePrompts.length}.`, toolCalls: [], rawResponseJson: "{}" };
|
return { text: `Reply ${wakePrompts.length}.`, toolCalls: [], rawResponseJson: "{}" };
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-chatctx-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
const chat = new ChatService(connection.db);
|
const chat = new ChatService(connection.db);
|
||||||
@@ -154,7 +154,7 @@ describe("chat wake context", () => {
|
|||||||
}
|
}
|
||||||
return { text: "Switching to Postgres.", toolCalls: [], rawResponseJson: "{}" };
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let ceoId = "";
|
let ceoId = "";
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const testVault = {
|
|||||||
|
|
||||||
function company(turn: (input: ProviderToolTurnInput, db: DatabaseConnection["db"]) => AgentTurnResult) {
|
function company(turn: (input: ProviderToolTurnInput, db: DatabaseConnection["db"]) => AgentTurnResult) {
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-goalchg-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("interactive execution lane", () => {
|
|||||||
}
|
}
|
||||||
return turn("done");
|
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" });
|
providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ describe("interactive execution lane", () => {
|
|||||||
}
|
}
|
||||||
return turn("done");
|
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" });
|
providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ describe("interactive execution lane", () => {
|
|||||||
const alwaysOverloaded = async (): Promise<AgentTurnResult> => {
|
const alwaysOverloaded = async (): Promise<AgentTurnResult> => {
|
||||||
throw new ProviderHttpError("The engine is currently overloaded, please try again later", null, 529, false, "overloaded_error");
|
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" });
|
providers.saveCredential({ orgId, providerId, secret: "sk-test-key", model: "gpt-5.4" });
|
||||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||||
const wakeDana = () => dispatchAgentTool({ db: connection.db, organization, chat }, {
|
const wakeDana = () => dispatchAgentTool({ db: connection.db, organization, chat }, {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("session checkpoints", () => {
|
|||||||
? { text: "Audit done: all clean.", toolCalls: [], rawResponseJson: "{}" }
|
? { text: "Audit done: all clean.", toolCalls: [], rawResponseJson: "{}" }
|
||||||
: { text: "Looking into it.", toolCalls: [{ id: `c_${randomUUID()}`, name: "read_messages", arguments: {} }], 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 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("run journal", () => {
|
|||||||
throw new Error("Provider dropped the connection mid-run");
|
throw new Error("Provider dropped the connection mid-run");
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-journal-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function company(reply: (prompt: string) => AgentTurnResult) {
|
|||||||
return reply(prompt);
|
return reply(prompt);
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-memory-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const testVault = {
|
|||||||
|
|
||||||
function company(turn: (input: ProviderToolTurnInput) => Promise<AgentTurnResult>) {
|
function company(turn: (input: ProviderToolTurnInput) => Promise<AgentTurnResult>) {
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-stop-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
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: "{}" };
|
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 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
@@ -116,7 +116,7 @@ describe("stopping runs", () => {
|
|||||||
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-deadline-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers, undefined, undefined, undefined, 200);
|
const execution = new TaskExecutionService(connection.db, providers, undefined, undefined, undefined, 200);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -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 } }) };
|
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 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function company() {
|
|||||||
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-stale-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe("task prompt scope", () => {
|
|||||||
return { text: step.text, toolCalls: step.toolCalls, rawResponseJson: "{}" };
|
return { text: step.text, toolCalls: step.toolCalls, rawResponseJson: "{}" };
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-scope-${randomUUID()}.db`));
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
@@ -91,7 +91,7 @@ describe("task prompt scope", () => {
|
|||||||
calls += 1;
|
calls += 1;
|
||||||
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
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 () => {
|
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 connection = new DatabaseConnection(join(tmpdir(), `ac-wsplock-${randomUUID()}.db`));
|
||||||
const turn = async (): Promise<AgentTurnResult> => ({ text: "done", toolCalls: [], rawResponseJson: "{}" });
|
const turn = async (): Promise<AgentTurnResult> => ({ 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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function company() {
|
|||||||
prompts.push(first.text);
|
prompts.push(first.text);
|
||||||
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
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 onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ describe("agent instruction wiring", () => {
|
|||||||
capturedInstructions = input.instructions ?? "";
|
capturedInstructions = input.instructions ?? "";
|
||||||
return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-1" }) };
|
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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
|
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
@@ -190,7 +190,7 @@ describe("agent instruction wiring", () => {
|
|||||||
capturedPrompt = first.text;
|
capturedPrompt = first.text;
|
||||||
return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-1" }) };
|
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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let taskId = "";
|
let taskId = "";
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("character accounting", () => {
|
|||||||
const toolCalls = [{ id: "c1", name: "send_message", arguments: { channel: "general", body: "Shipping today." } }];
|
const toolCalls = [{ id: "c1", name: "send_message", arguments: { channel: "general", body: "Shipping today." } }];
|
||||||
const turn = async (): Promise<AgentTurnResult> => ({ text: "Posting.", toolCalls, rawResponseJson: "{}" });
|
const turn = async (): Promise<AgentTurnResult> => ({ text: "Posting.", toolCalls, rawResponseJson: "{}" });
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-chars-${randomUUID()}.db`));
|
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 orgId = "";
|
||||||
let providerId = "";
|
let providerId = "";
|
||||||
connection.transaction(() => {
|
connection.transaction(() => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const TEXT_ONLY = async (): Promise<AgentTurnResult> => ({ text: "Sure, the code
|
|||||||
|
|
||||||
function harness(turn: (input: ProviderToolTurnInput) => Promise<AgentTurnResult>) {
|
function harness(turn: (input: ProviderToolTurnInput) => Promise<AgentTurnResult>) {
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-model-check-${randomUUID()}.db`));
|
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 orgId = "";
|
||||||
let providerId = "";
|
let providerId = "";
|
||||||
connection.transaction(() => {
|
connection.transaction(() => {
|
||||||
|
|||||||
@@ -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 () => {
|
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 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 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 orgId = "";
|
||||||
let providerId = "";
|
let providerId = "";
|
||||||
connection.transaction(() => {
|
connection.transaction(() => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-co
|
|||||||
import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow";
|
import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow";
|
||||||
import { readProjectId, requireAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow";
|
import { readProjectId, requireAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow";
|
||||||
import { generatePkce } from "../../src/main/services/oauth/pkce";
|
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, unknown>): string {
|
function makeJwt(claims: Record<string, unknown>): string {
|
||||||
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
const part = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||||
@@ -112,18 +112,20 @@ describe("pkce generation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("oauth credential parsing", () => {
|
describe("oauth credential parsing", () => {
|
||||||
test("accepts a raw token", () => {
|
const signIn = (extra: Record<string, unknown> = {}) => JSON.stringify({ access: "live_token", refresh: "r", expires: 1_900_000_000_000, ...extra });
|
||||||
expect(parseAccessToken("ghu_raw")).toEqual({ type: "access_token", accessToken: "ghu_raw" });
|
|
||||||
expect(parseAccountToken("chatgpt_raw")).toEqual({ type: "account_token", accessToken: "chatgpt_raw" });
|
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", () => {
|
test("a pasted token, or a sign-in missing its refresh token, expiry or provider extras, is refused at save time", () => {
|
||||||
const secret = JSON.stringify({ access: "live_token", refresh: "r", expires: 123 });
|
expect(() => providerAdapters["github-copilot"].parseAuthProfile("ghu_raw")).toThrow("Sign in to GitHub Copilot");
|
||||||
expect(parseAccessToken(secret)).toEqual({ type: "access_token", accessToken: "live_token" });
|
expect(() => providerAdapters["claude-pro"].parseAuthProfile(JSON.stringify({ access: "a", expires: 1 }))).toThrow("missing its refresh token");
|
||||||
expect(parseAccountToken(secret)).toEqual({ type: "account_token", accessToken: "live_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");
|
||||||
test("rejects credential JSON without an access token", () => {
|
|
||||||
expect(() => parseAccessToken(JSON.stringify({ refresh: "r" }))).toThrow("missing an access token");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ describe("provider service", () => {
|
|||||||
let mode: "capable" | "text" | "down" = "capable";
|
let mode: "capable" | "text" | "down" = "capable";
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
const capable: (input: ProviderToolTurnInput) => Promise<AgentTurnResult> = answeringModelCheck(async () => ({ text: "", toolCalls: [], rawResponseJson: "{}" }));
|
const capable: (input: ProviderToolTurnInput) => Promise<AgentTurnResult> = 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}`);
|
seen.push(`${input.credential.apiKey}:${input.model}`);
|
||||||
if (mode === "down") throw new Error("401 invalid x-api-key");
|
if (mode === "down") throw new Error("401 invalid x-api-key");
|
||||||
return mode === "text" ? { text: "I am healthy.", toolCalls: [], rawResponseJson: "{}" } : capable(input);
|
return mode === "text" ? { text: "I am healthy.", toolCalls: [], rawResponseJson: "{}" } : capable(input);
|
||||||
@@ -173,98 +173,27 @@ describe("provider service", () => {
|
|||||||
connection.close();
|
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 connection = createConnection();
|
||||||
const organization = new OrganizationService(connection.db);
|
const organization = new OrganizationService(connection.db);
|
||||||
const snapshot = new SnapshotService(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);
|
||||||
const providers = new ProviderService(connection.db, providerAdapters, testVault, (input) => { launched = input; });
|
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let employeeId = "";
|
|
||||||
let copilotProviderId = "";
|
let copilotProviderId = "";
|
||||||
|
const signIn = JSON.stringify({ access: "ghu_live_provider", refresh: "ghr_refresh", expires: 1_900_000_000_000 });
|
||||||
|
|
||||||
connection.transaction(() => {
|
connection.transaction(() => {
|
||||||
orgId = createOrganization(organization);
|
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");
|
copilotProviderId = providerId(connection, orgId, "github-copilot");
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(() => providers.launchSignInWindow({
|
expect(() => connection.transaction(() => providers.saveCredential({ orgId, providerId: copilotProviderId, secret: "ghu_live_provider", model: "claude-opus-4-5" })))
|
||||||
orgId,
|
.toThrow("Sign in to GitHub Copilot; a pasted token cannot be renewed when it expires.");
|
||||||
providerId: copilotProviderId,
|
connection.transaction(() => providers.saveCredential({ orgId, providerId: copilotProviderId, secret: signIn, model: "claude-opus-4-5" }));
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = snapshot.load();
|
const state = snapshot.load();
|
||||||
const profile = state.providerAuthProfiles.find((item) => item.providerId === copilotProviderId && item.active);
|
const profile = state.providerAuthProfiles.find((item) => item.providerId === copilotProviderId && item.active);
|
||||||
const session = state.providerAuthSessions.find((item) => item.providerId === copilotProviderId);
|
expect(profile).toMatchObject({ sourceType: "provider_oauth_login", sourceRef: null });
|
||||||
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(state.providers.find((item) => item.id === copilotProviderId)?.enabled).toBe(true);
|
expect(state.providers.find((item) => item.id === copilotProviderId)?.enabled).toBe(true);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("monthly quota reservation", () => {
|
|||||||
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
return { text: "done", toolCalls: [], rawResponseJson: "{}" };
|
||||||
};
|
};
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-quota-${randomUUID()}.db`));
|
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 orgId = "";
|
||||||
let providerId = "";
|
let providerId = "";
|
||||||
connection.transaction(() => {
|
connection.transaction(() => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("rate limits seen on successful calls", () => {
|
|||||||
});
|
});
|
||||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-ratelimit-${randomUUID()}.db`));
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-ratelimit-${randomUUID()}.db`));
|
||||||
const organization = new OrganizationService(connection.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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let providerId = "";
|
let providerId = "";
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("task execution service", () => {
|
|||||||
if (first?.role === "user") capturedPrompt = first.text;
|
if (first?.role === "user") capturedPrompt = first.text;
|
||||||
return { text: "executed:gpt-5.4", toolCalls: [], rawResponseJson: JSON.stringify({ id: "resp-task-1" }) };
|
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 skills = new SkillService(connection.db, join(tmpdir(), `agentcompany-task-skills-${randomUUID()}`));
|
||||||
const execution = new TaskExecutionService(connection.db, providers);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
const skillDir = join(tmpdir(), `agentcompany-task-skill-${randomUUID()}`);
|
const skillDir = join(tmpdir(), `agentcompany-task-skill-${randomUUID()}`);
|
||||||
@@ -154,7 +154,7 @@ describe("task execution service", () => {
|
|||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
rawResponseJson: JSON.stringify({ id: "resp-task-approved" }),
|
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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let employeeId = "";
|
let employeeId = "";
|
||||||
@@ -293,7 +293,7 @@ describe("task execution service", () => {
|
|||||||
const fakeToolTurn = async (): Promise<AgentTurnResult> => {
|
const fakeToolTurn = async (): Promise<AgentTurnResult> => {
|
||||||
throw new Error("provider rejected task");
|
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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let taskId = "";
|
let taskId = "";
|
||||||
@@ -351,7 +351,7 @@ describe("task execution service", () => {
|
|||||||
turns += 1;
|
turns += 1;
|
||||||
return { text: "done", toolCalls: [], rawResponseJson: JSON.stringify({ id: `resp-${turns}` }) };
|
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);
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
let taskId = "";
|
let taskId = "";
|
||||||
@@ -850,7 +850,7 @@ describe("task execution service", () => {
|
|||||||
const fakeToolTurn = async (): Promise<AgentTurnResult> => {
|
const fakeToolTurn = async (): Promise<AgentTurnResult> => {
|
||||||
throw new ProviderHttpError("The usage limit has been reached", null, 429, false, "usage_limit_reached");
|
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();
|
let nowMs = Date.now();
|
||||||
const execution = new TaskExecutionService(connection.db, providers, undefined, undefined, () => nowMs);
|
const execution = new TaskExecutionService(connection.db, providers, undefined, undefined, () => nowMs);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
|||||||
Reference in new issue
Block a user