Merge refreshed Storybook layer into runtime hardening

Preserve managed OpenCode credential isolation while keeping the sandbox work-folder HOME.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-12 18:30:41 -05:00
commit 57c2f528f1
294 files changed
+115930 -1966

No files matched your search

@@ -425,7 +425,7 @@ describe("agent live run routes", () => {
expect(res.body).not.toHaveProperty("resultJson");
expect(res.body).not.toHaveProperty("contextSnapshot");
expect(res.body).not.toHaveProperty("logRef");
}, 10_000);
});
it("ignores a stale execution run from another issue and falls back to the assignee's matching run", async () => {
mockHeartbeatService.getRunIssueSummary.mockResolvedValue({
@@ -832,6 +832,7 @@ describe("agent live run routes", () => {
// Optional wake fields retain their existing shape; execution identity
// always comes from the authenticated caller.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
manualUserWake: true,
source: "on_demand",
triggerDetail: "manual",
reason: "issue_assigned",
@@ -863,6 +864,7 @@ describe("agent live run routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
manualUserWake: true,
source: "on_demand",
triggerDetail: "manual",
requestedByActorType: "user",
@@ -876,6 +878,18 @@ describe("agent live run routes", () => {
});
});
it.each(["wakeup", "heartbeat/invoke"])("lets an operator start an existing agent via %s without creating agents", async (endpoint) => {
mockAccessService.decide.mockImplementation(async ({ action }) => ({
allowed: action === "agent:wake", explanation: "Missing permission: agents:create",
}));
const res = await requestApp(await createApp(undefined, {
type: "board", userId: "operator", source: "session", companyIds: ["company-1"],
}), url => request(url).post(`/api/agents/${routeAgentId}/${endpoint}`).send({}));
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ action: "agent:wake" }));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({ manualUserWake: true }));
});
describe("exact failed chat run retry", () => {
const retryBody = {
failedRunId: failedChatRunId,
@@ -901,6 +915,10 @@ describe("agent live run routes", () => {
companyId: "company-1",
});
mockHeartbeatService.getRun.mockResolvedValue(selectedRun);
mockIssueService.getById.mockResolvedValue({
id: failedChatIssueId, companyId: "company-1", assigneeAgentId: routeAgentId,
assigneeUserId: null, projectId: null, parentId: null, status: "blocked",
});
mockChatRunRetries.prepareFailedChatRunRetry.mockResolvedValue({
actionId: retryActionId,
issueId: failedChatIssueId,
@@ -913,6 +931,50 @@ describe("agent live run routes", () => {
});
});
it("retries a task for an operator without agent-creation permission", async () => {
const fixture = createFailedChatRetryDb(false);
mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: {
issueId: failedChatIssueId,
} });
mockAccessService.decide.mockImplementation(async ({ action }) => ({
allowed: action === "issue:comment" || action === "agent:wake", explanation: "Missing permission: agents:create",
}));
const res = await requestApp(await createApp(fixture.db, {
type: "board", userId: "operator", source: "session", companyIds: ["company-1"],
}), url => request(url).post(`/api/agents/${routeAgentId}/wakeup`).send(retryBody));
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:comment", resource: expect.objectContaining({
type: "issue", companyId: "company-1", issueId: failedChatIssueId,
}),
}));
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({
requestedByActorType: "user", requestedByActorId: "operator", failedRunId: failedChatRunId,
payload: { issueId: failedChatIssueId },
}));
});
it.each(["viewer", "missing", "other-company", "reassigned", "other-chat-owner"])(
"rejects a %s task retry without dispatching or requiring agent creation", async (fault) => {
const fixture = createFailedChatRetryDb(false);
mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: { issueId: failedChatIssueId } });
if (fault === "viewer") mockAccessService.decide.mockResolvedValue({
allowed: false, explanation: "Viewer membership does not grant issue:comment.",
});
else mockIssueService.getById.mockResolvedValue(fault === "missing" ? null : {
id: failedChatIssueId, companyId: fault === "other-company" ? "elsewhere" : "company-1",
assigneeAgentId: "other-agent", assigneeUserId: null, projectId: null, parentId: null, status: "blocked",
...(fault === "other-chat-owner" ? { conversationAgentId: routeAgentId, conversationUserId: "someone-else" } : {}),
});
const res = await requestApp(await createApp(fixture.db), url =>
request(url).post(`/api/agents/${routeAgentId}/wakeup`).send(retryBody));
expect(res.status).toBe(fault === "viewer" || fault === "other-chat-owner" ? 403 : fault === "reassigned" ? 409 : 404);
expect(mockAccessService.decide.mock.calls.every(([input]) => input.action !== "agents:create")).toBe(true);
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
expect(mockChatRunRetries.prepareFailedChatRunRetry).not.toHaveBeenCalled();
},
);
it.each([
["failed", "deferred", null],
["timed_out", "running", "55555555-5555-4555-8555-555555555555"],
@@ -1073,7 +1135,7 @@ describe("agent live run routes", () => {
);
it.each(["agent", "company", "permission"])(
"denies %s authority before retry selection",
"denies %s authority before retry admission",
async (denial) => {
const fixture = createFailedChatRetryDb();
const actor =
@@ -1615,7 +1677,7 @@ describe("agent live run routes", () => {
id: "trace-1",
status: "incomplete",
deletedAt: null,
expiresAt: new Date(Date.now() + 60_000),
expiresAt: new Date("2099-01-01T00:00:00.000Z"),
},
"trace_incomplete",
],
+527
View File
@@ -0,0 +1,527 @@
import { connectionIntentService } from "../services/connection-intents.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
import { createDb, companies, agents, heartbeatRuns, companyMemberships, connectionGrants, connectionGrantDelegations, connectionGrantMembers, toolConnections, toolConnectionInstalls, aiConnectionDefaults, adapterAuthSessions, environments, issues, issueThreadInteractions, issueRecoveryActions, connectionIntentDeliveries, agentWakeupRequests } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { aiConnectionService } from "../services/ai-connections.js";
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services/ai-connection-runtime.js";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
import express from "express";
import request from "supertest";
import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js";
import { validateAiApiKey } from "../routes/ai-connections.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
let service: ReturnType<typeof aiConnectionService>;
const binding = { provider: "anthropic", method: "api_key", mode: "responsible_user" } as const;
const input = { companyId, agentId, adapterType: "claude_local", binding };
const create = (userId: string, name: string, ownership: "personal" | "shared" = "personal") => service.save(companyId, userId, { provider: "anthropic", method: "api_key", ownership, name, apiKey: "fixture", agentIds: [], allAgents: true }, `fixture-${name}`);
beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "paperclip-ai-tests-"));
vi.stubEnv("PAPERCLIP_HOME", home);
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "ai-connection-fixture");
database = await startEmbeddedPostgresTestDatabase("paperclip-ai-db-");
db = createDb(database.connectionString);
service = aiConnectionService(db);
await db.insert(companies).values([{ id: companyId, name: "AI connection tests", issuePrefix: "AIT" }, { id: otherCompanyId, name: "Other", issuePrefix: "AIO" }]);
await db.insert(agents).values({ id: agentId, companyId, name: "Nova", adapterType: "claude_local" });
await db.insert(companyMemberships).values(["alice", "bob"].map(principalId => ({ companyId, principalId, principalType: "user", status: "active", membershipRole: "member" })));
}, 90000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it("checks the selected environment for project auth overrides without exposing their contents", async () => {
const execute = vi.spyOn(executionTarget, "runAdapterExecutionTargetProcess");
const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace/project" } as Parameters<typeof assertManagedAiProjectAuth>[2];
try {
execute.mockResolvedValue({ exitCode: 42, stdout: "", stderr: "", signal: null, timedOut: false } as Awaited<ReturnType<typeof executionTarget.runAdapterExecutionTargetProcess>>);
await expect(assertManagedAiProjectAuth({}, "openai", target)).rejects.toThrow("project authentication settings");
expect(execute.mock.calls[0][3]).toContain("/workspace/project");
expect(execute.mock.calls[0][3]).toContain(".codex/config.toml");
execute.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "", signal: null, timedOut: false } as Awaited<ReturnType<typeof executionTarget.runAdapterExecutionTargetProcess>>);
await expect(assertManagedAiProjectAuth({}, "openai", target)).resolves.toBeUndefined();
await expect(assertManagedAiProjectAuth({ args: ["--api-key=override"] }, "xai", target)).rejects.toThrow("overrides");
} finally { execute.mockRestore(); }
});
it("keeps personal defaults separate and does not replace the first default", async () => {
const first = await create("alice", "Alice first");
await create("alice", "Alice second");
const bob = await create("bob", "Bob first");
const [a,b] = await Promise.all([service.select({ ...input, userId: "alice" }), service.select({ ...input, userId: "bob" })]);
expect(a.grant.id).toBe(first.grantId); expect(b.grant.id).toBe(bob.grantId);
expect(await service.credential(a)).toBe("fixture-Alice first");
expect(await service.credential(b)).toBe("fixture-Bob first");
expect(JSON.stringify(await service.list(companyId, "alice"))).not.toContain("fixture-");
expect(await service.list(otherCompanyId, "alice")).toEqual([]);
});
it("retains a revoked default without automatic fallback", async () => {
const selected = await service.select({ ...input, userId: "alice" });
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, selected.grant.id));
await create("alice", "Alice third");
expect(await toolAccessService(db).getConnection(selected.connection.id, companyId)).toMatchObject({ healthStatus: "missing_secret", requiresReauthorization: true });
expect((await toolAccessService(db).listConnections(companyId)).find(connection => connection.id === selected.connection.id)?.healthStatus).toBe("missing_secret");
await expect(service.select({ ...input, userId: "alice" })).rejects.toThrow("Reconnect");
const second = (await service.list(companyId, "alice")).find(a => a.name === "Alice second")!;
await service.setDefault(companyId, "alice", second.grantId);
expect((await service.select({ ...input, userId: "alice" })).grant.id).toBe(second.grantId);
await expect(service.setDefault(companyId, "bob", second.grantId)).rejects.toThrow("owner");
});
it("uses human access for every selection; an agent delegation cannot override Just me", async () => {
const personal = await service.select({ ...input, userId: "alice" });
const delegated = { ...binding, mode: "delegated" as const, connectionId: personal.connection.id, grantId: personal.grant.id };
await expect(service.select({ ...input, userId: "bob", binding: delegated })).rejects.toThrow("not shared");
// Existing delegation records no longer confer an independent AI permission.
await db.insert(connectionGrantDelegations).values({ companyId, grantId: personal.grant.id, agentId, createdByUserId: "alice" });
await expect(service.select({ ...input, userId: "bob", binding: delegated })).rejects.toThrow("not shared");
expect((await service.select({ ...input, userId: "alice", binding: delegated })).grant.id).toBe(personal.grant.id);
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === personal.connection.id)).toBe(false);
await expect(toolAccessService(db).createConnectionGrantDelegation(personal.connection.id, personal.grant.id, agentId, "alice")).rejects.toThrow("human access settings");
});
it("applies the existing human audience editor to AI listing and execution without a second authorization", async () => {
const shared = await create("alice", "Engineering", "shared");
const sharedBinding = { ...binding, mode: "shared" as const, ...shared };
const tools = toolAccessService(db);
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["alice"], { userId: "alice" });
await expect(service.select({ ...input, userId: "bob", binding: sharedBinding })).rejects.toThrow("not shared");
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(false);
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, ["bob"], { userId: "alice" });
expect((await service.select({ ...input, userId: "bob", binding: sharedBinding })).grant.id).toBe(shared.grantId);
expect((await service.list(companyId, "bob", agentId)).some(account => account.id === shared.connectionId)).toBe(true);
await expect(service.select({ ...input, userId: "alice", binding: sharedBinding })).rejects.toThrow("not shared");
await tools.replaceConnectionGrantMembers(shared.connectionId, shared.grantId, [], { userId: "alice" });
for (const userId of ["alice", "bob"]) {
expect((await service.select({ ...input, userId, binding: sharedBinding })).grant.id).toBe(shared.grantId);
}
await expect(service.select({ ...input, userId: null, binding: sharedBinding })).rejects.toThrow("not shared");
// Human permission still cannot bypass the separate agent-access setting.
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, shared.connectionId));
await expect(service.select({ ...input, userId: "bob", binding: sharedBinding })).rejects.toThrow("not permitted for this agent");
expect(sharedBinding).toEqual({ ...binding, mode: "shared", ...shared });
});
it("isolates concurrent homes and overrides ambient credentials without changing the model", async () => {
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-never-use");
const config = { model: "unchanged-model", env: { ANTHROPIC_API_KEY: "project-never-use" } };
const [a,b] = await Promise.all(["alice", "bob"].map(responsibleUserId => prepareManagedAiRuntime(db, { ...input, responsibleUserId, config })));
const ae = a.config.env as Record<string,string>, be = b.config.env as Record<string,string>;
expect(ae.ANTHROPIC_API_KEY).toBe("fixture-Alice second"); expect(be.ANTHROPIC_API_KEY).toBe("fixture-Bob first");
expect(ae.HOME).not.toBe(be.HOME); expect(a.identity).not.toBe(b.identity);
expect(a.config.model).toBe("unchanged-model"); expect(config.env.ANTHROPIC_API_KEY).toBe("project-never-use");
await Promise.all([a.cleanup(), b.cleanup()]); await expect(access(ae.HOME)).rejects.toThrow();
});
it("blocks missing identity, incompatible providers, and cross-company explicit selections", async () => {
await expect(service.select({ ...input, userId: null })).rejects.toThrow("responsible user");
await expect(service.select({ ...input, userId: "alice", adapterType: "codex_local" })).rejects.toThrow("compatible");
const account = await service.select({ ...input, userId: "alice" });
await expect(service.select({ ...input, companyId: otherCompanyId, userId: "alice", binding: { ...binding, mode: "shared", connectionId: account.connection.id, grantId: account.grant.id } })).rejects.toThrow();
});
it("resolves shared encrypted credentials through the existing secret binding system", async () => {
const created = await create("alice", "Shared credential proof", "shared");
const selected = await service.select({ ...input, userId: "bob", binding: { ...binding, mode: "shared", ...created } });
expect(await service.credential(selected)).toBe("fixture-Shared credential proof");
});
it("saves successful login completion once and rejects abandoned attempts", async () => {
const [environment] = await db.insert(environments).values({ name: "AI login test", driver: "sandbox" }).returning();
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Claude subscription", agentIds: [], allAgents: true } as const;
const sessionId = randomUUID();
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: sessionId, status: "submitting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() + 60000) });
const first = await service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-subscription", sessionId);
expect(await service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-subscription", sessionId)).toEqual(first);
const cancelled = randomUUID();
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: cancelled, status: "cancelled", expiresAt: new Date(Date.now() + 60000) });
await expect(service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-never-save", cancelled)).rejects.toThrow("no longer active");
});
it("preserves connection identity and defaults through reconnect; revocation wins over older attempts", async () => {
const current = await service.select({ ...input, userId: "bob" });
const reconnect = { ...binding, ownership: "personal" as const, name: current.connection.name, apiKey: "fixture", agentIds: [], allAgents: true, connectionId: current.connection.id };
const result = await service.save(companyId, "bob", reconnect, "fixture-reconnected");
expect(result.grantId).toBe(current.grant.id);
expect(await service.credential(await service.select({ ...input, userId: "bob" }))).toBe("fixture-reconnected");
const beforeRevocation = new Date(Date.now() - 1000);
await db.update(connectionGrants).set({ status: "revoked", updatedAt: new Date() }).where(eq(connectionGrants.id, current.grant.id));
await expect(service.save(companyId, "bob", reconnect, "fixture-stale", undefined, beforeRevocation)).rejects.toThrow("changed");
await expect(service.select({ ...input, userId: "bob" })).rejects.toThrow("Reconnect");
});
it("rejects invalid purpose/transport combinations in the database", async () => {
const selected = await service.select({ ...input, userId: "alice" });
await expect(db.update(toolConnections).set({ transport: "mcp_remote" }).where(eq(toolConnections.id, selected.connection.id))).rejects.toThrow();
await expect(db.update(toolConnections).set({ connectionPurpose: "tool" }).where(eq(toolConnections.id, selected.connection.id))).rejects.toThrow();
});
it("indexes only known user credentials, retains references, and is repeatable without adopting agents", async () => {
const selected = await service.select({ ...input, userId: "alice" });
const [emailConnection] = await db.insert(toolConnections).values({
companyId,
applicationId: selected.connection.applicationId,
name: "Existing AgentMail inbox",
uid: `agentmail-migration-${randomUUID()}`,
connectionPurpose: "channel",
transport: "rest_api",
authKind: "api_key",
config: { provider: "agentmail" },
}).returning();
const vault = secretService(db);
const definition = await vault.createUserSecretDefinition(companyId, { key: "legacy_claude", name: "Existing owned Claude key", provider: "local_encrypted" }, { userId: "alice" });
const secret = await vault.createCurrentUserSecretValue(companyId, "alice", { definitionId: definition.id, value: "fixture-legacy" }, { userId: "alice" });
await vault.syncUserSecretDeclarationsForTarget(companyId, { targetType: "agent", targetId: agentId }, [{ definitionKey: definition.key, configPath: "env.ANTHROPIC_API_KEY", envKey: "ANTHROPIC_API_KEY", required: true }]);
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
const adoption = migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")));
await db.execute(sql.raw(adoption));
const before = await service.list(companyId, "alice");
for (const statement of migration.split("--> statement-breakpoint").filter(value => value.trim())) await db.execute(sql.raw(statement));
expect(await service.list(companyId, "alice")).toEqual(before);
const [preservedEmail] = await db.select().from(toolConnections).where(eq(toolConnections.id, emailConnection.id));
expect(preservedEmail).toEqual(emailConnection);
const indexed = before.find(account => account.name === secret.name)!;
expect(indexed.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, indexed.grantId));
expect(grant.credentialSecretRefs[0].secretId).toBe(secret.id);
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
});
it("serializes subscription refresh and releases the lease after execution", async () => {
const subscription = { ...input, binding: { ...binding, method: "subscription" as const }, responsibleUserId: "alice", config: { model: "same-model" } };
const first = await prepareManagedAiRuntime(db, subscription);
await expect(prepareManagedAiRuntime(db, subscription)).rejects.toThrow("in use");
await first.cleanup();
const next = await prepareManagedAiRuntime(db, subscription);
expect(next.identity).toBe(first.identity);
await next.cleanup();
});
it("persists refreshed credentials only to their original grant and fences reconnects", async () => {
const auth = (marker: string, hour: number) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const intent = { provider: "openai" as const, method: "subscription" as const, name: "Refresh test", ownership: "personal" as const, agentIds: [], allAgents: true, loginSessionId: "fixture" };
const saved = await service.save(companyId, "alice", intent, auth("first", 10));
const runInput = { ...input, adapterType: "codex_local", responsibleUserId: "alice", binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { model: "same-model" } };
const first = await prepareManagedAiRuntime(db, runInput);
await writeFile(path.join(String(first.config.env.CODEX_HOME), "auth.json"), auth("refreshed", 11));
await first.cleanup();
const selected = await service.select({ ...runInput, userId: "alice" });
expect(await service.credential(selected)).toBe(auth("refreshed", 11));
const second = await prepareManagedAiRuntime(db, runInput);
expect(second.identity).not.toBe(first.identity);
await service.save(companyId, "alice", { ...intent, connectionId: saved.connectionId }, auth("reconnect", 12));
await writeFile(path.join(String(second.config.env.CODEX_HOME), "auth.json"), auth("stale-process", 13));
await second.cleanup();
expect(await service.credential(await service.select({ ...runInput, userId: "alice" }))).toBe(auth("reconnect", 12));
});
it("enforces the shared transport discriminator and existing harness compatibility", () => {
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "ai", transport: "mcp_remote" }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "tool", transport: "runtime_auth" }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "agentmail" } }).success).toBe(true);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "rest_api", config: { provider: "slack" } }).success).toBe(false);
expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "channel", transport: "runtime_auth", config: { provider: "agentmail" } }).success).toBe(false);
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "claude")).toBe(true);
expect(isAiConnectionCompatible(binding, "paperclip_runner", "same-model", "acpx", "codex")).toBe(false);
expect(isAiConnectionCompatible({ provider: "openrouter", method: "api_key" }, "opencode_local", "anthropic/model")).toBe(false);
});
it("does not let a forged delegation bypass human access or accept an expired subscription attempt", async () => {
const selected = await service.select({ ...input, userId: "alice" });
const otherAgent = randomUUID();
await db.insert(agents).values({ id: otherAgent, companyId, name: "Other" });
await db.insert(connectionGrantDelegations).values({ companyId, grantId: selected.grant.id, agentId: otherAgent, createdByUserId: "bob" });
await expect(service.select({ ...input, agentId: otherAgent, userId: "bob", binding: { ...binding, mode: "delegated", connectionId: selected.connection.id, grantId: selected.grant.id } })).rejects.toThrow("not shared");
const [environment] = await db.select().from(environments).limit(1);
const sessionId = randomUUID();
const intent = { provider: "anthropic", method: "subscription", ownership: "personal", name: "Expired", agentIds: [], allAgents: true } as const;
await db.insert(adapterAuthSessions).values({ companyId, environmentId: environment.id, adapterType: "claude_local", startedByUserId: "alice", publicSessionId: sessionId, status: "submitting", aiConnection: { ...intent, agentIds: [] }, expiresAt: new Date(Date.now() - 1000) });
await expect(service.save(companyId, "alice", { ...intent, agentIds: [] }, "fixture-never-save", sessionId)).rejects.toThrow("no longer active");
expect((await service.list(companyId, "alice")).some(account => account.name === "Expired")).toBe(false);
});
it("authorizes account creation, reconnect and defaults at the HTTP boundary before provider calls", async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
const userId = String(req.headers["x-test-user"] ?? "alice");
const role = req.headers["x-test-role"] === "viewer" ? "viewer" : "member";
req.actor = { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, membershipRole: role, status: "active" }] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const personal = await service.select({ ...input, userId: "alice" });
const base = `/api/companies/${companyId}/ai-connections`;
expect((await request(app).get(`/api/companies/${otherCompanyId}/ai-connections`)).status).toBe(403);
expect((await request(app).put(`${base}/default`).set("x-test-user", "bob").send({ grantId: personal.grant.id })).status).toBe(403);
expect((await request(app).put(`${base}/default`).set("x-test-role", "viewer").send({ grantId: personal.grant.id })).status).toBe(403);
const payload = { provider: "anthropic", method: "api_key", name: "Fixture", ownership: "personal", apiKey: "fixture", allAgents: false, agentIds: [] };
const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("must not reach provider"));
try {
expect((await request(app).post(base).set("x-test-role", "viewer").send(payload)).status).toBe(403);
expect((await request(app).post(base).send({ ...payload, ownership: "shared" })).status).toBe(403);
expect((await request(app).post(base).set("x-test-user", "bob").send({ ...payload, connectionId: personal.connection.id })).status).toBe(403);
expect(network).not.toHaveBeenCalled();
} finally { network.mockRestore(); }
});
it("imports only for the local operator and preserves identity and permissions on reconnect", async () => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("fixture-local-token");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: req.headers["x-local"] === "yes" ? "local_implicit" : "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const url = `/api/companies/${companyId}/ai-connections/local`;
const payload = { provider: "anthropic", method: "subscription", name: "Local account test", ownership: "personal", agentIds: [agentId], allAgents: false };
try {
expect((await request(app).post(url).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
expect((await request(app).post(`${url}/check`).send(payload)).status).toBe(403);
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${url}/check`).set("x-local", "yes").send(payload);
expect(checked.status).toBe(200);
expect(checked.body).toEqual({ status: "ready" });
expect((await service.list(companyId, "alice")).some(c => c.name === payload.name)).toBe(false);
const connected = await request(app).post(url).set("x-local", "yes").send(payload);
expect(connected.status).toBe(201);
expect(JSON.stringify(connected.body)).not.toContain("fixture-local-token");
const before = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
expect(before.map(i => [i.targetType, i.targetId])).toEqual([["agent", agentId]]);
const reconnected = await request(app).post(url).set("x-local", "yes").send({ ...payload, connectionId: connected.body.connectionId, allAgents: true });
expect(reconnected.status).toBe(201);
expect(reconnected.body).toEqual(connected.body);
const after = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connected.body.connectionId));
expect(after).toEqual(before);
reader.mockRejectedValueOnce(Object.assign(new Error("Sign in locally and retry"), { status: 422 }));
const failed = await request(app).post(url).set("x-local", "yes").send({ ...payload, name: "Unsuccessful local login" });
expect(failed.status).toBe(422);
expect((await service.list(companyId, "alice")).some(c => c.name === "Unsuccessful local login")).toBe(false);
const codex = { ...payload, provider: "openai", name: "Isolated terminal login" };
const attempts = `${url}/attempts`;
expect((await request(app).post(attempts).send(codex)).status).toBe(403); // This member cannot authorize agentId.
expect((await request(app).post(url).set("x-local", "yes").send(codex)).status).toBe(422);
const prepared = await request(app).post(attempts).set("x-local", "yes").send(codex);
expect(prepared.status).toBe(201);
expect(prepared.body.command).toMatch(/^\(export CODEX_HOME=.* && mkdir -p .* && codex -c .* login --device-auth\)$/);
expect((await request(app).post(attempts).set("x-local", "yes").send(codex)).body).toEqual(prepared.body);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-test-user", "bob").send()).status).toBe(404);
expect((await request(app).delete(`${attempts}/${prepared.body.sessionId}`).set("x-local", "yes").send()).status).toBe(200);
expect((await request(app).post(url).set("x-local", "yes").send({ ...codex, localSessionId: prepared.body.sessionId })).status).toBe(422);
} finally { reader.mockRestore(); }
});
it.each(["anthropic", "openai"] as const)("blocks server-host %s login on a public deployment without a trusted host", async provider => {
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "session", userId: "alice", companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: "member" }] };
next();
});
app.use("/api", aiConnectionRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "" }));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const base = `/api/companies/${companyId}/ai-connections/local`;
const intent = { provider, method: "subscription", ownership: "personal", name: "Hosted account", allAgents: false, agentIds: [] };
try {
for (const endpoint of [base, `${base}/attempts`, `${base}/check`]) {
const result = await request(app).post(endpoint).send(intent);
expect(result.status).toBe(422);
expect(result.body.error).toContain("unavailable on this hosted instance");
}
expect(reader).not.toHaveBeenCalled();
} finally { reader.mockRestore(); }
});
it.each(["anthropic", "openai"] as const)("lets authenticated users connect only their own isolated %s login", async provider => {
const owner = `self-hosted-${provider}`;
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "member" });
const reader = vi.spyOn(localCredentials, "readVerifiedLocalAiCredential").mockResolvedValue("isolated-fixture-token");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? owner), companyIds: [companyId], memberships: [{ companyId, status: "active", membershipRole: req.headers["x-viewer"] ? "viewer" : "member" }] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const base = `/api/companies/${companyId}/ai-connections/local`;
const intent = { provider, method: "subscription", ownership: "personal", name: `Self-hosted ${provider}`, allAgents: false, agentIds: [] };
try {
expect((await request(app).post(`${base}/attempts`).set("x-viewer", "yes").send(intent)).status).toBe(403);
const started = await request(app).post(`${base}/attempts`).send(intent);
expect(started.status).toBe(201);
expect(started.headers["cache-control"]).toBe("no-store");
expect(started.body.command).toContain(provider === "anthropic" ? "CLAUDE_CONFIG_DIR=" : "login --device-auth");
expect((await request(app).post(`${base}/attempts`).send(intent)).body).toEqual(started.body);
const input = { ...intent, localSessionId: started.body.sessionId };
for (const endpoint of [base, `${base}/check`]) {
expect((await request(app).post(endpoint).set("x-test-user", "bob").send(input)).status).toBe(404);
expect((await request(app).post(endpoint.replace(companyId, otherCompanyId)).send(input)).status).toBe(403);
}
expect(reader).not.toHaveBeenCalled();
const checked = await request(app).post(`${base}/check`).send(input);
expect(checked.body).toEqual({ status: "ready" });
expect(reader).toHaveBeenLastCalledWith(provider, path.join(home, "instances/ai-connection-fixture/ai-local-logins", started.body.sessionId));
const saved = await request(app).post(base).send(input);
expect(saved.status).toBe(201);
expect((await request(app).post(base).send(input)).body).toEqual(saved.body);
expect(JSON.stringify(saved.body)).not.toContain("isolated-fixture-token");
expect((await service.list(companyId, owner)).filter(c => c.name === intent.name)).toHaveLength(1);
} finally { reader.mockRestore(); }
});
it("rejects invalid credentials without exposing the provider response", async () => {
const request = vi.fn().mockResolvedValue(new Response("secret-provider-body", { status: 401 }));
await expect(validateAiApiKey("anthropic", "fixture", request)).rejects.toThrow("rejected");
expect(request.mock.calls[0][1].redirect).toBe("error");
});
it("uses the authenticated responsible user for agent-originated configuration and tests", async () => {
const req = { actor: { type: "agent", agentId, onBehalfOfUserId: "alice" } } as express.Request;
const selected = await service.select({ ...input, userId: responsibleUserForAiRequest(req) });
expect(selected.grant.subjectUserId).toBe("alice");
req.actor.onBehalfOfUserId = undefined;
expect(responsibleUserForAiRequest(req)).toBeNull();
await expect(service.select({ ...input, userId: responsibleUserForAiRequest(req) })).rejects.toThrow();
});
it("protects active-run attribution with the connection human audience", async () => {
const account = await create("alice", "Private run attribution");
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { aiConnection: { connectionId: account.connectionId, grantId: account.grantId } } });
const app = express();
app.use((req, _res, next) => {
req.actor = { type: "board", source: "session", userId: String(req.headers["x-test-user"] ?? "alice"), companyIds: [companyId] };
next();
});
app.use("/api", aiConnectionRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
const url = `/api/companies/${companyId}/ai-connections/${account.connectionId}/active-runs`;
const own = await request(app).get(url);
expect(own.status).toBe(200);
expect(own.headers["cache-control"]).toBe("no-store");
expect(own.body).toEqual([expect.objectContaining({ id: runId, agentId })]);
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
await db.update(connectionGrants).set({ kind: "organization", subjectUserId: null }).where(eq(connectionGrants.id, account.grantId));
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "alice" });
expect((await request(app).get(url).set("x-test-user", "bob")).status).toBe(404);
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
expect((await request(app).get(url).set("x-test-user", "bob")).body).toEqual(own.body);
expect((await request(app).get(url.replace(companyId, otherCompanyId))).status).toBe(403);
});
it("permits new-agent shared installation only for a connection configurator, without bypassing audience", async () => {
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Restricted shared", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-restricted");
const selected = { provider: "anthropic", method: "api_key", mode: "shared", ...account } as const;
const futureAgentId = randomUUID();
const req = (userId: string, role = "member") => ({ actor: { type: "board", source: "session", userId, companyIds: [companyId], memberships: [{ companyId, membershipRole: role, status: "active" }] } }) as express.Request;
expect(await canInstallSharedAiConnectionForNewAgent(db, req("alice"), companyId, selected)).toBe(true);
expect(await canInstallSharedAiConnectionForNewAgent(db, req("bob"), companyId, selected)).toBe(false);
expect(await canInstallSharedAiConnectionForNewAgent(db, req("alice", "viewer"), companyId, selected)).toBe(false);
expect(await canInstallSharedAiConnectionForNewAgent(db, { actor: { type: "agent", onBehalfOfUserId: "alice" } } as express.Request, companyId, selected)).toBe(false);
const selectionInput = { ...input, agentId: futureAgentId, userId: "alice", binding: selected };
await expect(service.select(selectionInput)).rejects.toThrow("not permitted for this agent");
const run = await prepareManagedAiRuntime(db, { companyId, agentId: futureAgentId, responsibleUserId: "alice", adapterType: "claude_local", binding: selected, config: { cwd: home, model: "same-model" }, allowUninstalledShared: true });
expect(run.config.model).toBe("same-model");
await run.cleanup();
await db.insert(connectionGrantMembers).values({ companyId, grantId: account.grantId, subjectType: "user", subjectId: "bob" });
await expect(service.select({ ...selectionInput, allowUninstalledShared: true })).rejects.toThrow("not shared with the responsible user");
await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, account.grantId));
await db.insert(agents).values({ id: futureAgentId, companyId, name: "New shared agent", adapterType: "claude_local" });
await db.insert(toolConnectionInstalls).values({ companyId, connectionId: account.connectionId, targetType: "agent", targetId: futureAgentId, createdByUserId: "alice" });
expect((await service.select(selectionInput)).grant.id).toBe(account.grantId);
});
it("creates and hires agents with an authorized restricted shared connection", async () => {
const { agentRoutes } = await import("../routes/agents.js");
await db.update(companies).set({ requireBoardApprovalForNewAgents: false }).where(eq(companies.id, companyId));
const account = await service.save(companyId, "alice", { provider: "anthropic", method: "api_key", ownership: "shared", name: "Shared creation routes", apiKey: "fixture", agentIds: [], allAgents: false }, "fixture-create-routes");
const selected = { ...binding, mode: "shared", ...account } as const;
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", source: "local_implicit", userId: "alice", companyIds: [companyId] };
next();
});
app.use("/api", agentRoutes(db));
app.use((error: { status?: number; message: string }, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(error.status ?? 500).json({ error: error.message }); });
for (const endpoint of ["agents", "agent-hires"]) {
const response = await request(app).post(`/api/companies/${companyId}/${endpoint}`).send({ name: `Shared ${endpoint}`, role: "general", adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4-6" }, runtimeConfig: { aiConnection: selected } });
expect(response.status, JSON.stringify(response.body)).toBe(201);
const agent = endpoint === "agents" ? response.body : response.body.agent;
expect(agent.adapterConfig.model).toBe("claude-sonnet-4-6");
expect(agent.runtimeConfig.aiConnection).toEqual(selected);
const installs = await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.connectionId, account.connectionId), eq(toolConnectionInstalls.targetId, agent.id)));
expect(installs).toHaveLength(1);
expect((await service.select({ ...input, agentId: agent.id, userId: "alice", binding: selected })).grant.id).toBe(account.grantId);
}
// A database failure between the two inserts must roll back the agent too.
await db.execute(sql`CREATE FUNCTION reject_test_ai_install() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture install failure'; END $$`);
await db.execute(sql`CREATE TRIGGER reject_test_ai_install BEFORE INSERT ON tool_connection_installs FOR EACH ROW EXECUTE FUNCTION reject_test_ai_install()`);
try {
for (const endpoint of ["agents", "agent-hires"]) {
const name = `Rollback ${endpoint}`;
const response = await request(app).post(`/api/companies/${companyId}/${endpoint}`).send({ name, role: "general", adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-4-6" }, runtimeConfig: { aiConnection: selected } });
expect(response.status).toBe(500);
expect(await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.name, name)))).toEqual([]);
}
} finally {
await db.execute(sql`DROP TRIGGER reject_test_ai_install ON tool_connection_installs`);
await db.execute(sql`DROP FUNCTION reject_test_ai_install()`);
}
}, 30000);
});
describe("AI connection recovery delivery", () => {
it.each(["restored", "newer failure", "different blocker", "revoked again", "closed task"])(
"continues only the repaired source failure: %s", async (scenario) => {
const userId = `recovery-${randomUUID()}`;
const recoveringAgentId = randomUUID();
const issueId = randomUUID();
const failedRunId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
await db.insert(agents).values({ id: recoveringAgentId, companyId, name: "Recovery agent", status: "active", adapterType: "claude_local", runtimeConfig: { aiConnection: binding } });
await db.insert(issues).values({ id: issueId, companyId, title: "Restore selected account", status: "in_progress", assigneeAgentId: recoveringAgentId });
await db.insert(heartbeatRuns).values({ id: failedRunId, companyId, agentId: recoveringAgentId, status: "running", responsibleUserId: userId, contextSnapshot: { issueId } });
const intents = connectionIntentService(db);
const pending = await intents.request({ sub: recoveringAgentId, company_id: companyId, run_id: failedRunId, responsible_user_id: userId }, "anthropic", { purpose: "ai" });
const account = await create(userId, `Recovered ${scenario}`);
expect((await intents.setupOptions(pending.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([account.connectionId]);
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" } } }).where(eq(heartbeatRuns.id, failedRunId));
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `ai:${issueId}`, nextAction: "Reconnect", ownerType: "board", evidence: { latestRunId: failedRunId } });
await intents.complete(pending.interactionId!, account.connectionId, userId);
if (scenario === "newer failure") await db.insert(heartbeatRuns).values({ companyId, agentId: recoveringAgentId, status: "failed", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) });
if (scenario === "different blocker") await db.update(issueRecoveryActions).set({ cause: "workspace_validation_failed" }).where(eq(issueRecoveryActions.sourceIssueId, issueId));
if (scenario === "closed task") await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
if (scenario === "revoked again") {
await toolAccessService(db).revokeConnectionGrant(account.connectionId, account.grantId, { actorType: "user", actorId: userId });
const repairOptions = await intents.setupOptions(pending.interactionId!);
expect(repairOptions.existingConnections).toEqual([]);
expect(repairOptions.aiRepair).toMatchObject({ canReconnect: true, connection: { id: account.connectionId, grantId: account.grantId, isDefault: true, status: "revoked" } });
}
const wakeup = vi.fn(async (_agentId, opts) => {
await db.insert(agentWakeupRequests).values({ companyId, agentId: recoveringAgentId, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
return null;
});
const delivery = connectionIntentDeliveryService(db, { wakeup } as never);
await delivery.deliver(pending.interactionId!);
await delivery.deliver(pending.interactionId!);
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
if (scenario === "restored") {
expect(issue.status).toBe("in_progress");
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(recoveringAgentId, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
const [receipt] = await db.select().from(connectionIntentDeliveries).where(eq(connectionIntentDeliveries.interactionId, pending.interactionId!));
expect(receipt.deliveredAt).not.toBeNull();
} else {
expect(wakeup).not.toHaveBeenCalled();
expect(issue.status).toBe(scenario === "closed task" ? "done" : "blocked");
}
}, 30000,
);
});
@@ -0,0 +1,175 @@
import { beforeAll, afterAll, it, expect, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { secretService } from "../services/secrets.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let home: string;
const companyId = randomUUID(), agentId = randomUUID(), owner = "audit-owner";
beforeAll(async () => {
home = await mkdtemp(path.join(os.tmpdir(), "ai-legacy-audit-"));
vi.stubEnv("PAPERCLIP_HOME", home);
database = await startEmbeddedPostgresTestDatabase("ai-legacy-audit-db-");
db = createDb(database.connectionString);
await db.insert(companies).values({ id: companyId, name: "Audit", issuePrefix: "AUD" });
await db.insert(companyMemberships).values({ companyId, principalId: owner, principalType: "user", status: "active", membershipRole: "owner" });
await db.insert(environments).values({ name: "Local audit", driver: "local" }).onConflictDoNothing();
await db.insert(agents).values({ id: agentId, companyId, name: "Unadopted legacy agent", adapterType: "claude_local" });
}, 90000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); if (home) await rm(home, { recursive: true, force: true }); });
it("reconnect detaches an indexed credential without changing unadopted legacy agents", async () => {
const vault = secretService(db), service = aiConnectionService(db);
const definition = await vault.createUserSecretDefinition(companyId, { key: "audit_legacy_claude", name: "Legacy key", provider: "local_encrypted" }, { userId: owner });
const secret = await vault.createCurrentUserSecretValue(companyId, owner, { definitionId: definition.id, value: "fixture-original" }, { userId: owner });
const legacyConfig = { env: { ANTHROPIC_API_KEY: { type: "user_secret_ref", key: definition.key, required: true } } };
await db.update(agents).set({ adapterConfig: legacyConfig }).where(eq(agents.id, agentId));
await vault.syncUserSecretDeclarationsForTarget(companyId, { targetType: "agent", targetId: agentId }, [{ definitionKey: definition.key, configPath: "env.ANTHROPIC_API_KEY", envKey: "ANTHROPIC_API_KEY", required: true }]);
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
await db.execute(sql.raw(migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")))));
const connection = (await service.list(companyId, owner)).find(c => c.name === secret.name)!;
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
expect((await resolve())?.value).toBe("fixture-original");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
expect((await resolve())?.value).toBe("fixture-original");
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, connection.grantId));
expect(grant.credentialSecretRefs[0].secretId).not.toBe(secret.id);
const selected = await service.select({ companyId, agentId, userId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
expect(await service.credential(selected)).toBe("fixture-new-account");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-second" }, "fixture-second");
expect((await resolve())?.value).toBe("fixture-original");
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
expect(agent.adapterConfig).toEqual(legacyConfig);
});
const intent = { provider: "openai", method: "subscription", name: "Isolated Codex", ownership: "personal", agentIds: [], allAgents: true } as const;
const loginIntent = () => ({ ...intent, agentIds: [] });
const auth = (mark: string, hour = 10) => JSON.stringify({ tokens: { account_id: "fixture-account", id_token: `id-${mark}`, access_token: `access-${mark}`, refresh_token: `refresh-${mark}` }, last_refresh: `2026-09-10T${hour}:00:00Z` });
const directoryFor = (id: string) => path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
it("isolates sign-in and refresh from the host, survives restart, and completes only once", async () => {
const hostHome = path.join(home, "host-codex");
await mkdir(hostHome);
await writeFile(path.join(hostHome, "auth.json"), auth("legacy"));
vi.stubEnv("CODEX_HOME", hostHome);
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}")));
const login = localAiLoginService(db);
const attempt = await login.start(companyId, owner, loginIntent());
const directory = directoryFor(attempt.sessionId);
expect(await localAiLoginService(db).start(companyId, owner, loginIntent())).toEqual(attempt);
expect(attempt.command).toContain(`(export CODEX_HOME='${directory}' && mkdir -p "$CODEX_HOME" && codex`);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "sign_in_required" });
// Resuming a valid attempt also repairs a missing directory without changing its ID.
await rm(directory, { recursive: true });
expect(await login.start(companyId, owner, loginIntent())).toEqual(attempt);
expect(await readFile(path.join(directory, "config.toml"), "utf8")).toContain('cli_auth_credentials_store = "file"');
// A valid host login cannot satisfy an unfinished connection-specific login.
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("sign-in command shown");
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await writeFile(path.join(directory, "auth.json"), auth("independent-login"));
expect(await login.check(companyId, owner, loginIntent(), attempt.sessionId)).toEqual({ status: "ready" });
expect((await aiConnectionService(db).list(companyId, owner)).filter(c => c.provider === "openai")).toHaveLength(0);
await expect(login.check(companyId, "another-owner", loginIntent(), attempt.sessionId)).rejects.toThrow("not found");
await expect(login.check(randomUUID(), owner, loginIntent(), attempt.sessionId)).rejects.toThrow("not found");
await expect(login.check(companyId, owner, { ...loginIntent(), ownership: "shared" }, attempt.sessionId)).rejects.toThrow("not found");
// New service instance simulates process restart: all intent is durable.
const results = await Promise.all([
localAiLoginService(db).complete(companyId, owner, attempt.sessionId, loginIntent()),
localAiLoginService(db).complete(companyId, owner, attempt.sessionId, loginIntent()),
]);
expect(results[0]).toEqual(results[1]);
await expect(readFile(path.join(directory, "auth.json"))).rejects.toHaveProperty("code", "ENOENT");
const input = { companyId, agentId, adapterType: "codex_local", responsibleUserId: owner, binding: { provider: "openai", method: "subscription", mode: "responsible_user" } as const, config: { cwd: home, model: "unchanged-model" } };
const run = await prepareManagedAiRuntime(db, input);
const credentialFile = path.join(String(run.config.env.CODEX_HOME), "auth.json");
expect(JSON.parse(await readFile(credentialFile, "utf8")).tokens.refresh_token).toBe("refresh-independent-login");
await writeFile(credentialFile, auth("rotated-independent", 11));
await run.cleanup();
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
const next = await prepareManagedAiRuntime(db, input);
expect(JSON.parse(await readFile(path.join(String(next.config.env.CODEX_HOME), "auth.json"), "utf8")).tokens.refresh_token).toBe("refresh-rotated-independent");
expect(next.config.model).toBe(input.config.model);
await next.cleanup();
// Cancellation after successful completion must not revoke the saved account.
await login.cancel(companyId, owner, attempt.sessionId);
expect((await aiConnectionService(db).list(companyId, owner)).find(c => c.id === results[0].connectionId)?.status).toBe("connected");
expect((await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId)))
.filter(event => event.action === "ai_connection.local_login_cancelled")).toHaveLength(0);
const reconnectIntent = { ...loginIntent(), connectionId: results[0].connectionId };
const reconnect = await login.start(companyId, owner, reconnectIntent);
expect(reconnect.sessionId).not.toBe(attempt.sessionId);
await writeFile(path.join(directoryFor(reconnect.sessionId), "auth.json"), auth("new-login"));
expect(await login.complete(companyId, owner, reconnect.sessionId, reconnectIntent)).toEqual(results[0]);
expect(await readFile(path.join(hostHome, "auth.json"), "utf8")).toBe(auth("legacy"));
});
it("enforces local attempt ownership, company, target, cancellation, and expiry", async () => {
const login = localAiLoginService(db);
const attempt = await login.start(companyId, owner, loginIntent());
const cancellations = async () => (await db.select().from(activityLog).where(eq(activityLog.entityId, attempt.sessionId)))
.filter(event => event.action === "ai_connection.local_login_cancelled");
await expect(login.complete(companyId, "another-owner", attempt.sessionId, loginIntent())).rejects.toThrow("not found");
await expect(login.cancel(companyId, "another-owner", attempt.sessionId)).rejects.toThrow("not found");
await expect(login.cancel(randomUUID(), owner, attempt.sessionId)).rejects.toThrow("not found");
await expect(login.complete(companyId, owner, attempt.sessionId, { ...loginIntent(), ownership: "shared" })).rejects.toThrow("not found");
expect(await cancellations()).toHaveLength(0);
await login.cancel(companyId, owner, attempt.sessionId);
await login.cancel(companyId, owner, attempt.sessionId);
expect(await cancellations()).toEqual([expect.objectContaining({
companyId, actorType: "user", actorId: owner, entityType: "adapter_auth_session",
entityId: attempt.sessionId, details: { provider: "openai" },
})]);
await expect(login.complete(companyId, owner, attempt.sessionId, loginIntent())).rejects.toThrow("cancelled");
const retry = await login.start(companyId, owner, loginIntent());
await writeFile(path.join(directoryFor(retry.sessionId), "auth.json"), auth("abandoned"));
await db.update(adapterAuthSessions).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(adapterAuthSessions.id, retry.sessionId));
await expect(login.complete(companyId, owner, retry.sessionId, loginIntent())).rejects.toThrow("expired");
expect(await login.check(companyId, owner, loginIntent(), retry.sessionId)).toEqual({ status: "expired" });
await localAiLoginService(db).reapExpired();
await expect(readFile(path.join(directoryFor(retry.sessionId), "auth.json"))).rejects.toHaveProperty("code", "ENOENT");
const [row] = await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, retry.sessionId));
expect(row.status).toBe("timed_out");
});
it("explicit retry replaces another owned local attempt while ordinary navigation preserves it", async () => {
const login = localAiLoginService(db);
const first = await login.start(companyId, owner, loginIntent());
const restricted = { ...loginIntent(), allAgents: false, agentIds: [agentId] };
await expect(login.start(companyId, owner, restricted)).rejects.toThrow("Another sign-in");
const retry = await login.start(companyId, owner, restricted, true);
expect(retry.sessionId).not.toBe(first.sessionId);
const [old] = await db.select().from(adapterAuthSessions).where(eq(adapterAuthSessions.id, first.sessionId));
expect(old.status).toBe("cancelled");
await expect(readFile(path.join(directoryFor(first.sessionId), "config.toml"))).rejects.toHaveProperty("code", "ENOENT");
expect(await login.start(companyId, owner, restricted)).toEqual(retry);
await login.cancel(companyId, owner, retry.sessionId);
});
it("blocks preview-era copied subscriptions until isolated reconnect, leaving legacy config intact", async () => {
const service = aiConnectionService(db);
const account = (await service.list(companyId, owner)).find(c => c.provider === "openai")!;
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, account.id));
await db.update(toolConnections).set({ config: { ...connection.config, aiIsolatedSubscription: false } }).where(eq(toolConnections.id, account.id));
await expect(prepareManagedAiRuntime(db, {
companyId, agentId, adapterType: "codex_local", responsibleUserId: owner,
binding: { provider: "openai", method: "subscription", mode: "responsible_user" }, config: { cwd: home },
})).rejects.toThrow("separate sign-in");
expect((await service.list(companyId, owner)).find(c => c.id === account.id)?.status).toBe("needs_attention");
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, account.grantId));
expect(grant.status).toBe("active");
});
@@ -1462,8 +1462,8 @@ describeEmbeddedPostgres("authorization service", () => {
})).resolves.toMatchObject({ allowed: false, reason: "deny_missing_membership" });
});
it("keeps denying self-gated null-mapped actions for board members", async () => {
const company = await createCompany(db, "BoardWakeDenied");
it("allows legacy member roles to wake agents while rejecting incomplete task mutation scope", async () => {
const company = await createCompany(db, "BoardWake");
const userId = `user-${randomUUID()}`;
const targetAgent = await createAgent(db, company.id, { role: "engineer" });
await db.insert(companyMemberships).values({
@@ -1481,8 +1481,8 @@ describeEmbeddedPostgres("authorization service", () => {
action: "agent:wake",
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
})).resolves.toMatchObject({
allowed: false,
reason: "deny_unsupported_action",
allowed: true,
reason: "allow_simple_company_member",
});
const issue = await createIssue(db, company.id, { title: "Wake denied issue" });
await expect(authorization.decide({
@@ -1749,6 +1749,25 @@ describeEmbeddedPostgres("authorization service", () => {
});
});
it.each(["session", "cloud_tenant"] as const)("allows %s operators to start agents, without granting hiring rights", async (source) => {
const company = await createCompany(db, "wake");
const agent = await createAgent(db, company.id);
const userId = await createUser(db);
await db.insert(companyMemberships).values({ companyId: company.id,
principalType: "user", principalId: userId, status: "active", membershipRole: "operator" });
const auth = authorizationService(db);
const actor = { type: "board" as const, source, userId, companyIds: [company.id] };
const resource = { type: "agent" as const, companyId: company.id, agentId: agent.id };
expect(await auth.decide({ actor, action: "agent:wake", resource })).toMatchObject({ allowed: true });
expect(await auth.decide({ actor, action: "agents:create", resource: { type: "company", companyId: company.id } })).toMatchObject({ allowed: false });
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, userId));
expect(await auth.decide({ actor, action: "agent:wake", resource })).toMatchObject({ allowed: false });
await db.update(companyMemberships).set({ membershipRole: "operator", status: "suspended" }).where(eq(companyMemberships.principalId, userId));
expect(await auth.decide({ actor, action: "agent:wake", resource })).toMatchObject({ allowed: false });
const otherCompany = await createCompany(db, "other-wake");
expect(await auth.decide({ actor, action: "agent:wake", resource: { ...resource, companyId: otherCompany.id } })).toMatchObject({ allowed: false });
});
it("limits viewer members to read-only visibility actions", async () => {
const company = await createCompany(db, "BoardViewerVisibility");
const userId = `user-${randomUUID()}`;
@@ -163,6 +163,19 @@ describeEmbeddedPostgres("companySearchService", () => {
return id;
}
it("keeps exact entity names ahead of speculative task typos and rejects empty quotes", async () => {
const companyId = await createCompany();
const agentId = await createAgent(companyId, { name: "Mibile" });
const projectId = await createProject(companyId, { name: "Mibile" });
const taskId = await createIssue(companyId, { title: "Mobile navigation" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile" }));
const ids = result.results.map((row) => row.id);
expect(ids).toContain(taskId);
expect(ids.indexOf(agentId)).toBeLessThan(ids.indexOf(taskId));
expect(ids.indexOf(projectId)).toBeLessThan(ids.indexOf(taskId));
expect((await svc.search(companyId, companySearchQuerySchema.parse({ q: '""' }))).results).toEqual([]);
});
it("ranks exact issue identifiers before weaker title matches", async () => {
const companyId = await createCompany();
const exactId = await createIssue(companyId, {
@@ -180,7 +193,7 @@ describeEmbeddedPostgres("companySearchService", () => {
expect(result.results[0]?.matchedFields).toContain("identifier");
});
it("ranks phrase and all-token issue matches before partial scattered-token matches", async () => {
it("ranks phrase before reordered title words and rejects partial matches", async () => {
const companyId = await createCompany();
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
const partialTokenId = await createIssue(companyId, {
@@ -201,7 +214,8 @@ describeEmbeddedPostgres("companySearchService", () => {
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "alpha beta", scope: "issues" }));
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId, partialTokenId]);
expect(result.results.map((row) => row.id)).toEqual([phraseId, allTokenId]);
expect(result.results.map((row) => row.id)).not.toContain(partialTokenId);
});
it("matches multiple tokens across the same issue thread and returns comment snippets", async () => {
@@ -682,6 +696,83 @@ describeEmbeddedPostgres("companySearchService", () => {
}
});
it("does not interpret short UI terms as the middle of unrelated words", async () => {
const companyId = await createCompany();
const target = await createIssue(companyId, { title: "Improve mobile UI" });
await createIssue(companyId, { title: "Build billing reports" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "UI" }));
expect(result.results.map((row) => row.id)).toEqual([target]);
});
it("keeps typo fallback inside the requested filters", async () => {
const companyId = await createCompany();
await createIssue(companyId, { title: "Mibile API", status: "done" });
const target = await createIssue(companyId, { title: "Mobile API", status: "todo" });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "mibile api", status: "todo" }));
expect(result.results.map((row) => row.id)).toEqual([target]);
});
it("keeps exact title hits on the task and chooses the most complete context evidence", async () => {
const companyId = await createCompany();
const task = await createIssue(companyId, { title: "Aurora callback" });
await db.insert(issueComments).values({ companyId, issueId: task, body: "Aurora callback is mentioned here too." });
const exact = await svc.search(companyId, companySearchQuerySchema.parse({ q: "Aurora callback" }));
expect(exact.results[0]?.href).not.toContain("#comment-");
const holder = await createIssue(companyId, { title: "Connection investigation" });
await db.insert(issueComments).values({ companyId, issueId: holder, body: "Aurora was discussed.", updatedAt: new Date("2026-06-01") });
const strongest = randomUUID();
await db.insert(issueComments).values({ id: strongest, companyId, issueId: holder,
body: "Aurora loses the callback state.", updatedAt: new Date("2026-01-01") });
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "aurora state callback" }));
expect(result.results[0]?.href).toContain(`#comment-${strongest}`);
expect(result.results[0]?.snippet).toContain("state");
});
it.each(["comment", "document"] as const)("preserves %s evidence when title and identifier both match", async (source) => {
const companyId = await createCompany();
const task = await createIssue(companyId, {
identifier: "CTX-123", title: "CTX callback investigation", description: "CTX callback details",
});
const sourceId = randomUUID();
if (source === "comment") {
await db.insert(issueComments).values({ id: sourceId, companyId, issueId: task, body: "The missing signal is quasar." });
} else {
await db.insert(documents).values({ id: sourceId, companyId, title: "Investigation plan", latestBody: "The missing signal is quasar.", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: sourceId, key: "plan" });
}
const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "CTX quasar" }));
const match = result.results.find((row) => row.id === task)!;
expect(match.matchedFields).toEqual(expect.arrayContaining(["identifier", "title", source]));
expect(match.score).toBeGreaterThanOrEqual(2000);
expect(match.score).toBeLessThan(3000);
expect(match.snippets).toHaveLength(2);
expect(match.snippets[0]).toMatchObject({ field: source, text: expect.stringContaining("quasar") });
expect(match.snippet).toContain("quasar");
expect(match.href).toContain(source === "comment" ? `#comment-${sourceId}` : "#document-plan");
});
it("reflects edits, deleted comments and document updates immediately", async () => {
const companyId = await createCompany();
const task = await createIssue(companyId, { title: "Old uniquequartz title" });
const find = () => svc.search(companyId, companySearchQuerySchema.parse({ q: '"uniquequartz"' }));
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(issues).set({ title: "New title" }).where(sql`${issues.id} = ${task}`);
expect((await find()).results).toEqual([]);
const comment = randomUUID();
await db.insert(issueComments).values({ id: comment, companyId, issueId: task, body: "uniquequartz" });
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(issueComments).set({ deletedAt: new Date() }).where(sql`${issueComments.id} = ${comment}`);
expect((await find()).results).toEqual([]);
const doc = randomUUID();
await db.insert(documents).values({ id: doc, companyId, title: "Findings", latestBody: "uniquequartz", format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: task, documentId: doc, key: "plan" });
expect((await find()).results.map((row) => row.id)).toEqual([task]);
await db.update(documents).set({ latestBody: "Updated findings" }).where(sql`${documents.id} = ${doc}`);
expect((await find()).results).toEqual([]);
});
it("uses pg_trgm for conservative fuzzy title matches", async () => {
const companyId = await createCompany();
const issueId = await createIssue(companyId, {
@@ -3,6 +3,7 @@ import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
aiConnectionDefaults,
agentWakeupRequests,
issueComments,
companies,
@@ -792,4 +793,33 @@ describeEmbeddedPostgres("connectionIntentService", () => {
await expect(service.search(claims, "notion"))
.rejects.toThrow("no longer active");
});
it("keeps runtime authentication requests distinct from the same provider's tool requests", async () => {
const companyId = claims.company_id;
const agentId = randomUUID();
const issueId = randomUUID();
const aiRunId = randomUUID();
const binding = { provider: "anthropic", method: "api_key", mode: "responsible_user" } as const;
await db.insert(agents).values({ id: agentId, companyId, name: "AI Agent", adapterType: "claude_local", runtimeConfig: { aiConnection: binding } });
await db.insert(issues).values({ id: issueId, companyId, title: "AI authentication", status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: aiRunId, companyId, agentId, status: "running", responsibleUserId: claims.responsible_user_id, contextSnapshot: { issueId } });
const [app] = await db.insert(toolApplications).values({ companyId, applicationKey: "ai-intent-fixture", name: "AI intent fixture", type: "mcp_http", metadata: { sourceTemplateKey: "anthropic" } }).returning();
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: app!.id, name: "Personal Claude API", uid: `ai-${randomUUID()}`, connectionPurpose: "ai", transport: "runtime_auth", authKind: "api_key", credentialPolicy: "per_user", healthStatus: "ok", status: "active", enabled: true, config: { sourceTemplateKey: "anthropic", ai: { provider: "anthropic", method: "api_key" } } }).returning();
const [grant] = await db.insert(connectionGrants).values({ companyId, connectionId: connection!.id, kind: "user", subjectUserId: claims.responsible_user_id, createdByUserId: claims.responsible_user_id }).returning();
await db.insert(aiConnectionDefaults).values({ companyId, userId: claims.responsible_user_id!, provider: "anthropic", method: "api_key", grantId: grant!.id });
const aiClaims = { ...claims, sub: agentId, run_id: aiRunId };
const service = connectionIntentService(db);
const toolRequest = await service.request(aiClaims, "anthropic");
const aiRequest = await service.request(aiClaims, "anthropic", { purpose: "ai" });
expect(aiRequest.state).toBe("needs_user_action");
expect(aiRequest.interactionId).not.toBe(toolRequest.interactionId);
expect((await service.setupOptions(aiRequest.interactionId!)).aiConnection).toEqual(binding);
expect((await service.setupOptions(toolRequest.interactionId!)).existingConnections).toEqual([]);
await expect(service.complete(toolRequest.interactionId!, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy");
await expect(service.complete(aiRequest.interactionId!, connection!.id, claims.responsible_user_id!)).resolves.toMatchObject({ status: "accepted" });
expect((await service.request(aiClaims, "anthropic", { purpose: "ai" })).state).toBe("ready");
expect((await service.request(aiClaims, "anthropic")).state).toBe("needs_user_action");
expect((await service.search(aiClaims, "openrouter")).results.some(result => result.service === "openrouter")).toBe(false);
});
});
@@ -0,0 +1,77 @@
// Authored relevance judgments, independent of the ranker's scoring constants.
export const taskSearchCorpus = [
{ key: "id", identifier: "PAP-42", title: "Repair callback state", status: "done" },
{ key: "id-mention", title: "PAP-42 follow-up discussion" },
{ key: "id-neighbor", identifier: "PAP-420", title: "Repair callback state later" },
{ key: "oauth", title: "Fix GitHub OAuth callback", status: "done" },
{ key: "oauth-noise", title: "GitHub release checklist", comments: ["OAuth is mentioned in an unrelated weekly update."] },
{ key: "oauth-partial", title: "GitHub repository badges" },
{ key: "oauth-body", title: "Repair the connection flow", description: "GitHub OAuth callback loses state on redirect." },
{ key: "oauth-comment", title: "Investigate sign-in", comments: ["The GitHub OAuth callback loses state on redirect."] },
{ key: "oauth-doc", title: "Connection investigation", document: { title: "GitHub OAuth callback findings", body: "State is lost on redirect." } },
{ key: "search", title: "Improve search performance" },
{ key: "search-noise", title: "Improve exports", description: "A search for performance numbers was included in the meeting." },
{ key: "search-partial", title: "Search typography" },
{ key: "mobile", title: "Polish mobile navigation" },
{ key: "mobile-api", title: "Build mobile API" },
{ key: "mobile-ui", title: "Build mobile UI" },
{ key: "onboarding", title: "Onboarding wizard polish" },
{ key: "quoted", title: "Repair connection timeout handling" },
{ key: "quoted-scattered", title: "Connection retry after timeout" },
{ key: "cross", title: "Checkout ownership", comments: ["A concurrency race needs a regression test."] },
{ key: "cross-partial", title: "Checkout style guide" },
{ key: "document", title: "Adapter investigation", document: { title: "Hermes parser plan", body: "Discover plugins from their package manifest." } },
{ key: "percentage", title: "Release 100% checklist" },
{ key: "percentage-decoy", title: "Release 1000 checklist" },
{ key: "path", title: "Fix foo_bar lookup" },
{ key: "path-decoy", title: "Fix fooXbar lookup" },
{ key: "unicode", title: "Réparer navigation mobile" },
{ key: "identifier-code", title: "Document heartbeat_run_events retention" },
{ key: "word", title: "Fix API authentication" },
{ key: "word-decoy", title: "Capistrano migration", description: "API details appear here.", comments: ["API is an incidental mention."] },
{ key: "freshness", title: "Reconcile billing ledger", status: "done" },
{ key: "freshness-noise", title: "Weekly financial update", comments: ["Reconcile billing ledger was one of many completed projects."] },
] as const;
export type TaskSearchCase = {
name: string;
q: string;
relevant: Record<string, number>; // 3 = intended task; 2 = useful; 1 = incidental; absent = irrelevant
first?: string;
absent?: string[];
scope?: "all" | "issues" | "comments" | "documents";
};
export const taskSearchCases: TaskSearchCase[] = [
{ name: "exact identifier", q: "PAP-42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "identifier case", q: "pap-42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "compact identifier", q: "pap42", relevant: { id: 3, "id-neighbor": 1 }, first: "id" },
{ name: "spaced identifier", q: "PAP 42", relevant: { id: 3, "id-mention": 1, "id-neighbor": 1 }, first: "id" },
{ name: "title phrase", q: "GitHub OAuth", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2, "oauth-noise": 1 }, first: "oauth", absent: ["oauth-partial"] },
{ name: "reordered title words", q: "OAuth GitHub callback", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2 }, first: "oauth", absent: ["oauth-partial", "oauth-noise"] },
{ name: "title beats body chatter", q: "performance search", relevant: { search: 3, "search-noise": 1 }, first: "search", absent: ["search-partial"] },
{ name: "title beats incidental comment", q: "billing ledger", relevant: { freshness: 3, "freshness-noise": 1 }, first: "freshness" },
{ name: "filler words", q: "the GitHub OAuth callback", relevant: { oauth: 3, "oauth-body": 2, "oauth-comment": 2, "oauth-doc": 2 }, first: "oauth", absent: ["oauth-noise"] },
{ name: "quoted phrase", q: '"connection timeout"', relevant: { quoted: 3 }, first: "quoted", absent: ["quoted-scattered"] },
{ name: "quoted phrase plus term", q: 'repair "connection timeout"', relevant: { quoted: 3 }, first: "quoted", absent: ["quoted-scattered"] },
{ name: "transposition", q: "serach", relevant: { search: 3, "search-partial": 3 } },
{ name: "substitution", q: "mibile navigation", relevant: { mobile: 3, unicode: 3 }, absent: ["mobile-api", "mobile-ui"] },
{ name: "two missing letters", q: "onbordng wizard", relevant: { onboarding: 3 }, first: "onboarding" },
{ name: "short token constrains typo", q: "mibile api", relevant: { "mobile-api": 3 }, first: "mobile-api", absent: ["mobile", "mobile-ui"] },
{ name: "cross-field thread", q: "checkout concurrency", relevant: { cross: 3 }, first: "cross", absent: ["cross-partial"] },
{ name: "document title", q: "Hermes parser", relevant: { document: 3 }, first: "document" },
{ name: "document body", q: "plugins manifest", relevant: { document: 3 }, first: "document" },
{ name: "literal percent", q: "100%", relevant: { percentage: 3 }, first: "percentage", absent: ["percentage-decoy"] },
{ name: "literal underscore", q: "foo_bar", relevant: { path: 3 }, first: "path", absent: ["path-decoy"] },
{ name: "code identifier", q: "heartbeat_run_events", relevant: { "identifier-code": 3 }, first: "identifier-code" },
{ name: "unicode", q: "réparer mobile", relevant: { unicode: 3 }, first: "unicode" },
{ name: "whole word title", q: "api", relevant: { word: 3, "mobile-api": 3, "word-decoy": 1 } },
{ name: "no result", q: "quasarxylophone", relevant: {} },
];
export function searchQualityMetrics(keys: string[], relevant: Record<string, number>) {
const gain = (grade: number, index: number) => (2 ** grade - 1) / Math.log2(index + 2);
const dcg = keys.slice(0, 5).reduce((sum, key, index) => sum + gain(relevant[key] ?? 0, index), 0);
const ideal = Object.values(relevant).sort((a, b) => b - a).slice(0, 5).reduce((sum, grade, index) => sum + gain(grade, index), 0);
const rank = keys.findIndex((key) => relevant[key] === 3);
return { ndcg5: ideal === 0 ? Number(keys.length === 0) : dcg / ideal, reciprocalRank: rank < 0 ? 0 : 1 / (rank + 1) };
}
@@ -106,6 +106,7 @@ describe("GET /health dev-server supervisor access", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
localAiLoginSupported: true,
commit: null,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
+3
View File
@@ -343,6 +343,7 @@ describe("GET /health", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
localAiLoginSupported: false,
commit: testServerInfo.git.fullSha,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
@@ -400,6 +401,7 @@ describe("GET /health", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
localAiLoginSupported: false,
commit: testServerInfo.git.fullSha,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
@@ -438,6 +440,7 @@ describe("GET /health", () => {
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "public",
localAiLoginSupported: false,
commit: testServerInfo.git.fullSha,
bootstrapStatus: "ready",
bootstrapInviteActive: false,
@@ -149,6 +149,142 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => {
return { companyId, ownerUserId, agentId };
}
it("dispatches an interrupted queue under the clicking operator through the real startup path", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`, issueId = randomUUID(), commentId = randomUUID(), queueId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Interrupted queue", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: ownerUserId, body: "Continue the task" });
await db.insert(agentWakeupRequests).values({ id: queueId, companyId, agentId,
source: "automation", status: "deferred_issue_execution", requestedByActorType: "system",
payload: { issueId, commentId, queuedCommentInterrupt: { actorId: operatorId, requestedAt: new Date().toISOString() },
_paperclipWakeContext: { wakeCommentIds: [commentId], responsibleUserId: ownerUserId,
retryOfRunId: randomUUID(), originIdentityContextId: randomUUID() } },
});
await heartbeat.resumeQueuedCommentInterrupt(companyId, queueId);
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, queueId));
expect(receipt.status).toBe("coalesced");
const completed = await waitForRun(db, receipt.runId!);
expect(completed).toMatchObject({ status: "succeeded", responsibleUserId: operatorId });
expect(completed?.activeIdentityContextId).toBeTruthy();
expect(completed?.contextSnapshot?.originIdentityContextId).toBeUndefined();
expect(completed?.contextSnapshot?.retryOfRunId).toBeUndefined();
expect(mockAdapterExecute).toHaveBeenCalled();
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
expect(runs.every(run => run.responsibleUserId === operatorId && run.status === "succeeded")).toBe(true);
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0].authorUserId).toBe(ownerUserId);
});
it("keeps a board manual wake under its caller even when it adopts someone else's queue", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`, issueId = randomUUID(), commentId = randomUUID(), queueId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Manual wake", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: ownerUserId, body: "Pending work" });
await db.insert(agentWakeupRequests).values({ id: queueId, companyId, agentId,
source: "automation", reason: "issue_commented", status: "deferred_issue_execution", requestedByActorType: "user", requestedByActorId: ownerUserId,
payload: { issueId, commentId, _paperclipWakeContext: { wakeCommentIds: [commentId] } },
});
const run = await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
payload: { issueId }, requestedByActorType: "user", requestedByActorId: operatorId,
contextSnapshot: { responsibleUserId: operatorId } });
expect(run?.responsibleUserId).toBe(operatorId);
const completed = await waitForRun(db, run!.id);
expect(completed).toMatchObject({ status: "succeeded", responsibleUserId: operatorId });
expect(completed?.contextSnapshot?.wakeCommentIds).toEqual([commentId]);
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect((await db.select().from(heartbeatRuns)).every(row => row.responsibleUserId === operatorId)).toBe(true);
});
it("keeps the clicking user when a manual wake merges into an older deferred receipt", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`, issueId = randomUUID(), commentId = randomUUID(), queueId = randomUUID();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Deferred manual wake", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId });
let finish!: () => void;
const blocked = new Promise<void>(resolve => { finish = resolve; });
const execute = mockAdapterExecute.getMockImplementation()!;
mockAdapterExecute.mockImplementationOnce(async () => { await blocked; return execute(); });
const first = await heartbeat.wakeup(agentId, { payload: { issueId },
requestedByActorType: "user", requestedByActorId: ownerUserId });
try {
await vi.waitFor(() => expect(mockAdapterExecute).toHaveBeenCalled(), { timeout: 5_000 });
await db.insert(issueComments).values({ id: commentId, companyId, issueId, authorUserId: ownerUserId, body: "Pending work" });
await db.insert(agentWakeupRequests).values({ id: queueId, companyId, agentId,
source: "automation", reason: "issue_commented", status: "deferred_issue_execution",
requestedByActorType: "user", requestedByActorId: ownerUserId,
payload: { issueId, commentId, _paperclipWakeContext: { wakeCommentIds: [commentId] } },
});
expect(await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
payload: { issueId }, requestedByActorType: "user", requestedByActorId: operatorId,
contextSnapshot: { responsibleUserId: operatorId } })).toBeNull();
const [pending] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, queueId));
expect(pending).toMatchObject({ requestedByActorType: "user", requestedByActorId: operatorId,
payload: { manualUserWake: true } });
} finally {
finish();
}
await drainHeartbeatRunsToQuiescence(db, heartbeat);
const successors = (await db.select().from(heartbeatRuns)).filter(run => run.id !== first!.id);
expect(successors.length).toBeGreaterThan(0);
expect(successors.every(run => run.responsibleUserId === operatorId && run.status === "succeeded")).toBe(true);
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0].authorUserId).toBe(ownerUserId);
});
it("starts an unscoped manual wake with its own user instead of joining another user's run", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const operatorId = `operator-${randomUUID()}`;
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: operatorId,
membershipRole: "operator", status: "active" });
let finish!: () => void;
const blocked = new Promise<void>(resolve => { finish = resolve; });
const execute = mockAdapterExecute.getMockImplementation()!;
mockAdapterExecute.mockImplementationOnce(async () => { await blocked; return execute(); });
const first = await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
requestedByActorType: "user", requestedByActorId: ownerUserId });
let second: Awaited<ReturnType<typeof heartbeat.wakeup>>;
try {
await vi.waitFor(() => expect(mockAdapterExecute).toHaveBeenCalled(), { timeout: 5_000 });
second = await heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
requestedByActorType: "user", requestedByActorId: operatorId });
expect(second?.id).not.toBe(first!.id);
expect(second?.responsibleUserId).toBe(operatorId);
} finally {
finish();
}
await drainHeartbeatRunsToQuiescence(db, heartbeat);
expect(await waitForRun(db, second!.id)).toMatchObject({ status: "succeeded", responsibleUserId: operatorId });
expect(await waitForRun(db, first!.id)).toMatchObject({ status: "succeeded", responsibleUserId: ownerUserId });
});
it("denies a manual wake of another user's private conversation", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const issueId = randomUUID();
await db.insert(issues).values({ id: issueId, companyId, title: "Private conversation", status: "todo",
assigneeAgentId: agentId, responsibleUserId: ownerUserId, conversationAgentId: agentId, conversationUserId: ownerUserId, conversationState: "active" });
await expect(heartbeat.wakeup(agentId, { manualUserWake: true, source: "on_demand", triggerDetail: "manual",
payload: { issueId }, requestedByActorType: "user", requestedByActorId: "another-user" })).rejects.toThrow("conversation owner");
expect(mockAdapterExecute).not.toHaveBeenCalled();
expect(await db.select().from(heartbeatRuns)).toHaveLength(0);
});
it("does not accept a caller-supplied manual-wake authority marker", async () => {
const { companyId, agentId, ownerUserId } = await seedCompany();
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual",
requestedByActorType: "agent", requestedByActorId: agentId, payload: { manualUserWake: true },
contextSnapshot: { responsibleUserId: ownerUserId } });
expect((await waitForRun(db, run!.id))?.status).toBe("succeeded");
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, run!.wakeupRequestId!));
expect(wake.payload?.manualUserWake).toBeUndefined();
});
it("uses the issue responsible user for automated dependency wakes without a message context", async () => {
const { companyId, agentId } = await seedCompany();
const issueResponsibleUserId = `issue-owner-${randomUUID()}`;
@@ -1638,6 +1638,11 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => {
originCommentIds: [wakeCommentId],
interactionResolvedAt: new Date().toISOString(), mutation: "interaction", source: `${interactionKind}.resolved`, forceFreshSession: true } });
await heartbeat.resumeQueuedRuns();
expect(await waitForCondition(async () => (await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]?.status === "succeeded")).toBe(true);
// Terminal status precedes completion bookkeeping. Drain those writes before
// afterEach truncates the fixture, otherwise PostgreSQL can deadlock.
await heartbeat.waitForRunExecutionDrain(runId);
expect(countExecuteCallsForRun(runId)).toBe(1);
await waitForCondition(async () => claimedIssue !== null);
expect(claimedIssue).toEqual({ status: "in_progress", executionRunId: runId });
});
@@ -14,13 +14,14 @@ import {
createDb,
heartbeatRuns,
issueComments,
issueRecoveryActions,
issues,
runIdentityContexts,
} from "@paperclipai/db";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { heartbeatService } from "../services/heartbeat.js";
import { reconcileSteeredIdentity } from "../services/run-identity.js";
import { initializeRunIdentity, reconcileSteeredIdentity } from "../services/run-identity.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -195,6 +196,145 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
},
);
it("does not accept interruption authority from an agent wake payload", async () => {
const seeded = await seedQueue();
await db.update(agents).set({ adapterType: "claude_local",
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } },
}).where(eq(agents.id, seeded.agentId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, seeded.runId));
await heartbeatService(db).wakeup(seeded.agentId, {
source: "on_demand", reason: "issue_commented",
requestedByActorType: "agent", requestedByActorId: seeded.agentId,
payload: { issueId: seeded.issueId, commentId: seeded.commentIds[1],
queuedCommentInterrupt: { actorId: "other-operator", requestedAt: new Date().toISOString() } },
contextSnapshot: { issueId: seeded.issueId, wakeCommentId: seeded.commentIds[1] },
});
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, seeded.companyId));
expect(wakes.length).toBeGreaterThan(0);
expect(wakes.every(wake => !wake.payload?.queuedCommentInterrupt)).toBe(true);
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId));
expect(runs.find(run => run.id === seeded.runId)?.status).toBe("running");
expect(runs.every(run => !run.contextSnapshot?.explicitUserContinuation)).toBe(true);
});
it("denies a viewer's interrupt before persisting intent or cancelling a run", async () => {
const seeded = await seedQueue();
await db.update(companyMemberships).set({ membershipRole: "viewer" })
.where(eq(companyMemberships.principalId, "other-operator"));
const client = app(seeded.companyId, "other-operator");
const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
await request(client).post(`/api/issues/${seeded.issueId}/queued-comments/interrupt`).send({
queueId: seeded.wakeId, revision: queue.body.revision, targetRunId: seeded.runId,
}).expect(403);
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(wake.payload?.queuedCommentInterrupt).toBeUndefined();
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId)))[0].status).toBe("running");
});
it.each([null, "stopped-target", "system-receipt"])("sends a stopped legacy queue once with target %s", async (target) => {
const seeded = await seedQueue();
if (target === "system-receipt") await db.update(agentWakeupRequests).set({
requestedByActorType: "system", requestedByActorId: "heartbeat",
}).where(eq(agentWakeupRequests.id, seeded.wakeId));
await db.update(agents).set({ adapterType: "claude_local",
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } },
}).where(eq(agents.id, seeded.agentId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "succeeded",
finishedAt: new Date("2026-08-22T15:03:00.000Z"),
}).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seeded.issueId));
// Occupy this agent on a different task so the actual successor remains
// queued and the test never launches a provider.
await db.insert(heartbeatRuns).values({ companyId: seeded.companyId, agentId: seeded.agentId,
status: "running", contextSnapshot: { issueId: randomUUID() },
});
const client = app(seeded.companyId, "other-operator");
const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
expect(queue.body.targetRunId).toBeNull();
const body = { queueId: seeded.wakeId, revision: queue.body.revision,
targetRunId: target === "stopped-target" ? seeded.runId : null };
await request(client).post(`/api/issues/${seeded.issueId}/queued-comments/interrupt`).send(body).expect(200);
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(wake.status).toBe("coalesced");
const [successor] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, wake.runId!));
expect(successor.status).toBe("queued");
expect(successor.responsibleUserId).toBe("other-operator");
const identity = await initializeRunIdentity(db, {
companyId: seeded.companyId, issueId: seeded.issueId,
runId: successor.id, messageIds: seeded.commentIds, responsibleUserId: "queue-owner", cause: "dispatch",
});
expect(identity.responsibleUserId).toBe("other-operator");
expect(identity.cause).toBe("queued_comment_interrupt");
expect(successor.contextSnapshot?.wakeCommentIds).toEqual(seeded.commentIds);
await heartbeatService(db).resumeQueuedCommentInterrupt(seeded.companyId, seeded.wakeId);
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(3);
await request(client).post(`/api/issues/${seeded.issueId}/queued-comments/interrupt`).send(body).expect(409);
});
it.each(["user", "system"])("keeps stopped-run interruption intent on a %s receipt across restart until the process stops, then delivers once", async (actorType) => {
const seeded = await seedQueue();
await db.update(agentWakeupRequests).set({ requestedByActorType: actorType })
.where(eq(agentWakeupRequests.id, seeded.wakeId));
await db.update(agents).set({ adapterType: "claude_local",
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } },
}).where(eq(agents.id, seeded.agentId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "failed",
processPid: process.pid, errorCode: "process_lost",
finishedAt: new Date("2026-08-22T15:03:00.000Z"),
}).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seeded.issueId));
await db.insert(issueRecoveryActions).values({ companyId: seeded.companyId, sourceIssueId: seeded.issueId,
kind: "active_run_watchdog", cause: "legacy_execution_requires_reconciliation", fingerprint: seeded.runId,
status: "resolved", outcome: "blocked", nextAction: "Automatic recovery stopped.",
evidence: { runId: seeded.runId, automaticRecovery: { replay: "blocked", actionOutcome: "unknown" } },
});
await db.insert(heartbeatRuns).values({ companyId: seeded.companyId, agentId: seeded.agentId,
status: "running", contextSnapshot: { issueId: randomUUID() },
});
const client = app(seeded.companyId, "other-operator");
const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200);
await request(client).post(`/api/issues/${seeded.issueId}/queued-comments/interrupt`).send({
queueId: seeded.wakeId, revision: queue.body.revision, targetRunId: null,
}).expect(200);
const [waiting] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(waiting.status).toBe("deferred_issue_execution");
expect(waiting.payload?.queuedCommentInterrupt).toMatchObject({ actorId: "other-operator" });
expect(waiting.payload?.executionWait).toMatchObject({ reason: "process_running" });
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(2);
await db.update(heartbeatRuns).set({ processPid: 999999999 }).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(agentWakeupRequests).set({ updatedAt: new Date(0) }).where(eq(agentWakeupRequests.id, seeded.wakeId));
// New service instances have no memory of the HTTP request. Concurrent
// periodic workers must consume its durable receipt exactly once.
await Promise.all([heartbeatService(db).resumeQueuedRuns(), heartbeatService(db).resumeQueuedRuns()]);
const [delivered] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(delivered.status).toBe("coalesced");
const [successor] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, delivered.runId!));
expect(successor.contextSnapshot).toMatchObject({ wakeCommentIds: seeded.commentIds,
previousRunId: seeded.runId, forceFreshSession: true });
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(3);
});
it("recovers a message deferred after legacy finalization released the task lock", async () => {
const seeded = await seedQueue();
await db.update(agents).set({ adapterType: "claude_local",
runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } },
}).where(eq(agents.id, seeded.agentId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "succeeded",
finishedAt: new Date("2026-08-22T15:03:00.000Z"),
}).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seeded.issueId));
await db.insert(heartbeatRuns).values({ companyId: seeded.companyId, agentId: seeded.agentId,
status: "running", contextSnapshot: { issueId: randomUUID() },
});
await heartbeatService(db).resumeQueuedRuns();
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId));
expect(wake.status).toBe("queued");
const [successor] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, wake.runId!));
expect(successor.contextSnapshot?.wakeCommentIds).toEqual(seeded.commentIds);
await heartbeatService(db).resumeQueuedRuns();
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(3);
});
async function promoteQueue(seeded: Awaited<ReturnType<typeof seedQueue>>) {
const queueRunId = randomUUID();
const wake = await db
+2 -2
View File
@@ -1466,7 +1466,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([recentMediumIssueId]);
});
it("ranks comment matches ahead of description-only matches", async () => {
it("ranks direct description matches ahead of comment-only matches", async () => {
const companyId = randomUUID();
const commentMatchId = randomUUID();
const descriptionMatchId = randomUUID();
@@ -1508,7 +1508,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
includeRoutineExecutions: true,
});
expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]);
expect(result.map((issue) => issue.id)).toEqual([descriptionMatchId, commentMatchId]);
});
it("filters issue lists to the full descendant tree for a root issue", async () => {
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { chmod, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { readLocalAiCredentialFile } from "../services/local-ai-credential-file.js";
let home: string;
beforeEach(async () => { home = await realpath(await mkdtemp(path.join(os.tmpdir(), "ai-auth-read-"))); });
afterEach(async () => { await rm(home, { recursive: true, force: true }); });
describe("isolated credential file safety", () => {
it("reads only bounded private regular files", async () => {
const filename = path.join(home, "credentials.json");
await writeFile(filename, "fixture", { mode: 0o600 });
await expect(readLocalAiCredentialFile(filename)).resolves.toBe("fixture");
await chmod(filename, 0o644);
await expect(readLocalAiCredentialFile(filename)).rejects.toThrow();
await chmod(filename, 0o600);
await writeFile(filename, Buffer.alloc(64 * 1024 + 1));
await expect(readLocalAiCredentialFile(filename)).rejects.toThrow();
await expect(readLocalAiCredentialFile(home)).rejects.toThrow();
});
it("rejects file and ancestor symlinks", async () => {
const filename = path.join(home, "credentials.json");
await writeFile(filename, "fixture", { mode: 0o600 });
await symlink(filename, path.join(home, "linked.json"));
await expect(readLocalAiCredentialFile(path.join(home, "linked.json"))).rejects.toThrow();
await symlink(home, path.join(home, "linked-home"));
await expect(readLocalAiCredentialFile(path.join(home, "linked-home", "credentials.json"))).rejects.toThrow();
});
});
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
const mocks = vi.hoisted(() => ({ claude: vi.fn(), claudeQuota: vi.fn(), codex: vi.fn(), codexQuota: vi.fn(), readFile: vi.fn(), credentialFile: vi.fn() }));
vi.mock("@paperclipai/adapter-claude-local/server", () => ({ readClaudeToken: mocks.claude, fetchClaudeQuota: mocks.claudeQuota }));
vi.mock("@paperclipai/adapter-codex-local/server", () => ({ readCodexAuthInfo: mocks.codex, fetchCodexQuota: mocks.codexQuota }));
vi.mock("../services/local-ai-credential-file.js", () => ({ readLocalAiCredentialFile: mocks.credentialFile }));
vi.mock("node:fs/promises", () => ({ default: { readFile: mocks.readFile } }));
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); });
describe("explicit local subscription import", () => {
it("verifies Claude only from the selected isolated home, never the host account", async () => {
mocks.credentialFile.mockResolvedValue(JSON.stringify({ claudeAiOauth: { accessToken: "isolated-claude" } }));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("isolated-claude");
expect(mocks.credentialFile).toHaveBeenCalledWith("/isolated/claude/.credentials.json");
expect(mocks.claudeQuota).toHaveBeenCalledWith("isolated-claude");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("does not fall back to ambient Claude auth when an isolated login is absent or invalid", async () => {
mocks.claude.mockResolvedValue("server-operator-token");
mocks.credentialFile.mockRejectedValue(new Error("No file"));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
mocks.credentialFile.mockResolvedValue("malformed");
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).rejects.toThrow("sign-in command shown");
expect(mocks.claude).not.toHaveBeenCalled();
expect(mocks.claudeQuota).not.toHaveBeenCalled();
});
it("tries the alternate Claude filename after malformed JSON", async () => {
mocks.credentialFile.mockResolvedValueOnce("malformed").mockResolvedValueOnce(JSON.stringify({ claudeAiOauth: { accessToken: "alternate-token" } }));
await expect(readVerifiedLocalAiCredential("anthropic", "/isolated/claude")).resolves.toBe("alternate-token");
expect(mocks.credentialFile).toHaveBeenLastCalledWith("/isolated/claude/credentials.json");
expect(mocks.claude).not.toHaveBeenCalled();
});
it("verifies Claude's local credential, including explicit Keychain access", async () => {
mocks.claude.mockResolvedValue("fixture-claude");
await expect(readVerifiedLocalAiCredential("anthropic")).resolves.toBe("fixture-claude");
expect(mocks.claude).toHaveBeenCalledWith({ allowKeychain: true });
expect(mocks.claudeQuota).toHaveBeenCalledWith("fixture-claude");
});
it("reads Codex refresh credentials only from the isolated login home", async () => {
mocks.codex.mockResolvedValue({ accessToken: "access", refreshToken: "refresh", idToken: "identity", accountId: "account", lastRefresh: "date" });
const result = JSON.parse(await readVerifiedLocalAiCredential("openai", "/isolated/login"));
expect(result.tokens).toEqual({ access_token: "access", refresh_token: "refresh", id_token: "identity", account_id: "account" });
expect(mocks.codexQuota).toHaveBeenCalledWith("access", "account");
expect(mocks.codex).toHaveBeenCalledWith("/isolated/login");
});
it("verifies a Grok subscription against a fixed endpoint before saving", async () => {
const credential = JSON.stringify({ "https://issuer.x.ai::11111111-1111-4111-8111-111111111111": { key: "fixture-key", refresh_token: "fixture-refresh" } });
mocks.readFile.mockResolvedValue(credential);
const fetch = vi.fn().mockResolvedValue(new Response("{}")); vi.stubGlobal("fetch", fetch);
await expect(readVerifiedLocalAiCredential("xai", "/isolated/grok")).resolves.toBe(credential);
expect(mocks.readFile).toHaveBeenCalledWith("/isolated/grok/auth.json", "utf8");
expect(fetch).toHaveBeenCalledWith("https://api.x.ai/v1/models", expect.objectContaining({ redirect: "error" }));
});
it("rejects missing and invalid logins with actionable, redacted errors", async () => {
mocks.claude.mockResolvedValue(null);
await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow("claude auth login");
mocks.claude.mockResolvedValue("fixture-secret");
mocks.claudeQuota.mockRejectedValue(new Error("credential fixture-secret rejected"));
await expect(readVerifiedLocalAiCredential("anthropic")).rejects.toThrow(/^Could not verify the local subscription\. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again\.$/);
mocks.codex.mockResolvedValue({ accessToken: "incomplete" });
await expect(readVerifiedLocalAiCredential("openai", "/isolated/login")).rejects.toThrow("sign-in command shown");
expect(mocks.codexQuota).not.toHaveBeenCalled();
});
it.each(["openai", "xai"] as const)("never clones the ambient rotating %s login", async (provider) => {
await expect(readVerifiedLocalAiCredential(provider)).rejects.toThrow("separate local sign-in");
expect(mocks.codex).not.toHaveBeenCalled();
expect(mocks.readFile).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
describe("server-host subscription login policy", () => {
it("permits private self-hosted instances and explicit trusted hosts", () => {
expect(supportsLocalAiLogin({ deploymentMode: "local_trusted", deploymentExposure: "private" })).toBe(true);
expect(supportsLocalAiLogin({ deploymentMode: "authenticated", deploymentExposure: "private" })).toBe(true);
expect(supportsLocalAiLogin({ deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "trusted-host" })).toBe(true);
expect(supportsLocalAiLogin({ deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: "" })).toBe(false);
});
});
+18 -1
View File
@@ -20,6 +20,7 @@ const apiPrefixes: Record<string, string> = {
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"ai-connections.ts": "/api",
"attention.ts": "/api",
"approvals.ts": "/api",
"assets.ts": "/api",
@@ -391,6 +392,7 @@ describe("openapi routes", () => {
["post", "/api/chat-endpoints/{endpointId}/setup"],
["post", "/api/chat-endpoints/{endpointId}/setup-secret"],
["post", "/api/chat-endpoints/{endpointId}/test"],
["post", "/api/chat-endpoints/{endpointId}/photon/inspect"],
["get", "/api/chat-endpoints/{endpointId}/resources"],
["put", "/api/chat-endpoints/{endpointId}/resources"],
["get", "/api/chat-endpoints/{endpointId}/principals"],
@@ -455,7 +457,7 @@ describe("openapi routes", () => {
properties: {
provider: {
type: "string",
enum: ["slack", "github", "discord", "microsoft-teams", "telegram"],
enum: ["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"],
},
assignedAgentId: { type: "string", format: "uuid" },
},
@@ -515,6 +517,21 @@ describe("openapi routes", () => {
);
expect(setup.responses["409"]).toBeDefined();
expect(setup.responses["422"]).toBeDefined();
expect(setup.responses["502"]).toBeDefined();
expect(setup.responses["503"]).toBeDefined();
const photon = spec.paths["/api/chat-endpoints/{endpointId}/photon/inspect"].post;
expect(photon.requestBody.content["application/json"].schema.required).toEqual([
"projectId", "projectSecret",
]);
const photonResponse = photon.responses["200"].content["application/json"].schema;
expect(photonResponse.properties.allocation.enum).toEqual(["dedicated", "shared"]);
expect(photonResponse.properties.lines.items.additionalProperties).toBe(false);
expect(JSON.stringify(photonResponse)).not.toMatch(/projectSecret|token/);
expect(photon.responses["422"]).toBeDefined();
expect(photon.responses["429"]).toBeDefined();
expect(photon.responses["502"]).toBeDefined();
expect(photon.responses["503"]).toBeDefined();
const setupSecret =
spec.paths["/api/chat-endpoints/{endpointId}/setup-secret"].post;
@@ -20,6 +20,7 @@ describe("opencode_local environment diagnostics", () => {
config: {
command: process.execPath,
cwd,
env: { XDG_CONFIG_HOME: path.join(cwd, "config") },
},
});
@@ -45,6 +46,7 @@ describe("opencode_local environment diagnostics", () => {
cwd,
env: {
OPENAI_API_KEY: "",
XDG_CONFIG_HOME: path.join(cwd, "config"),
},
},
});
@@ -84,6 +86,7 @@ describe("opencode_local environment diagnostics", () => {
config: {
command: fakeOpencode,
cwd,
env: { XDG_CONFIG_HOME: path.join(cwd, "config") },
},
});
@@ -34,6 +34,9 @@ vi.mock("../services/issue-assignment-wakeup.js", () => ({
queueIssueAssignmentWakeup: vi.fn(),
}));
import { activityRoutes } from "../routes/activity.js";
import { issueRoutes } from "../routes/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -52,10 +55,6 @@ function agentActor(companyId: string, agentId: string): Express.Request["actor"
async function createApp(db: Db, actor: Express.Request["actor"]) {
process.env.PAPERCLIP_LOG_DIR = "/tmp/paperclip-test-home/logs";
process.env.PAPERCLIP_IN_WORKTREE = "false";
const [{ activityRoutes }, { issueRoutes }] = await Promise.all([
import("../routes/activity.js"),
import("../routes/issues.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
File diff suppressed because it is too large. Load diff
+222
View File
@@ -0,0 +1,222 @@
import { vi } from "vitest";
import {
TypedEventStream,
type GrpcAdvancedIMessage,
type Chat,
type Message,
type LiveEvent,
type CatchUpEvent,
} from "@photon-ai/advanced-imessage";
import type {
ChatSdkStatePersistence,
ChatSdkStateRecord,
} from "../../services/chat-sdk-state.js";
import { PhotonState } from "../../services/photon/state.js";
import {
PhotonCloudClient,
PhotonLineAuthentication,
} from "../../services/photon/cloud.js";
import {
PhotonChatAdapter,
photonThreadId,
} from "../../services/photon/adapter.js";
export function memoryPersistence(): ChatSdkStatePersistence {
const rows = new Map<string, ChatSdkStateRecord>();
const key = (scope: { companyId: string; endpointId: string }, key: string) =>
`${scope.companyId}:${scope.endpointId}:${key}`;
return {
async read(scope, id) {
return structuredClone(rows.get(key(scope, id)) ?? null);
},
async compareAndSet(input) {
const id = key(input, input.key),
current = rows.get(id);
if ((current?.version ?? null) !== input.expectedVersion) return false;
rows.set(id, {
value: structuredClone(input.value),
version: (current?.version ?? 0) + 1,
expiresAt: input.expiresAt,
});
return true;
},
async deleteIfVersion(input) {
const id = key(input, input.key);
if (rows.get(id)?.version !== input.expectedVersion) return false;
return rows.delete(id);
},
};
}
export function stream<T>(values: T[]): TypedEventStream<T> {
return new TypedEventStream(
(async function* () {
for (const value of values) yield value;
})(),
async () => {},
);
}
export function photonChat(
guid = "iMessage;-;+15555550101",
isGroup = false,
): Chat {
return {
guid,
service: "iMessage",
isGroup,
isArchived: false,
displayName: isGroup ? "Test group" : "",
participants: [{ address: "+15555550101", service: "iMessage" }],
properties: {},
} as unknown as Chat;
}
export function photonMessage(
guid: string,
chatGuid: string,
text = "Hello",
fromMe = false,
): Message {
return {
guid,
chatGuids: [chatGuid],
content: { text, attachments: [], parts: [], mentions: [] },
sender: { address: "+15555550101", service: "iMessage" },
dateCreated: new Date(),
isFromMe: fromMe,
isSystemMessage: false,
isServiceMessage: false,
} as unknown as Message;
}
export function photonEvent(
sequence: number,
chat = photonChat(),
text = "Hello",
): LiveEvent {
return {
type: "message.received",
chatGuid: chat.guid,
message: photonMessage(`message-${sequence}`, chat.guid, text),
sequence,
occurredAt: new Date(),
isFromMe: false,
} as LiveEvent;
}
export function photonFixture() {
const persistence = memoryPersistence();
const state = new PhotonState(
{ companyId: "company", endpointId: "endpoint" },
persistence,
);
const cloud = new PhotonCloudClient();
const allocation = vi.spyOn(cloud, "allocation").mockResolvedValue({
inspection: {
projectId: "project",
projectName: "Tests",
allocation: "dedicated",
eligible: true,
lines: [{ lineId: "line", phoneNumber: "+15555550100", eligible: true }],
},
tokens: new Map([["line", "private-line-token"]]),
expiresIn: 300,
});
const authentication = new PhotonLineAuthentication(
{ projectId: "project", lineId: "line", phoneNumber: "+15555550100" },
"private-project-secret",
cloud,
);
const chat = photonChat();
const receipts = new Map<string, Message>();
const polls = new Map<
string,
{
pollMessageGuid: string;
options: { optionIdentifier: string; text: string }[];
}
>();
const events: CatchUpEvent[] = [];
const sendText = vi.fn(
async (
chatGuid: string,
text: string,
opts: { clientMessageId: string },
) => {
if (!receipts.has(opts.clientMessageId))
receipts.set(
opts.clientMessageId,
photonMessage(`sent-${receipts.size}`, chatGuid, text, true),
);
return receipts.get(opts.clientMessageId)!;
},
);
const client = {
close: vi.fn(async () => {}),
chats: {
get: vi.fn(async () => chat),
setTyping: vi.fn(async () => {}),
subscribeEvents: vi.fn(() => stream([])),
},
messages: {
sendText,
sendAttachment: vi.fn(
async (
chatGuid: string,
attachment: string,
opts: { clientMessageId: string },
) => sendText(chatGuid, attachment, opts),
),
edit: vi.fn(),
get: vi.fn(async (id) => photonMessage(id, chat.guid)),
subscribeEvents: vi.fn(() => stream([])),
},
attachments: {
upload: vi.fn(async () => ({ attachment: { guid: "uploaded-file" } })),
downloadStream: vi.fn(),
},
groups: { subscribeEvents: vi.fn(() => stream([])) },
polls: {
create: vi.fn(
async (
_chat: string,
_title: string,
labels: string[],
opts: { clientMessageId: string },
) => {
if (!polls.has(opts.clientMessageId))
polls.set(opts.clientMessageId, {
pollMessageGuid: `poll-${polls.size}`,
options: labels.map((text, index) => ({
optionIdentifier: `${polls.size}-option-${index}`,
text,
})),
});
return polls.get(opts.clientMessageId)!;
},
),
subscribeEvents: vi.fn(() => stream([])),
},
events: { catchUp: vi.fn(() => stream(events)) },
};
const adapter = new PhotonChatAdapter(
"Agent",
authentication,
state,
client as unknown as GrpcAdvancedIMessage,
);
const threadId = photonThreadId({
lineId: "line",
chatGuid: chat.guid,
isGroup: false,
});
return {
state,
persistence,
allocation,
cloud,
authentication,
chat,
client,
adapter,
threadId,
receipts,
events,
polls,
};
}
@@ -0,0 +1 @@
Synthetic HEIC fixture generated on macOS from a 16 × 16 solid RGB (40, 120, 180) PNG with `sips -s format heic`. Contains no personal photo or metadata. The test exercises the installed heif2jpeg native binary. This does not replace qualification with an actual iPhone photo.
Binary file not shown.
+712
View File
@@ -0,0 +1,712 @@
import { describe, it, expect, vi } from "vitest";
import { Client, Server, ServerCredentials, credentials } from "@grpc/grpc-js";
import { IMessageError } from "@photon-ai/advanced-imessage";
import type { AskUserQuestionsInteraction } from "@paperclipai/shared";
import {
PhotonCloudClient,
PhotonLineAuthentication, photonSharedIdentity, photonSharedScope,
PhotonError,
photonFailure,
} from "../../services/photon/cloud.js";
import { PhotonReceiver } from "../../services/photon/receiver.js";
import {
PhotonChatAdapter,
splitPhotonText,
} from "../../services/photon/adapter.js";
import { PhotonState } from "../../services/photon/state.js";
import {
photonCatchUpRequest,
photonEnvelopeSequence,
decodePhotonRecoveryFrame,
PhotonRecoveryTransport,
PHOTON_CATCHUP_PATH,
} from "../../services/photon/recovery-transport.js";
import {
publishPhotonPrompt,
parsePhotonQuestionAnswer,
photonResponseCommand,
} from "../../services/photon/interactions.js";
import { photonFixture, photonEvent, stream } from "./fixture.js";
import {
downloadPhotonAttachment,
takePhotonCompanion,
} from "../../services/photon/attachments.js";
import {
photonHeifPreview,
validateHeifDimensions,
validatePhotonImage,
} from "../../services/photon/media.js";
import sharp from "sharp";
import { readFile } from "node:fs/promises";
const question = (id = "interaction"): AskUserQuestionsInteraction =>
({
id,
kind: "ask_user_questions",
payload: {
questions: [
{
id: "q1",
prompt: "Same title",
options: [
{ id: "a", label: "Same" },
{ id: "b", label: "Same" },
],
selectionMode: "single",
allowOther: false,
required: true,
},
],
},
}) as AskUserQuestionsInteraction;
const guard = async () => {};
describe("Photon Cloud and fixed line identity", () => {
it("uses Basic project auth, verifies dedicated allocation, and never exposes tokens", async () => {
const fetcher = vi.fn(
async (url: string | URL | Request, init?: RequestInit) => {
expect(String(url)).toMatch(
/^https:\/\/spectrum.photon.codes\/projects\/p\//,
);
expect(new Headers(init?.headers).get("authorization")).toBe(
`Basic ${Buffer.from("p:secret").toString("base64")}`,
);
return Response.json({
succeed: true,
data: String(url).endsWith("tokens")
? {
type: "dedicated",
auth: { one: "TOKEN", two: "TOKEN2" },
numbers: { one: "+15555550100", two: "+15555550102" },
expiresIn: 300,
}
: { id: "p", name: "Project" },
});
},
);
const result = await new PhotonCloudClient(fetcher).inspect("p", "secret");
expect(result.lines).toHaveLength(2);
expect(JSON.stringify(result)).not.toMatch(/TOKEN|secret/);
});
it("rejects credentials and missing dedicated lines while inspecting shared DMs without exposing tokens", async () => {
const client = new PhotonCloudClient(
async () => new Response("secret=BAD", { status: 401 }),
);
await expect(client.inspect("p", "secret")).rejects.toMatchObject({
code: "credentials",
});
const shared = new PhotonCloudClient(async () =>
Response.json({
succeed: true,
data: { type: "shared", token: "PRIVATE", expiresIn: 300 },
}),
);
expect(await shared.inspect("p", "secret")).toMatchObject({
eligible: true,
allocation: "shared",
lines: [],
});
const missing = new PhotonCloudClient(async () =>
Response.json({
succeed: true,
data: { type: "dedicated", auth: {}, numbers: {}, expiresIn: 300 },
}),
);
expect(await missing.inspect("p", "secret")).toMatchObject({
eligible: false,
lines: [],
});
});
it("binds shared gateway tokens to one project and fences renewal and group access", async () => {
let now = 0;
const cloud = new PhotonCloudClient();
const allocation = vi.spyOn(cloud, "allocation").mockResolvedValue({
inspection: {projectId: "p", projectName: "Shared", allocation: "shared", eligible: true, lines: []},
tokens: new Map(), sharedToken: "first", expiresIn: 60,
});
const identity = {allocation: "shared" as const, projectId: "p", lineId: photonSharedScope("p"), phoneNumber: photonSharedIdentity("p")};
const auth = new PhotonLineAuthentication(identity, "secret", cloud, () => now);
expect(auth.address).toBe("imessage.spectrum.photon.codes:443");
await expect(auth.token()).resolves.toBe("first");
expect(photonSharedScope("other")).not.toBe(identity.lineId);
expect(() => new PhotonLineAuthentication({...identity, projectId: "other"}, "secret", cloud)).toThrow(/match its project/);
const f = photonFixture();
const adapter = new PhotonChatAdapter("Shared", auth, f.state, f.adapter.client);
expect(() => adapter.encodeThreadId({lineId: identity.lineId, chatGuid: "group", isGroup: true})).toThrow(/direct messages only/);
expect(() => adapter.decodeThreadId(`imessage-photon:${identity.lineId}:g:Z3JvdXA`)).toThrow(/direct messages only/);
now = 60_000;
allocation.mockResolvedValueOnce({inspection: {projectId:"p", projectName:"Moved", allocation:"dedicated", eligible:true, lines:[]}, tokens:new Map(), expiresIn:60});
await expect(auth.token()).rejects.toMatchObject({code:"line_unavailable"});
});
it("renews only the selected line and refuses replacement identity or retired ownership", async () => {
const f = photonFixture();
await expect(f.authentication.token()).resolves.toBe("private-line-token");
f.authentication.retire();
await expect(f.authentication.token()).rejects.toMatchObject({
code: "credentials",
});
const other = photonFixture();
other.allocation.mockResolvedValueOnce({
inspection: {
projectId: "project",
projectName: "Test",
allocation: "dedicated",
eligible: true,
lines: [
{ lineId: "line", phoneNumber: "+15555550999", eligible: true },
],
},
tokens: new Map([["line", "TOKEN"]]),
expiresIn: 60,
});
await expect(other.authentication.token()).rejects.toMatchObject({
code: "line_unavailable",
});
});
});
describe("Photon publication receipts", () => {
it("keeps Unicode and paragraph order and reuses immutable receipts across restart", async () => {
const f = photonFixture();
const text = "😀".repeat(3999) + "\n\n" + "tail";
expect(splitPhotonText(text).join("")).toBe(text);
expect(
splitPhotonText(text).every((part) => Array.from(part).length <= 4000),
).toBe(true);
const first = await f.adapter.publish(
f.threadId,
"pub",
{ markdown: text },
{ assertCurrent: guard },
);
const restarted = new PhotonChatAdapter(
"Agent",
f.authentication,
new PhotonState(f.state.scope, f.persistence),
f.adapter.client,
);
expect(
await restarted.publish(
f.threadId,
"pub",
{ markdown: text },
{ assertCurrent: guard },
),
).toEqual(first);
expect(f.client.messages.sendText).toHaveBeenCalledTimes(2);
await expect(
restarted.publish(
f.threadId,
"pub",
{ markdown: "changed" },
{ assertCurrent: guard },
),
).rejects.toThrow("payload changed");
});
it("holds an unknown send until explicit retry and reuses the same key", async () => {
const f = photonFixture();
const original = f.client.messages.sendText.getMockImplementation()!;
f.client.messages.sendText.mockImplementationOnce(async (...args) => {
await original(...args);
throw new Error("socket closed after send");
});
await expect(
f.adapter.publish(f.threadId, "pub", "hello", { assertCurrent: guard }),
).rejects.toMatchObject({ code: "delivery_unknown" });
await expect(
f.adapter.publish(f.threadId, "pub", "hello", { assertCurrent: guard }),
).rejects.toMatchObject({ code: "delivery_unknown" });
expect(f.client.messages.sendText).toHaveBeenCalledTimes(1);
await f.adapter.publish(f.threadId, "pub", "hello", {
assertCurrent: guard,
retryUnknown: true,
});
expect(f.receipts.size).toBe(1);
expect(f.client.messages.sendText.mock.calls[0][2]).toEqual(
f.client.messages.sendText.mock.calls[1][2],
);
});
it("stores an upload before a failed send and never uploads it twice", async () => {
const f = photonFixture();
f.client.messages.sendAttachment.mockRejectedValueOnce(
new Error("lost receipt"),
);
const message = {
markdown: "",
files: [{ filename: "photo.png", data: Buffer.from("data") }],
};
await expect(
f.adapter.publish(f.threadId, "filepub", message, {
assertCurrent: guard,
}),
).rejects.toMatchObject({ code: "delivery_unknown" });
await f.adapter.publish(f.threadId, "filepub", message, {
assertCurrent: guard,
retryUnknown: true,
});
expect(f.client.attachments.upload).toHaveBeenCalledTimes(1);
});
it("retains unknown delivery when the shared gateway rejects a duplicate without a receipt", async () => {
const f = photonFixture();
f.client.messages.sendText.mockRejectedValueOnce(new Error("lost receipt"));
await expect(f.adapter.publish(f.threadId, "duplicate", "hello", {
assertCurrent: guard,
})).rejects.toMatchObject({ code: "delivery_unknown" });
// Observed on Photon Pro: ALREADY_EXISTS arrives as internalError, without
// a message GUID. Neither the wording nor duplicate status proves a receipt.
f.client.messages.sendText.mockRejectedValueOnce(new IMessageError(
"[upstream] Operation already processed with this client message ID",
{ code: "internalError", grpcCode: 6, retryable: false },
));
await expect(f.adapter.publish(f.threadId, "duplicate", "hello", {
assertCurrent: guard, retryUnknown: true,
})).rejects.toMatchObject({ code: "delivery_unknown" });
await expect(f.adapter.publish(f.threadId, "duplicate", "hello", {
assertCurrent: guard,
})).rejects.toMatchObject({ code: "delivery_unknown" });
expect(f.client.messages.sendText).toHaveBeenCalledTimes(2);
expect(f.client.messages.sendText.mock.calls[0][2]).toEqual(
f.client.messages.sendText.mock.calls[1][2],
);
});
it("distinguishes explicit quota errors and rechecks authorization between parts", async () => {
const f = photonFixture();
f.client.messages.sendText.mockRejectedValueOnce(
new IMessageError("secret upstream text", {
code: "dailyLimitExceeded",
grpcCode: 8,
retryable: true,
retryAfter: 12345,
}),
);
await expect(
f.adapter.publish(f.threadId, "quota", "hello", { assertCurrent: guard }),
).rejects.toMatchObject({ code: "quota", retryAfterMs: 12345 });
await f.adapter.publish(f.threadId, "quota", "hello", {
assertCurrent: guard,
});
let calls = 0;
await expect(
f.adapter.publish(f.threadId, "parts", "x".repeat(9000), {
assertCurrent: async () => {
if (++calls === 2) throw new Error("revoked");
},
}),
).rejects.toThrow("revoked");
expect(f.client.messages.sendText).toHaveBeenCalledTimes(3);
});
});
describe("Photon durable event recovery", () => {
it("records preceding events, deduplicates, and does not checkpoint an unadmitted event", async () => {
const f = photonFixture();
const events = [photonEvent(1), photonEvent(2)];
let crash = true;
const admitted = vi.fn(async (event) => {
if (event.sequence === 2 && crash) throw new Error("crash");
});
const receiver = new PhotonReceiver({
client: f.adapter.client,
state: f.state,
lineId: "line",
intakeAfter: 0,
assertOwned: guard,
admit: admitted,
failure: guard,
catchUp: () =>
stream([...events, { type: "catchup.complete", headSequence: 2 }]),
});
await expect(receiver.catchUp()).rejects.toThrow("crash");
expect(await f.state.read("checkpoint")).toMatchObject({ sequence: 1 });
crash = false;
await receiver.catchUp();
expect(await f.state.read("checkpoint")).toMatchObject({ sequence: 2 });
expect(admitted.mock.calls.map(([event]) => event.sequence)).toEqual([
1, 2, 2,
]);
});
it("advances irrelevant frames and cutoff history, and stops at genuine gaps or lost leases", async () => {
const f = photonFixture();
const admit = vi.fn();
const receiver = new PhotonReceiver({
client: f.adapter.client,
state: f.state,
lineId: "line",
intakeAfter: Date.now() + 1000,
assertOwned: guard,
admit,
failure: guard,
catchUp: () =>
stream([
photonEvent(10),
{ type: "photon.ignored", sequence: 11 },
{ type: "catchup.complete", headSequence: 11 },
]),
});
await receiver.catchUp();
expect(admit).not.toHaveBeenCalled();
expect(await f.state.read("checkpoint")).toMatchObject({ sequence: 11 });
const gap = new PhotonReceiver({
client: f.adapter.client,
state: f.state,
lineId: "line",
intakeAfter: 0,
assertOwned: guard,
admit,
failure: guard,
catchUp: () =>
stream([
photonEvent(13),
{ type: "catchup.complete", headSequence: 13 },
]),
});
await expect(gap.catchUp()).rejects.toMatchObject({ code: "history_gap" });
const lost = new PhotonReceiver({
client: f.adapter.client,
state: f.state,
lineId: "line",
intakeAfter: 0,
assertOwned: async () => {
throw new Error("lease lost");
},
admit,
failure: guard,
});
await expect(lost.catchUp()).rejects.toThrow("lease lost");
expect(admit).not.toHaveBeenCalled();
});
it("recovers sparse shared-project sequences and commits only a complete ordered replay", async () => {
const f = photonFixture();
const admit = vi.fn(guard);
await f.state.update("checkpoint", () => ({schema:1, lineId:"line", sequence:0}));
let events: any[] = [photonEvent(1_008_648_034), {type:"catchup.complete",headSequence:1_008_648_034}];
const receiver = new PhotonReceiver({client:f.adapter.client,state:f.state,lineId:"line",allocation:"shared",intakeAfter:0,assertOwned:guard,admit,failure:guard,catchUp:()=>stream(events)});
await receiver.catchUp();
expect(admit).toHaveBeenCalledTimes(1);
expect(await f.state.read("checkpoint")).toMatchObject({sequence:1_008_648_034});
events=[photonEvent(1_008_648_090),photonEvent(1_008_648_080),{type:"catchup.complete",headSequence:1_008_648_090}];
await expect(receiver.catchUp()).rejects.toThrow(/out of order/);
expect(await f.state.read("checkpoint")).toMatchObject({sequence:1_008_648_034});
events=[photonEvent(1_008_648_080)];
await expect(receiver.catchUp()).rejects.toMatchObject({code:"network"});
expect(await f.state.read("checkpoint")).toMatchObject({sequence:1_008_648_034});
events=[{type:"catchup.complete",headSequence:0}];
await expect(receiver.catchUp()).rejects.toMatchObject({code:"history_gap"});
});
it("reads sequence-only and complete frames over authenticated synthetic gRPC", async () => {
const f = photonFixture();
const server = new Server();
server.addService(
{
catchUp: {
path: PHOTON_CATCHUP_PATH,
requestStream: false,
responseStream: true,
requestSerialize: (b: Buffer) => b,
requestDeserialize: (b: Buffer) => b,
responseSerialize: (b: Buffer) => b,
responseDeserialize: (b: Buffer) => b,
},
},
{
catchUp: (call: any) => {
expect(call.metadata.get("authorization")).toEqual([
"Bearer private-line-token",
]);
expect(call.request).toEqual(photonCatchUpRequest(2));
call.write(Buffer.from([8, 3]));
call.write(Buffer.from([162, 1, 2, 8, 3]));
call.end();
},
},
);
const port = await new Promise<number>((resolve, reject) =>
server.bindAsync(
"127.0.0.1:0",
ServerCredentials.createInsecure(),
(error, port) => (error ? reject(error) : resolve(port)),
),
);
const transport = new PhotonRecoveryTransport(
f.authentication,
new Client(`127.0.0.1:${port}`, credentials.createInsecure()),
);
try {
const result = [];
for await (const event of transport.catchUp(2)) result.push(event);
expect(result).toEqual([
{ type: "photon.ignored", sequence: 3 },
{ type: "catchup.complete", headSequence: 3 },
]);
} finally {
transport.close();
server.forceShutdown();
}
});
it("keeps state company scoped and rejects corrupt cursor envelopes", async () => {
const f = photonFixture();
await f.state.update("checkpoint", () => ({ sequence: 1 }));
expect(
await new PhotonState(
{ ...f.state.scope, companyId: "other" },
f.persistence,
).read("checkpoint"),
).toBeNull();
expect(() => photonEnvelopeSequence(Buffer.from([8, 128]))).toThrow();
expect(() =>
decodePhotonRecoveryFrame(Buffer.from([8, 1, 8, 2])),
).toThrow();
});
});
describe("Photon native prompts and media", () => {
it("binds duplicate titles and option labels only by returned IDs across restart", async () => {
const f = photonFixture();
const binding = {
version: 1 as const,
reference: "referenceA",
interactionId: "one",
publicationId: "publication1",
sessionGeneration: 1,
expiresAt: new Date(Date.now() + 60000).toISOString(),
};
const first = await publishPhotonPrompt({
adapter: f.adapter,
threadId: f.threadId,
binding,
interaction: question("one"),
questionIndex: 0,
assertCurrent: guard,
});
const second = await publishPhotonPrompt({
adapter: f.adapter,
threadId: f.threadId,
binding: {
...binding,
reference: "referenceB",
interactionId: "two",
publicationId: "publication2",
},
interaction: question("two"),
questionIndex: 0,
assertCurrent: guard,
});
expect(first.pollMessageGuid).not.toBe(second.pollMessageGuid);
expect(Object.values(first.options)).toEqual(["a", "b"]);
expect(
await f.state.read(`poll-message:${first.pollMessageGuid}`),
).toMatchObject({ reference: "referenceA" });
await publishPhotonPrompt({
adapter: f.adapter,
threadId: f.threadId,
binding,
interaction: question("one"),
questionIndex: 0,
assertCurrent: guard,
});
expect(f.client.polls.create).toHaveBeenCalledTimes(2);
});
it("reuses poll clientMessageId after an accepted create loses its local receipt", async () => {
const f = photonFixture();
const create = f.client.polls.create.getMockImplementation()!;
f.client.polls.create.mockImplementationOnce(async (...args) => {
await create(...args);
throw new Error("crash");
});
const input = {
adapter: f.adapter,
threadId: f.threadId,
binding: {
version: 1 as const,
reference: "referenceA",
interactionId: "one",
publicationId: "p",
sessionGeneration: 1,
expiresAt: new Date().toISOString(),
},
interaction: question(),
questionIndex: 0,
assertCurrent: guard,
};
await expect(publishPhotonPrompt(input)).rejects.toMatchObject({
code: "delivery_unknown",
});
await expect(publishPhotonPrompt(input)).rejects.toMatchObject({
code: "delivery_unknown",
});
await publishPhotonPrompt({ ...input, retryUnknown: true });
expect(f.polls.size).toBe(1);
});
it("validates numbered selection, custom answers, optional skips and explicit references", () => {
expect(parsePhotonQuestionAnswer(question(), 0, "2")).toEqual({
questionId: "q1",
optionIds: ["b"],
});
expect(() => parsePhotonQuestionAnswer(question(), 0, "yes")).toThrow();
expect(() => parsePhotonQuestionAnswer(question(), 0, "1,2")).toThrow();
expect(photonResponseCommand("yes")).toBeNull();
expect(
photonResponseCommand("/answer referenceA.2 custom answer"),
).toMatchObject({
reference: "referenceA",
questionIndex: 1,
value: "custom answer",
});
const multi = question();
multi.payload.questions[0].selectionMode = "multi";
expect(parsePhotonQuestionAnswer(multi, 0, "1,2").optionIds).toEqual([
"a",
"b",
]);
multi.payload.questions[0].required = false;
expect(parsePhotonQuestionAnswer(multi, 0, "skip").optionIds).toEqual([]);
});
it("rejects foreign attachment provenance before downloading and bounds decoded images", async () => {
const f = photonFixture();
await expect(
downloadPhotonAttachment(f.adapter.client, "line", {
kind: "photon_attachment",
lineId: "other",
chatGuid: f.chat.guid,
messageGuid: "m",
attachmentGuid: "a",
}),
).rejects.toThrow("another line");
await expect(
downloadPhotonAttachment(f.adapter.client, "line", {
kind: "photon_attachment",
lineId: "line",
chatGuid: "other",
messageGuid: "m",
attachmentGuid: "a",
}),
).rejects.toThrow("does not belong");
const wrongMessage = await f.client.messages.get("different-message");
wrongMessage.content.attachments = [{ guid: "a" }] as typeof wrongMessage.content.attachments;
f.client.messages.get.mockResolvedValueOnce(wrongMessage);
await expect(downloadPhotonAttachment(f.adapter.client, "line", {
kind: "photon_attachment",
lineId: "line",
chatGuid: f.chat.guid,
messageGuid: "m",
attachmentGuid: "a",
})).rejects.toThrow("does not belong");
expect(f.client.attachments.downloadStream).not.toHaveBeenCalled();
const png = await sharp({
create: { width: 2, height: 2, channels: 3, background: "white" },
})
.png()
.toBuffer();
await validatePhotonImage(png, "image/png");
await expect(validatePhotonImage(png, "image/jpeg")).rejects.toThrow(
"declared",
);
expect(() => validateHeifDimensions(png)).toThrow();
});
it("preserves numerical free-text answers for canonical numeric validation", () => {
const interaction = question();
interaction.payload.questions[0].allowOther = true;
interaction.payload.questions[0].options = [
{ id: "number", label: "Enter a number", freeText: true },
];
expect(parsePhotonQuestionAnswer(interaction, 0, "42")).toEqual({
questionId: "q1",
optionIds: ["number"],
otherText: "42",
});
});
it("downloads a shared project alias with a native header UUID only after source ownership is verified", async () => {
const f = photonFixture();
const body = Buffer.from("synthetic shared attachment");
const alias = "spc-att-11111111-1111-4111-8111-111111111111";
const message = photonEvent(1).message;
const info = {guid: alias, totalBytes: body.length, fileName: "test.txt", mimeType: "text/plain", isHidden: false, isSticker: false};
(message.content.attachments as any[]).push(info);
f.client.messages.get.mockResolvedValue(message);
const native = {...info, guid: "22222222-2222-4222-8222-222222222222"};
f.client.attachments.downloadStream.mockImplementation(() => stream([
{type: "header", info: native}, {type: "primaryChunk", data: body},
]));
const locator = {kind: "photon_attachment" as const, lineId: "line", chatGuid: f.chat.guid, messageGuid: message.guid, attachmentGuid: alias};
await expect(downloadPhotonAttachment(f.adapter.client, "line", locator)).rejects.toThrow("metadata changed");
await expect(downloadPhotonAttachment(f.adapter.client, "line", locator, "shared")).resolves.toEqual(body);
expect(f.client.attachments.downloadStream).toHaveBeenLastCalledWith(alias);
native.mimeType = "application/octet-stream";
await expect(downloadPhotonAttachment(f.adapter.client, "line", locator, "shared")).rejects.toThrow("metadata changed");
f.client.attachments.downloadStream.mockClear();
await expect(downloadPhotonAttachment(f.adapter.client, "line", {...locator, chatGuid: "wrong-chat"}, "shared")).rejects.toThrow("does not belong");
expect(f.client.attachments.downloadStream).not.toHaveBeenCalled();
});
it("retains source-bound Live Photo companion bytes and waits for incomplete companions", async () => {
const f = photonFixture();
const photo = Buffer.from("primary"),
video = Buffer.from("companion");
const message = photonEvent(1).message;
(message.content.attachments as any[]).push({
guid: "live-photo",
totalBytes: photo.length,
isHidden: false,
isSticker: false,
});
f.client.messages.get.mockResolvedValue(message);
const locator = {
kind: "photon_attachment" as const,
lineId: "line",
chatGuid: f.chat.guid,
messageGuid: message.guid,
attachmentGuid: "live-photo",
};
const header = {
type: "header",
info: { guid: "live-photo", totalBytes: photo.length },
companionInfo: {
kind: "live-photo-video",
mimeType: "video/quicktime",
fileName: "photo.mov",
totalBytes: video.length,
},
};
f.client.attachments.downloadStream.mockImplementationOnce(() =>
stream([header, { type: "primaryChunk", data: photo }]),
);
await expect(
downloadPhotonAttachment(f.adapter.client, "line", locator),
).rejects.toMatchObject({ code: "attachment_not_ready" });
f.client.attachments.downloadStream.mockImplementationOnce(() =>
stream([
header,
{ type: "primaryChunk", data: photo },
{ type: "companionChunk", data: video },
]),
);
const result = await downloadPhotonAttachment(
f.adapter.client,
"line",
locator,
);
expect(result).toEqual(photo);
expect(takePhotonCompanion(result)).toEqual({
unavailable: false,
fileName: "photo.mov",
mimeType: "video/quicktime",
data: video,
});
expect(takePhotonCompanion(result)).toBeUndefined();
});
it("converts a real synthetic HEIC with the installed native binary and bounds its dimensions", async () => {
const heic = await readFile(
new URL("./fixtures/synthetic.heic", import.meta.url),
);
const jpeg = await photonHeifPreview(heic);
expect(await sharp(jpeg).metadata()).toMatchObject({
format: "jpeg",
width: 16,
height: 16,
});
const oversized = Buffer.from(heic);
const at = oversized.indexOf("ispe");
expect(at).toBeGreaterThan(0);
oversized.writeUInt32BE(100_000, at + 8);
expect(() => validateHeifDimensions(oversized)).toThrow("pixel limit");
});
});
@@ -213,12 +213,18 @@ describe("ensureLocalPluginBuilt", () => {
expect(execStub).not.toHaveBeenCalled();
});
it("bootstraps standalone bundled plugins before building them", async () => {
it.each([
undefined,
"allowBuilds:\n protobufjs: false\n",
"packages:\n - ../untrusted\ndangerouslyAllowAllBuilds: true\n",
])("bootstraps standalone plugins without trusting workspace policy: %s", async (localPolicy) => {
const fixture = await createBundledPluginFixture("standalone", { rootDir: standaloneRepoPluginRoot });
cleanupPaths.add(fixture.packageRoot);
if (localPolicy) await writeFile(path.join(fixture.packageRoot, "pnpm-workspace.yaml"), localPolicy);
const installArgs = ["install", "--ignore-workspace", ...(localPolicy ? ["--ignore-scripts"] : []), "--no-lockfile"];
const execStub = vi.fn(async (_file: string, args: readonly string[]) => {
if (args.join(" ") === "install --ignore-workspace --no-lockfile") {
if (args.join(" ") === installArgs.join(" ")) {
await mkdir(path.join(fixture.packageRoot, "node_modules", "@paperclipai", "plugin-sdk"), { recursive: true });
}
if (args.join(" ") === "build") {
@@ -239,7 +245,7 @@ describe("ensureLocalPluginBuilt", () => {
expect(execStub).toHaveBeenNthCalledWith(
1,
"pnpm",
["install", "--ignore-workspace", "--no-lockfile"],
installArgs,
{ cwd: fixture.packageRoot, timeout: 120_000 },
);
expect(execStub).toHaveBeenNthCalledWith(
+66 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { acceptSteeredIdentity, captureRunIdentity, initializeRunIdentity, listRunIdentityContexts, rejectSteeredIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
@@ -41,6 +41,71 @@ const support = await getEmbeddedPostgresTestSupport();
await initializeRunIdentity(db, { ...input, responsibleUserId: "B", cause: "restart" });
expect(await listRunIdentityContexts(db, input.companyId, input.runId)).toHaveLength(4);
});
async function seedInterrupt() {
const input = await seed();
const queueId = randomUUID(), wakeupRequestId = randomUUID();
const contextSnapshot = { issueId: input.issueId, wakeCommentIds: input.messageIds };
await db.insert(agentWakeupRequests).values([
{ id: queueId, companyId: input.companyId, agentId: input.agentId, source: "automation",
status: "coalesced", runId: input.runId, requestedByActorType: "system",
payload: { issueId: input.issueId, _paperclipWakeContext: { wakeCommentIds: input.messageIds },
queuedCommentInterrupt: { actorId: "operator", requestedAt: new Date().toISOString() } } },
{ id: wakeupRequestId, companyId: input.companyId, agentId: input.agentId, source: "on_demand",
status: "queued", runId: input.runId, requestedByActorType: "user", requestedByActorId: "operator",
idempotencyKey: `queued-comment-interrupt:${queueId}` },
]);
await db.update(heartbeatRuns).set({ wakeupRequestId, contextSnapshot }).where(eq(heartbeatRuns.id, input.runId));
return { ...input, queueId, wakeupRequestId, contextSnapshot };
}
it("uses the clicking operator through startup and restart without changing message authors", async () => {
const input = await seedInterrupt();
// A stale originating context cannot replace the explicit click's identity.
const identity = await initializeRunIdentity(db, {
...input, responsibleUserId: "A", parentContextId: randomUUID(), cause: "dispatch",
});
expect(identity).toMatchObject({ responsibleUserId: "operator", cause: "queued_comment_interrupt" });
const history = await listRunIdentityContexts(db, input.companyId, input.runId);
expect(history.map(row => row.responsibleUserId)).toEqual(["operator", "operator", "operator", "operator"]);
expect(await initializeRunIdentity(db, { ...input, responsibleUserId: "B", cause: "restart" })).toEqual(identity);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, input.issueId));
expect(input.messageIds.map(id => comments.find(c => c.id === id)?.authorUserId)).toEqual(["A", "B", "A"]);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("operator");
const retryRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: retryRunId, companyId: input.companyId,
agentId: input.agentId, contextSnapshot: input.contextSnapshot, status: "queued", retryOfRunId: input.runId });
const retried = await initializeRunIdentity(db, { companyId: input.companyId, runId: retryRunId,
issueId: input.issueId, parentRunId: input.runId, responsibleUserId: "A", cause: "retry" });
expect(retried.responsibleUserId).toBe("operator");
});
it.each(["malformed", "missing", "unconsumed", "other-run", "other-task", "other-agent", "other-actor", "other-message"])(
"rejects %s interrupt authority before creating any execution identity", async (fault) => {
const input = await seedInterrupt();
if (fault === "malformed" || fault === "missing") {
await db.update(agentWakeupRequests).set({
idempotencyKey: `queued-comment-interrupt:${fault === "malformed" ? "not-an-id" : randomUUID()}`,
}).where(eq(agentWakeupRequests.id, input.wakeupRequestId));
} else if (fault === "other-actor") {
await db.update(agentWakeupRequests).set({ requestedByActorId: "someone-else" }).where(eq(agentWakeupRequests.id, input.wakeupRequestId));
} else {
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, input.queueId));
if (fault === "other-agent") {
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId: input.companyId, name: "Other", role: "engineer" });
await db.update(agentWakeupRequests).set({ agentId }).where(eq(agentWakeupRequests.id, input.queueId));
} else await db.update(agentWakeupRequests).set(
fault === "unconsumed" ? { status: "deferred_issue_execution" } :
fault === "other-run" ? { runId: null } :
{ payload: { ...receipt.payload, ...(fault === "other-task" ? { issueId: randomUUID() } :
{ _paperclipWakeContext: { wakeCommentIds: [randomUUID()] } }) } },
).where(eq(agentWakeupRequests.id, input.queueId));
}
await expect(initializeRunIdentity(db, { ...input, responsibleUserId: "A", cause: "dispatch" })).rejects.toThrow("interrupt authority");
expect(await listRunIdentityContexts(db, input.companyId, input.runId)).toHaveLength(0);
},
);
it("holds acquisition during uncertain steering, preserves snapshots, and never rewinds on replay", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [input.messageIds[0]], responsibleUserId: "A", cause: "instruction" });
@@ -0,0 +1,147 @@
import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
import { writeFile } from "node:fs/promises";
import { cpus, platform, release, totalmem } from "node:os";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { companies, createDb, documents, issueComments, issueDocuments, issues, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import { companySearchQuerySchema } from "@paperclipai/shared";
import { companySearchService } from "../services/company-search.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "../services/task-search.js";
import { issueService } from "../services/issues.js";
import { searchQualityMetrics, taskSearchCases, taskSearchCorpus } from "./fixtures/task-search-corpus.js";
const support = await getEmbeddedPostgresTestSupport();
const baseline = process.env.SEARCH_EVAL_BASELINE === "1";
describe.skipIf(!support.supported)("task search relevance rubric (real PostgreSQL)", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
const companyId = randomUUID();
const keys = new Map<string, string>();
const plans: Record<string, unknown> = {};
const latency: Array<{ engine: string; q: string; firstMs: number; p95Ms: number; warmMs: number[] }> = [];
let postgresVersion = "";
const report: Array<{ engine: string; name: string; q: string; keys: string[]; ndcg5: number; reciprocalRank: number; ms: number }> = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-search-quality-");
db = createDb(tempDb.connectionString);
postgresVersion = String((await db.execute(sql`SELECT version()`))[0]!.version);
await db.insert(companies).values({ id: companyId, name: "Search benchmark", issuePrefix: "EVAL" });
for (const [index, entry] of taskSearchCorpus.entries()) {
const id = randomUUID();
keys.set(id, entry.key);
const updatedAt = new Date(Date.UTC(2026, 0, 1) + index * 60_000);
await db.insert(issues).values({ id, companyId, title: entry.title,
identifier: "identifier" in entry ? entry.identifier : `EVAL-${index + 1}`,
description: "description" in entry ? entry.description : null,
status: "status" in entry ? entry.status : "todo", updatedAt, createdAt: updatedAt });
if ("comments" in entry) {
for (const body of entry.comments) await db.insert(issueComments).values({ companyId, issueId: id, body, updatedAt });
}
if ("document" in entry) {
const documentId = randomUUID();
await db.insert(documents).values({ id: documentId, companyId, title: entry.document.title, latestBody: entry.document.body, format: "markdown" });
await db.insert(issueDocuments).values({ companyId, issueId: id, documentId, key: "plan" });
}
}
});
afterAll(async () => {
const summary = ["full", "quick"].map((engine) => {
const rows = report.filter((row) => row.engine === engine);
const known = rows.filter((row) => Object.keys(taskSearchCases.find((item) => item.name === row.name)!.relevant).length > 0);
return { engine, queries: rows.length, ndcg5: rows.reduce((sum, row) => sum + row.ndcg5, 0) / rows.length,
mrr: known.reduce((sum, row) => sum + row.reciprocalRank, 0) / known.length };
});
console.log("SEARCH QUALITY", JSON.stringify(summary));
if (process.env.SEARCH_EVAL_REPORT) await writeFile(process.env.SEARCH_EVAL_REPORT, JSON.stringify({
environment: { postgresVersion, platform: platform(), release: release(), cpu: cpus()[0]?.model, memoryBytes: totalmem(), warmSamples: 20 },
summary, queries: report, latency, plans,
}, null, 2));
await tempDb?.cleanup();
});
for (const engine of ["full", "quick"] as const) {
for (const testCase of taskSearchCases) {
it(`${engine}: ${testCase.name}`, async () => {
const start = performance.now();
const rows = engine === "full"
? (await companySearchService(db).search(companyId, companySearchQuerySchema.parse({ q: testCase.q }))).results.filter((row) => row.type === "issue")
: await issueService(db).list(companyId, { q: testCase.q, limit: 50 });
const resultKeys = rows.map((row) => keys.get(row.id)!);
report.push({ engine, name: testCase.name, q: testCase.q, keys: resultKeys, ...searchQualityMetrics(resultKeys, testCase.relevant), ms: performance.now() - start });
if (baseline) return;
if (testCase.first) expect(resultKeys[0], JSON.stringify(resultKeys)).toBe(testCase.first);
for (const absent of testCase.absent ?? []) expect(resultKeys).not.toContain(absent);
for (const [key, grade] of Object.entries(testCase.relevant)) if (grade === 3) expect(resultKeys.slice(0, 5)).toContain(key);
if (Object.keys(testCase.relevant).length === 0) expect(resultKeys).toEqual([]);
});
}
}
it("meets the aggregate relevance gates", () => {
if (baseline) return;
for (const engine of ["full", "quick"]) {
const rows = report.filter((row) => row.engine === engine);
const known = rows.filter((row) => taskSearchCases.find((entry) => entry.name === row.name)!.q !== "quasarxylophone");
expect(known.reduce((sum, row) => sum + row.reciprocalRank, 0) / known.length).toBeGreaterThanOrEqual(0.95);
expect(rows.reduce((sum, row) => sum + row.ndcg5, 0) / rows.length).toBeGreaterThanOrEqual(0.90);
}
});
it("handles empty quotes, literal punctuation, and oversized title words without errors", async () => {
if (baseline) return;
await db.insert(issues).values({ companyId, title: "x".repeat(300) });
for (const q of ['""', "%", "_", "\\", "z".repeat(200)]) {
const full = await companySearchService(db).search(companyId, companySearchQuerySchema.parse({ q }));
const quick = await issueService(db).list(companyId, { q, limit: 50 });
if (q === '""' || q.startsWith("z")) {
expect(full.results).toEqual([]);
expect(quick).toEqual([]);
} else {
for (const row of quick) expect(row.title).toContain(q);
}
}
});
it.runIf(process.env.SEARCH_EVAL_SCALE === "1")("measures 10k tasks / 30k comments", async () => {
await db.execute(sql`
INSERT INTO issues (company_id, title, description, identifier)
SELECT ${companyId}, 'Routine deployment checkpoint ' || n,
repeat('Review the build output and update the deployment checklist. ', 5), 'SCALE-' || n
FROM generate_series(1, 10000) n
`);
await db.execute(sql`
INSERT INTO issue_comments (company_id, issue_id, body)
SELECT ${companyId}, id, repeat('Routine progress report: verified the output and recorded the findings. ', 5)
FROM issues CROSS JOIN generate_series(1, 3) n
WHERE company_id = ${companyId} AND identifier LIKE 'SCALE-%'
`);
await db.execute(sql`ANALYZE issues`);
await db.execute(sql`ANALYZE issue_comments`);
for (const engine of ["full", "quick"] as const) {
for (const q of ["GitHub OAuth", "OAuth callback GitHub", "mibile api", "search", "quasarxylophone", "routine"]) {
const durations: number[] = [];
for (let i = 0; i < 21; i++) {
const start = performance.now();
if (engine === "full") await companySearchService(db).search(companyId, companySearchQuerySchema.parse({ q }));
else await issueService(db).list(companyId, { q, limit: 20 });
durations.push(performance.now() - start);
}
const warmMs = durations.slice(1);
latency.push({ engine, q, firstMs: durations[0]!, p95Ms: [...warmMs].sort((a, b) => a - b)[18]!, warmMs });
}
}
for (const q of ["GitHub OAuth", "mibile api", "routine"]) {
const search = parseTaskSearch(q);
if (!baseline) plans[q] = await db.execute(sql`
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
${taskSearchCtes(companyId, search)}
SELECT m.id, ${taskSearchScore(search)} AS score FROM matched m ORDER BY score DESC LIMIT 20
`);
}
console.log("SEARCH LATENCY", JSON.stringify(latency.map(({ warmMs: _warmMs, ...row }) => row)));
}, 120_000);
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { parseTaskSearch } from "../services/task-search.js";
describe("task search query intent", () => {
it("keeps negation, short domain terms and quoted filler", () => {
expect(parseTaskSearch('the API is not "in the UI"').tokens).toEqual(["api", "is", "not", "in the ui"]);
expect(parseTaskSearch("the and").tokens).toEqual(["the", "and"]);
});
it("retains the strictest intent for repeated terms", () => {
expect(parseTaskSearch('callback "callback"').terms).toEqual([{ text: "callback", quoted: true }]);
});
it("normalizes task identifiers without guessing their numbers", () => {
for (const q of ["PAP-42", "pap42", "PAP 42"]) expect(parseTaskSearch(q).identifierQuery).toBe("pap-42");
expect(parseTaskSearch("T123-42").identifierQuery).toBe("t123-42");
expect(parseTaskSearch("PAP-420").identifierQuery).toBe("pap-420");
});
});
@@ -4974,6 +4974,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual(
expect.arrayContaining([
"agentmail",
"imessage-photon",
"jira",
"airtable",
"asana",
@@ -4994,7 +4995,7 @@ describeEmbeddedPostgres("tool access service", () => {
"github",
]),
);
expect(res.body.apps).toHaveLength(41);
expect(res.body.apps).toHaveLength(46);
expect(
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
.ownershipAvailability,
+2
View File
@@ -1,3 +1,4 @@
import { aiConnectionRoutes } from "./routes/ai-connections.js";
import { projectToolRoutes } from "./routes/project-tools.js";
import { emailChannelService } from "./services/email-channels.js";
import { emailRoutes, emailWebhookRoutes } from "./routes/email.js";
@@ -826,6 +827,7 @@ export async function createApp(
app.locals.toolGateway = toolGateway;
app.locals.toolActionDeliveries = toolActionDeliveries;
app.use(mcpGatewayProtocolRoutes(toolGateway));
api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost }));
api.use(
toolAccessRoutes(db, {
deploymentMode: opts.deploymentMode,
+4
View File
@@ -20,6 +20,10 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/jpg",
"image/webp",
"image/gif",
"image/heic",
"image/heif",
"image/heic-sequence",
"image/heif-sequence",
"audio/mpeg",
"audio/mp4",
"audio/ogg",
+2
View File
@@ -94,6 +94,7 @@ import {
createProductionLoginSessionReaperRuntime,
} from "./services/device-login-reaper.js";
import { createProductionSetupTokenReaper } from "./services/setup-token-reaper.js";
import { localAiLoginService } from "./services/local-ai-login.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
@@ -1153,6 +1154,7 @@ async function startServerWithDatabaseTeardown(
["reconciliation_delivery", () => heartbeat ? deliverReconciledExecutions(db, heartbeat.wakeup) : undefined],
["status_delivery", () => deliverExecutionStatuses(db)],
["automatic_disposition", () => settleUnrecoverableExecutions(db)],
["local_ai_login_cleanup", () => localAiLoginService(db).reapExpired()],
] as const;
const sweepExecutionControl = () => {
if (heartbeatSchedulerStopped) return;
@@ -242,6 +242,24 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => {
});
}
it.each(["queued", "running", "scheduled_retry"])("does not promote another turn behind a %s successor without an execution lock", async (status) => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
const issueId = await seedIssue({ companyId, assigneeAgentId: agentId });
const runId = await seedRun({ companyId, agentId, status: "succeeded", contextSnapshot: { issueId } });
await seedRun({ companyId, agentId, status, contextSnapshot: { issueId } });
const wakeId = await seedDeferredWake({ companyId, agentId, issueId });
const adapter = createPostgresWakeQueueAdapter(db, stubDeps);
let drained = false;
await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async () => {
drained = true;
return { outcome: { kind: "released" }, postCommitEffects: [] };
});
expect(drained).toBe(false);
const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId));
expect(wake.status).toBe("deferred_issue_execution");
});
it("leaves deferred work untouched until the effective execution hold clears", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent({ companyId });
@@ -894,6 +894,10 @@ export function createWakeAdmissionWriter(): WakeAdmissionWriter {
.set({
payload: input.mergedPayload,
coalescedCount: input.nextCoalescedCount,
...(input.manualUserWakeActorId ? {
requestedByActorType: "user",
requestedByActorId: input.manualUserWakeActorId,
} : {}),
updatedAt: new Date(),
})
.where(
@@ -1052,6 +1056,20 @@ export function createPostgresWakeQueueAdapter(db: Db, deps: WakeQueuePostgresAd
throw new Error(`wake-queue: pre-drain decision ${preDrain.kind} reached without an issue row`);
}
// Enqueue does not stamp executionRunId until dispatch. A concurrent
// queued successor still owns the next turn, including during a late
// finalization/stranded-queue retry under this issue lock. Another
// agent's review participation retains its separate recovery path.
const [successor] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, run.agentId),
sql`${heartbeatRuns.id} <> ${run.id}`,
or(eq(heartbeatRuns.nativeIssueId, issueRow.id),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueRow.id}`),
inArray(heartbeatRuns.status, ["queued", "running", "scheduled_retry"]),
)).limit(1);
if (successor) return { outcome: { kind: "released" }, postCommitEffects: [], run: runSnapshot };
if (preDrain.kind === "blocked") {
return {
outcome: {
@@ -428,6 +428,8 @@ export interface WakeAdmissionWriter {
existingDeferredWakeId: string;
mergedPayload: Record<string, unknown>;
nextCoalescedCount: number;
/** A fresh manual click replaces the merged queue's execution requester. */
manualUserWakeActorId?: string;
/** Persist each durable input's own receipt atomically with the merge. */
coalescedReceipt?: CoalescedDeferredAdmissionReceipt;
},
@@ -848,6 +848,17 @@ describe("admitWakeBehindIssueExecution", () => {
expect(writer.coalesceIntoActiveExecutionRun).not.toHaveBeenCalled();
});
it("gives manual input its own run boundary even when the active receipt has the same requester", async () => {
const writer = createFakeAdmissionWriter();
const admit = createAdmitWakeBehindIssueExecution({
reader: createFakeAdmissionReader(), writer, helpers: createFakeAdmissionHelpers(),
});
expect(await admit(SCOPE, admissionInput({ payload: { issueId: "issue-1", manualUserWake: true } })))
.toEqual({ kind: "deferred" });
expect(writer.coalesceIntoActiveExecutionRun).not.toHaveBeenCalled();
expect(writer.insertNewDeferredWake).toHaveBeenCalledTimes(1);
});
it("keeps ordinary non-durable coalescing independent of durable actor lookup", async () => {
const writer = createFakeAdmissionWriter();
const reader = createFakeAdmissionReader({
@@ -342,6 +342,7 @@ async function promoteDeferredWake(
const promotedTriggerDetail = workingCandidate.triggerDetail ?? null;
const promotedPayload = { ...workingCandidate.payload };
delete promotedPayload["_paperclipWakeContext"];
delete promotedPayload["queuedCommentInterrupt"];
const promotedContextSeed: Record<string, unknown> = { ...workingCandidate.deferredContextSeed };
if (pauseHold.activePauseHold) {
@@ -716,6 +717,12 @@ export function createAdmitWakeBehindIssueExecution(deps: {
scope: TransactionScope,
input: AdmitWakeBehindIssueExecutionInput,
): Promise<AdmitWakeBehindIssueExecutionResult> {
const manualUserWakeActorId = input.payload?.manualUserWake === true
? readNonEmptyString(input.requestedByActorId) : null;
if (input.payload?.manualUserWake === true &&
(input.requestedByActorType !== "user" || !manualUserWakeActorId)) {
throw new Error("wake-queue: manual wake requires an authenticated user");
}
const isSameExecutionAgent = await deps.reader.isSameExecutionAgent(scope, {
companyId: input.companyId,
activeExecutionRunAgentId: input.activeExecutionRun.agentId,
@@ -723,8 +730,10 @@ export function createAdmitWakeBehindIssueExecution(deps: {
agentNameKey: input.agentNameKey,
});
// A manual click establishes a fresh execution identity. Even a matching
// requester can have a different originating identity on an exact retry.
const shouldDeferFollowupWake =
deps.helpers.shouldDeferFollowupWakeForSameIssue({
Boolean(manualUserWakeActorId) || deps.helpers.shouldDeferFollowupWakeForSameIssue({
activeRunStatus: input.activeExecutionRun.status,
isSameExecutionAgent,
wakeCommentId: input.wakeCommentId,
@@ -830,6 +839,7 @@ export function createAdmitWakeBehindIssueExecution(deps: {
existingDeferredWakeId: existingDeferred.id,
mergedPayload,
nextCoalescedCount: (existingDeferred.coalescedCount ?? 0) + 1,
...(manualUserWakeActorId ? { manualUserWakeActorId } : {}),
...(input.durableReceipt
? {
coalescedReceipt: {
+137 -14
View File
@@ -1,3 +1,10 @@
import { listOpenRouterModels } from "../services/openrouter-models.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, aiConnectionBindingSchema, type AiConnectionBinding } from "@paperclipai/shared";
import { toolConnections } from "@paperclipai/db";
import { aiConnectionService } from "../services/ai-connections.js";
import { assertAiConnectionCreateAccess, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "./ai-connections.js";
import { isAiConnectionCompatible } from "@paperclipai/shared";
import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSkills, isConnectorSkill } from "../services/connector-runtime.js";
import { getExecutionBlocker } from "../services/execution-blocker.js";
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils";
@@ -679,7 +686,10 @@ export function agentRoutes(
factory: setupTokenLoginFactory,
leases: guardedSetupTokenLeaseManager,
store: setupTokenCleanupStore,
completeCredential: setupTokenSecretWriter,
completeCredential: async (input) => {
if (!input.scope.aiConnection) return setupTokenSecretWriter(input);
await aiConnectionService(db).save(input.scope.companyId, input.scope.ownerUserId, input.scope.aiConnection, input.token, input.sessionId);
},
rateLimiter: setupTokenRateLimiter,
});
@@ -786,6 +796,15 @@ export function agentRoutes(
// bind its own secret while the first login's later, unrelated
// secret-write failure removes the directory both logins now share.
async promote(authBytes, context) {
const managedSession = await adapterLoginStore.get(context.sessionId);
if (managedSession?.aiConnection) {
await adapterLoginStore.withCompanyAdapterPromotionLock(context.companyId, context.startedByUserId, context.adapterType, async () => {
if (!(await checkStagedCredentialReadiness(authBytes)).ready) throw new Error("Provider credential is not ready");
await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), context.sessionId);
});
return;
}
return withCodexAccountHomePromotionLock(undefined, context.companyId, async () => {
// Hold the promotion critical-section lock across the ownership check
// and the credential write. The reaper takes the same lock before it
@@ -1022,6 +1041,15 @@ export function agentRoutes(
},
grok_local: {
async promote(authBytes, context) {
const managedSession = await adapterLoginStore.get(context.sessionId);
if (managedSession?.aiConnection) {
await adapterLoginStore.withCompanyAdapterPromotionLock(context.companyId, context.startedByUserId, context.adapterType, async () => {
if (!(await checkStagedGrokCredentialReadiness(authBytes)).ready) throw new Error("Provider credential is not ready");
await aiConnectionService(db).save(context.companyId, context.startedByUserId, managedSession.aiConnection!, authBytes.toString("utf8"), context.sessionId);
});
return;
}
// The same promotion critical-section lock as the Codex entry above,
// keyed by the same `(companyId, startedByUserId, adapterType)` tuple,
// so a Grok reclaim and a Grok write never interleave.
@@ -1707,6 +1735,17 @@ export function agentRoutes(
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
async function assertBoardCanWakeAgent(req: Request, agent: { id: string; companyId: string }) {
assertBoard(req);
if (!hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found");
assertCompanyAccess(req, agent.companyId);
const decision = await access.decide({
actor: req.actor, action: "agent:wake",
resource: { type: "agent", companyId: agent.companyId, agentId: agent.id },
});
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
// The single owner-authorization helper for the three adapter login routes. It
// requires a board actor, company access, and the same configuration
// permission as the adapter Test route (`agents:create`). It returns the
@@ -3071,6 +3110,10 @@ export function agentRoutes(
return;
}
const provider = asNonEmptyString(req.query.provider);
if (type === "opencode_local" && provider === "openrouter") {
res.json(await listOpenRouterModels(refresh));
return;
}
if (type === "paperclip_runner" && provider && !isPaperclipRunnerProvider(provider)) {
throw unprocessable("Unknown Paperclip Runner provider");
}
@@ -3131,6 +3174,42 @@ export function agentRoutes(
});
}
async function testManagedEnvironment(adapterType: string, context: Parameters<ReturnType<typeof requireServerAdapter>["testEnvironment"]>[0], binding: AiConnectionBinding) {
await assertManagedAiProjectAuth(context.config, binding.provider, context.executionTarget);
const result = await requireServerAdapter(adapterType).testEnvironment(context);
if (result.status === "fail") return result;
if (!result.checks.some(check => check.code.includes("hello_probe"))) {
const providerAdapter = { anthropic: "claude_local", openai: "codex_local", openrouter: "opencode_local", xai: "grok_local" }[binding.provider];
const probe = await requireServerAdapter(providerAdapter).testEnvironment({ ...context, adapterType: providerAdapter, config: { ...context.config, engine: "cli" } });
result.checks.push(...probe.checks);
result.status = probe.status === "fail" ? "fail" : result.status === "warn" || probe.status === "warn" ? "warn" : "pass";
}
if (!result.checks.some(check => /hello_probe_(passed|succeeded)$/.test(check.code))) {
result.status = "fail";
result.checks.push({ code: "ai_connection_validation_incomplete", level: "error", message: "The selected account has not completed a provider hello test. Retry before adopting it." });
}
return result;
}
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
const userId = responsibleUserForAiRequest(req);
const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding);
const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, model: config.model, runnerProvider: config.provider, acpxAgent: config.acpxAgent, binding, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: test });
if (test) {
if (environmentId) await assertAdapterTestEnvironmentForCompany(companyId, environmentId);
const target = await resolveAdapterTestExecutionContext({ companyId, adapterType, environmentId: environmentId ?? null });
let managed: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
try {
if (!target.executionTarget && target.fallbackChecks.some(check => check.level === "error")) throw unprocessable("The agent environment is not available for adoption");
managed = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: userId, adapterType, binding, config, allowUninstalledPersonal: newAgent, allowUninstalledShared, allowLegacyValidation: true });
const result = await testManagedEnvironment(adapterType, { companyId, adapterType, config: managed.config, executionTarget: target.executionTarget, environmentName: target.environmentName }, binding);
if (result.status === "fail" || result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)) throw unprocessable("The selected AI connection failed validation in this agent’s environment");
if (selection.connection.config.aiLegacyAdoption === true) await db.update(toolConnections).set({ healthStatus: "ok", config: { ...selection.connection.config, aiLegacyAdoption: false }, updatedAt: new Date() }).where(eq(toolConnections.id, selection.connection.id));
} finally { try { await managed?.cleanup(); } finally { await target.release("released"); } }
}
return selection.connection.id;
}
router.post(
"/companies/:companyId/adapters/:type/test-environment",
validate(testAdapterEnvironmentSchema),
@@ -3141,8 +3220,9 @@ export function agentRoutes(
const adapter = requireServerAdapter(type);
const inputAdapterConfig =
(req.body?.adapterConfig ?? {}) as Record<string, unknown>;
const aiBinding = req.body.aiConnection ? aiConnectionBindingSchema.parse(req.body.aiConnection) : undefined;
if (aiBinding && req.body.testCredentials && Object.keys(req.body.testCredentials).length) throw unprocessable("A managed connection test cannot override its credentials");
const inputAdapterConfig = aiBinding ? { ...req.body.adapterConfig, env: stripAiAuthBindings(req.body.adapterConfig?.env) } : (req.body?.adapterConfig ?? {}) as Record<string, unknown>;
const requestedEnvironmentId =
typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0
? (req.body.environmentId as string)
@@ -3215,7 +3295,7 @@ export function agentRoutes(
if (requestedEnvironmentId) {
const selectedEnvironment = await environmentsSvc.getById(requestedEnvironmentId);
const environmentEnv = Object.fromEntries(
Object.entries(parseObject(selectedEnvironment?.envVars)).filter(
Object.entries(aiBinding ? stripAiAuthBindings(selectedEnvironment?.envVars) : parseObject(selectedEnvironment?.envVars)).filter(
([key]) => !isForbiddenConfigEnvKey(key),
),
);
@@ -3295,13 +3375,12 @@ export function agentRoutes(
effectiveAdapterConfig.apiKey = req.body.testCredentials.API_SERVER_KEY;
}
}
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: effectiveAdapterConfig,
executionTarget,
environmentName,
});
const managed = aiBinding ? await prepareManagedAiRuntime(db, { companyId, agentId: req.body.agentId ?? "", responsibleUserId: responsibleUserForAiRequest(req), adapterType: type, binding: aiBinding, config: effectiveAdapterConfig, allowUninstalledPersonal: !req.body.agentId, allowUninstalledShared: !req.body.agentId && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, aiBinding) }) : null;
let result;
try {
result = managed && aiBinding ? await testManagedEnvironment(type, { companyId, adapterType: type, config: managed.config, executionTarget, environmentName }, aiBinding) : await adapter.testEnvironment({ companyId, adapterType: type, config: effectiveAdapterConfig, executionTarget, environmentName });
if (managed) result.checks.unshift({ code: "ai_connection_tested", level: "info", message: `Tested ${managed.accountName} — ${managed.accountOwnerUserId ? managed.accountOwnerUserId === responsibleUserForAiRequest(req) ? "your personal account" : "the owner’s account authorized for this agent" : "company-shared account"}. Responsible user: ${req.actor.type === "agent" ? responsibleUserForAiRequest(req) ?? "unavailable" : "the signed-in user"}.` });
} finally { await managed?.cleanup(); }
const prefixChecks = [
...(sandboxIdentityCheck ? [sandboxIdentityCheck] : []),
@@ -3483,6 +3562,10 @@ export function agentRoutes(
if (!resolved) return;
const { ownerUserId: startedByUserId, data } = resolved;
if (data.aiConnection) {
await assertAiConnectionCreateAccess(db, req, companyId, data.aiConnection);
if (!isAiConnectionCompatible(data.aiConnection, type)) throw unprocessable("Incompatible login method");
}
const controller = new AbortController();
let result: Awaited<ReturnType<typeof adapterLoginService.start>>;
try {
@@ -3492,6 +3575,7 @@ export function agentRoutes(
adapterType: type,
startedByUserId,
ttlSeconds: data.ttlSeconds,
aiConnection: data.aiConnection,
signal: controller.signal,
});
} catch (error) {
@@ -4292,6 +4376,8 @@ export function agentRoutes(
const requiresApproval = company.requireBoardApprovalForNewAgents;
const status = requiresApproval ? "pending_approval" : "idle";
const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined;
const managedHireConnectionId = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined;
const createdAgent = await svc.create(
companyId,
{
@@ -4302,6 +4388,7 @@ export function agentRoutes(
lastHeartbeatAt: null,
},
{
aiConnectionInstall: managedHireConnectionId ? { connectionId: managedHireConnectionId, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: hireStoredSessionId ?? null,
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
@@ -4528,6 +4615,8 @@ export function agentRoutes(
allowedSandboxProviders: allowedSandboxProvidersForAgent(createInput.adapterType),
});
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
const managedConnectionId = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
const createdAgent = await svc.create(
companyId,
{
@@ -4540,6 +4629,7 @@ export function agentRoutes(
lastHeartbeatAt: null,
},
{
aiConnectionInstall: managedConnectionId ? { connectionId: managedConnectionId, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: createStoredSessionId ?? null,
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
@@ -5043,6 +5133,15 @@ export function agentRoutes(
adapterConfig: patchData.adapterConfig,
});
}
if (existing.runtimeConfig.aiConnection && requestedRuntimeConfig && !requestedRuntimeConfig.aiConnection) requestedRuntimeConfig.aiConnection = existing.runtimeConfig.aiConnection;
const nextAiBinding = aiConnectionBindingSchema.safeParse(requestedRuntimeConfig?.aiConnection ?? existing.runtimeConfig.aiConnection).data;
if (nextAiBinding) {
await assertCanUpdateAgent(req, existing);
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (!isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
if (changed) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
}
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
await assertAgentDefaultEnvironmentSelection(
@@ -5446,7 +5545,7 @@ export function agentRoutes(
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
await assertBoardCanWakeAgent(req, agent);
}
if (req.body.debug?.providerTrace === "raw") {
assertInstanceAdmin(req);
@@ -5488,6 +5587,23 @@ export function agentRoutes(
typeof failedContext.issueId === "string"
? failedContext.issueId
: null;
if (issueId) {
const issue = await issueService(db).getById(issueId);
if (!issue || issue.companyId !== agent.companyId) throw notFound("Task not found");
if (issue.conversationAgentId && issue.conversationUserId !== req.actor.userId) {
throw forbidden("Only the conversation owner can retry a chat run");
}
const decision = await access.decide({
actor: req.actor, action: "issue:comment",
resource: {
type: "issue", companyId: issue.companyId, issueId: issue.id,
projectId: issue.projectId, parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId, assigneeUserId: issue.assigneeUserId, status: issue.status,
},
});
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
if (issue.assigneeAgentId !== agent.id) throw conflict("The task is no longer assigned to this agent.");
}
const chatBinding = issueId
? await db
.select({ id: chatConversations.id })
@@ -5554,6 +5670,7 @@ export function agentRoutes(
}
const run = await heartbeat.wakeup(id, {
failedRunId: req.body.failedRunId ?? null,
...(req.actor.type === "board" && !req.body.failedRunId ? { manualUserWake: true } : {}),
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
@@ -5648,7 +5765,7 @@ export function agentRoutes(
return;
}
} else {
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
await assertBoardCanWakeAgent(req, agent);
}
const providerTraceRequested = req.body?.debug?.providerTrace === "raw";
if (providerTraceRequested) {
@@ -5687,6 +5804,7 @@ export function agentRoutes(
}
}
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
...(req.actor.type === "board" ? { manualUserWake: true } : {}),
source: "on_demand",
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
@@ -6046,6 +6164,10 @@ export function agentRoutes(
if (!resolved) return;
const { ownerUserId, data } = resolved;
const { environmentId, adapterType } = data;
if (data.aiConnection) {
await assertAiConnectionCreateAccess(db, req, companyId, data.aiConnection);
if (!isAiConnectionCompatible(data.aiConnection, adapterType)) throw unprocessable("Incompatible login method");
}
const confirmedOverwrite: ClaudeSetupTokenOverwrite | null = data.overwrite ?? null;
const scope: SetupTokenSessionScope = {
@@ -6054,6 +6176,7 @@ export function agentRoutes(
adapterType,
environmentId,
confirmedOverwrite,
aiConnection: data.aiConnection,
};
// Read the panel mode from the adapter capability. The guard already checked
// the capability, so it is present here. The client renders the panel from
@@ -6110,7 +6233,7 @@ export function agentRoutes(
? { authorizationUrl: descriptor.loginUrl, transportAdvisory: assessSetupTokenTransport(req) }
: null,
};
res.json(body);
res.json({ ...body, ...(descriptor.aiConnection ? { aiConnection: descriptor.aiConnection } : {}) });
});
router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", async (req, res) => {
+371
View File
@@ -0,0 +1,371 @@
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { z } from "zod";
import { Router, type Request } from "express";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
type Db,
adapterAuthSessions,
heartbeatRuns,
toolConnections,
connectionGrants,
agents,
} from "@paperclipai/db";
import {
createAiConnectionSchema,
aiConnectionLoginIntentSchema,
localAiConnectionSchema,
localAiLoginStartSchema,
isAiConnectionCompatible,
type AiConnectionLoginIntent,
type AiProvider,
type AiConnectionBinding,
} from "@paperclipai/shared";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { accessService } from "../services/access.js";
import { logActivity } from "../services/activity-log.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { validate } from "../middleware/validate.js";
/** Agent API calls inherit authenticated run identity, never the agent's own ID. */
export function responsibleUserForAiRequest(req: Request): string | null {
return req.actor.type === "agent"
? req.actor.onBehalfOfUserId ?? null
: getActorInfo(req).actorId;
}
export async function assertAiConnectionCreateAccess(
db: Db,
req: Request,
companyId: string,
input: Pick<
AiConnectionLoginIntent,
"ownership" | "allAgents" | "agentIds" | "connectionId"
>,
) {
assertBoard(req);
assertCompanyAccess(req, companyId);
const actor = getActorInfo(req);
const userId = actor.actorId;
if (input.connectionId) {
const [grant] = await db
.select({
owner: connectionGrants.subjectUserId,
creator: toolConnections.createdByUserId,
})
.from(connectionGrants)
.innerJoin(
toolConnections,
eq(toolConnections.id, connectionGrants.connectionId),
)
.where(
and(
eq(toolConnections.id, input.connectionId),
eq(toolConnections.companyId, companyId),
eq(toolConnections.connectionPurpose, "ai"),
),
)
.limit(1);
if (!grant || (grant.owner ?? grant.creator) !== userId)
throw forbidden(
"Only the account owner can reconnect this AI connection",
);
}
const membership = req.actor.memberships?.find(
(m) => m.companyId === companyId && m.status === "active",
);
const manager =
req.actor.source === "local_implicit" ||
req.actor.isInstanceAdmin ||
membership?.membershipRole === "owner" ||
membership?.membershipRole === "admin" ||
(await accessService(db).hasPermission(
companyId,
"user",
userId,
"tools:manage_connections",
));
if (
!input.connectionId &&
!manager &&
(input.ownership === "shared" || input.allAgents)
)
throw forbidden(
"A connection manager must authorize company-shared access",
);
if (!input.connectionId && !manager && input.agentIds.length) {
for (const id of input.agentIds) {
if (
!(
await accessService(db).decide({
actor: { type: "board", userId },
action: "agent_config:update",
resource: { type: "agent", companyId, agentId: id },
})
).allowed
)
throw forbidden("You cannot configure this agent");
}
}
if (membership?.membershipRole === "viewer")
throw forbidden("Viewers cannot create AI connections");
return userId;
}
/** Creating an agent may install a shared connection only with the existing
* connection-configure authority. An agent actor cannot grant itself access. */
export async function canInstallSharedAiConnectionForNewAgent(
db: Db, req: Request, companyId: string, binding: AiConnectionBinding,
): Promise<boolean> {
if (req.actor.type !== "board" || binding.mode !== "shared") return false;
assertCompanyAccess(req, companyId);
const member = req.actor.memberships?.find(m => m.companyId === companyId && m.status === "active");
if (member?.membershipRole === "viewer") return false;
const userId = getActorInfo(req).actorId;
const [connection] = await db.select({ creator: toolConnections.createdByUserId })
.from(toolConnections).where(and(eq(toolConnections.companyId, companyId),
eq(toolConnections.id, binding.connectionId), eq(toolConnections.connectionPurpose, "ai")));
if (!connection) return false;
return req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true ||
connection.creator === userId || await accessService(db).hasPermission(companyId, "user", userId, "tools:manage_connections");
}
/** Fixed provider endpoints; credentials are never sent to a caller-supplied URL or through a redirect. */
export async function validateAiApiKey(
provider: AiProvider,
key: string,
request: typeof fetch = fetch,
) {
const endpoints = {
anthropic: "https://api.anthropic.com/v1/models?limit=1",
openai: "https://api.openai.com/v1/models",
openrouter: "https://openrouter.ai/api/v1/key",
xai: "https://api.x.ai/v1/models",
};
let response: Response;
try {
response = await request(endpoints[provider], {
redirect: "error",
signal: AbortSignal.timeout(15000),
headers:
provider === "anthropic"
? { "x-api-key": key, "anthropic-version": "2023-06-01" }
: { Authorization: `Bearer ${key}` },
});
} catch {
throw unprocessable("Could not verify the account. Try again.");
}
await response.body?.cancel();
if (!response.ok)
throw unprocessable(
response.status === 401 || response.status === 403
? "The provider rejected this API key."
: "The provider could not verify this account. Try again.",
);
}
export function aiConnectionRoutes(db: Db, options: Parameters<typeof supportsLocalAiLogin>[0] = {}) {
function assertLocalLoginAvailable() {
if (!supportsLocalAiLogin(options)) throw unprocessable("Server-host subscription sign-in is unavailable on this hosted instance. Choose a supported sign-in environment or use an API key.");
}
const router = Router();
const service = aiConnectionService(db);
const localLogin = localAiLoginService(db);
function assertLocalOperator(req: Request) {
assertBoard(req);
assertCompanyAccess(req, req.params.companyId as string);
if (req.actor.source !== "local_implicit")
throw forbidden("Only the local operator can connect this machine's CLI account.");
}
router.post("/companies/:companyId/ai-connections/local/attempts", validate(localAiLoginStartSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const { restart, ...intent } = localAiLoginStartSchema.parse(req.body);
assertLocalLoginAvailable();
const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent);
res.setHeader("Cache-Control", "no-store");
res.status(201).json(await localLogin.start(companyId, userId, intent, restart));
});
router.post("/companies/:companyId/ai-connections/local/check", validate(localAiConnectionSchema), async (req, res) => {
const companyId = req.params.companyId as string;
const { localSessionId, ...intent } = localAiConnectionSchema.parse(req.body);
assertLocalLoginAvailable();
// Only implicit local operators may inspect ambient Claude credentials.
// Authenticated users sign in to their own company/user-scoped attempt.
if (intent.provider === "anthropic" && !localSessionId) assertLocalOperator(req);
const userId = await assertAiConnectionCreateAccess(db, req, companyId, intent);
res.setHeader("Cache-Control", "no-store");
res.json(await localLogin.check(companyId, userId, intent, localSessionId));
});
router.delete("/companies/:companyId/ai-connections/local/attempts/:sessionId", async (req, res) => {
assertBoard(req);
assertCompanyAccess(req, req.params.companyId as string);
const id = z.string().uuid().parse(req.params.sessionId);
await localLogin.cancel(req.params.companyId as string, getActorInfo(req).actorId, id);
res.json({ ok: true });
});
router.get("/companies/:companyId/ai-connections", async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertCompanyAccess(req, companyId);
const currentUserId = getActorInfo(req).actorId;
res.setHeader("Cache-Control", "no-store");
const agentId = req.query.agentId;
if (agentId !== undefined && !z.string().uuid().safeParse(agentId).success)
throw unprocessable("Invalid agent ID");
res.json({
currentUserId,
connections: await service.list(
companyId,
currentUserId,
agentId as string | undefined,
),
});
});
router.get(
"/companies/:companyId/ai-connections/:connectionId/active-runs",
async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertCompanyAccess(req, companyId);
if (!z.string().uuid().safeParse(req.params.connectionId).success)
throw unprocessable("Invalid connection ID");
const [connection] = await db
.select()
.from(toolConnections)
.where(
and(
eq(toolConnections.companyId, companyId),
eq(toolConnections.id, req.params.connectionId as string),
eq(toolConnections.connectionPurpose, "ai"),
),
);
if (!connection || !(await service.list(companyId, getActorInfo(req).actorId)).some(account => account.id === connection.id))
throw notFound("AI connection not found");
res.setHeader("Cache-Control", "no-store");
res.json(
await db
.select({
id: heartbeatRuns.id,
agentId: agents.id,
agentName: agents.name,
status: heartbeatRuns.status,
})
.from(heartbeatRuns)
.innerJoin(agents, eq(agents.id, heartbeatRuns.agentId))
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, ["queued", "running"]),
sql`${heartbeatRuns.contextSnapshot}->'aiConnection'->>'connectionId' = ${connection.id}`,
),
),
);
},
);
router.post(
"/companies/:companyId/ai-connections",
validate(createAiConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const input = createAiConnectionSchema.parse(req.body);
const userId = await assertAiConnectionCreateAccess(
db,
req,
companyId,
input,
);
if (input.method !== "api_key")
throw unprocessable(
"Use the existing provider sign-in flow to connect a subscription",
);
const attemptStartedAt = new Date();
await validateAiApiKey(input.provider, input.apiKey!);
const result = await service.save(
companyId,
userId,
input,
input.apiKey!,
undefined,
attemptStartedAt,
);
res.status(201).json(result);
},
);
router.post(
"/companies/:companyId/ai-connections/local",
validate(localAiConnectionSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const { localSessionId, ...input } = localAiConnectionSchema.parse(req.body);
assertLocalLoginAvailable();
if (input.provider === "anthropic" && !localSessionId) assertLocalOperator(req);
const userId = await assertAiConnectionCreateAccess(db, req, companyId, input);
if (localSessionId || input.provider === "openai" || input.provider === "xai") {
if (!localSessionId) throw unprocessable("Start a separate local sign-in for this connection before connecting.");
res.status(201).json(await localLogin.complete(companyId, userId, localSessionId, input));
return;
}
const attemptStartedAt = new Date();
const credential = await readVerifiedLocalAiCredential(input.provider);
res.status(201).json(await service.save(companyId, userId, input, credential, undefined, attemptStartedAt));
},
);
router.put(
"/companies/:companyId/ai-connections/default",
async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertCompanyAccess(req, companyId);
const userId = getActorInfo(req).actorId;
if (
req.actor.memberships?.some(
(m) => m.companyId === companyId && m.membershipRole === "viewer",
)
)
throw forbidden("Viewers cannot change defaults");
if (!z.string().uuid().safeParse(req.body.grantId).success)
throw unprocessable("Choose a personal connection");
await service.setDefault(companyId, userId, req.body.grantId);
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "ai_connection.default_changed",
entityType: "connection_grant",
entityId: req.body.grantId,
});
res.json({ ok: true });
},
);
router.get(
"/companies/:companyId/ai-connections/login/:sessionId",
async (req, res) => {
const companyId = req.params.companyId as string;
assertBoard(req);
assertCompanyAccess(req, companyId);
const [session] = await db
.select({
connectionId: adapterAuthSessions.connectionId,
grantId: adapterAuthSessions.connectionGrantId,
})
.from(adapterAuthSessions)
.where(
and(
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, getActorInfo(req).actorId),
eq(
adapterAuthSessions.publicSessionId,
req.params.sessionId as string,
),
),
);
if (!session?.connectionId)
throw notFound("The login has not saved a connection");
res.setHeader("Cache-Control", "no-store");
res.json(session);
},
);
return router;
}
+7
View File
@@ -7,6 +7,7 @@ import type { Db } from "@paperclipai/db";
import {
CHAT_PROVIDERS,
configureChatEndpointSchema,
inspectPhotonProjectSchema,
confirmChatIdentityLinkSchema,
createChatEndpointSchema,
createChatIdentityLinkIntentSchema,
@@ -153,6 +154,12 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
},
);
router.post("/chat-endpoints/:endpointId/photon/inspect", validate(inspectPhotonProjectSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.inspectPhoton(endpointId(req), req.body));
});
router.post(
"/chat-endpoints/:endpointId/setup",
validate(configureChatEndpointSchema),
+4 -2
View File
@@ -180,8 +180,10 @@ export function connectionIntentBoardRoutes(db: Db, heartbeat: Heartbeat) {
}
router.get("/connection-intents/:interactionId/setup-options", async (req, res) => {
await addressedIntent(req);
res.json(await service.setupOptions(req.params.interactionId as string));
const { loaded } = await addressedIntent(req);
res.json(await service.setupOptions(req.params.interactionId as string, {
canManageOrganizationGrant: await canManageCompanyConnections(req, loaded.issue.companyId),
}));
});
router.post("/connection-intents/:interactionId/phase", async (req, res) => {
+3
View File
@@ -1,3 +1,4 @@
import { supportsLocalAiLogin } from "../services/local-ai-login-policy.js";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { Router } from "express";
import type { Db } from "@paperclipai/db";
@@ -391,6 +392,7 @@ export function healthRoutes(
status: healthStatus,
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
localAiLoginSupported: supportsLocalAiLogin(opts),
commit,
bootstrapStatus,
bootstrapInviteActive,
@@ -414,6 +416,7 @@ export function healthRoutes(
commit,
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
localAiLoginSupported: supportsLocalAiLogin(opts),
authReady: opts.authReady,
bootstrapStatus,
bootstrapInviteActive,
+36 -8
View File
@@ -357,6 +357,9 @@ const queuedCommentSteeringTargetSchema =
queuedCommentMutationTargetSchema.extend({
targetRunId: z.string().min(1),
});
const queuedCommentInterruptTargetSchema = queuedCommentMutationTargetSchema.extend({
targetRunId: z.string().min(1).nullable(),
});
const editQueuedCommentSchema = queuedCommentMutationTargetSchema.extend({
body: z
.string()
@@ -6943,9 +6946,10 @@ export function issueRoutes(
actor: ReturnType<typeof getActorInfo>;
queueId: string;
targetRunId?: string;
allowStoppedTarget?: boolean;
}) {
await input.tx
.select({ id: issueRows.id })
const [currentIssue] = await input.tx
.select()
.from(issueRows)
.where(
and(
@@ -6954,6 +6958,8 @@ export function issueRoutes(
),
)
.for("update");
if (!currentIssue) throw notFound("Issue not found");
input.issue = currentIssue;
const wake = await input.tx
.select()
.from(agentWakeupRequests)
@@ -7030,7 +7036,7 @@ export function issueRoutes(
and(
eq(heartbeatRuns.id, activeRunId),
eq(heartbeatRuns.companyId, input.issue.companyId),
eq(heartbeatRuns.status, "running"),
input.allowStoppedTarget ? undefined : eq(heartbeatRuns.status, "running"),
),
)
.for("update")
@@ -15216,12 +15222,14 @@ export function issueRoutes(
router.post(
"/issues/:id/queued-comments/interrupt",
validate(queuedCommentSteeringTargetSchema),
validate(queuedCommentInterruptTargetSchema),
async (req, res) => {
assertBoard(req);
if (!req.actor.userId) throw forbidden("Board user context required");
const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found");
if (!issue) return;
const decision = await decideIssueAccess(req, issue, "issue:comment");
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
if (issue.conversationAgentId) {
if (!(await instanceSettings.getExperimental()).enableAgentChat) throw notFound("Agent Chat is disabled");
if (req.actor.userId !== issue.conversationUserId) {
@@ -15229,23 +15237,43 @@ export function issueRoutes(
}
}
const actor = getActorInfo(req);
await db.transaction(async (tx) => {
const runToInterrupt = await db.transaction(async (tx) => {
const locked = await lockQueuedCommentState({
tx, issue, actor, queueId: req.body.queueId, targetRunId: req.body.targetRunId,
tx, issue, actor, queueId: req.body.queueId, targetRunId: req.body.targetRunId ?? undefined,
allowStoppedTarget: true,
});
assertQueueMutationTarget({ queue: locked.queue, queueId: req.body.queueId, revision: req.body.revision });
if (locked.queue.protocol !== "legacy" || locked.activeRun?.agentId !== issue.assigneeAgentId) {
if (locked.queue.protocol !== "legacy" || locked.state !== "deferred" ||
!locked.queue.entries.length ||
(locked.activeRun && locked.activeRun.agentId !== locked.wake.agentId)) {
throw conflict("This queue does not support legacy interruption");
}
if (locked.activeRun && locked.activeRun.status !== "running" &&
!["succeeded", "failed", "timed_out", "interrupted", "cancelled"].includes(locked.activeRun.status)) {
throw conflict("The previous run has not stopped");
}
if (locked.activeRun?.status === "running" && locked.activeRun.id !== req.body.targetRunId) {
throw conflict("The queued message targets a stale run", { code: "queued_comment_stale_target" });
}
// The click is durable fresh user intent, including when the message
// predates a failed run's stop. Keep its content and original attribution.
await tx.update(agentWakeupRequests).set({
payload: { ...readObject(locked.wake.payload), queuedCommentInterrupt: {
actorId: actor.actorId, requestedAt: new Date().toISOString(),
} },
updatedAt: new Date(),
}).where(eq(agentWakeupRequests.id, locked.wake.id));
return locked.activeRun?.status === "running" ? locked.activeRun.id : null;
});
// Never hold the issue lock while joining the adapter. Queue edits and
// discards stay authoritative until the dispatcher claims the successor.
const options = operatorInterruptCancelOptions({ issueId: issue.id, actor });
await heartbeat.cancelRun(req.body.targetRunId, "Interrupted to send queued messages", {
if (runToInterrupt) await heartbeat.cancelRun(runToInterrupt, "Interrupted to send queued messages", {
...options,
suppressImmediateRecovery: true,
resultJson: { ...options.resultJson, queuedCommentInterruptQueueId: req.body.queueId },
});
await heartbeat.resumeQueuedCommentInterrupt(issue.companyId, req.body.queueId, { retryCleanup: true });
await logActivity(db, {
companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId,
+135 -3
View File
@@ -6,6 +6,10 @@ import {
import { Router } from "express";
import { z } from "zod";
import {
createAiConnectionSchema,
aiConnectionLoginIntentSchema,
localAiConnectionSchema,
localAiLoginStartSchema,
emailEndpointSetupSchema,
emailConnectionSchema,
emailSendSchema,
@@ -262,6 +266,9 @@ import {
confirmChatIdentityLinkSchema,
createChatEndpointSchema,
createChatIdentityLinkIntentSchema,
inspectPhotonProjectSchema,
photonProjectIdSchema,
photonLineIdSchema,
publishChatPublicationSchema,
resolveChatActionSchema,
resolveChatPublicationSchema,
@@ -794,6 +801,7 @@ const chatEndpointResponseSchema = z
providerAccountId: z.string().nullable(),
providerAccountLabel: z.string().nullable(),
botExternalId: z.string().nullable(),
photonAllocation: z.enum(["dedicated", "shared"]).optional(),
botUsername: z.string().nullable(),
botLabel: z.string().nullable(),
botAvatarUrl: z.string().nullable(),
@@ -827,6 +835,7 @@ const chatEndpointResourceResponseSchema = z
availability: chatResourceAvailabilitySchema,
enabled: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
participants: z.array(z.string()).optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})
@@ -1284,6 +1293,16 @@ const BOARD_ONLY_PREFIXES = [
];
const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/companies/{companyId}/ai-connections",
"POST /api/companies/{companyId}/ai-connections",
"POST /api/companies/{companyId}/ai-connections/local",
"POST /api/companies/{companyId}/ai-connections/local/attempts",
"POST /api/companies/{companyId}/ai-connections/local/check",
"DELETE /api/companies/{companyId}/ai-connections/local/attempts/{sessionId}",
"PUT /api/companies/{companyId}/ai-connections/default",
"GET /api/companies/{companyId}/ai-connections/{connectionId}/active-runs",
"GET /api/companies/{companyId}/ai-connections/login/{sessionId}",
"GET /api/companies/{companyId}/project-repositories",
"PUT /api/projects/{id}/repositories",
"DELETE /api/issues/{id}/documents/{key}",
@@ -1455,6 +1474,7 @@ const BOARD_ONLY_OPERATIONS = new Set([
"POST /api/chat-endpoints/{endpointId}/setup",
"POST /api/chat-endpoints/{endpointId}/setup-secret",
"POST /api/chat-endpoints/{endpointId}/test",
"POST /api/chat-endpoints/{endpointId}/photon/inspect",
"GET /api/chat-endpoints/{endpointId}/resources",
"PUT /api/chat-endpoints/{endpointId}/resources",
"GET /api/chat-endpoints/{endpointId}/principals",
@@ -2131,7 +2151,7 @@ registry.registerPath({
tags: ["chat-channels"],
summary: "Configure or change chat endpoint lifecycle state",
description:
"Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`). Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.",
"Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.",
request: {
params: z.object({ endpointId: z.string().uuid() }),
body: jsonBody(configureChatEndpointSchema),
@@ -2144,6 +2164,9 @@ registry.registerPath({
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
429: { description: "Provider request limit reached; retry later" },
502: { description: "Provider returned an invalid response; inspect provider health" },
503: { description: "Provider temporarily unavailable; retry later" },
},
});
@@ -2165,13 +2188,48 @@ registry.registerPath({
},
});
registry.registerPath({
method: "post",
path: "/api/chat-endpoints/{endpointId}/photon/inspect",
tags: ["chat-channels"],
summary: "Inspect Photon shared project or dedicated numbers for channel setup",
description:
"Requires a board user with connection-management access. The project secret is write-only input. Returns the project's actual allocation and eligibility for shared DMs or dedicated lines, never project secrets or minted line tokens. Responses are not cached. Inspection alone does not activate the channel.",
request: {
params: z.object({ endpointId: z.string().uuid() }),
body: jsonBody(inspectPhotonProjectSchema),
},
responses: {
200: r.ok(z.object({
projectId: photonProjectIdSchema,
projectName: z.string(),
allocation: z.enum(["dedicated", "shared"]),
eligible: z.boolean(),
lines: z.array(z.object({
lineId: photonLineIdSchema,
phoneNumber: z.string(),
eligible: z.boolean(),
unavailableReason: z.string().optional(),
}).strict()),
}).strict()),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
422: r.unprocessable,
429: { description: "Photon request limit reached; retry later" },
502: { description: "Photon returned an invalid response; inspect provider health" },
503: { description: "Photon temporarily unavailable; retry later" },
},
});
registry.registerPath({
method: "post",
path: "/api/chat-endpoints/{endpointId}/test",
tags: ["chat-channels"],
summary: "Complete a chat endpoint setup test",
description:
"Activates a verifying endpoint only after Paperclip has received a real provider event since the server-issued setup test boundary.",
"Activates a verifying endpoint only after Paperclip has received a real provider event since the server-issued setup test boundary. iMessage Photon additionally requires a fresh linked sender's task and a successful outbound agent publication.",
request: { params: z.object({ endpointId: z.string().uuid() }) },
responses: {
200: r.ok(chatEndpointResponseSchema),
@@ -2188,7 +2246,7 @@ registry.registerPath({
tags: ["chat-channels"],
summary: "List destinations discovered for a chat endpoint",
description:
"Lists provider destinations such as Slack and Discord channels, Teams channels, GitHub repositories, and Telegram chats. Direct-message resources are intentionally omitted.",
"Lists provider destinations such as Slack and Discord channels, Teams channels, GitHub repositories, Telegram chats, and iMessage Photon groups. Direct-message resources are intentionally omitted.",
request: { params: z.object({ endpointId: z.string().uuid() }) },
responses: {
200: r.ok(z.array(chatEndpointResourceResponseSchema)),
@@ -9879,6 +9937,51 @@ registerCurrentRoute({
body: declineConnectionIntentSchema,
});
// --- AI runtime connections -------------------------------------------------
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/ai-connections",
tags: ["ai-connections"],
summary: "List available AI connections and personal defaults",
query: z.object({ agentId: z.string().uuid().optional() }),
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/ai-connections",
tags: ["ai-connections"],
summary: "Validate and connect an AI API key, or reconnect its existing grant",
body: createAiConnectionSchema,
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "put",
path: "/api/companies/{companyId}/ai-connections/default",
tags: ["ai-connections"],
summary: "Set the signed-in owner’s personal AI default",
body: z.object({ grantId: z.string().uuid() }),
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/ai-connections/{connectionId}/active-runs",
tags: ["ai-connections"],
summary: "List active runs attributed to an AI connection",
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "get",
path: "/api/companies/{companyId}/ai-connections/login/{sessionId}",
tags: ["ai-connections"],
summary: "Get the connection saved by an owned completed login",
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
// --- Tool access -------------------------------------------------------------
registerCurrentRoute({
@@ -11026,3 +11129,32 @@ export function openApiRoutes() {
});
return router;
}
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/ai-connections/local",
tags: ["ai-connections"],
summary: "Verify and save the local operator's CLI subscription account",
body: localAiConnectionSchema,
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/ai-connections/local/attempts",
tags: ["ai-connections"], summary: "Prepare an isolated local subscription sign-in",
body: localAiLoginStartSchema,
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registerCurrentRoute({
method: "delete",
path: "/api/companies/{companyId}/ai-connections/local/attempts/{sessionId}",
tags: ["ai-connections"], summary: "Cancel an owned local subscription sign-in",
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registerCurrentRoute({
method: "post",
path: "/api/companies/{companyId}/ai-connections/local/check",
tags: ["ai-connections"], summary: "Check the local operator's subscription sign-in without saving a connection",
body: localAiConnectionSchema,
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
+9
View File
@@ -3,6 +3,7 @@ import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
toolConnectionInstalls,
agentConfigRevisions,
agentApiKeys,
agentRuntimeState,
@@ -113,6 +114,7 @@ interface UpdateAgentOptions {
}
interface CreateAgentOptions {
aiConnectionInstall?: { connectionId: string; createdByUserId: string | null };
allowBuiltInAgentMetadata?: boolean;
claudeLogin?: ClaudeLoginContext;
}
@@ -857,6 +859,13 @@ export function agentService(db: Db) {
})
.returning()
.then((rows) => rows[0]);
if (options?.aiConnectionInstall) {
await tx.insert(toolConnectionInstalls).values({
companyId, connectionId: options.aiConnectionInstall.connectionId,
targetType: "agent", targetId: created.id,
createdByUserId: options.aiConnectionInstall.createdByUserId,
}).onConflictDoNothing();
}
await syncAgentSecretBindings(created, txDb);
const normalizedCreated = await agentService(txDb).getById(created.id);
if (!normalizedCreated) {
@@ -0,0 +1,388 @@
import { createHash } from "node:crypto";
import { unprocessable } from "../errors.js";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { type Db, connectionGrants } from "@paperclipai/db";
import {
AI_CONNECTION_CAPABILITIES,
type AiConnectionBinding,
} from "@paperclipai/shared";
import { aiConnectionService } from "./ai-connections.js";
import { secretService } from "./secrets.js";
import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
// Blank values intentionally override inherited credentials in CLI child environments.
export const AI_AUTH_ENV_KEYS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"OPENAI_API_KEY",
"CODEX_API_KEY",
"OPENROUTER_API_KEY",
"XAI_API_KEY",
"GROK_API_KEY",
"CODEX_HOME",
"GROK_HOME",
"CLAUDE_CONFIG_DIR",
"OPENCODE_AUTH_JSON",
"OPENCODE_CONFIG_CONTENT",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
"PAPERCLIP_OPENCODE_PROVIDERS",
"ANTHROPIC_BASE_URL",
"OPENAI_BASE_URL",
"XAI_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
] as const;
export function stripAiAuthBindings(env: unknown): Record<string, unknown> {
const result = {
...(env && typeof env === "object" ? (env as Record<string, unknown>) : {}),
};
for (const key of AI_AUTH_ENV_KEYS)
if (
![
"ANTHROPIC_BASE_URL",
"OPENAI_BASE_URL",
"XAI_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"PAPERCLIP_OPENCODE_PROVIDERS",
].includes(key)
)
delete result[key];
return result;
}
export async function assertManagedAiProjectAuth(
config: Record<string, unknown>,
provider: AiConnectionBinding["provider"],
target?: AdapterExecutionTarget | null,
) {
const extraArgs = [
...(Array.isArray(config.extraArgs) ? config.extraArgs : []),
...(Array.isArray(config.args) ? config.args : []),
];
if (
extraArgs.some(
(arg) =>
typeof arg === "string" &&
/^(--config|-c|--settings|--setting-sources|--api-key|--auth-token)(=|$)/.test(
arg,
),
)
) {
throw unprocessable(
"Remove authentication/configuration overrides before selecting a managed AI connection",
{ code: "ai_connection_incompatible" },
);
}
const files =
provider === "anthropic"
? [".claude/settings.json", ".claude/settings.local.json"]
: provider === "openai"
? [".codex/config.toml"]
: [];
const pattern =
"apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider[[:space:]]*=|env_key[[:space:]]*=|experimental_bearer_token|cli_auth_credentials_store";
if (target?.kind === "remote" && files.length) {
// Only inspect for conflicting keys; never return configuration or credential values.
const result = await runAdapterExecutionTargetProcess(
`ai-auth-check-${Date.now()}`,
target,
"sh",
[
"-c",
`
directory=$1; pattern=$2; shift 2
while :; do
for relative in "$@"; do
file="$directory/$relative"
if test -f "$file"; then
grep -Eq "$pattern" "$file"
result=$?
if test "$result" -eq 0; then exit 42; fi
if test "$result" -ne 1; then exit 43; fi
fi
done
parent=$(dirname "$directory")
if test "$parent" = "$directory"; then break; fi
directory=$parent
done`,
"ai-auth-check",
target.remoteCwd,
pattern,
...files,
],
{
cwd: target.remoteCwd,
env: {},
timeoutSec: 15,
graceSec: 1,
onLog: async () => {},
},
);
if (result.exitCode !== 0)
throw unprocessable(
"The environment's project authentication settings must be checked before using this AI connection",
{ code: "ai_connection_incompatible" },
);
return;
}
let directory =
typeof config.cwd === "string" ? path.resolve(config.cwd) : process.cwd();
for (;;) {
for (const relative of files) {
try {
const content = await readFile(path.join(directory, relative), "utf8");
if (
/apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider\s*=|env_key\s*=|experimental_bearer_token|cli_auth_credentials_store/.test(
content,
)
) {
throw unprocessable(
"Project authentication settings conflict with the selected AI connection",
{ code: "ai_connection_incompatible" },
);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
const parent = path.dirname(directory);
if (parent === directory) break;
directory = parent;
}
}
async function acquireCredentialLease(db: Db, grantId: string) {
const client = await db.$client.reserve();
try {
const [result] =
await client`select pg_try_advisory_lock(hashtextextended(${`ai-runtime:${grantId}`}, 0)) as acquired`;
if (!result.acquired)
throw unprocessable(
"This subscription is in use. Retry when its current execution finishes.",
{ code: "ai_connection_busy" },
);
} catch (error) {
client.release();
throw error;
}
let released = false;
return async () => {
if (released) return;
released = true;
try {
await client`select pg_advisory_unlock(hashtextextended(${`ai-runtime:${grantId}`}, 0))`;
} finally {
client.release();
}
};
}
export async function prepareManagedAiRuntime(
db: Db,
input: {
companyId: string;
agentId: string;
responsibleUserId: string | null;
adapterType: string;
binding: AiConnectionBinding;
allowUninstalledPersonal?: boolean;
allowUninstalledShared?: boolean;
allowLegacyValidation?: boolean;
config: Record<string, unknown>;
},
) {
const configuredEnv =
input.config.env && typeof input.config.env === "object"
? (input.config.env as Record<string, unknown>)
: {};
for (const key of [
"ANTHROPIC_BASE_URL",
"OPENAI_BASE_URL",
"XAI_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"PAPERCLIP_OPENCODE_PROVIDERS",
]) {
if (configuredEnv[key])
throw unprocessable(
"The configured provider routing is incompatible with this AI connection",
{ code: "ai_connection_incompatible" },
);
}
await assertManagedAiProjectAuth(input.config, input.binding.provider);
const service = aiConnectionService(db);
let selection = await service.select({
...input,
userId: input.responsibleUserId,
model: input.config.model,
runnerProvider: input.config.provider,
acpxAgent: input.config.acpxAgent,
});
const release =
input.binding.method === "subscription"
? await acquireCredentialLease(db, selection.grant.id)
: async () => {};
let home: string | undefined;
try {
const selectedGrantId = selection.grant.id;
selection = await service.select({
...input,
userId: input.responsibleUserId,
model: input.config.model,
runnerProvider: input.config.provider,
acpxAgent: input.config.acpxAgent,
});
if (selection.grant.id !== selectedGrantId)
throw unprocessable(
"The selected default changed. Retry this execution.",
);
const value = await service.credential(selection);
home = await mkdtemp(
path.join(
os.tmpdir(),
`paperclip-ai-${input.companyId}-${selection.grant.id}-`,
),
);
const providerHome = path.join(home, "provider");
await mkdir(providerHome, { mode: 0o700 });
const env: Record<string, unknown> = {
...stripAiAuthBindings(input.config.env),
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
HOME: home,
XDG_CONFIG_HOME: path.join(home, "config"),
XDG_DATA_HOME: path.join(home, "data"),
CODEX_HOME: providerHome,
GROK_HOME: providerHome,
CLAUDE_CONFIG_DIR: providerHome,
};
const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
input.binding.method
]!;
const authFile = path.join(providerHome, "auth.json");
if (input.binding.provider === "openai")
await writeFile(
path.join(providerHome, "config.toml"),
'cli_auth_credentials_store = "file"\n',
{ mode: 0o600 },
);
const subscriptionFile =
input.binding.method === "subscription" &&
input.binding.provider !== "anthropic";
if (subscriptionFile) await writeFile(authFile, value, { mode: 0o600 });
else env[capability.envKey] = value;
if (
input.binding.provider === "openai" &&
input.binding.method === "api_key"
) {
env.CODEX_API_KEY = value;
await writeFile(authFile, JSON.stringify({ OPENAI_API_KEY: value }), {
mode: 0o600,
});
}
if (input.binding.provider === "openrouter") {
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
provider: { openrouter: { options: { apiKey: value } } },
});
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
}
const generation = createHash("sha256")
.update(value)
.digest("hex")
.slice(0, 16);
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
return {
config: {
...input.config,
env,
managedAiConnection: { ...selection.attribution, identity },
},
attribution: selection.attribution,
accountName: selection.connection.name,
accountOwnerUserId: selection.grant.subjectUserId,
identity,
cleanup: async () => {
try {
if (subscriptionFile) {
const refreshed = await readFile(authFile, "utf8");
if (refreshed !== value)
await db.transaction(async (tx) => {
const [grant] = await tx
.select()
.from(connectionGrants)
.where(
and(
eq(connectionGrants.id, selection.grant.id),
eq(connectionGrants.companyId, input.companyId),
),
)
.for("update");
// Reconnect/revocation wins over a process holding an older credential.
if (
!grant ||
grant.status !== "active" ||
grant.updatedAt.getTime() !==
selection.grant.updatedAt.getTime()
)
return;
const current = await service.credential({
...selection,
grant,
});
const destination = path.join(
providerHome,
"current-auth.json",
);
await writeFile(destination, current, { mode: 0o600 });
const decision =
input.binding.provider === "openai"
? await decideCodexAuthMerge(authFile, destination, {
errorLabel: "AI account refresh",
})
: await decideGrokAuthMerge(authFile, destination, {
errorLabel: "AI account refresh",
});
if (decision !== 10) return;
const ref = grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
)!;
await secretService(tx).rotate(
ref.secretId,
{ value: refreshed },
{ userId: grant.subjectUserId },
);
await tx
.update(connectionGrants)
.set({ updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
});
}
} finally {
try {
if (home) await rm(home, { recursive: true, force: true });
} finally {
await release();
}
}
},
};
} catch (error) {
try {
if (home) await rm(home, { recursive: true, force: true });
} finally {
await release();
}
throw error;
}
}
+775
View File
@@ -0,0 +1,775 @@
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray, or } from "drizzle-orm";
import {
type Db,
authUsers,
adapterAuthSessions,
aiConnectionDefaults,
agents,
companyMemberships,
companySecrets,
userSecretDefinitions,
connectionGrants,
connectionGrantMembers,
toolApplications,
toolConnections,
toolConnectionInstalls,
} from "@paperclipai/db";
import {
AI_CONNECTION_CAPABILITIES,
aiConnectionMetadataSchema,
aiSubscriptionNeedsIsolatedLogin,
isAiConnectionCompatible,
type AiConnectionBinding,
type AiConnectionAttribution,
type AiConnectionMetadata,
type AiManagedConnectionSummary,
type CreateAiConnection,
type AiConnectionLoginIntent,
} from "@paperclipai/shared";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
import { secretService } from "./secrets.js";
/** Same human audience displayed by the existing Connections identity controls. */
function canUseCredential(
grant: { kind: string; subjectUserId: string | null },
userId: string | null,
audience: { subjectType: string; subjectId: string }[],
) {
if (!userId) return false;
if (grant.kind === "user") return grant.subjectUserId === userId;
return grant.kind === "organization" && (
audience.length === 0 || audience.some((member) => member.subjectType === "user" && member.subjectId === userId)
);
}
export function aiConnectionService(db: Db) {
const secrets = secretService(db);
async function membership(companyId: string, userId: string | null) {
if (!userId) return false;
return Boolean(
(
await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.limit(1)
)[0],
);
}
async function rows(companyId: string) {
return db
.select({ connection: toolConnections, grant: connectionGrants })
.from(toolConnections)
.innerJoin(
connectionGrants,
and(
eq(connectionGrants.companyId, toolConnections.companyId),
eq(connectionGrants.connectionId, toolConnections.id),
),
)
.where(
and(
eq(toolConnections.companyId, companyId),
eq(toolConnections.connectionPurpose, "ai"),
),
);
}
async function list(
companyId: string,
userId: string,
_agentId?: string,
): Promise<AiManagedConnectionSummary[]> {
const [accounts, defaults, members, owners] =
await Promise.all([
rows(companyId),
db
.select()
.from(aiConnectionDefaults)
.where(
and(
eq(aiConnectionDefaults.companyId, companyId),
eq(aiConnectionDefaults.userId, userId),
),
),
db
.select()
.from(connectionGrantMembers)
.where(eq(connectionGrantMembers.companyId, companyId)),
db
.select({ id: authUsers.id, name: authUsers.name })
.from(authUsers)
.innerJoin(
companyMemberships,
and(
eq(companyMemberships.principalId, authUsers.id),
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
),
),
]);
return accounts.flatMap(({ connection, grant }) => {
const metadata = aiConnectionMetadataSchema.safeParse(
connection.config.ai,
);
if (!metadata.success) return [];
const needsReconnect = aiSubscriptionNeedsIsolatedLogin(connection.config);
if (!canUseCredential(grant, userId, members.filter((m) => m.grantId === grant.id)))
return [];
return [
{
id: connection.id,
grantId: grant.id,
companyId,
...metadata.data,
name: connection.name,
accountLabel: grant.providerTenant?.name,
...(needsReconnect ? { unavailableReason: "Reconnect with a separate sign-in to protect your existing terminal login." } : {}),
ownership:
grant.kind === "user" ? ("personal" as const) : ("shared" as const),
ownerUserId: grant.subjectUserId ?? undefined,
ownerName:
owners.find((owner) => owner.id === grant.subjectUserId)?.name ??
(grant.subjectUserId === userId ? "You" : "Account owner"),
isDefault: defaults.some((d) => d.grantId === grant.id),
status:
grant.status === "revoked"
? ("revoked" as const)
: grant.status === "expired"
? ("expired" as const)
: grant.status !== "active" ||
!connection.enabled ||
connection.status !== "active" ||
connection.healthStatus !== "ok" || needsReconnect
? ("needs_attention" as const)
: ("connected" as const),
},
];
});
}
async function setDefault(
companyId: string,
userId: string,
grantId: string,
) {
return db.transaction(async (tx) => {
const [row] = await tx
.select({ connection: toolConnections, grant: connectionGrants })
.from(connectionGrants)
.innerJoin(
toolConnections,
eq(toolConnections.id, connectionGrants.connectionId),
)
.where(
and(
eq(connectionGrants.companyId, companyId),
eq(connectionGrants.id, grantId),
eq(connectionGrants.subjectUserId, userId),
eq(connectionGrants.kind, "user"),
),
)
.for("update");
if (!row)
throw forbidden("Only the owner can choose their personal default");
if (
row.connection.connectionPurpose !== "ai" ||
row.grant.status !== "active" ||
!row.connection.enabled ||
row.connection.healthStatus !== "ok"
)
throw unprocessable(
"Reconnect this account before making it your default",
);
const metadata = aiConnectionMetadataSchema.parse(
row.connection.config.ai,
);
await tx
.insert(aiConnectionDefaults)
.values({ companyId, userId, ...metadata, grantId })
.onConflictDoUpdate({
target: [
aiConnectionDefaults.companyId,
aiConnectionDefaults.userId,
aiConnectionDefaults.provider,
aiConnectionDefaults.method,
],
set: { grantId, updatedAt: new Date() },
});
});
}
async function select(input: {
companyId: string;
userId: string | null;
agentId: string;
adapterType: string;
model?: unknown;
runnerProvider?: unknown;
acpxAgent?: unknown;
allowUninstalledPersonal?: boolean;
allowUninstalledShared?: boolean;
allowLegacyValidation?: boolean;
binding: AiConnectionBinding;
}) {
const { companyId, userId, agentId, binding } = input;
if (
!isAiConnectionCompatible(
binding,
input.adapterType,
input.model,
input.runnerProvider,
input.acpxAgent,
)
)
throw unprocessable(
"Select an AI connection compatible with this harness and model",
{ code: "ai_connection_incompatible" },
);
if (binding.mode === "responsible_user" && !userId)
throw unprocessable(
"This run needs a responsible user to select an AI connection",
{ code: "ai_connection_responsible_user_missing" },
);
if (userId && !(await membership(companyId, userId)))
throw forbidden("The responsible user is not an active company member");
const defaultRow =
binding.mode === "responsible_user"
? (
await db
.select()
.from(aiConnectionDefaults)
.where(
and(
eq(aiConnectionDefaults.companyId, companyId),
eq(aiConnectionDefaults.userId, userId!),
eq(aiConnectionDefaults.provider, binding.provider),
eq(aiConnectionDefaults.method, binding.method),
),
)
.limit(1)
)[0]
: null;
const grantId =
binding.mode === "responsible_user"
? defaultRow?.grantId
: binding.grantId;
if (!grantId)
throw unprocessable(
"Connect an account and choose your personal default",
{ code: "ai_connection_default_missing" },
);
const row = (await rows(companyId)).find(
(r) =>
r.grant.id === grantId &&
(binding.mode === "responsible_user" ||
r.connection.id === binding.connectionId),
);
if (!row)
throw unprocessable("The selected AI connection is unavailable", {
code: "ai_connection_missing",
});
const { connection, grant } = row;
const metadata = aiConnectionMetadataSchema.safeParse(connection.config.ai);
if (
!metadata.success ||
metadata.data.provider !== binding.provider ||
metadata.data.method !== binding.method
)
throw unprocessable("The selected AI connection is incompatible", {
code: "ai_connection_incompatible",
});
if (aiSubscriptionNeedsIsolatedLogin(connection.config))
throw unprocessable("Reconnect this subscription with a separate sign-in to protect your existing terminal login.", {
code: "ai_connection_unavailable", connectionId: connection.id,
});
if (
grant.status !== "active" ||
!connection.enabled ||
connection.status !== "active" ||
(connection.healthStatus !== "ok" &&
!(
input.allowLegacyValidation &&
connection.config.aiLegacyAdoption === true
))
)
throw unprocessable("Reconnect or validate the selected AI account", {
code: "ai_connection_unavailable",
connectionId: connection.id,
});
if (
grant.kind === "user" &&
!(await membership(companyId, grant.subjectUserId))
)
throw forbidden(
"The account owner is no longer an active company member",
);
if (
binding.mode === "responsible_user" &&
(grant.kind !== "user" || grant.subjectUserId !== userId)
)
throw forbidden("The default must belong to the responsible user");
if (binding.mode === "shared" && grant.kind !== "organization")
throw forbidden("Select a company-shared account");
if (binding.mode === "delegated" && grant.kind !== "user")
throw forbidden("Select a personal account");
const audience = await db
.select()
.from(connectionGrantMembers)
.where(and(
eq(connectionGrantMembers.companyId, companyId),
eq(connectionGrantMembers.grantId, grant.id),
));
// The existing human-access permission is authoritative for every binding,
// including old explicit personal selections. Agent delegation cannot bypass it.
if (!canUseCredential(grant, userId, audience))
throw forbidden("This credential is not shared with the responsible user");
const installs = await db
.select()
.from(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, companyId),
eq(toolConnectionInstalls.connectionId, connection.id),
or(
and(
eq(toolConnectionInstalls.targetType, "company"),
eq(toolConnectionInstalls.targetId, companyId),
),
and(
eq(toolConnectionInstalls.targetType, "agent"),
eq(toolConnectionInstalls.targetId, agentId),
),
),
),
);
if (
!installs.length &&
!(
(input.allowUninstalledPersonal &&
binding.mode === "responsible_user" && grant.subjectUserId === userId) ||
(input.allowUninstalledShared && binding.mode === "shared" && grant.kind === "organization")
)
)
throw forbidden("This connection is not permitted for this agent");
return {
...row,
attribution: {
connectionId: connection.id,
grantId: grant.id,
provider: binding.provider,
method: binding.method,
mode: binding.mode,
responsibleUserId: userId,
} satisfies AiConnectionAttribution,
};
}
async function credential(row: Awaited<ReturnType<typeof select>>) {
const ref = row.grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
);
if (!ref)
throw unprocessable("Reconnect this AI account", {
code: "ai_connection_credential_missing",
});
const [secret] = await db
.select()
.from(companySecrets)
.where(
and(
eq(companySecrets.id, ref.secretId),
eq(companySecrets.companyId, row.connection.companyId),
),
);
if (!secret)
throw unprocessable("Reconnect this AI account", {
code: "ai_connection_credential_missing",
});
const context = {
consumerType: "tool_connection" as const,
consumerId: row.connection.id,
configPath: ref.configPath,
responsibleUserId: row.grant.subjectUserId,
actorType: "system" as const,
};
if (secret.scope === "user") {
if (
secret.ownerUserId !== row.grant.subjectUserId ||
!secret.userSecretDefinitionId
)
throw forbidden("Credential ownership mismatch");
const result = await secrets.resolveUserSecretValue(
row.connection.companyId,
{
definitionId: secret.userSecretDefinitionId,
responsibleUserId: secret.ownerUserId,
required: true,
version: "latest",
},
context,
);
if (!result) throw unprocessable("Reconnect this AI account");
return result.value;
}
return secrets.resolveSecretValue(
row.connection.companyId,
secret.id,
"latest",
context,
);
}
async function save(
companyId: string,
userId: string,
input: CreateAiConnection | AiConnectionLoginIntent,
verifiedCredential: string,
sessionId?: string,
attemptStartedAt = new Date(),
) {
if (!(await membership(companyId, userId)))
throw forbidden("An active company member must own this connection");
const reconnect = input.connectionId
? (await rows(companyId)).find(
(r) => r.connection.id === input.connectionId,
)
: undefined;
if (input.connectionId && !reconnect)
throw notFound("AI connection not found");
if (
reconnect &&
(reconnect.grant.createdByUserId !== userId ||
(reconnect.grant.kind === "user" &&
reconnect.grant.subjectUserId !== userId))
)
throw forbidden("Only the account owner can reconnect it");
if (
reconnect &&
(reconnect.connection.config.ai as AiConnectionMetadata).provider !==
input.provider
)
throw unprocessable("Reconnect cannot change providers");
if (
reconnect &&
((reconnect.connection.config.ai as AiConnectionMetadata).method !==
input.method ||
(reconnect.grant.kind === "user") !== (input.ownership === "personal"))
)
throw unprocessable(
"Reconnect cannot change the sign-in method or ownership",
);
const id = reconnect?.connection.id ?? randomUUID();
const grantId = reconnect?.grant.id ?? randomUUID();
return db.transaction(async (tx) => {
const secrets = secretService(tx);
if (sessionId) {
const [session] = await tx
.select()
.from(adapterAuthSessions)
.where(
and(
input.provider === "anthropic"
? eq(adapterAuthSessions.publicSessionId, sessionId)
: eq(adapterAuthSessions.id, sessionId),
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
),
)
.for("update");
if (!session) throw forbidden("Login session ownership mismatch");
attemptStartedAt = session.createdAt;
if (session.connectionId && session.connectionGrantId)
return {
connectionId: session.connectionId,
grantId: session.connectionGrantId,
};
if (
!["promoting", "submitting", "awaiting_code"].includes(
session.status,
) ||
(session.expiresAt && session.expiresAt.getTime() <= Date.now())
)
throw unprocessable("The login attempt is no longer active");
if (
!session.aiConnection ||
session.aiConnection.provider !== input.provider ||
session.aiConnection.method !== input.method ||
session.aiConnection.connectionId !== input.connectionId ||
session.aiConnection.ownership !== input.ownership ||
session.aiConnection.allAgents !== input.allAgents ||
JSON.stringify(session.aiConnection.agentIds) !==
JSON.stringify(input.agentIds)
)
throw forbidden("Login target mismatch");
}
if (reconnect) {
const [current] = await tx
.select()
.from(connectionGrants)
.where(eq(connectionGrants.id, grantId))
.for("update");
if (
!current ||
current.updatedAt.getTime() !== reconnect.grant.updatedAt.getTime() ||
current.updatedAt.getTime() > attemptStartedAt.getTime()
)
throw unprocessable("The connection changed. Start reconnect again.");
}
let secretId = reconnect?.grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
)?.secretId;
// Adoption indexes existing credentials without transferring ownership.
// Only rotate the private slot created for this grant. A reconnect of an
// indexed credential must leave every legacy consumer's value untouched,
// even after adoption has cleared the connection's validation marker.
if (secretId) {
const [source] = await tx.select({ name: companySecrets.name, key: userSecretDefinitions.key })
.from(companySecrets)
.leftJoin(userSecretDefinitions, eq(userSecretDefinitions.id, companySecrets.userSecretDefinitionId))
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.id, secretId)));
const privateSlot = input.ownership === "personal"
? source?.key === `ai_${grantId.replaceAll("-", "_")}`
: source?.name === `ai-${grantId}`;
if (!privateSlot) secretId = undefined;
}
if (secretId)
await secrets.rotate(
secretId,
{ value: verifiedCredential },
{ userId },
);
else if (input.ownership === "personal") {
const definition = await secrets.createUserSecretDefinition(
companyId,
{
key: `ai_${grantId.replaceAll("-", "_")}`,
name: input.name,
provider: "local_encrypted",
},
{ userId },
);
const secret = await secrets.createCurrentUserSecretValue(
companyId,
userId,
{ definitionId: definition.id, value: verifiedCredential },
{ userId },
);
secretId = secret.id;
} else
secretId = (
await secrets.create(
companyId,
{
name: `ai-${grantId}`,
provider: "local_encrypted",
value: verifiedCredential,
},
{ userId },
)
).id;
if (input.agentIds.length) {
const targets = await tx
.select({ id: agents.id })
.from(agents)
.where(
and(
eq(agents.companyId, companyId),
inArray(agents.id, input.agentIds),
),
);
if (targets.length !== new Set(input.agentIds).size)
throw forbidden("Agent does not belong to this company");
}
const key = `app-gallery:${input.provider}`;
await tx
.insert(toolApplications)
.values({
companyId,
applicationKey: key,
name: AI_CONNECTION_CAPABILITIES[input.provider].name,
type: "mcp_http",
metadata: { sourceTemplateKey: input.provider },
ownerUserId: userId,
})
.onConflictDoNothing();
const [app] = await tx
.select()
.from(toolApplications)
.where(
and(
eq(toolApplications.companyId, companyId),
or(
eq(toolApplications.applicationKey, key),
eq(
toolApplications.name,
AI_CONNECTION_CAPABILITIES[input.provider].name,
),
),
),
);
if (!app) throw unprocessable("Could not find the provider application");
if (reconnect)
await tx
.update(toolConnections)
.set({
enabled: true,
status: "active",
healthStatus: "ok",
healthMessage: null,
config: { ...reconnect.connection.config, aiIsolatedSubscription: input.method === "subscription" && input.provider !== "anthropic" },
updatedAt: new Date(),
})
.where(eq(toolConnections.id, id));
else
await tx
.insert(toolConnections)
.values({
id,
companyId,
applicationId: app.id,
name: input.name,
uid: `ai-${id}`,
connectionPurpose: "ai",
transport: "runtime_auth",
authKind: input.method === "api_key" ? "api_key" : "oauth",
credentialPolicy:
input.ownership === "personal" ? "per_user" : "shared",
status: "active",
enabled: true,
healthStatus: "ok",
config: {
sourceTemplateKey: input.provider,
ai: { provider: input.provider, method: input.method },
aiIsolatedSubscription: input.method === "subscription" && input.provider !== "anthropic",
},
createdByUserId: userId,
});
let accountLabel: string | undefined;
if (input.method === "subscription" && input.provider !== "anthropic") {
try {
const credential = JSON.parse(verifiedCredential);
const claims = credential.tokens?.id_token
? JSON.parse(
Buffer.from(
credential.tokens.id_token.split(".")[1],
"base64url",
).toString(),
)
: credential;
const email = claims.email ?? claims.user?.email;
if (
typeof email === "string" &&
/^[^\s@/\\]{1,100}@[^\s@/\\]{1,100}\.[^\s@/\\]{2,40}$/.test(email)
)
accountLabel = email;
} catch {
/* Safe account identity is optional. */
}
}
const refs = [
{
secretId: secretId!,
configPath: "ai.credential",
required: true,
versionSelector: "latest" as const,
},
];
if (reconnect)
await tx
.update(connectionGrants)
.set({
status: "active",
providerTenant: accountLabel ? { name: accountLabel } : {},
credentialSecretRefs: refs,
revokedAt: null,
updatedAt: new Date(),
})
.where(eq(connectionGrants.id, grantId));
else
await tx
.insert(connectionGrants)
.values({
id: grantId,
companyId,
connectionId: id,
kind: input.ownership === "personal" ? "user" : "organization",
subjectUserId: input.ownership === "personal" ? userId : null,
isDefault: input.ownership === "shared",
providerTenant: accountLabel ? { name: accountLabel } : {},
credentialSecretRefs: refs,
createdByUserId: userId,
});
const [savedConnection] = await tx
.select()
.from(toolConnections)
.where(eq(toolConnections.id, id));
await syncConnectionCredentialBindings(tx, savedConnection, refs);
if (input.ownership === "personal")
await tx
.insert(aiConnectionDefaults)
.values({
companyId,
userId,
provider: input.provider,
method: input.method,
grantId,
})
.onConflictDoNothing();
if (!reconnect) {
const installs = input.allAgents
? [{ targetType: "company" as const, targetId: companyId }]
: input.agentIds.map((targetId) => ({
targetType: "agent" as const,
targetId,
}));
if (installs.length)
await tx
.insert(toolConnectionInstalls)
.values(
installs.map((i) => ({
...i,
companyId,
connectionId: id,
createdByUserId: userId,
})),
);
}
if (sessionId)
await tx
.update(adapterAuthSessions)
.set({
connectionId: id,
connectionGrantId: grantId,
connectionMethod: input.method,
...(input.provider === "anthropic"
? { status: "stored" as const }
: {}),
})
.where(
input.provider === "anthropic"
? eq(adapterAuthSessions.publicSessionId, sessionId)
: eq(adapterAuthSessions.id, sessionId),
);
await logActivity(tx as unknown as Db, {
companyId,
actorType: "user",
actorId: userId,
action: reconnect
? "ai_connection.reconnected"
: "ai_connection.connected",
entityType: "tool_connection",
entityId: id,
details: { provider: input.provider, method: input.method, grantId },
});
return { connectionId: id, grantId };
});
}
return { list, select, credential, save, setDefault, membership };
}
+2
View File
@@ -1784,6 +1784,7 @@ export function authorizationService(db: Db | DbTransaction) {
}
if (
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "company_scope:read" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
@@ -1798,6 +1799,7 @@ export function authorizationService(db: Db | DbTransaction) {
// Mirroring the tasks:assign carve-out above, viewers keep the
// read-only visibility actions but not the privileged ones.
const requiresNonViewer =
input.action === "agent:wake" ||
input.action === "runtime:manage" ||
input.action === "secrets:read" ||
input.action === "decision_queue:manage" ||
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,4 @@
import { nativePhotonInteraction } from "./photon/interactions.js";
import { randomBytes } from "node:crypto";
import { and, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@@ -313,6 +314,7 @@ export async function enqueueIssueInteractionChatPublications(
)
: null;
const supportsCallbacks =
endpoint.provider !== "imessage-photon" &&
formDraft === null &&
question !== null &&
endpoint.capabilities.actions === true;
@@ -430,7 +432,13 @@ export async function enqueueIssueInteractionChatPublications(
.onConflictDoNothing()
.returning();
const publication = rows[0];
if (publication && formDraft) {
if (publication && endpoint.provider === "imessage-photon" && nativePhotonInteraction(interaction)) {
const reference = randomBytes(9).toString("base64url");
await db.insert(chatActions).values({ companyId: interaction.companyId, endpointId: endpoint.id, conversationId: conversation.id,
kind: "photon_interaction", providerActionId: `photon:${reference}`,
payload: { version: 1, reference, interactionId: interaction.id, publicationId: publication.id, sessionGeneration: conversation.sessionGeneration,
expiresAt: new Date(publication.createdAt.getTime() + CHAT_QUESTION_ACTION_TOKEN_TTL_MS).toISOString() }, status: "issued" });
} else if (publication && formDraft) {
await db.insert(chatActions).values(
chatQuestionFormActionRecords(formDraft, {
companyId: interaction.companyId,
@@ -540,10 +548,12 @@ export async function enqueueTerminalIssueInteractionChatPublications(
and(
eq(chatActions.companyId, interaction.companyId),
inArray(chatActions.kind, [
"photon_interaction",
"question_answer",
"question_form_open",
"question_form_submit",
"confirmation_response",
"photon_interaction",
]),
eq(chatActions.status, "issued"),
eq(
@@ -763,6 +773,7 @@ export async function cancelPendingIssueInteractionChatPublications(
"question_form_open",
"question_form_submit",
"confirmation_response",
"photon_interaction",
]),
eq(chatActions.status, "issued"),
inArray(sql<string>`${chatActions.payload}->>'interactionId'`, [
@@ -493,6 +493,7 @@ export function parseChatProviderLifecycle(
input: ParseChatProviderLifecycleInput,
): ChatProviderLifecycleEffect[] {
switch (input.provider) {
case "imessage-photon": return []; // Authenticated gRPC events own lifecycle.
case "agentmail": return [];
case "slack":
return parseSlackLifecycle(input);
@@ -192,6 +192,23 @@ export function classifyChatPublicationError(
.filter((candidate): candidate is string => typeof candidate === "string")
.map((candidate) => candidate.toLowerCase());
const reason = text(error);
if (names.includes("PhotonError")) {
if (codes.includes("delivery_unknown")) return { kind: "delivery_unknown", reason };
if (codes.includes("credentials") || codes.includes("line_unavailable") || codes.includes("history_gap")) {
return { kind: "endpoint_attention", reason };
}
if (codes.includes("quota") || codes.includes("network") || codes.includes("attachment_not_ready")) {
return {
kind: "retry",
retryAfterMs: values
.map((value) => finitePositive(value.retryAfterMs))
.find((value) => value !== null) ?? Math.min(60_000, 1000 * 2 ** Math.min(attempt, 6)),
providerRateLimit: codes.includes("quota"),
reason,
};
}
return { kind: "failed", reason };
}
const responseHeaders = values
.map((value) => value.response?.headers)
.find((headers) => headers !== undefined);
+56 -3
View File
@@ -1,3 +1,9 @@
import { PhotonChatAdapter, parsePhotonThreadId } from "./photon/adapter.js";
import { PhotonLineAuthentication } from "./photon/cloud.js";
import { PhotonState } from "./photon/state.js";
import { PhotonReceiver } from "./photon/receiver.js";
import { photonAttachmentLocator, photonAttachmentLocatorSchema, downloadPhotonAttachment, type PhotonAttachmentLocator } from "./photon/attachments.js";
import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage";
import {
createGitHubAdapter,
type GitHubAdapter,
@@ -130,8 +136,8 @@ const DISCORD_GATEWAY_HEALTHY_SESSION_MS = 60_000;
/** Public Paperclip provider ids. The Teams SDK name remains an internal detail. */
export type ChatSdkProvider =
"slack" | "github" | "discord" | "microsoft-teams" | "telegram";
type ChatSdkAdapterKey = "slack" | "github" | "discord" | "teams" | "telegram";
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "imessage-photon";
type ChatSdkAdapterKey = "slack" | "github" | "discord" | "teams" | "telegram" | "imessage-photon";
interface ProviderConfigBase {
/** Agent-derived native bot display/mention name. */
@@ -200,7 +206,13 @@ export interface ResolvedTelegramChatConfig extends ProviderConfigBase {
};
}
export interface ResolvedPhotonChatConfig extends ProviderConfigBase {
provider: "imessage-photon";
intakeAfter: number;
credentials: { allocation?: "dedicated" | "shared"; projectId: string; projectSecret: string; lineId: string; phoneNumber: string };
}
export type ResolvedChatSdkProviderConfig =
| ResolvedPhotonChatConfig
| ResolvedSlackChatConfig
| ResolvedGitHubChatConfig
| ResolvedDiscordChatConfig
@@ -222,6 +234,7 @@ interface DurableAttachmentMetadata {
}
type ChatSdkAttachmentLocator =
| PhotonAttachmentLocator
| GitHubPublicAttachmentLocator
| TeamsInlineImageLocator
| TelegramMediaLocator
@@ -453,6 +466,10 @@ export interface DiscordGatewayCallbackEvent extends ChatSdkCallbackEvent<Discor
* Chat SDK event escape hatches; callers must never publish them directly.
*/
export interface ChatSdkRuntimeCallbacks {
onPhotonEvent?(event: PhotonEvent): Promise<void>;
onPhotonAssertOwned?(activeThreadId?: string): Promise<void>;
onPhotonCheckpoint?(sequence: number): Promise<void>;
onPhotonFailure?(error: unknown): Promise<void>;
onMessage(event: ChatSdkMessageCallbackEvent): Promise<void> | void;
onTelegramGenerationStopped?(
event: ChatSdkCallbackEvent<TelegramGenerationStoppedProof>,
@@ -1339,6 +1356,7 @@ function createProviderAdapter(
): Adapter {
const resolvedLogger = adapterLogger(logger);
switch (config.provider) {
case "imessage-photon": throw new Error("Photon adapter requires scoped persistence");
case "slack": {
const adapterConfig: SlackAdapterConfig = {
...config.credentials,
@@ -2023,10 +2041,13 @@ export class ChatSdkEndpointRuntime {
private discordGatewayFatal = false;
private initialization: Promise<void> | null = null;
private retired = false;
private photonReceiver?: PhotonReceiver;
private readonly runtimeOptions: CreateChatSdkEndpointRuntimeOptions;
private shutdownTask: Promise<void> | null = null;
private shutdownCompleted = false;
constructor(options: CreateChatSdkEndpointRuntimeOptions) {
this.runtimeOptions = options;
this.companyId = options.companyId;
this.endpointId = options.endpointId;
this.provider = options.providerConfig.provider;
@@ -2076,7 +2097,11 @@ export class ChatSdkEndpointRuntime {
1,
Math.min(options.webhookIngressTimeoutMs ?? 2_500, 10_000),
);
this.adapter = createProviderAdapter(
this.adapter = options.providerConfig.provider === "imessage-photon"
? new PhotonChatAdapter(options.providerConfig.userName,
new PhotonLineAuthentication(options.providerConfig.credentials, options.providerConfig.credentials.projectSecret),
new PhotonState({ companyId: options.companyId, endpointId: options.endpointId }, options.persistence))
: createProviderAdapter(
options.providerConfig,
options.logger,
options.callbacks,
@@ -2256,6 +2281,18 @@ export class ChatSdkEndpointRuntime {
async initialize(): Promise<void> {
await this.initializeChat();
this.assertNotRetired();
if (this.adapter instanceof PhotonChatAdapter && this.discordGatewayEnabled && !this.photonReceiver) {
const options = this.runtimeOptions;
const config = options.providerConfig as ResolvedPhotonChatConfig;
if (!options.callbacks.onPhotonCheckpoint || !options.callbacks.onPhotonAssertOwned || !options.callbacks.onPhotonEvent || !options.callbacks.onPhotonFailure) throw new Error("Photon receiver requires durable admission callbacks");
this.adapter.typingGuard = async (activeThreadId) => { this.assertNotRetired(); await options.callbacks.onPhotonAssertOwned!(activeThreadId); };
this.photonReceiver = new PhotonReceiver({ client: this.adapter.client, state: this.adapter.state,
lineId: config.credentials.lineId, intakeAfter: config.intakeAfter, allocation: config.credentials.allocation,
catchUp: (sequence) => (this.adapter as PhotonChatAdapter).recoveryStream(sequence),
assertOwned: async () => { this.assertNotRetired(); await (this.adapter as PhotonChatAdapter).authentication.token(); await options.callbacks.onPhotonAssertOwned!(); },
commitCheckpoint: options.callbacks.onPhotonCheckpoint, admit: options.callbacks.onPhotonEvent, failure: options.callbacks.onPhotonFailure });
this.photonReceiver.start();
}
if (this.provider === "discord" && this.discordGatewayEnabled) {
this.startDiscordGateway();
}
@@ -2706,6 +2743,12 @@ export class ChatSdkEndpointRuntime {
const metadata = durableAttachmentMetadata(attachment);
return locator && metadata ? { version: 1, provider: "telegram", attachment: metadata, locator } : null;
}
if (this.adapter instanceof PhotonChatAdapter && source?.message) {
const thread = this.adapter.decodeThreadId(source.threadId);
const locator = photonAttachmentLocator(attachment, thread.lineId, thread.chatGuid, source.message);
const metadata = durableAttachmentMetadata(attachment);
return locator && metadata ? { version: 1, provider: "imessage-photon", attachment: metadata, locator } : null;
}
const retained = this.teamsInlineImageDescriptors.get(attachment);
if (retained) {
if (!source) return retained;
@@ -2875,6 +2918,14 @@ export class ChatSdkEndpointRuntime {
descriptor: unknown,
source?: ChatSdkAttachmentSource,
): Attachment | null {
if (this.adapter instanceof PhotonChatAdapter && isRecord(descriptor) && descriptor.version === 1 && descriptor.provider === this.provider && source) {
const parsed = photonAttachmentLocatorSchema.safeParse(descriptor.locator);
const thread = this.adapter.decodeThreadId(source.threadId);
const metadata = isRecord(descriptor.attachment) ? durableAttachmentMetadata(descriptor.attachment as unknown as Attachment) : null;
if (!parsed.success || !metadata || parsed.data.lineId !== thread.lineId || parsed.data.chatGuid !== thread.chatGuid || parsed.data.messageGuid !== source.messageId) return null;
const adapter = this.adapter;
return { ...metadata, fetchData: () => downloadPhotonAttachment(adapter.client, thread.lineId, parsed.data, adapter.authentication.identity.allocation) };
}
if (
this.provider === "microsoft-teams" &&
isRecord(descriptor) &&
@@ -3048,6 +3099,7 @@ export class ChatSdkEndpointRuntime {
connectorOrigin: validated.locator.connectorOrigin,
};
break;
case "photon_attachment":
case "teams_inline_image":
return null; // Only the exact source-bound branch above may authorize it.
case "telegram_media":
@@ -3086,6 +3138,7 @@ export class ChatSdkEndpointRuntime {
await this.initialization?.catch(() => undefined);
await this.discordGatewayTask?.catch(() => undefined);
this.discordGatewayAbort = null;
await this.photonReceiver?.close();
await this.chat.shutdown();
this.shutdownCompleted = true;
})();
@@ -90,11 +90,13 @@ export async function reconcileCodexLocalManagedHomesOnStartup(
id: agents.id,
companyId: agents.companyId,
adapterConfig: agents.adapterConfig,
runtimeConfig: agents.runtimeConfig,
})
.from(agents)
.where(eq(agents.adapterType, "codex_local"));
for (const row of rows) {
if (row.runtimeConfig?.aiConnection) continue;
summary.scanned += 1;
const env = asRecord(asRecord(row.adapterConfig)?.env);
const configuredCodexHome = env ? readPlainEnvValue(env.CODEX_HOME) : null;
+51 -265
View File
@@ -15,7 +15,6 @@ import {
import {
COMPANY_SEARCH_MAX_LIMIT,
COMPANY_SEARCH_MAX_OFFSET,
COMPANY_SEARCH_MAX_TOKENS,
COMPANY_SEARCH_UPDATED_WITHIN_OPTIONS,
COMPANY_ARTIFACTS_MAX_LIMIT,
COMPANY_ARTIFACTS_MAX_QUERY_LENGTH,
@@ -39,18 +38,8 @@ import {
import { companyArtifactsService } from "./company-artifacts.js";
import { companySearchExtractService } from "./company-search-extract.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore, taskSearchFieldMatch, taskSearchTermMatch } from "./task-search.js";
const MIN_TOKEN_LENGTH = 2;
const MIN_FUZZY_QUERY_LENGTH = 4;
const MIN_FUZZY_TOKEN_LENGTH = 4;
// Cap fuzzy edits using the shorter of (query token, title word) so common
// 4–5 letter English words don't sweep in noise (e.g. "serach" vs "each").
const FUZZY_PAIR_LONG_LENGTH = 6;
const FUZZY_PAIR_LONG_MAX_EDITS = 2;
const FUZZY_PAIR_MEDIUM_LENGTH = 5;
const FUZZY_PAIR_MEDIUM_MAX_EDITS = 1;
const FUZZY_PAIR_SHORT_MAX_EDITS = 0;
const FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD = 0.45;
const SNIPPET_MAX_CHARS = 240;
export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1;
@@ -95,30 +84,6 @@ type SearchAggregateRow = {
count: number | string;
};
function normalizeQuery(query: string) {
return query.trim().replace(/\s+/g, " ").toLowerCase();
}
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
}
function tokenizeQuery(normalizedQuery: string) {
const matches = normalizedQuery.match(/"[^"]+"|[^\s]+/g) ?? [];
const tokens: string[] = [];
for (const match of matches) {
const token = match.replace(/^"|"$/g, "").replace(/^[^\p{L}\p{N}%_\\-]+|[^\p{L}\p{N}%_\\-]+$/gu, "");
if (token.length < MIN_TOKEN_LENGTH) continue;
if (!tokens.includes(token)) tokens.push(token);
if (tokens.length >= COMPANY_SEARCH_MAX_TOKENS) break;
}
return tokens;
}
function fuzzyEligibleTokens(tokens: string[]): string[] {
return tokens.filter((token) => token.length >= MIN_FUZZY_TOKEN_LENGTH);
}
function sqlTextArray(values: string[]) {
if (values.length === 0) return sql`ARRAY[]::text[]`;
return sql`ARRAY[${sql.join(values.map((value) => sql`${value}`), sql`, `)}]::text[]`;
@@ -138,7 +103,7 @@ function plainText(value: string | null | undefined) {
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_~|]+/g, " ")
.replace(/(^|\s)[#>*_~|]+|[#>*_~|]+(?=\s|$)/g, " ")
.replace(/\s+/g, " ")
.trim();
}
@@ -431,6 +396,8 @@ function scopeIncludesProjects(scope: CompanySearchScope) {
function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tokens: string[]) {
const terms = matchTerms(normalizedQuery, tokens);
const identifierQuery = parseTaskSearch(normalizedQuery).identifierQuery;
if (!terms.includes(identifierQuery)) terms.push(identifierQuery);
const matchedFields = new Set(row.matchedFields ?? []);
const candidates: Array<CompanySearchSnippet | null> = [];
if (matchedFields.has("identifier")) {
@@ -439,15 +406,22 @@ function selectPrimarySnippets(row: IssueSearchRow, normalizedQuery: string, tok
if (matchedFields.has("title")) {
candidates.push(createSnippet("title", "Title", row.title, terms));
}
if (matchedFields.has("comment")) {
candidates.push(createSnippet("comment", "Comment", row.commentSnippet, terms));
}
if (matchedFields.has("document")) {
candidates.push(createSnippet("document", row.documentTitle || "Document", row.documentSnippet, terms));
}
if (matchedFields.has("description")) {
candidates.push(createSnippet("description", "Description", row.description, terms));
}
const description = matchedFields.has("description")
? createSnippet("description", "Description", row.description, terms) : null;
const directMatch = Number(row.score) >= 3000 || Number(row.score) < 2000;
if (directMatch) candidates.push(description);
const context = [
{ field: "comment", label: "Comment", text: row.commentSnippet },
{ field: "document", label: row.documentTitle || "Document", text: [row.documentTitle, row.documentSnippet].filter(Boolean).join(" ") },
].filter((source) => matchedFields.has(source.field));
const coverage = (text: string | null) => tokens.filter((term) => (text ?? "").toLowerCase().includes(term)).length;
context.sort((left, right) => coverage(right.text) - coverage(left.text));
const contextSnippets = context.map((source) => createSnippet(source.field, source.label, source.text, terms));
// The title and identifier are already visible in the row. For thread-only
// coverage, preserve the evidence before applying the two-snippet limit.
if (directMatch) candidates.push(...contextSnippets);
else candidates.unshift(...contextSnippets);
if (!directMatch) candidates.push(description);
return candidates.filter((snippet): snippet is CompanySearchSnippet => Boolean(snippet)).slice(0, 2);
}
@@ -456,7 +430,11 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin
const sourceLabel = snippets[0]?.label ?? null;
const documentSuffix = row.documentKey ? `#document-${encodeURIComponent(row.documentKey)}` : "";
const commentSuffix = row.commentId ? `#comment-${encodeURIComponent(row.commentId)}` : "";
const suffix = row.commentId ? commentSuffix : documentSuffix;
// Direct task matches open the task; context matches open the evidence shown.
const directMatch = Number(row.score) >= 3000 || Number(row.score) < 2000;
const evidence = snippets.find((snippet) => snippet.field === "comment" || snippet.field === "document");
const suffix = directMatch ? "" : evidence?.field === "comment" ? commentSuffix
: evidence?.field === "document" ? documentSuffix : "";
const issue: CompanySearchIssueSummary = {
id: row.id,
identifier: row.identifier,
@@ -495,7 +473,9 @@ function scoreSimpleRow(row: SimpleSearchRow, normalizedQuery: string, tokens: s
if (haystack.includes(token)) score += 20;
}
if (row.title.toLowerCase().startsWith(normalizedQuery)) score += 80;
return score;
// Keep other entity types on the same scale as the task relevance bands:
// an exact agent/project name must still outrank a speculative task typo.
return score * 10;
}
function artifactResult(artifact: CompanyArtifact, normalizedQuery: string, tokens: string[]): CompanySearchResult {
@@ -559,9 +539,10 @@ export function companySearchService(db: Db) {
return {
extract: extractService.extract,
search: async (companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse> => {
const normalizedQuery = normalizeQuery(query.q);
const taskSearch = parseTaskSearch(query.q);
const normalizedQuery = taskSearch.normalizedQuery;
const hasSearchText = normalizedQuery.length > 0;
const tokens = tokenizeQuery(normalizedQuery);
const tokens = taskSearch.tokens;
const scope = query.scope;
const sort = query.sort;
const limit = query.limit;
@@ -583,147 +564,20 @@ export function companySearchService(db: Db) {
}
const fetchLimit = companySearchBranchFetchLimit(limit, offset);
const escapedTokens = tokens.map(escapeLikePattern);
// LIKE/ILIKE both treat backslash as the default escape character, so the
// escaped tokens stay literal inside ILIKE ANY(...) patterns too.
const tokenPatterns = escapedTokens.map((token) => `%${token}%`);
const tokenPatternArray = sqlTextArray(tokenPatterns);
const fuzzyTokens = fuzzyEligibleTokens(tokens);
const fuzzyTokenArray = sqlTextArray(fuzzyTokens);
const escapedQuery = escapeLikePattern(normalizedQuery);
const containsPattern = hasSearchText ? `%${escapedQuery}%` : "__paperclip_no_match__";
const startsWithPattern = hasSearchText ? `${escapedQuery}%` : "__paperclip_no_match__";
const fuzzyEnabled = hasSearchText && normalizedQuery.length >= MIN_FUZZY_QUERY_LENGTH && !/[\\%_]/.test(normalizedQuery);
const fuzzyTokensEnabled = fuzzyEnabled && fuzzyTokens.length > 0;
const tokenPatternArray = sqlTextArray(taskSearch.patterns);
const containsPattern = hasSearchText && tokens.length > 0 ? taskSearch.containsPattern : "__paperclip_no_match__";
const tokenCount = tokens.length;
// --- shared match expressions against the `issues` table -------------
// Raw-column ILIKE keeps the predicates compatible with the existing
// pg_trgm GIN indexes (lower(col) LIKE expressions cannot use them).
const titlePhraseMatch = hasSearchText ? sql<boolean>`issues.title ILIKE ${containsPattern}` : noMatchSql();
const titleStartsWith = hasSearchText ? sql<boolean>`issues.title ILIKE ${startsWithPattern}` : noMatchSql();
const titleExactMatch = hasSearchText ? sql<boolean>`lower(issues.title) = ${normalizedQuery}` : noMatchSql();
const identifierPhraseMatch = hasSearchText ? sql<boolean>`coalesce(issues.identifier, '') ILIKE ${containsPattern}` : noMatchSql();
const identifierStartsWith = hasSearchText ? sql<boolean>`coalesce(issues.identifier, '') ILIKE ${startsWithPattern}` : noMatchSql();
const identifierExactMatch = hasSearchText ? sql<boolean>`lower(coalesce(issues.identifier, '')) = ${normalizedQuery}` : noMatchSql();
const descriptionPhraseMatch = hasSearchText ? sql<boolean>`coalesce(issues.description, '') ILIKE ${containsPattern}` : noMatchSql();
const titleTokenMatch = tokenCount > 0 ? sql<boolean>`issues.title ILIKE ANY(${tokenPatternArray})` : noMatchSql();
const identifierTokenMatch = tokenCount > 0 ? sql<boolean>`coalesce(issues.identifier, '') ILIKE ANY(${tokenPatternArray})` : noMatchSql();
const descriptionTokenMatch = tokenCount > 0 ? sql<boolean>`coalesce(issues.description, '') ILIKE ANY(${tokenPatternArray})` : noMatchSql();
// Comment/document matches are computed once per request into tagged
// CTEs (issue_id, ord) where ord 1 is the phrase pattern and ord k+1 is
// token k. Flags and per-token coverage become cheap hashed IN probes
// against those sets instead of per-issue-row correlated subqueries.
// Single-pattern queries stay a bare `col ILIKE pattern` so the pg_trgm
// GIN indexes can bitmap-scan them; multi-pattern queries use one tagged
// pass over the table (an OR/ANY form would seq-scan anyway).
const matchPatterns = hasSearchText
? [containsPattern, ...tokenPatterns.filter((pattern) => pattern !== containsPattern)]
: [];
const matchPatternOrdinal = (pattern: string) => matchPatterns.indexOf(pattern) + 1;
const matchPatternArray = sqlTextArray(matchPatterns);
const commentMatchesCte = !hasSearchText
? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: matchPatterns.length === 1
? sql`
SELECT search_comments.issue_id, 1 AS ord
FROM issue_comments search_comments
WHERE search_comments.company_id = ${companyId}
AND search_comments.deleted_at IS NULL
AND search_comments.body ILIKE ${matchPatterns[0]!}
GROUP BY 1, 2
`
: sql`
SELECT search_comments.issue_id, pat.ord::int AS ord
FROM issue_comments search_comments
INNER JOIN unnest(${matchPatternArray}) WITH ORDINALITY AS pat(pattern, ord)
ON search_comments.body ILIKE pat.pattern
WHERE search_comments.company_id = ${companyId}
AND search_comments.deleted_at IS NULL
GROUP BY 1, 2
`;
// Documents get one UNION ALL arm per pattern (each arm a bare
// `col ILIKE pattern`) so the planner can pick a pg_trgm bitmap scan per
// pattern; latest_body is large enough that skipping the seq scan for
// selective patterns dwarfs the duplicate-recheck cost on common ones.
const documentMatchesCte = !hasSearchText
? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: sql.join(matchPatterns.map((pattern, index) => sql`
SELECT search_issue_documents.issue_id, ${index + 1}::int AS ord
FROM issue_documents search_issue_documents
INNER JOIN documents search_documents
ON search_documents.id = search_issue_documents.document_id
AND search_documents.company_id = search_issue_documents.company_id
WHERE search_issue_documents.company_id = ${companyId}
AND (
search_documents.title ILIKE ${pattern}
OR search_documents.latest_body ILIKE ${pattern}
)
GROUP BY 1, 2
`), sql` UNION ALL `);
const commentMatch = hasSearchText
? sql<boolean>`issues.id IN (SELECT comment_matches.issue_id FROM comment_matches)`
: noMatchSql();
const documentMatch = hasSearchText
? sql<boolean>`issues.id IN (SELECT document_matches.issue_id FROM document_matches)`
: noMatchSql();
// Each query token (length >= MIN_FUZZY_TOKEN_LENGTH) must have at least
// one title word within Levenshtein edit distance. This handles typos
// like "serach" -> "search" (transposition) and "mibile" -> "mobile"
// (substitution) without the trigram noise that drop-character variants
// produced (e.g. "serac" matching "service"). Edit budget is gated on
// the SHORTER of the two strings so 4–5 letter English words don't get
// swept in by lev=2 collisions.
const fuzzyMaxEditsExpr = sql.raw(
`CASE
WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_LONG_LENGTH} THEN ${FUZZY_PAIR_LONG_MAX_EDITS}
WHEN least(length(qt.value), length(title_word.value)) >= ${FUZZY_PAIR_MEDIUM_LENGTH} THEN ${FUZZY_PAIR_MEDIUM_MAX_EDITS}
ELSE ${FUZZY_PAIR_SHORT_MAX_EDITS}
END`,
);
const fuzzyMinTitleWordLengthExpr = sql.raw(`${MIN_FUZZY_TOKEN_LENGTH}`);
const fuzzyTokenTitleMatch = fuzzyTokensEnabled
? sql<boolean>`
coalesce((
SELECT bool_and(
EXISTS (
SELECT 1
FROM regexp_split_to_table(lower(issues.title), '[^a-z0-9]+') AS title_word(value)
WHERE length(title_word.value) >= ${fuzzyMinTitleWordLengthExpr}
AND levenshtein_less_equal(qt.value, title_word.value, ${fuzzyMaxEditsExpr}) <= ${fuzzyMaxEditsExpr}
)
)
FROM unnest(${fuzzyTokenArray}) AS qt(value)
), false)
`
: noMatchSql();
const fuzzyIdentifierMatch = fuzzyEnabled
? sql<boolean>`similarity(lower(coalesce(issues.identifier, '')), ${normalizedQuery}) >= ${FUZZY_IDENTIFIER_SIMILARITY_THRESHOLD}`
: noMatchSql();
const issueTextMatch = sql<boolean>`(
${titlePhraseMatch}
OR ${identifierPhraseMatch}
OR ${descriptionPhraseMatch}
OR ${titleTokenMatch}
OR ${identifierTokenMatch}
OR ${descriptionTokenMatch}
)`;
const fuzzyMatch = sql<boolean>`(${fuzzyTokenTitleMatch} OR ${fuzzyIdentifierMatch})`;
const anySearchMatch = sql<boolean>`(${issueTextMatch} OR ${commentMatch} OR ${documentMatch} OR ${fuzzyMatch})`;
const issueFilters = issueFilterConditions(companyId, query);
const hasIssueOnlyFilters = issueOnlyFiltersActive(query);
// Scope conditions over precomputed flag columns (alias-qualified).
function flagTextMatch(alias: string) {
return sql<boolean>`(
${sql.raw(alias)}.title_phrase OR ${sql.raw(alias)}.ident_phrase OR ${sql.raw(alias)}.desc_phrase
OR ${sql.raw(alias)}.title_token OR ${sql.raw(alias)}.ident_token OR ${sql.raw(alias)}.desc_token
)`;
return sql<boolean>`(${sql.raw(alias)}.issue_coverage = ${tokenCount}
OR ${sql.raw(alias)}.ident_exact OR ${sql.raw(alias)}.ident_starts)`;
}
function flagFuzzyMatch(alias: string) {
return sql<boolean>`(${sql.raw(alias)}.fuzzy_title OR ${sql.raw(alias)}.fuzzy_ident)`;
return sql<boolean>`${sql.raw(alias)}.fuzzy_title`;
}
function flagScopeCondition(alias: string, forScope: CompanySearchScope): SQL<boolean> {
if (!hasSearchText) {
@@ -732,7 +586,7 @@ export function companySearchService(db: Db) {
if (forScope === "comments") return sql<boolean>`${sql.raw(alias)}.comment_match`;
if (forScope === "documents") return sql<boolean>`${sql.raw(alias)}.document_match`;
if (forScope === "issues") return sql<boolean>`(${flagTextMatch(alias)} OR ${flagFuzzyMatch(alias)})`;
return sql<boolean>`(${flagTextMatch(alias)} OR ${sql.raw(alias)}.comment_match OR ${sql.raw(alias)}.document_match OR ${flagFuzzyMatch(alias)})`;
return sql<boolean>`true`;
}
// --- combined issue results + aggregates statement ---------------------
@@ -764,24 +618,7 @@ export function companySearchService(db: Db) {
const wantResultRows = scopeIncludesIssues(scope)
&& !(!hasSearchText && (scope === "comments" || scope === "documents"));
if (wantResultRows) {
const allTokensBonus = tokenCount > 0
? sql`CASE WHEN m.token_coverage = ${tokenCount} THEN 260 ELSE 0 END`
: sql`0`;
const scoreSql = sql`(
CASE WHEN m.ident_exact THEN 1200 ELSE 0 END
+ CASE WHEN m.ident_starts THEN 700 ELSE 0 END
+ CASE WHEN m.title_exact THEN 900 ELSE 0 END
+ CASE WHEN m.title_starts THEN 550 ELSE 0 END
+ CASE WHEN m.title_phrase THEN 350 ELSE 0 END
+ CASE WHEN m.ident_phrase THEN 320 ELSE 0 END
+ CASE WHEN m.comment_match THEN 180 ELSE 0 END
+ CASE WHEN m.document_match THEN 170 ELSE 0 END
+ CASE WHEN m.desc_phrase THEN 120 ELSE 0 END
+ ${allTokensBonus}
+ (m.token_coverage * 70)
+ CASE WHEN (m.fuzzy_title OR m.fuzzy_ident) THEN 110 ELSE 0 END
+ CASE m.status WHEN 'done' THEN 0 WHEN 'cancelled' THEN -30 ELSE 20 END
)::double precision`;
const scoreSql = taskSearchScore(taskSearch);
const priorityOrderSql = sql`CASE m.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const orderBySql = sort === "updated"
? sql`m.updated_at DESC, score DESC, m.id DESC`
@@ -798,7 +635,8 @@ export function companySearchService(db: Db) {
m.id,
m.identifier,
m.title,
m.description,
(SELECT issue_text.description FROM issues issue_text
WHERE issue_text.id = m.id AND issue_text.company_id = ${companyId}) AS description,
m.status,
m.priority,
m.assignee_agent_id AS "assigneeAgentId",
@@ -808,9 +646,9 @@ export function companySearchService(db: Db) {
m.updated_at AS "updatedAt",
${scoreSql} AS score,
array_remove(ARRAY[
CASE WHEN m.ident_phrase OR m.ident_token OR m.fuzzy_ident THEN 'identifier' END,
CASE WHEN m.title_phrase OR m.title_token OR m.fuzzy_title THEN 'title' END,
CASE WHEN m.desc_phrase OR m.desc_token THEN 'description' END,
CASE WHEN m.ident_exact OR m.ident_starts OR m.ident_phrase OR m.ident_token THEN 'identifier' END,
CASE WHEN m.title_token OR m.fuzzy_title THEN 'title' END,
CASE WHEN m.desc_token THEN 'description' END,
CASE WHEN m.comment_match THEN 'comment' END,
CASE WHEN m.document_match THEN 'document' END
], NULL)::text[] AS "matchedFields"
@@ -825,10 +663,10 @@ export function companySearchService(db: Db) {
branches.push(sql`SELECT 'type:issue' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, titleCond])}`);
}
if (hasSearchText && (scope === "all" || scope === "comments")) {
branches.push(sql`SELECT 'type:comment' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, sql`m.comment_match`])}`);
branches.push(sql`SELECT 'type:comment' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, flagScopeCondition("m", "comments")])}`);
}
if (hasSearchText && (scope === "all" || scope === "documents")) {
branches.push(sql`SELECT 'type:document' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, sql`m.document_match`])}`);
branches.push(sql`SELECT 'type:document' AS kind, NULL::text AS value, count(*)::int AS count ${countTail} FROM matched m ${branchWhere([...facetsAll, flagScopeCondition("m", "documents")])}`);
}
const facetBranch = (kind: string, valueSql: SQL, omit: CompanySearchIssueFilterKey, extra: SQL[] = []) => sql`
@@ -878,59 +716,8 @@ export function companySearchService(db: Db) {
}
}
// Per-token coverage counts matches across issue text and the tagged
// comment/document match sets (hashed IN probes, one set per token).
const coverageSql = tokenCount > 0
? sql`(${sql.join(tokens.map((_, index) => {
const pattern = tokenPatterns[index]!;
const ord = matchPatternOrdinal(pattern);
return sql`(CASE WHEN
issues.title ILIKE ${pattern}
OR coalesce(issues.identifier, '') ILIKE ${pattern}
OR coalesce(issues.description, '') ILIKE ${pattern}
OR issues.id IN (SELECT comment_matches.issue_id FROM comment_matches WHERE comment_matches.ord = ${ord})
OR issues.id IN (SELECT document_matches.issue_id FROM document_matches WHERE document_matches.ord = ${ord})
THEN 1 ELSE 0 END)`;
}), sql` + `)})`
: sql`0`;
const matchedWhere = hasSearchText ? sql` AND ${anySearchMatch}` : sql``;
const resultRows = await db.execute(sql`
WITH comment_matches AS MATERIALIZED (${commentMatchesCte}),
document_matches AS MATERIALIZED (${documentMatchesCte}),
matched AS MATERIALIZED (
SELECT
issues.id,
issues.identifier,
issues.title,
issues.description,
issues.status,
issues.priority,
issues.assignee_agent_id,
issues.assignee_user_id,
issues.project_id,
issues.created_at,
issues.updated_at,
${titlePhraseMatch} AS title_phrase,
${titleStartsWith} AS title_starts,
${titleExactMatch} AS title_exact,
${identifierPhraseMatch} AS ident_phrase,
${identifierStartsWith} AS ident_starts,
${identifierExactMatch} AS ident_exact,
${descriptionPhraseMatch} AS desc_phrase,
${titleTokenMatch} AS title_token,
${identifierTokenMatch} AS ident_token,
${descriptionTokenMatch} AS desc_token,
${commentMatch} AS comment_match,
${documentMatch} AS document_match,
${fuzzyTokenTitleMatch} AS fuzzy_title,
${fuzzyIdentifierMatch} AS fuzzy_ident,
${coverageSql} AS token_coverage
FROM issues
WHERE issues.company_id = ${companyId}
AND ${visibleIssueCondition()}
${matchedWhere}
)
${taskSearchCtes(companyId, taskSearch, scope !== "issues", and(...issueFilters))}
${sql.join(branches, sql` UNION ALL `)}
`) as unknown as Array<SearchAggregateRow & Omit<IssueSearchRow, "commentSnippet" | "commentId" | "documentSnippet" | "documentTitle" | "documentKey">>;
@@ -1014,11 +801,11 @@ export function companySearchService(db: Db) {
AND search_comments.issue_id = target.id
AND search_comments.deleted_at IS NULL
AND (
search_comments.body ILIKE ${containsPattern}
OR search_comments.body ILIKE ANY(${tokenPatternArray})
${taskSearchFieldMatch(sql`search_comments.body`, taskSearch)}
)
ORDER BY
CASE WHEN search_comments.body ILIKE ${containsPattern} THEN 0 ELSE 1 END,
${sql.join(tokens.map((_, index) => sql`CASE WHEN ${taskSearchTermMatch(sql`search_comments.body`, taskSearch, index)} THEN 1 ELSE 0 END`), sql` + `)} DESC,
search_comments.updated_at DESC,
search_comments.id DESC
LIMIT 1
@@ -1032,10 +819,8 @@ export function companySearchService(db: Db) {
WHERE search_issue_documents.company_id = ${companyId}
AND search_issue_documents.issue_id = target.id
AND (
coalesce(search_documents.title, '') ILIKE ${containsPattern}
OR search_documents.latest_body ILIKE ${containsPattern}
OR coalesce(search_documents.title, '') ILIKE ANY(${tokenPatternArray})
OR search_documents.latest_body ILIKE ANY(${tokenPatternArray})
${taskSearchFieldMatch(sql`search_documents.title`, taskSearch)}
OR ${taskSearchFieldMatch(sql`search_documents.latest_body`, taskSearch)}
)
ORDER BY
CASE
@@ -1043,6 +828,7 @@ export function companySearchService(db: Db) {
WHEN search_documents.latest_body ILIKE ${containsPattern} THEN 1
ELSE 2
END,
${sql.join(tokens.map((_, index) => sql`CASE WHEN ${taskSearchTermMatch(sql`search_documents.title`, taskSearch, index)} OR ${taskSearchTermMatch(sql`search_documents.latest_body`, taskSearch, index)} THEN 1 ELSE 0 END`), sql` + `)} DESC,
search_documents.updated_at DESC,
search_documents.id DESC
LIMIT 1
@@ -0,0 +1,113 @@
import { and, eq, inArray } from "drizzle-orm";
import { type Db, toolConnections, companySecretBindings, connectionGrants, companySecrets, userSecretDefinitions } from "@paperclipai/db";
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
import { secretService } from "./secrets.js";
function credentialRefConfigPath(ref: { name: string }): string { return ref.name.startsWith("credentials.") ? ref.name : `credentials.${ref.name}`; }
export async function syncConnectionCredentialBindings(
db: Db | Parameters<Parameters<Db["transaction"]>[0]>[0],
connection: typeof toolConnections.$inferSelect,
grantSecretRefs: ToolCredentialSecretRef[] = [],
dbClient: Pick<Db, "select" | "insert" | "update" | "delete"> = db,
) {
const secrets = secretService(db);
await dbClient
.delete(companySecretBindings)
.where(
and(
eq(companySecretBindings.companyId, connection.companyId),
eq(companySecretBindings.targetType, "tool_connection"),
eq(companySecretBindings.targetId, connection.id),
),
);
// A metadata edit or pause/resume must retain declarations for every
// active personal/dedicated grant, not just connection-owned credentials.
const activeGrants = await dbClient.select({ refs: connectionGrants.credentialSecretRefs })
.from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.status, "active"),
));
const rawBindings = [
...connection.credentialRefs.map((ref) => ({
secretId: ref.secretId,
configPath: credentialRefConfigPath(ref),
projectionClass: "unclassified",
projectionAllowlistKey: null,
required: true,
label: null,
})),
...[...connection.credentialSecretRefs, ...grantSecretRefs, ...activeGrants.flatMap((grant) => grant.refs)].map((ref) => ({
secretId: ref.secretId,
configPath: ref.configPath,
projectionClass: ref.projectionClass ?? "unclassified",
projectionAllowlistKey: ref.projectionAllowlistKey ?? null,
required: ref.required ?? true,
label: ref.label ?? null,
})),
];
// Organization grants can mirror connection-owned credentials, and more
// than one personal grant can reference the same client registration.
// Binding rows are unique per secret/config path, so collapse those mirrors
// before replacing the durable projection declarations.
const bindings = [...new Map(rawBindings.map((ref) => [
`${ref.secretId}:${ref.configPath}`,
ref,
])).values()];
const secretRows = bindings.length > 0
? await dbClient.select({
id: companySecrets.id,
scope: companySecrets.scope,
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
}).from(companySecrets).where(and(
eq(companySecrets.companyId, connection.companyId),
inArray(companySecrets.id, [...new Set(bindings.map((ref) => ref.secretId))]),
))
: [];
const secretById = new Map(secretRows.map((row) => [row.id, row]));
const definitionIds = [...new Set(secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []))];
const definitions = definitionIds.length > 0
? await dbClient.select({ id: userSecretDefinitions.id, key: userSecretDefinitions.key })
.from(userSecretDefinitions)
.where(and(
eq(userSecretDefinitions.companyId, connection.companyId),
inArray(userSecretDefinitions.id, definitionIds),
))
: [];
const definitionKeyById = new Map(definitions.map((row) => [row.id, row.key]));
const userDeclarations = [...new Map(bindings.flatMap((ref) => {
const secret = secretById.get(ref.secretId);
const definitionKey = secret?.scope === "user" && secret.userSecretDefinitionId
? definitionKeyById.get(secret.userSecretDefinitionId)
: null;
return definitionKey
? [{
definitionKey,
configPath: ref.configPath,
envKey: ref.configPath,
versionSelector: "latest" as const,
required: ref.required,
label: ref.label,
}]
: [];
}).map((ref) => [`${ref.definitionKey}:${ref.configPath}`, ref])).values()];
await secrets.syncUserSecretDeclarationsForTarget(
connection.companyId,
{ targetType: "tool_connection", targetId: connection.id },
userDeclarations,
{ replaceAll: true, db: dbClient },
);
const companyBindings = bindings.filter((ref) => secretById.get(ref.secretId)?.scope !== "user");
if (companyBindings.length === 0) return;
await dbClient.insert(companySecretBindings).values(companyBindings.map((ref) => ({
companyId: connection.companyId,
secretId: ref.secretId,
targetType: "tool_connection" as const,
targetId: connection.id,
configPath: ref.configPath,
required: ref.required,
label: ref.label,
projectionClass: ref.projectionClass,
projectionAllowlistKey: ref.projectionAllowlistKey,
})));
}
@@ -1,7 +1,10 @@
import { connectionIntentService } from "./connection-intents.js";
import { and, eq, isNull, lte, asc, notInArray } from "drizzle-orm";
import { connectionIntentDeliveries, issueThreadInteractions, issues, agentWakeupRequests, companyMemberships, type Db } from "@paperclipai/db";
import { and, eq, isNull, lte, asc, notInArray, desc, sql } from "drizzle-orm";
import { connectionIntentDeliveries, issueThreadInteractions, issues, agentWakeupRequests, companyMemberships, heartbeatRuns, chatConversations, chatEndpoints, type Db } from "@paperclipai/db";
import type { heartbeatService } from "./heartbeat.js";
import { issueService } from "./issues.js";
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
type Heartbeat = ReturnType<typeof heartbeatService>;
export async function wakeConnectionIntentAfterResolution(
@@ -56,6 +59,51 @@ export async function wakeConnectionIntentAfterResolution(
export function connectionIntentDeliveryService(db: Db, heartbeat: Pick<Heartbeat, "wakeup">) {
// Only a repaired AI-authentication failure may reopen a blocked task. An old
// card must never resume a newer failure, a reassignment, or a manual hold.
async function restoreAiBlockedTask(loaded: {
issue: typeof issues.$inferSelect;
interaction: typeof issueThreadInteractions.$inferSelect;
}) {
const publications: ActivityPublication[] = [];
const restored = await db.transaction(async (tx) => {
const [issue] = await tx.select().from(issues).where(and(
eq(issues.id, loaded.issue.id), eq(issues.companyId, loaded.issue.companyId),
)).for("update");
if (!issue || issue.status !== "blocked" || issue.assigneeAgentId !== loaded.issue.assigneeAgentId) return null;
const [latest] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, issue.companyId),
sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${issue.id}`,
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1);
const gap = latest?.resultJson?.configurationIncomplete as { reason?: string } | undefined;
if (latest?.id !== loaded.interaction.sourceRunId || latest.status !== "failed"
|| latest.errorCode !== "configuration_incomplete" || gap?.reason !== "ai_connection_unavailable") return null;
// Restricted external chat retries require their original chat provenance.
// Their existing Try again path owns that authorization and delivery.
const [restrictedChat] = await tx.select({ id: chatConversations.id }).from(chatConversations)
.innerJoin(chatEndpoints, eq(chatEndpoints.id, chatConversations.endpointId))
.where(and(eq(chatConversations.companyId, issue.companyId), eq(chatConversations.issueId, issue.id),
eq(chatEndpoints.externalExecutionPolicy, "restricted"))).limit(1);
if (restrictedChat) return null;
const recoveries = issueRecoveryActionService(db);
const recovery = await recoveries.getActiveForIssue(issue.companyId, issue.id, tx);
if (!recovery || recovery.cause !== "configuration_incomplete" || recovery.evidence.latestRunId !== latest.id) return null;
const actorId = loaded.interaction.resolvedByUserId ?? loaded.interaction.addresseeUserId!;
const updated = await issueService(db).update(issue.id, {
status: "in_progress", actorUserId: actorId, companyGuard: issue.companyId,
}, tx, publications);
await recoveries.resolveActiveForIssue({ companyId: issue.companyId, sourceIssueId: issue.id,
actionId: recovery.id, status: "resolved", outcome: "restored", resolutionNote: "AI connection restored by the responsible user." }, tx);
return updated;
});
for (const publication of publications) publishActivity(publication);
if (restored) await logActivity(db, { companyId: restored.companyId, actorType: "user",
actorId: loaded.interaction.resolvedByUserId ?? loaded.interaction.addresseeUserId!,
action: "issue.updated", entityType: "issue", entityId: restored.id,
details: { status: restored.status, _previous: { status: "blocked" }, source: "ai_connection_restored", interactionId: loaded.interaction.id } });
return restored;
}
async function deliver(interactionId: string) {
// Deterministic acceptance-test failpoint: preserve committed outcomes across a server restart.
if (process.env.NODE_ENV === "test" && process.env.PAPERCLIP_TEST_CONNECTION_DELIVERY_HOLD === "1") return;
@@ -69,7 +117,7 @@ export function connectionIntentDeliveryService(db: Db, heartbeat: Pick<Heartbea
.from(issueThreadInteractions).innerJoin(issues, eq(issues.id, issueThreadInteractions.issueId))
.where(and(eq(issueThreadInteractions.id, interactionId), eq(issueThreadInteractions.companyId, claimed.companyId), eq(issues.companyId, claimed.companyId)));
const interaction = loaded?.interaction;
const payload = interaction?.payload as { requestingAgentId?: string; serviceSlug?: string } | undefined;
const payload = interaction?.payload as { requestingAgentId?: string; serviceSlug?: string; purpose?: "ai" } | undefined;
if (!loaded || !interaction || !["accepted", "rejected"].includes(interaction.status)
|| ["done", "cancelled"].includes(loaded.issue.status) || loaded.issue.assigneeAgentId !== payload?.requestingAgentId) {
await db.update(connectionIntentDeliveries).set({ deliveredAt: new Date() }).where(eq(connectionIntentDeliveries.interactionId, interactionId));
@@ -88,9 +136,14 @@ export function connectionIntentDeliveryService(db: Db, heartbeat: Pick<Heartbea
}
if (interaction.status === "accepted") {
const ready = await connectionIntentService(db).usableConnectionForAgent({ companyId: claimed.companyId,
agentId: payload!.requestingAgentId!, responsibleUserId: userId!, serviceSlug: payload!.serviceSlug! });
agentId: payload!.requestingAgentId!, responsibleUserId: userId!, serviceSlug: payload!.serviceSlug!, purpose: payload!.purpose });
if (!ready) return;
}
if (interaction.status === "accepted" && payload?.purpose === "ai" && loaded.issue.status === "blocked") {
const restored = await restoreAiBlockedTask(loaded);
if (!restored) return;
loaded.issue = restored;
}
if (!["in_progress", "in_review"].includes(loaded.issue.status)) return;
const durableWake = () => db.select({ id: agentWakeupRequests.id }).from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, claimed.companyId),
+85 -15
View File
@@ -1,3 +1,6 @@
import { logActivity } from "./activity-log.js";
import { aiConnectionService } from "./ai-connections.js";
import { aiConnectionBindingSchema } from "@paperclipai/shared";
import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -64,7 +67,7 @@ function availableToolConnectionMethods(
app: (typeof CONNECTABLE_APP_DEFINITIONS)[number],
) {
return getAvailableConnectionMethods(app).filter(
(method) => (method.purpose ?? "tool") === "tool",
(method) => (method.purpose ?? "tool") === "tool" && method.transport !== "runtime_auth",
);
}
@@ -207,17 +210,32 @@ export function connectionIntentService(db: Db) {
};
}
async function managedAgent(companyId: string, agentId: string, serviceSlug: string) {
const [agent] = await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
const binding = aiConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
return agent && binding?.provider === serviceSlug ? { agent, binding } : null;
}
async function usableConnectionForAgent(input: {
companyId: string;
agentId: string;
responsibleUserId: string;
serviceSlug: string;
purpose?: "ai";
inventory?: Awaited<ReturnType<typeof connectionInventory>>;
}) {
const managed = input.purpose === "ai" ? await managedAgent(input.companyId, input.agentId, input.serviceSlug) : null;
if (managed) {
try {
const selected = await aiConnectionService(db).select({ companyId: input.companyId, agentId: input.agentId, userId: input.responsibleUserId, adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model, runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent, binding: managed.binding });
return access.getConnection(selected.connection.id, input.companyId);
} catch (error) { if ([403, 404, 422].includes((error as { status?: number }).status ?? 0)) return null; throw error; }
}
if (input.purpose === "ai") return null;
const inventory = input.inventory ?? await connectionInventory(input.companyId);
const matching = inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById) === input.serviceSlug
&& connection.status !== "archived"
&& connection.status !== "archived" && connection.connectionPurpose !== "ai"
);
if (matching.length === 0) return null;
const effective = await access.getEffectiveProfilesForAgent(input.companyId, input.agentId);
@@ -284,14 +302,15 @@ export function connectionIntentService(db: Db) {
));
}
async function resolveService(service: string, companyId: string, userId: string, agentId: string) {
async function resolveService(service: string, companyId: string, userId: string, agentId: string, purpose?: "ai") {
if (!service.startsWith("connection:")) {
const app = getAppStoreDefinition(service);
if (!app) throw notFound("Connection service was not found");
const methods = purpose === "ai" ? getAvailableConnectionMethods(app).filter(method => method.transport === "runtime_auth") : availableToolConnectionMethods(app);
return { ...app, available: app.availability?.available !== false,
searchCapabilities: availableToolConnectionMethods(app).map((method) =>
searchCapabilities: methods.map((method) =>
`${method.whenToUse} ${method.capabilityProfile?.label ?? ""} ${method.capabilityProfile?.description ?? ""}`).join(" "),
methods: availableToolConnectionMethods(app).map((method) => ({
methods: methods.map((method) => ({
key: method.key, label: method.label ?? method.key, auth: method.auth,
})), source: "catalog" as const };
}
@@ -300,6 +319,7 @@ export function connectionIntentService(db: Db) {
throw notFound("Configured connection was not found");
}
const connection = await access.getConnection(id, companyId);
if (connection.connectionPurpose === "ai" && purpose !== "ai") throw notFound("AI authentication is not a tool connection");
const { grants } = await access.listConnectionGrants(id, companyId);
if (connection.status === "archived" || !grants.some((grant) => grant.status === "active" && (
grant.kind === "organization" || (grant.kind === "user" && grant.subjectUserId === userId)
@@ -322,7 +342,7 @@ export function connectionIntentService(db: Db) {
const tokens = normalized.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
const inventory = await connectionInventory(run.companyId);
const candidates: Array<{ item: ConnectionSearchResultItem; score: number }> = [];
const services = [...APP_STORE_DEFINITIONS.map((app) => app.slug),
const services = [...APP_STORE_DEFINITIONS.filter(app => getAvailableConnectionMethods(app).some(method => method.transport !== "runtime_auth")).map((app) => app.slug),
...inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById)?.startsWith("connection:")
&& connection.status !== "archived").map((connection) => `connection:${connection.id}`)];
@@ -331,7 +351,7 @@ export function connectionIntentService(db: Db) {
try { app = await resolveService(service, run.companyId, run.responsibleUserId!, agent.id); }
catch (error) { if (service.startsWith("connection:") && (error as { status?: number }).status === 404) continue; throw error; }
const matching = inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById) === service && connection.status !== "archived");
sourceSlugForConnection(connection, inventory.applicationsById) === service && connection.status !== "archived" && connection.connectionPurpose !== "ai");
// Indexed descriptions can contain private workspace metadata, including
// for catalog providers. Check each configured connection's audience first.
const catalogs = await Promise.all(matching.map(async (connection) => {
@@ -367,9 +387,10 @@ export function connectionIntentService(db: Db) {
async function request(
claims: ConnectionRunClaims,
serviceSlug: string,
options: { purpose?: "ai" } = {},
): Promise<ConnectionRequestResult> {
const context = await loadRunContext(claims);
const app = await resolveService(serviceSlug, context.run.companyId, context.run.responsibleUserId!, context.agent.id);
const app = await resolveService(serviceSlug, context.run.companyId, context.run.responsibleUserId!, context.agent.id, options.purpose);
if (!app.available || app.methods.length === 0) {
throw unprocessable(`Connection service ${serviceSlug} is not available`);
}
@@ -378,6 +399,7 @@ export function connectionIntentService(db: Db) {
agentId: context.agent.id,
responsibleUserId: context.run.responsibleUserId!,
serviceSlug: app.slug,
purpose: options.purpose,
});
if (ready) {
return {
@@ -386,16 +408,16 @@ export function connectionIntentService(db: Db) {
state: "ready",
connectionId: ready.id,
interactionId: null,
instruction: `${app.name} is connected. Use its installed tools; a native continuation will refresh tools if needed.`,
instruction: options.purpose === "ai" ? `${app.name} authentication is available for the next execution.` : `${app.name} is connected. Use its installed tools; a native continuation will refresh tools if needed.`,
};
}
if (await administrativeDenial(context.run.companyId, context.agent.id, app.slug, await connectionInventory(context.run.companyId))) {
if (options.purpose !== "ai" && await administrativeDenial(context.run.companyId, context.agent.id, app.slug, await connectionInventory(context.run.companyId))) {
throw forbidden("This agent has no permitted actions for this service. Ask an administrator to review tool permissions; reconnecting will not remove a denial.");
}
const outcomeId = context.run.contextSnapshot?.interactionId;
if (typeof outcomeId === "string") {
const [outcome] = await db.select().from(issueThreadInteractions).where(and(eq(issueThreadInteractions.id, outcomeId), eq(issueThreadInteractions.companyId, context.run.companyId), eq(issueThreadInteractions.issueId, context.issue.id)));
if (outcome?.kind === "connection_intent" && outcome.status === "rejected" && connectionIntentPayloadSchema.parse(outcome.payload).serviceSlug === app.slug) {
if (outcome?.kind === "connection_intent" && outcome.status === "rejected" && connectionIntentPayloadSchema.parse(outcome.payload).serviceSlug === app.slug && connectionIntentPayloadSchema.parse(outcome.payload).purpose === options.purpose) {
throw conflict("The user declined this connection. Pursue alternatives; do not request it again in this continuation.");
}
}
@@ -405,6 +427,7 @@ export function connectionIntentService(db: Db) {
payload: {
version: 1,
serviceSlug: app.slug,
...(options.purpose ? { purpose: options.purpose } : {}),
serviceName: app.name,
serviceLogoUrl: app.branding.logoUrl ?? null,
serviceDarkLogoUrl: app.branding.darkLogoUrl ?? null,
@@ -415,10 +438,16 @@ export function connectionIntentService(db: Db) {
sourceRunId: context.run.id,
sourceIdentityContextId: context.run.activeIdentityContextId,
addresseeUserId: context.run.responsibleUserId!,
idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}`,
idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}${options.purpose ? ":ai" : ""}`,
},
);
if (interaction.status !== "pending") throw conflict("This connection request has already been resolved. Follow its recorded outcome.");
await logActivity(db, {
companyId: context.run.companyId, actorType: "agent", actorId: context.agent.id,
agentId: context.agent.id, runId: context.run.id,
action: "issue.thread_interaction_created", entityType: "issue", entityId: context.issue.id,
details: { interactionId: interaction.id, interactionKind: "connection_intent", purpose: options.purpose },
});
return {
version: 1,
service: app.slug,
@@ -441,24 +470,40 @@ export function connectionIntentService(db: Db) {
return { ...row, interaction };
}
async function setupOptions(interactionId: string): Promise<ConnectionIntentSetupOptions> {
async function setupOptions(interactionId: string, options: { canManageOrganizationGrant?: boolean } = {}): Promise<ConnectionIntentSetupOptions> {
const loaded = await loadIntent(interactionId);
const payload = connectionIntentPayloadSchema.parse(loaded.interaction.payload);
const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId);
const app = await resolveService(payload.serviceSlug, loaded.issue.companyId, loaded.interaction.addresseeUserId!, payload.requestingAgentId, payload.purpose);
const managed = payload.purpose === "ai" ? await managedAgent(loaded.issue.companyId, payload.requestingAgentId, app.slug) : null;
if (payload.purpose === "ai" && !managed) throw conflict("The agent’s AI configuration changed. Start a new execution.");
const inventory = await connectionInventory(loaded.issue.companyId);
const usableAiConnection = managed ? await usableConnectionForAgent({
companyId: loaded.issue.companyId, agentId: payload.requestingAgentId,
responsibleUserId: loaded.interaction.addresseeUserId!, serviceSlug: app.slug, purpose: "ai",
}) : null;
const aiAccounts = managed ? await aiConnectionService(db).list(loaded.issue.companyId, loaded.interaction.addresseeUserId!) : [];
const selectedAiAccount = managed ? aiAccounts.find((account) =>
account.provider === managed.binding.provider && account.method === managed.binding.method
&& (managed.binding.mode === "responsible_user" ? account.isDefault
: account.id === managed.binding.connectionId && account.grantId === managed.binding.grantId)
) : undefined;
const selectedAiGrant = selectedAiAccount
? (await access.listConnectionGrants(selectedAiAccount.id, loaded.issue.companyId)).grants.find(grant => grant.id === selectedAiAccount.grantId)
: undefined;
const matchingConnections = inventory.connections.filter((connection) =>
sourceSlugForConnection(connection, inventory.applicationsById) === app.slug
&& connection.status === "active"
&& connection.enabled
);
const existingConnections = (await Promise.all(matchingConnections.map(async (connection) => {
if (managed) return connection.id === usableAiConnection?.id ? connection : null;
const { grants } = await access.listConnectionGrants(connection.id, loaded.issue.companyId);
const eligible = grants.some((grant) =>
grant.status === "active"
&& (grant.kind === "organization" || grant.subjectUserId === loaded.interaction.addresseeUserId
|| (grant.kind === "agent" && grant.subjectAgentId === payload.requestingAgentId))
);
return eligible ? connection : null;
return eligible && connection.connectionPurpose !== "ai" ? connection : null;
}))).filter((connection): connection is ToolConnection => connection !== null);
return {
version: 1,
@@ -477,6 +522,14 @@ export function connectionIntentService(db: Db) {
id, applicationId, name, status, enabled,
})),
requestedAgentId: payload.requestingAgentId,
aiConnection: managed?.binding,
aiRepair: selectedAiAccount ? {
connection: selectedAiAccount,
canReconnect: selectedAiGrant?.createdByUserId === loaded.interaction.addresseeUserId
&& (selectedAiAccount.ownership === "personal"
? selectedAiAccount.ownerUserId === loaded.interaction.addresseeUserId
: options.canManageOrganizationGrant === true),
} : undefined,
};
}
@@ -533,6 +586,23 @@ export function connectionIntentService(db: Db) {
throw conflict("Finish and test this connection before using it for the task");
}
if (payload.purpose === "ai" && selectedConnection.connectionPurpose !== "ai") throw conflict("Select an AI account for this authentication request");
if (selectedConnection.connectionPurpose === "ai") {
if (payload.purpose !== "ai") throw conflict("AI authentication cannot satisfy a tool connection request");
const managed = await managedAgent(loaded.issue.companyId, payload.requestingAgentId, payload.serviceSlug);
if (!managed) throw conflict("Configure the agent’s AI connection before using this account");
const service = aiConnectionService(txDb);
if (managed.binding.mode === "responsible_user") {
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model, runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent, binding: managed.binding, allowUninstalledPersonal: true });
if (selected.connection.id !== selectedConnection.id) throw conflict("Choose this account as your personal default in Connections first");
const installs = await txAccess.listConnectionInstalls(selectedConnection.id, loaded.issue.companyId);
await txAccess.putConnectionInstalls(selectedConnection.id, { installs: [...installs, { targetType: "agent", targetId: payload.requestingAgentId }] }, { actorType: "user", actorId: userId });
}
const selected = await service.select({ companyId: loaded.issue.companyId, agentId: payload.requestingAgentId, userId, adapterType: managed.agent.adapterType, model: managed.agent.adapterConfig.model, runnerProvider: managed.agent.adapterConfig.provider, acpxAgent: managed.agent.adapterConfig.acpxAgent, binding: managed.binding });
if (selected.connection.id !== selectedConnection.id) throw conflict("This is not the account selected for the agent");
return txInteractions.resolveConnectionIntent(loaded.issue, interactionId, { version: 1, outcome: "connected", connectionId: selected.connection.id }, { userId });
}
let { grants } = await txAccess.listConnectionGrants(
selectedConnection.id,
loaded.issue.companyId,
@@ -208,6 +208,7 @@ export interface LoginSessionActivityEvent {
export type LoginSessionActivityRecorder = (event: LoginSessionActivityEvent) => void;
export interface StartDeviceLoginInput {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
environmentId: string;
adapterType: AgentAdapterType;
@@ -262,6 +263,7 @@ export class AdapterAuthSessionConflictError extends Error {
// ---------------------------------------------------------------------------
export interface AdapterAuthSessionRow {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
id: string;
/** The public, CSPRNG session identifier. The API returns and looks up this
* value. It never equals the internal primary-key `id`, so a caller cannot
@@ -286,6 +288,7 @@ export interface AdapterAuthSessionRow {
}
export interface InsertAdapterAuthSessionInput {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
id: string;
/** The public, CSPRNG session identifier. The service builds it and returns it
* to the client; the store persists it in `public_session_id`. */
@@ -486,6 +489,7 @@ function isUniqueViolation(error: unknown): boolean {
function toRow(row: typeof adapterAuthSessions.$inferSelect): AdapterAuthSessionRow {
return {
id: row.id,
...(row.aiConnection ? { aiConnection: row.aiConnection } : {}),
publicSessionId: row.publicSessionId,
companyId: row.companyId,
environmentId: row.environmentId,
@@ -534,6 +538,7 @@ export function createDbAdapterAuthSessionStore(
environmentId: input.environmentId,
adapterType: input.adapterType,
startedByUserId: input.startedByUserId,
aiConnection: input.aiConnection,
// The unified table requires a unique public session id. The service
// builds it from a CSPRNG and returns it to the client, so the store
// persists that value here. It never uses the internal id, a timestamp,
@@ -934,6 +939,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
environmentId: input.environmentId,
adapterType: input.adapterType,
startedByUserId: input.startedByUserId,
aiConnection: input.aiConnection,
expiresAt,
at: startedAt,
});
@@ -1307,6 +1313,7 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
const prompt = isOwner ? promptsBySession.get(row.id) ?? null : null;
return {
sessionId: row.publicSessionId,
...(isOwner && row.aiConnection ? { aiConnection: row.aiConnection } : {}),
environmentId: row.environmentId,
status,
expiresAt: row.expiresAt?.toISOString() ?? null,
+30 -7
View File
@@ -1,4 +1,5 @@
import { and, asc, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm";
import { z } from "zod";
import {
agentWakeupRequests,
heartbeatRuns,
@@ -11,6 +12,7 @@ import {
import type { ExecutionContinuationEnvelope } from "@paperclipai/shared";
import { sanitizeQuarantinedCommentForHigherTrust } from "./source-trust.js";
import { hasConversationContinuationPolicy } from "./conversation-continuation.js";
import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
const object = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
@@ -268,18 +270,39 @@ export async function buildExecutionContinuation(input: {
eq(agentWakeupRequests.reason, "retry_failed_run"), eq(agentWakeupRequests.requestedByActorType, "user"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
)) : [];
const authorization = reconciliations.map(row => object(row.evidence.explicitUserContinuation))
.find(value => value.previousRunId === explicitUserSource &&
// Admission records the board operator's authority separately from the
// message author. At dispatch, prove that exact queue was adopted by this
// run; caller-supplied continuation context cannot grant this authority.
const continuationAuthorizations = reconciliations.map(row => object(row.evidence.explicitUserContinuation))
.filter(value => value.previousRunId === explicitUserSource &&
(!input.runId || value.runId === input.runId) &&
value.commentId === explicitContinuation.commentId &&
priorRuns.some(run => run.id === value.runId) &&
(failedRunId
priorRuns.some(run => run.id === value.runId));
const interruptQueueIds = [...new Set(continuationAuthorizations.flatMap(value => {
const parsed = z.string().guid().safeParse(value.queuedCommentInterruptId);
return parsed.success ? [parsed.data] : [];
}))];
const interruptQueues = interruptQueueIds.length
? await db.select().from(agentWakeupRequests).where(and(
inArray(agentWakeupRequests.id, interruptQueueIds),
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, input.agentId),
eq(agentWakeupRequests.status, "coalesced"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is not null`,
)) : [];
const authorization = continuationAuthorizations.find(value => failedRunId
? value.failedRunId === failedRunId && retryWakes.some(wake =>
wake.runId === value.runId && wake.requestedByActorId === value.actorId &&
priorRuns.some(run => run.id === wake.runId && run.retryOfRunId === failedRunId))
: rows.some(comment => comment.id === value.commentId &&
comment.authorType === "user" && comment.authorUserId === value.actorId &&
!comment.createdByRunId && !comment.deletedAt)));
comment.authorType === "user" &&
(value.queuedCommentInterruptId
? interruptQueues.some(queue => queue.id === value.queuedCommentInterruptId &&
queue.runId === value.runId &&
object(object(queue.payload).queuedCommentInterrupt).actorId === value.actorId &&
queuedCommentIdsFromWakePayload(queue.payload).includes(comment.id))
: comment.authorUserId === value.actorId) &&
!comment.createdByRunId && !comment.deletedAt));
if (!predecessor || !authorization || explicitUserSource !== sourceRunId)
throw new Error("continuation_user_authorization_missing");
}
@@ -43,6 +43,143 @@ const support = await getEmbeddedPostgresTestSupport();
actorType: "user", actorId: "board", reason: "issue_commented" };
}
type Fixture = Awaited<ReturnType<typeof seed>>;
it.each(["pending", "failed", "historical", "shared", "retained"])("an explicit queued interrupt retries only its stopped sandbox, without granting automatic retries (%s)", async scenario => {
const fails = scenario === "failed";
const protectedLease = scenario === "shared" || scenario === "retained";
const f = await seed(), other = await seed();
await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId));
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", nativeIssueId: null, processPid: null })
.where(eq(heartbeatRuns.id, f.sourceRunId));
await db.update(issueRecoveryActions).set({ cause: "legacy_execution_requires_reconciliation" })
.where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
const queueId = randomUUID();
await db.insert(agentWakeupRequests).values({ id: queueId, companyId: f.companyId, agentId: f.agentId,
source: "automation", reason: "issue_commented", status: "deferred_issue_execution",
requestedByActorType: "system", payload: { issueId: f.issueId, commentId: f.commentId,
_paperclipWakeContext: { wakeCommentIds: [f.commentId] },
queuedCommentInterrupt: { actorId: "board", requestedAt: new Date().toISOString() } },
});
const identities = [f, other].map(fixture => ({ id: randomUUID(), companyId: fixture.companyId,
heartbeatRunId: fixture.sourceRunId, provider: "daytona", providerLeaseId: fixture.sourceRunId }));
for (const identity of identities) await db.insert(environmentLeases).values({ ...identity,
status: "pending_cleanup", leasePolicy: "ephemeral", releasedAt: new Date(), cleanupStatus: "failed",
metadata: { pendingCleanupRetryAttempts: 5, pendingCleanupRetryCapWarned: true } });
if (scenario === "historical" || protectedLease) await db.update(environmentLeases).set({
status: "failed", cleanupStatus: "success",
}).where(eq(environmentLeases.id, identities[0].id));
if (scenario === "shared") await db.update(environmentLeases).set({
providerLeaseId: identities[0].providerLeaseId, status: "active", releasedAt: null,
}).where(eq(environmentLeases.id, identities[1].id));
if (scenario === "retained") await db.update(environmentLeases).set({
status: "retained", leasePolicy: "retain_on_failure",
}).where(eq(environmentLeases.id, identities[0].id));
const attempted: string[] = [];
const heartbeat = heartbeatService(db, { environmentRuntime: {
isPendingCleanupWorkerReady: async () => true,
retryPendingSandboxTeardown: async ({ lease }: { lease: { id: string; providerLeaseId: string } }) => {
attempted.push(lease.id);
if (fails) throw new Error("Provider unavailable");
return { providerLeaseId: lease.providerLeaseId, state: "destroyed" };
},
} as unknown as HeartbeatEnvironmentRuntime });
try {
await heartbeat.resumeQueuedCommentInterrupt(f.companyId, queueId);
expect(attempted).toEqual([]);
await heartbeat.resumeQueuedCommentInterrupt(f.companyId, queueId, { retryCleanup: true });
expect(attempted).toEqual(protectedLease ? [] : [identities[0].id]);
await heartbeat.resumeQueuedCommentInterrupt(f.companyId, queueId);
expect(attempted).toHaveLength(protectedLease ? 0 : 1);
const [queue] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, queueId));
expect(queue.status).toBe(fails || protectedLease ? "deferred_issue_execution" : "coalesced");
expect(Boolean(queue.runId)).toBe(!fails && !protectedLease);
const [untouched] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, identities[1].id));
expect(untouched).toMatchObject({ status: scenario === "shared" ? "active" : "pending_cleanup", metadata: { pendingCleanupRetryAttempts: 5 } });
} finally {
for (const identity of identities) await db.delete(environmentLeases).where(eq(environmentLeases.id, identity.id));
}
});
it("a durable queue interrupt authorizes older legacy messages but still requires the provider to stop", async () => {
const f = await seed();
await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId));
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", nativeIssueId: null,
processPid: process.pid,
}).where(eq(heartbeatRuns.id, f.sourceRunId));
await db.update(issueRecoveryActions).set({ cause: "legacy_execution_requires_reconciliation" })
.where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
await db.update(issueComments).set({ authorUserId: "original-author", createdAt: new Date("2026-09-11T09:00:00Z") })
.where(eq(issueComments.id, f.commentId));
const queueId = randomUUID();
await db.insert(agentWakeupRequests).values({ id: queueId, companyId: f.companyId, agentId: f.agentId,
source: "on_demand", reason: "issue_commented", status: "deferred_issue_execution",
requestedByActorType: "user", requestedByActorId: "original-author",
payload: { issueId: f.issueId, _paperclipWakeContext: { wakeCommentIds: [f.commentId] },
queuedCommentInterrupt: { actorId: "board", requestedAt: new Date().toISOString() } },
});
const attempt = (queue = queueId) => db.transaction(async tx => {
await tx.select().from(issues).where(eq(issues.id, f.issueId)).for("update");
return admitExplicitNativeContinuation({ ...f, db: tx as unknown as typeof db,
queuedCommentInterruptId: queue, dryRun: true });
});
expect(await attempt()).toBeNull();
await db.update(heartbeatRuns).set({ processPid: 999999999 }).where(eq(heartbeatRuns.id, f.sourceRunId));
expect(await attempt(randomUUID())).toBeNull();
expect(await attempt()).toMatchObject({ previousRunId: f.sourceRunId, commentId: f.commentId });
await db.update(agentWakeupRequests).set({ status: "cancelled" }).where(eq(agentWakeupRequests.id, queueId));
expect(await attempt()).toBeNull();
});
it("dispatches another user's queued legacy message using the consumed board interrupt receipt", async () => {
const f = await seed();
await db.update(agents).set({ adapterType: "claude_local" }).where(eq(agents.id, f.agentId));
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", nativeIssueId: null })
.where(eq(heartbeatRuns.id, f.sourceRunId));
await db.update(issueRecoveryActions).set({ cause: "legacy_execution_requires_reconciliation" })
.where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
await db.update(issueComments).set({ authorUserId: "original-author", createdAt: new Date("2026-09-11T09:00:00Z") })
.where(eq(issueComments.id, f.commentId));
// Hold adapter startup so the test can exercise the real dispatch envelope
// deterministically, without invoking a provider.
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
const queueId = randomUUID();
await db.insert(agentWakeupRequests).values({ id: queueId, companyId: f.companyId, agentId: f.agentId,
source: "automation", reason: "issue_commented", status: "deferred_issue_execution",
requestedByActorType: "system", payload: { issueId: f.issueId, commentId: f.commentId,
_paperclipWakeContext: { wakeCommentIds: [f.commentId] },
queuedCommentInterrupt: { actorId: "board", requestedAt: new Date().toISOString() } },
});
await heartbeatService(db).resumeQueuedCommentInterrupt(f.companyId, queueId);
const [receipt] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, queueId));
expect(receipt.status).toBe("coalesced");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, receipt.runId!));
const dispatch = (runId = run.id) => buildExecutionContinuation({ db, companyId: f.companyId,
issueId: f.issueId, agentId: f.agentId, runId, context: run.contextSnapshot!,
summary: null, exposeLowTrustRaw: false });
const envelope = await dispatch();
expect(envelope.interruptedRunId).toBe(f.sourceRunId);
expect(envelope.originCommentIds).toContain(f.commentId);
expect(envelope.messages).toEqual(expect.arrayContaining([expect.objectContaining({ id: f.commentId, body: "What happened?" })]));
await expect(dispatch(randomUUID())).rejects.toThrow("continuation_user_authorization_missing");
for (const patch of [
{ status: "cancelled" }, { runId: f.sourceRunId },
{ payload: { ...receipt.payload, issueId: randomUUID() } },
{ payload: { ...receipt.payload, queuedCommentInterrupt: { actorId: "someone-else" } } },
{ payload: { ...receipt.payload, _paperclipWakeContext: { wakeCommentIds: [] }, commentId: undefined } },
]) {
await db.update(agentWakeupRequests).set(patch).where(eq(agentWakeupRequests.id, queueId));
await expect(dispatch()).rejects.toThrow("continuation_user_authorization_missing");
await db.update(agentWakeupRequests).set({ status: receipt.status, runId: receipt.runId, payload: receipt.payload })
.where(eq(agentWakeupRequests.id, queueId));
}
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
await db.update(issueRecoveryActions).set({ evidence: { ...action.evidence,
explicitUserContinuation: { ...(action.evidence.explicitUserContinuation as Record<string, unknown>),
queuedCommentInterruptId: "malformed-historical-receipt" },
} }).where(eq(issueRecoveryActions.id, action.id));
await expect(dispatch()).rejects.toThrow("continuation_user_authorization_missing");
});
const admit = (f: Fixture, dryRun = false) => db.transaction(async tx => {
await tx.select().from(issues).where(eq(issues.id, f.issueId)).for("update");
const result = await admitExplicitNativeContinuation({ ...f, dryRun, db: tx as unknown as typeof db });
@@ -5,7 +5,7 @@ import { hasRemoteTerminationReceipt, remoteLeaseCleanupScope } from "./remote-e
import { z } from "zod";
import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import {
agents, approvals, issueApprovals, issueThreadInteractions,
agents, agentWakeupRequests, approvals, issueApprovals, issueThreadInteractions,
environmentLeases, heartbeatRuns, issueComments, issueRecoveryActions,
issues, nativeRunFinalizations, type Db,
} from "@paperclipai/db";
@@ -15,6 +15,7 @@ import { adapterExecutionControls } from "./adapter-execution-control.js";
import { persistActivity } from "./activity-log.js";
import { historicalAdapterType, isConversationAdapter } from "./conversation-continuation.js";
import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
type Run = typeof heartbeatRuns.$inferSelect;
const terminal = ["failed", "interrupted", "timed_out", "cancelled"];
@@ -33,6 +34,8 @@ export async function admitExplicitNativeContinuation(input: {
actorType: string | null | undefined; actorId: string | null | undefined;
reason: string | null; commentId: string | null; successorRunId: string;
failedRunId?: string | null;
/** Server-recorded board intent to send an existing legacy message queue. */
queuedCommentInterruptId?: string;
dryRun?: boolean;
resumingSavedMessage?: boolean;
onBlocked?: (reason: string, message: string) => void;
@@ -48,16 +51,27 @@ export async function admitExplicitNativeContinuation(input: {
eq(issues.companyId, companyId), eq(issues.id, issueId),
));
if (!task || task.assigneeAgentId !== agentId || ["done", "cancelled"].includes(task.status)) return null;
const [interruptQueue] = input.queuedCommentInterruptId ? await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, input.queuedCommentInterruptId),
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt'->>'actorId' = ${actorId}`,
)) : [];
const queuedInterrupt = Boolean(interruptQueue && commentId &&
queuedCommentIdsFromWakePayload(interruptQueue.payload).includes(commentId));
if (input.queuedCommentInterruptId && !queuedInterrupt) return null;
const [comment] = retry ? [] : await db.select().from(issueComments).where(and(
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
eq(issueComments.id, commentId!), eq(issueComments.authorType, "user"),
eq(issueComments.authorUserId, actorId), isNull(issueComments.createdByRunId),
queuedInterrupt ? undefined : eq(issueComments.authorUserId, actorId), isNull(issueComments.createdByRunId),
isNull(issueComments.deletedAt),
));
if (!retry && !comment?.body.trim()) return null;
const authorizedAt = comment?.createdAt ?? new Date();
const [agent] = await db.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
if (!agent || (!isConversationAdapter(agent.adapterType) && agent.adapterType !== "paperclip_runner")) return null;
if (queuedInterrupt && !isConversationAdapter(agent.adapterType)) return null;
const actions = await db.select().from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, companyId), eq(issueRecoveryActions.sourceIssueId, issueId),
executionBlockerPredicate(),
@@ -90,7 +104,7 @@ export async function admitExplicitNativeContinuation(input: {
if (!run || run.agentId !== agentId || !terminal.includes(run.status) ||
(run.nativeIssueId ?? run.contextSnapshot?.issueId) !== issueId ||
!run.finishedAt) return blocked("source_unavailable", "The previous execution has not finished or its owner changed. Your message is saved.");
if (authorizedAt <= run.finishedAt) return blocked("message_predates_stop", "This message arrived before the previous run stopped. Send a new message to continue.");
if (!queuedInterrupt && authorizedAt <= run.finishedAt) return blocked("message_predates_stop", "This message arrived before the previous run stopped. Send a new message to continue.");
if (adapterExecutionControls.has(run.id)) return blocked("execution_settling", "Waiting for the previous run to stop. Your message will start automatically.");
const unusedAdmission = run.status === "cancelled" && !run.startedAt &&
run.errorCode === "execution_reconciliation_required" &&
@@ -98,6 +112,7 @@ export async function admitExplicitNativeContinuation(input: {
const legacyUserTurn = run.runtimeMode === "legacy" &&
action.cause === "legacy_execution_requires_reconciliation" &&
isConversationAdapter(agent.adapterType);
if (queuedInterrupt && !legacyUserTurn) return null;
if (legacyUserTurn) {
const historicalAdapter = await historicalAdapterType(db, run);
// A settings change never converts a known process/webhook execution into
@@ -165,7 +180,8 @@ export async function admitExplicitNativeContinuation(input: {
context: { previousRunId: previous.id, wakeCommentId: commentId },
summary: null, exposeLowTrustRaw: false });
if (input.dryRun) return { previousRunId: previous.id, commentId, ...(retry ? { failedRunId: input.failedRunId! } : {}) };
const authorization = { actorId, commentId, ...(retry ? { failedRunId: input.failedRunId } : {}), runId: input.successorRunId,
const authorization = { actorId, commentId, ...(retry ? { failedRunId: input.failedRunId } : {}),
...(queuedInterrupt ? { queuedCommentInterruptId: input.queuedCommentInterruptId } : {}), runId: input.successorRunId,
previousRunId: previous.id, recordedAt: new Date().toISOString() };
for (const runId of cancelledStartupIds) {
await db.update(nativeRunFinalizations).set({
+269 -32
View File
@@ -2,9 +2,12 @@ import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isConversatio
import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js";
import { legacyControllerBootId, legacyControllerClaim, renewLegacyControllerLease, hasLiveLegacyController, revokeExpiredLegacyController, watchLegacyControllerLease } from "./legacy-controller-lease.js";
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
import { remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js";
import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js";
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
import { admitExplicitNativeContinuation } from "./explicit-native-continuation.js";
import { connectionIntentService } from "./connection-intents.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js";
import { aiConnectionBindingSchema } from "@paperclipai/shared";
import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js";
import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js";
import { recordExecutionWait } from "./execution-wait.js";
@@ -24,7 +27,7 @@ import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-
export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
import { buildExecutionContinuation } from "./execution-continuation.js";
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
import { initializeRunIdentity } from "./run-identity.js";
import { initializeRunIdentity, explicitOperatorRunIdentity } from "./run-identity.js";
import {
assertDurableChatWakeupReceipt,
assertDurableChatWakeupRequest,
@@ -62,6 +65,7 @@ import {
gte,
inArray,
isNull,
isNotNull,
lt,
lte,
ne,
@@ -1484,6 +1488,7 @@ function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) {
}
export async function resolveExecutionRunAdapterConfig(input: {
managedAiCredentials?: boolean;
companyId: string;
agentId?: string | null;
adapterType?: string | null;
@@ -1833,7 +1838,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
// host-side login never exists at all. The adapter's execute-time gate
// remains the authority there; it probes the sandbox before failing.
if (
(input.adapterType ?? null) === "codex_local" &&
!input.managedAiCredentials && (input.adapterType ?? null) === "codex_local" &&
(input.environmentDriver ?? null) !== "sandbox"
) {
const resolvedEnv = parseObject(resolvedConfig.env);
@@ -3501,6 +3506,10 @@ function normalizeMaxConcurrentRuns(value: unknown) {
}
interface WakeupOptions {
/** Set only by authenticated board wake routes; never copied from caller payloads. */
manualUserWake?: boolean;
/** Internal resume of a queue with persisted board interruption intent. */
queuedCommentInterruptId?: string;
/** Exact failed run selected by an authenticated board Retry request. */
failedRunId?: string | null;
durableChatRequest?: DurableChatWakeupRequest;
@@ -5654,6 +5663,7 @@ type EffectiveRunWorkspaceConfigCategory =
(typeof EFFECTIVE_RUN_WORKSPACE_CONFIG_CATEGORIES)[number];
type EffectiveRunSessionConfigMetadata = {
aiCredentialIdentity?: string;
version: typeof EFFECTIVE_RUN_CONFIG_FINGERPRINT_VERSION;
fingerprint: string;
categories: EffectiveRunSessionConfigCategory[];
@@ -6623,6 +6633,7 @@ function attachPaperclipSessionMetadataToSessionParams(
const next = { ...(sessionParams ?? {}) };
if (configuredModel) next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel;
if (configMetadata) {
if (configMetadata.aiCredentialIdentity) next.paperclipAiCredentialIdentity = configMetadata.aiCredentialIdentity;
next[SESSION_CONFIG_FINGERPRINT_KEY] = configMetadata.fingerprint;
next[SESSION_CONFIG_FINGERPRINT_VERSION_KEY] = configMetadata.version;
next[SESSION_CONFIG_CATEGORIES_KEY] = configMetadata.categories;
@@ -10059,6 +10070,10 @@ export function heartbeatService(
for (const wake of pending) {
if (wake.idempotencyKey?.startsWith("chat-inbound:")) continue;
const payload = parseObject(wake.payload);
if (payload.queuedCommentInterrupt) {
await resumeQueuedCommentInterrupt(wake.companyId, wake.id);
continue;
}
const context = parseObject(payload[DEFERRED_WAKE_CONTEXT_KEY]);
const commentId = deriveCommentId(context, payload);
if (legacyContinuation) {
@@ -10098,6 +10113,93 @@ export function heartbeatService(
}
}
async function resumeQueuedCommentInterrupt(companyId: string, queueId: string, opts?: { retryCleanup?: boolean }) {
const [wake] = await db.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, queueId), eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
));
if (!wake) return;
const payload = parseObject(wake.payload);
const actorId = readNonEmptyString(parseObject(payload.queuedCommentInterrupt).actorId);
const commentIds = queuedCommentIdsFromWakePayload(payload);
const issueId = readNonEmptyString(payload.issueId);
if (!actorId || !issueId || !commentIds.length) return;
const agent = await getAgent(wake.agentId);
if (!agent || agent.companyId !== companyId || agent.adapterType === "paperclip_runner") return;
const [active] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}`,
inArray(heartbeatRuns.status, ["running", "queued", "scheduled_retry"]),
)).limit(1);
if (active) return;
if (opts?.retryCleanup) {
// Only the HTTP click grants an extra cleanup attempt. Periodic retries
// reuse the intent to deliver, never a fresh provider teardown budget.
const sourceRun = await db.transaction(async tx => {
const [task] = await tx.select().from(issues).where(and(
eq(issues.companyId, companyId), eq(issues.id, issueId),
)).for("update");
const [current] = await tx.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, queueId), eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.agentId, wake.agentId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt'->>'actorId' = ${actorId}`,
));
if (!task || task.assigneeAgentId !== wake.agentId || ["done", "cancelled"].includes(task.status) ||
!current || !queuedCommentIdsFromWakePayload(current.payload).length) return null;
const [successor] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}`,
inArray(heartbeatRuns.status, ["running", "queued", "scheduled_retry"]),
)).limit(1);
if (successor) return null;
const blocker = await getExecutionBlocker(tx as unknown as Db, companyId, issueId);
const run = blocker?.runId ? await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, blocker.runId),
eq(heartbeatRuns.agentId, wake.agentId), eq(heartbeatRuns.runtimeMode, "legacy"),
inArray(heartbeatRuns.status, ["failed", "timed_out", "interrupted", "cancelled"]),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}`,
)).then(rows => rows[0]) : null;
if (!run || activeRunExecutions.has(run.id) || adapterExecutionControls.has(run.id)) return null;
// Older ephemeral leases recorded successful cleanup without a provider
// receipt. Re-verify them through the recorded teardown path; a timestamp
// alone never certifies termination. Retained/reusable resources stay put.
const historical = await tx.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run.id),
eq(environmentLeases.leasePolicy, "ephemeral"), isNotNull(environmentLeases.releasedAt),
inArray(environmentLeases.status, ["released", "expired", "failed"]),
)).for("update");
for (const lease of historical) {
if (!lease.provider || lease.provider === "local" || !lease.providerLeaseId || hasRemoteTerminationReceipt(lease)) continue;
const [otherOwner] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
ne(environmentLeases.id, lease.id), eq(environmentLeases.provider, lease.provider),
eq(environmentLeases.providerLeaseId, lease.providerLeaseId),
or(isNull(environmentLeases.releasedAt), inArray(environmentLeases.status, ["active", "retained", "pending_cleanup"])),
)).limit(1);
if (otherOwner) continue;
await tx.update(environmentLeases).set({ status: "pending_cleanup", updatedAt: new Date() })
.where(eq(environmentLeases.id, lease.id));
}
return run;
});
if (sourceRun) await sweepPendingCleanupLeases({ explicitRetry: {
companyId, runId: sourceRun.id, actorId, reason: "queued_comment_interrupt",
} });
}
const deliveryPayload = { ...payload };
delete deliveryPayload.queuedCommentInterrupt;
await enqueueWakeup(wake.agentId, {
source: "on_demand", triggerDetail: "manual", reason: "issue_commented",
payload: deliveryPayload, contextSnapshot: withQueuedCommentIdsInRunContext({
issueId, triggeredBy: "board", actorId, responsibleUserId: actorId,
}, commentIds),
requestedByActorType: "user", requestedByActorId: actorId,
queuedCommentInterruptId: queueId,
issueStateGuard: { assigneeAgentId: wake.agentId, statuses: ["todo", "in_progress", "in_review", "blocked"] },
idempotencyKey: `queued-comment-interrupt:${queueId}`,
}, queueId);
}
async function resumeExecutionWaitComments() {
if ((await getSchedulingSuppression()).suppressed) return;
const waits = await db.select({ wake: agentWakeupRequests })
@@ -10109,6 +10211,7 @@ export function heartbeatService(
.where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"),
eq(agentWakeupRequests.requestedByActorType, "user"),
sql`${agentWakeupRequests.payload}->'executionWait' is not null`,
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is null`,
lte(agentWakeupRequests.updatedAt, new Date(Date.now() - 30_000)),
notInArray(issues.status, ["done", "cancelled"])))
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
@@ -10633,7 +10736,8 @@ export function heartbeatService(
ReturnType<typeof getRoutineEnvForExecutionIssue>
>;
}) {
const responsibleUserId = await resolveResponsibleUserIdForRunSeed({
const operatorIdentity = await explicitOperatorRunIdentity(db, input.run);
const responsibleUserId = operatorIdentity?.actorId ?? await resolveResponsibleUserIdForRunSeed({
companyId: input.run.companyId,
contextSnapshot: input.contextSnapshot,
issueContext: input.issueContext,
@@ -17854,7 +17958,7 @@ export function heartbeatService(
* A later user Retry may try again after a provider failure; automatic
* sweeps retain their exhausted budget and never gain extra attempts.
*/
explicitRetry?: { companyId: string; runId: string; actorId: string };
explicitRetry?: { companyId: string; runId: string; actorId: string; reason?: "retry_failed_run" | "queued_comment_interrupt" };
}): Promise<{
swept: number;
destroyed: number;
@@ -17984,7 +18088,7 @@ export function heartbeatService(
if (opts?.explicitRetry) await logActivity(db, {
companyId: row.companyId, actorType: "user", actorId: opts.explicitRetry.actorId,
action: "environment_lease.cleanup_retried", entityType: "environment_lease", entityId: row.id,
runId: opts.explicitRetry.runId, details: { attempt: attempts + 1, reason: "retry_failed_run" },
runId: opts.explicitRetry.runId, details: { attempt: attempts + 1, reason: opts.explicitRetry.reason ?? "retry_failed_run" },
});
try {
@@ -18696,6 +18800,52 @@ export function heartbeatService(
if ((await getSchedulingSuppression()).suppressed) return;
await resumeExecutionWaitComments();
const cutoff = await getWorktreeExecutionCutoff();
const pendingInterrupts = await db.select({ id: agentWakeupRequests.id, companyId: agentWakeupRequests.companyId })
.from(agentWakeupRequests).innerJoin(companies, eq(companies.id, agentWakeupRequests.companyId))
.where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"),
eq(companies.status, "active"),
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is not null`,
lte(agentWakeupRequests.updatedAt, new Date(Date.now() - 30_000)),
cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined))
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
for (const wake of pendingInterrupts) {
await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and(
eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.status, "deferred_issue_execution"),
));
await resumeQueuedCommentInterrupt(wake.companyId, wake.id).catch(err => {
logger.warn({ err, queueId: wake.id }, "failed to resume interrupted comment queue");
});
}
// A server restart or a message/cleanup race can leave a deferred wake
// after its owner has released the issue lock. Revisit it through the same
// release admission, so recovery holds and operator Stops still apply.
const strandedQueues = await db.select({ wake: agentWakeupRequests })
.from(agentWakeupRequests)
.innerJoin(issues, and(eq(issues.companyId, agentWakeupRequests.companyId),
sql`${issues.id}::text = ${agentWakeupRequests.payload}->>'issueId'`,
eq(issues.assigneeAgentId, agentWakeupRequests.agentId)))
.innerJoin(companies, and(eq(companies.id, issues.companyId), eq(companies.status, "active")))
.where(and(eq(agentWakeupRequests.status, "deferred_issue_execution"),
isNull(issues.executionRunId),
sql`jsonb_typeof(${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}') = 'array'`,
sql`${agentWakeupRequests.payload} #> '{_paperclipWakeContext,wakeCommentIds}' <> '[]'::jsonb`,
sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt' is null`,
cutoff ? gte(agentWakeupRequests.requestedAt, cutoff) : undefined))
.orderBy(asc(agentWakeupRequests.updatedAt)).limit(50);
for (const { wake } of strandedQueues) {
if (!queuedCommentIdsFromWakePayload(wake.payload).length) continue;
const [latest] = await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, wake.companyId), eq(heartbeatRuns.agentId, wake.agentId),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${String(wake.payload?.issueId)}`,
)).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
await db.update(agentWakeupRequests).set({ updatedAt: new Date() }).where(and(
eq(agentWakeupRequests.id, wake.id), eq(agentWakeupRequests.status, "deferred_issue_execution"),
));
if (!latest || latest.runtimeMode !== "legacy" || !isHeartbeatRunTerminalStatus(latest.status)) continue;
await releaseIssueExecutionAndPromote(latest, { suppressImmediateRecovery: true }).catch(err => {
logger.warn({ err, queueId: wake.id }, "failed to promote stranded legacy comments");
});
}
// The cancellation marker is durable intent. Retry while its exact queue
// is still deferred, including after a failed cleanup promotion or restart.
@@ -19424,6 +19574,7 @@ export function heartbeatService(
"keep_running" | "stop_and_reuse" | "destroy_after_turn";
}
| undefined;
let managedAiRuntime: Awaited<ReturnType<typeof prepareManagedAiRuntime>> | undefined;
let providerTraceCapture: Awaited<
ReturnType<typeof traceStore.prepare>
> | null = null;
@@ -20492,8 +20643,10 @@ export function heartbeatService(
["local", "ssh"].includes(
selectedEnvironmentForConfig?.driver ?? "local",
);
const aiBinding = agent.runtimeConfig?.aiConnection ? aiConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined;
const { resolvedConfig, secretKeys, secretManifest } =
await resolveExecutionRunAdapterConfig({
managedAiCredentials: Boolean(aiBinding),
managedGitHubCredentials: !useHostGitHub,
companyId: agent.companyId,
agentId: agent.id,
@@ -20501,17 +20654,40 @@ export function heartbeatService(
issueId,
heartbeatRunId: run.id,
environmentId: selectedEnvironmentForConfig?.id ?? null,
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
environmentEnv: aiBinding ? stripAiAuthBindings(selectedEnvironmentForConfig?.envVars) : selectedEnvironmentForConfig?.envVars ?? null,
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
projectId: projectContext?.id ?? null,
routineId: routineEnvContext.routineId,
responsibleUserId,
executionRunConfig,
projectEnv: projectContext?.env ?? null,
routineEnv: routineEnvContext.env,
executionRunConfig: aiBinding ? { ...executionRunConfig, env: stripAiAuthBindings(executionRunConfig.env) } : executionRunConfig,
projectEnv: aiBinding ? stripAiAuthBindings(projectContext?.env) : projectContext?.env ?? null,
routineEnv: aiBinding ? stripAiAuthBindings(routineEnvContext.env) : routineEnvContext.env,
secretsSvc,
trustPreset,
});
if (aiBinding) {
try {
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
} catch (error) {
if (responsibleUserId && issueId && aiBinding.mode === "responsible_user") {
await connectionIntentService(db).request({ sub: agent.id, company_id: agent.companyId, run_id: run.id, responsible_user_id: responsibleUserId }, aiBinding.provider, { purpose: "ai" }).catch(() => {
logger.warn({ runId: run.id, agentId: agent.id }, "Could not attach AI connection request; runtime configuration action remains available");
});
}
throw new ConfigurationIncompleteFailure(error instanceof Error ? error.message : "Configure this agent’s AI connection", {
configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId,
provider: aiBinding.provider, method: aiBinding.method, actionUrl: `/agents/${agent.id}/runtime`,
fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` },
});
}
if (persistedNativeExecutionInput && parseObject(run.contextSnapshot?.aiConnection).identity !== managedAiRuntime.identity) {
throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } });
}
Object.assign(resolvedConfig, managedAiRuntime.config);
for (const key of AI_AUTH_ENV_KEYS) secretKeys.add(key);
context.aiConnection = { ...managedAiRuntime.attribution, identity: managedAiRuntime.identity };
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiConnection: context.aiConnection })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
}
if (secretManifest.length > 0) {
context.paperclipSecrets = {
manifest: secretManifest,
@@ -21451,6 +21627,10 @@ export function heartbeatService(
].filter(Boolean).join("\n\n");
workFolderSaveFailed = false;
}
if (managedAiRuntime && aiBinding) {
try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); }
catch { throw new ConfigurationIncompleteFailure("Project authentication conflicts with this agent’s managed AI connection", { configurationIncomplete: { reason: "ai_connection_incompatible", actionUrl: `/agents/${agent.id}/runtime` } }); }
}
const remoteExecution = realizationResult.remoteExecution;
if (
nativeChatWorkspaceScope &&
@@ -21820,6 +22000,15 @@ export function heartbeatService(
delete context.paperclipPreviousSessionId;
}
if (managedAiRuntime) {
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.identity;
if (taskSessionDecodedParams?.paperclipAiCredentialIdentity !== managedAiRuntime.identity) {
runtimeSessionIdForAdapter = null;
runtimeSessionParamsForAdapter = null;
previousSessionDisplayId = null;
delete executionContinuation?.resumeDelta;
}
}
const runtimeForAdapter = {
sessionId: runtimeSessionIdForAdapter,
sessionParams: runtimeSessionParamsForAdapter,
@@ -22323,7 +22512,9 @@ export function heartbeatService(
return requests.length > 0 ? requests : undefined;
})(),
});
const taskNativeSessionId = sandboxWorkFolders?.identityChanged ? null : readNonEmptyString(
const taskSessionIdentityChanged = Boolean(sandboxWorkFolders?.identityChanged
|| (managedAiRuntime && taskSessionDecodedParams?.paperclipAiCredentialIdentity !== managedAiRuntime.identity));
const taskNativeSessionId = taskSessionIdentityChanged ? null : readNonEmptyString(
taskSessionDecodedParams?.sessionId,
);
// Compatibility for native retry rows created before same-run restart
@@ -22331,7 +22522,7 @@ export function heartbeatService(
// inherit its source checkpoint; any process/provider evidence on the
// replacement makes the ownership ambiguous and therefore ineligible.
const legacyRetrySource =
!sandboxWorkFolders?.identityChanged && run.retryOfRunId && !isFailedChatRunRetry
!taskSessionIdentityChanged && run.retryOfRunId && !isFailedChatRunRetry
? await db
.select({
id: heartbeatRuns.id,
@@ -22379,7 +22570,7 @@ export function heartbeatService(
.then((rows) => rows.length > 0)
: false;
const compatibleLegacyRetrySource =
!isConversation(issueContext) && context.forceFreshSession !== true && isUnusedLegacyNativeRetryReplacement({
!managedAiRuntime && !isConversation(issueContext) && context.forceFreshSession !== true && isUnusedLegacyNativeRetryReplacement({
replacement: run,
source: legacyRetrySource,
hasProviderEvents: nativeBootstrapHasProviderEvidence,
@@ -22443,8 +22634,7 @@ export function heartbeatService(
executionTarget.transport === "sandbox"
? (executionTarget.runnerLifecyclePolicy ?? null)
: null;
const effectiveLifecyclePolicy =
environmentLifecyclePolicy ?? agentLifecyclePolicy;
const effectiveLifecyclePolicy = managedAiRuntime ? { mode: "per_turn" as const, idleTimeoutMs: null } : environmentLifecyclePolicy ?? agentLifecyclePolicy;
if (
effectiveLifecyclePolicy.mode === "warm" &&
executionTarget?.kind === "remote" &&
@@ -23346,6 +23536,7 @@ export function heartbeatService(
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
// workspace boundary authoritative.
managedAiCredentialHome: managedAiRuntime ? String((managedAiRuntime.config.env as Record<string, unknown>).CODEX_HOME) : undefined,
runnerEnvironment: {
...buildNativeProviderEnvironment(
adapterEnv,
@@ -25055,6 +25246,7 @@ export function heartbeatService(
try { await sandboxWorkFolders.stop(beforeWorkFolderCompletion); workFolderSaveFailed = false; }
catch (error) { workFolderSaveFailed = true; logger.error({ err: error, runId: run.id }, "Work folder save failed; retaining sandbox for recovery"); }
}
if (managedAiRuntime) await managedAiRuntime.cleanup().catch(() => logger.warn({ runId: run.id }, "AI connection refresh or cleanup failed"));
let latestRun = await getRun(run.id).catch(() => null);
try {
if (latestRun && isHeartbeatRunTerminalStatus(latestRun.status)) {
@@ -25225,18 +25417,6 @@ export function heartbeatService(
${JSON.stringify({ startupPreparationSettledAt: new Date().toISOString() })}::jsonb`,
}).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "cancelled")));
}
// Interrupting a queued message explicitly authorizes the pending queue.
// Retry its normal promotion after leases and adapter cleanup have settled;
// the earlier terminal write can still have an execution blocker here.
if (
latestRun?.status === "cancelled" &&
latestRun.runtimeMode !== "native" &&
readNonEmptyString(latestRun.resultJson?.queuedCommentInterruptQueueId)
) {
await releaseIssueExecutionAndPromote(latestRun, { suppressImmediateRecovery: true }).catch((err) => {
logger.error({ err, runId: run.id }, "failed to promote interrupted comment queue after cleanup");
});
}
} finally {
controllerLease.stop();
activeRunExecutions.delete(run.id);
@@ -25249,6 +25429,20 @@ export function heartbeatService(
adapterExecutionControls.delete(run.id);
}
}
// Terminalization precedes lease and adapter cleanup. Only now is the
// owner gone; retry pending input for ordinary completions as well as Stop.
if (latestRun?.runtimeMode === "legacy" && isHeartbeatRunTerminalStatus(latestRun.status)) {
const [pending] = await db.select({ id: agentWakeupRequests.id, payload: agentWakeupRequests.payload }).from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.companyId, run.companyId), eq(agentWakeupRequests.agentId, run.agentId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${String(latestRun.contextSnapshot?.issueId)}`,
)).limit(1);
if (pending) await (pending.payload?.queuedCommentInterrupt
? resumeQueuedCommentInterrupt(run.companyId, pending.id)
: releaseIssueExecutionAndPromote(latestRun, { suppressImmediateRecovery: true })).catch(err => {
logger.error({ err, runId: run.id }, "failed to promote legacy comment queue after cleanup");
});
}
if (
!nativeSessionResumeScheduled &&
!nativeWorkspaceFinalizeScheduled &&
@@ -25294,7 +25488,19 @@ export function heartbeatService(
...(opts.contextSnapshot ?? {}),
};
const reason = opts.reason ?? null;
const payload = opts.payload ?? null;
let payload = opts.payload ? { ...opts.payload } : null;
// Only the board queue route can record interruption authority on an
// existing receipt. Never accept this internal marker from a wake caller.
if (payload) {
delete payload.queuedCommentInterrupt;
delete payload.manualUserWake;
}
if (opts.manualUserWake) {
if (opts.requestedByActorType !== "user" || !opts.requestedByActorId || opts.failedRunId) {
throw new HttpError(403, "Manual wake requires an authenticated user");
}
payload = { ...payload, manualUserWake: true };
}
const executionReconciliationWake =
contextSnapshot.source === "execution.reconciled" ||
opts.idempotencyKey?.startsWith("execution-reconciliation:") === true;
@@ -25319,6 +25525,9 @@ export function heartbeatService(
if (issueId) {
const conversation = await getIssueExecutionContext(agent.companyId, issueId);
if (isConversation(conversation)) {
if (opts.manualUserWake && conversation!.conversationUserId !== opts.requestedByActorId) {
throw new HttpError(403, "Only the conversation owner can start a chat run");
}
if (isConversationExecutionWake(conversation, reason ?? readNonEmptyString(enrichedContextSnapshot.wakeReason))) return null;
if (agent.id !== conversation!.conversationAgentId) return null;
if (!(await instanceSettings.getExperimental()).enableAgentChat) return null;
@@ -25640,8 +25849,10 @@ export function heartbeatService(
const isolatedWorkspacesEnabled = issueId
? (await instanceSettings.getExperimental()).enableIsolatedWorkspaces
: false;
let operatorResponsibleUserId: string | null = opts.manualUserWake ? opts.requestedByActorId! : null;
let queuedResponsibleUserIdPromise: Promise<string> | null = null;
const resolveQueuedResponsibleUserId = () => {
if (operatorResponsibleUserId) return Promise.resolve(operatorResponsibleUserId);
queuedResponsibleUserIdPromise ??= (async () => {
const queuedIssueContext = issueId
? await getIssueExecutionContext(agent.companyId, issueId)
@@ -25821,8 +26032,13 @@ export function heartbeatService(
const [pending] = await tx.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, executionWaitRequestId), eq(agentWakeupRequests.companyId, agent.companyId),
eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
eq(agentWakeupRequests.requestedByActorType, "user"),
eq(agentWakeupRequests.requestedByActorId, opts.requestedByActorId ?? ""),
// A user message can join a queue originally created by a
// system wake. The recorded board click supplies fresh authority.
opts.queuedCommentInterruptId === executionWaitRequestId
? undefined : eq(agentWakeupRequests.requestedByActorType, "user"),
opts.queuedCommentInterruptId === executionWaitRequestId
? sql`${agentWakeupRequests.payload}->'queuedCommentInterrupt'->>'actorId' = ${opts.requestedByActorId ?? ""}`
: eq(agentWakeupRequests.requestedByActorId, opts.requestedByActorId ?? ""),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
));
// The issue lock serializes cleanup callbacks and periodic workers.
@@ -25833,11 +26049,27 @@ export function heartbeatService(
const [comment] = await tx.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, agent.companyId), eq(issueComments.issueId, issueId),
sql`${issueComments.id}::text = ${wakeCommentId}`, eq(issueComments.authorType, "user"),
eq(issueComments.authorUserId, opts.requestedByActorId ?? ""),
opts.queuedCommentInterruptId === executionWaitRequestId
? undefined : eq(issueComments.authorUserId, opts.requestedByActorId ?? ""),
isNull(issueComments.deletedAt), isNull(issueComments.createdByRunId),
sql`length(trim(${issueComments.body})) > 0`,
));
if (!comment) return { kind: "deferred" as const };
if (!opts.queuedCommentInterruptId && pending.payload?.manualUserWake === true) {
// A persisted manual wake keeps its actor when an execution wait
// resumes. The locked receipt above has revalidated that actor.
payload = { ...payload, manualUserWake: true };
operatorResponsibleUserId = opts.requestedByActorId!;
}
if (opts.queuedCommentInterruptId) {
// The locked board receipt supplies execution authority even when
// another user authored the messages. Dispatch revalidates the receipt.
operatorResponsibleUserId = opts.requestedByActorId!;
// Edits/discards between the click and dispatch remain authoritative.
Object.assign(enrichedContextSnapshot, withQueuedCommentIdsInRunContext(
enrichedContextSnapshot, queuedCommentIdsFromWakePayload(pending.payload),
));
}
}
let automaticParentRunId: string | null = null;
if (
@@ -26217,6 +26449,7 @@ export function heartbeatService(
db: tx as unknown as Db, companyId: issue.companyId, issueId: issue.id,
agentId, actorType: opts.requestedByActorType, actorId: opts.requestedByActorId,
reason, commentId: wakeCommentId ?? null, failedRunId: opts.failedRunId, successorRunId: explicitContinuationRunId,
queuedCommentInterruptId: opts.queuedCommentInterruptId,
dryRun: true,
onBlocked: (reason, message) => { continuationWait = { reason, message }; },
}))) return deferBlockedExecution(executionBlocker);
@@ -26985,6 +27218,7 @@ export function heartbeatService(
continuationRejected = true;
continuationWait = { reason, message };
},
queuedCommentInterruptId: opts.queuedCommentInterruptId,
});
// Recovery can change while earlier admission gates await I/O. Use
// the current blocker, not the snapshot from the start of admission.
@@ -27035,6 +27269,7 @@ export function heartbeatService(
.orderBy(asc(agentWakeupRequests.requestedAt))
: [];
const adoptedComments = pendingComments.filter((wake) => {
if (wake.id === opts.queuedCommentInterruptId) return true;
const deferredPayload = parseObject(wake.payload);
const deferredContext = parseObject(
deferredPayload[DEFERRED_WAKE_CONTEXT_KEY],
@@ -27222,8 +27457,9 @@ export function heartbeatService(
contextSnapshot: enrichedContextSnapshot,
wakeCommentId,
});
// Unscoped manual wakes need their own receipt and execution identity too.
const rawCoalescedTarget =
opts.allowRunCoalescing === false
opts.allowRunCoalescing === false || opts.manualUserWake
? null
: (sameScopeQueuedRun ??
sameScopeScheduledRetryRun ??
@@ -28627,6 +28863,7 @@ export function heartbeatService(
releaseEnvironmentLeasesForRun,
resumeRemoteStopComments,
resumeQueuedCommentInterrupt,
resumeExecutionWaitComments,
sweepStaleIssueLocks,
@@ -2537,7 +2537,7 @@ export function issueThreadInteractionService(
|| existing.sourceRunId !== input.sourceRunId
|| existing.addresseeUserId !== input.addresseeUserId
|| (existing.kind === "connection_intent"
? connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug
? (connectionIntentPayloadSchema.parse(existing.payload).serviceSlug !== payload.serviceSlug || connectionIntentPayloadSchema.parse(existing.payload).purpose !== payload.purpose)
: !isDeepStrictEqual(existing.payload, payload))
) {
throw conflict(
@@ -2574,7 +2574,7 @@ export function issueThreadInteractionService(
eq(issueThreadInteractions.addresseeUserId, input.addresseeUserId),
));
const reusable = pending.find((candidate) =>
connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug);
connectionIntentPayloadSchema.parse(candidate.payload).serviceSlug === payload.serviceSlug && connectionIntentPayloadSchema.parse(candidate.payload).purpose === payload.purpose);
if (reusable) return reusable;
const [sourceRun] = await tx.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
@@ -2628,7 +2628,7 @@ export function issueThreadInteractionService(
);
return (
candidatePayload.success &&
candidatePayload.data.serviceSlug === payload.serviceSlug
candidatePayload.data.serviceSlug === payload.serviceSlug && candidatePayload.data.purpose === payload.purpose
);
})
.map((candidate) => candidate.id);
+11 -42
View File
@@ -1,4 +1,5 @@
import { documentService } from "./documents.js";
import { parseTaskSearch, taskSearchCtes, taskSearchScore } from "./task-search.js";
import { createdFromIssueCondition } from "./issue-creation-origin.js";
import { executionProjectionsForRuns } from "./execution-projection.js";
import type { ExecutionProjection } from "@paperclipai/shared";
@@ -7810,24 +7811,7 @@ export function issueService(db: Db) {
filters?.includeLiveDescendantSummary === true;
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
const startsWithPattern = `${escapedSearch}%`;
const containsPattern = `%${escapedSearch}%`;
const titleStartsWithMatch = sql<boolean>`${issues.title} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const titleContainsMatch = sql<boolean>`${issues.title} ILIKE ${containsPattern} ESCAPE '\\'`;
const identifierStartsWithMatch = sql<boolean>`${issues.identifier} ILIKE ${startsWithPattern} ESCAPE '\\'`;
const identifierContainsMatch = sql<boolean>`${issues.identifier} ILIKE ${containsPattern} ESCAPE '\\'`;
const descriptionContainsMatch = sql<boolean>`${issues.description} ILIKE ${containsPattern} ESCAPE '\\'`;
const commentContainsMatch = sql<boolean>`
EXISTS (
SELECT 1
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND ${issueComments.deletedAt} IS NULL
AND ${issueComments.body} ILIKE ${containsPattern} ESCAPE '\\'
)
`;
const taskSearch = parseTaskSearch(rawSearch);
if (filters?.createdFromIssueId) {
conditions.push(createdFromIssueCondition(companyId, filters.createdFromIssueId));
}
@@ -7935,16 +7919,6 @@ export function issueService(db: Db) {
),
);
}
if (hasSearch) {
conditions.push(
or(
titleContainsMatch,
identifierContainsMatch,
descriptionContainsMatch,
commentContainsMatch,
)!,
);
}
if (filters?.updatedSince) {
const since = new Date(filters.updatedSince);
if (Number.isFinite(since.getTime())) {
@@ -7959,20 +7933,15 @@ export function issueService(db: Db) {
conditions.push(ne(issues.originKind, "routine_execution"));
}
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
const searchOrder = sql<number>`
CASE
WHEN ${titleStartsWithMatch} THEN 0
WHEN ${titleContainsMatch} THEN 1
WHEN ${identifierStartsWithMatch} THEN 2
WHEN ${identifierContainsMatch} THEN 3
WHEN ${commentContainsMatch} THEN 4
WHEN ${descriptionContainsMatch} THEN 5
ELSE 6
END
`;
const baseQuery = db
.select(issueListSelect)
.from(issues)
const searchOrder = sql<number>`-task_search.score`;
const issueSource = db.select(issueListSelect).from(issues);
const searchedSource = hasSearch
? issueSource.innerJoin(sql`(
${taskSearchCtes(companyId, taskSearch, true, and(...conditions))}
SELECT m.id, ${taskSearchScore(taskSearch)} AS score FROM matched m
) task_search`, sql`task_search.id = ${issues.id}`)
: issueSource;
const baseQuery = searchedSource
.where(and(...conditions))
.orderBy(
...issueListOrderBy(companyId, {
@@ -0,0 +1,25 @@
import { openRunnerApiWorkspaceFile } from "./native-runtime/runner-api-files.js";
const MAX_CREDENTIAL_BYTES = 64 * 1024;
/** Bounded descriptor read; never follows symlinks or reopens a checked path. */
export async function readLocalAiCredentialFile(filename: string): Promise<string> {
const file = await openRunnerApiWorkspaceFile(filename);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600 || stat.size > MAX_CREDENTIAL_BYTES) {
throw new Error("Invalid credential file");
}
const bytes = Buffer.alloc(MAX_CREDENTIAL_BYTES + 1);
let size = 0;
while (size < bytes.length) {
const read = await file.read(bytes, size, bytes.length - size, size);
if (!read.bytesRead) break;
size += read.bytesRead;
}
if (size > MAX_CREDENTIAL_BYTES) throw new Error("Invalid credential file");
return bytes.subarray(0, size).toString("utf8");
} finally {
await file.close();
}
}
@@ -0,0 +1,58 @@
import { readLocalAiCredentialFile } from "./local-ai-credential-file.js";
import fs from "node:fs/promises";
import path from "node:path";
import { readClaudeToken, fetchClaudeQuota } from "@paperclipai/adapter-claude-local/server";
import { readCodexAuthInfo, fetchCodexQuota } from "@paperclipai/adapter-codex-local/server";
import { parseGrokAuthPayload, hasUsableGrokAuthValue } from "@paperclipai/adapter-grok-local/server";
import type { AiProvider } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
/** Read an owned login home, or an explicitly authorized local-operator import. */
export async function readVerifiedLocalAiCredential(provider: AiProvider, loginHome?: string): Promise<string> {
if (provider === "openrouter") throw unprocessable("OpenRouter requires an API key.");
if ((provider === "openai" || provider === "xai") && !loginHome)
throw unprocessable("Start a separate local sign-in for this connection before connecting.");
try {
if (provider === "anthropic") {
// Never change process.env or fall back to the server account when an
// authenticated user's isolated login is missing or invalid.
let token: string | null = null;
if (loginHome) {
for (const name of [".credentials.json", "credentials.json"]) {
const raw = await readLocalAiCredentialFile(path.join(loginHome, name)).catch(() => null);
if (!raw) continue;
let parsed;
try { parsed = JSON.parse(raw); } catch { continue; }
const value = parsed?.claudeAiOauth?.accessToken;
if (typeof value === "string" && value.length) { token = value; break; }
}
} else {
token = await readClaudeToken({ allowKeychain: true });
}
if (!token) throw new Error("Missing login");
await fetchClaudeQuota(token);
return token;
}
if (provider === "openai") {
const auth = await readCodexAuthInfo(loginHome);
if (!auth?.accessToken || !auth.refreshToken || !auth.idToken) throw new Error("Missing login");
await fetchCodexQuota(auth.accessToken, auth.accountId);
return JSON.stringify({ tokens: { access_token: auth.accessToken, refresh_token: auth.refreshToken, id_token: auth.idToken, account_id: auth.accountId }, last_refresh: auth.lastRefresh });
}
const raw = await fs.readFile(path.join(loginHome!, "auth.json"), "utf8");
const payload = parseGrokAuthPayload(JSON.parse(raw));
if (!payload || !hasUsableGrokAuthValue(payload.value)) throw new Error("Missing login");
const response = await fetch("https://api.x.ai/v1/models", {
headers: { Authorization: `Bearer ${payload.value.key}` },
redirect: "error", signal: AbortSignal.timeout(15000),
});
await response.body?.cancel();
if (!response.ok) throw new Error("Invalid login");
return raw;
} catch {
// Provider/CLI errors may contain credential material; never return them.
throw unprocessable(provider === "anthropic" && !loginHome
? "Could not verify the local subscription. Run claude auth login in a terminal on the machine running Paperclip, then try Connect again."
: "Could not verify the local subscription. Run the sign-in command shown for this connection, finish signing in, then try Connect again.");
}
}
@@ -0,0 +1,12 @@
import type { DeploymentMode, DeploymentExposure } from "@paperclipai/shared";
/** Same server-host boundary as local stdio runtimes. */
export function supportsLocalAiLogin(options: {
deploymentMode?: DeploymentMode;
deploymentExposure?: DeploymentExposure;
trustedLocalStdioRuntimeHost?: string | null;
}) {
return options.deploymentMode !== "authenticated" || options.deploymentExposure !== "public" || Boolean(
options.trustedLocalStdioRuntimeHost ?? process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST ?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST,
);
}
+205
View File
@@ -0,0 +1,205 @@
import { randomUUID } from "node:crypto";
import { mkdir, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { and, eq, inArray, lte, sql } from "drizzle-orm";
import { adapterAuthSessions, ADAPTER_AUTH_SESSION_ACTIVE_STATES, environments, type Db } from "@paperclipai/db";
import type { AiConnectionLoginIntent, LocalAiLoginAttempt, LocalAiLoginStatus } from "@paperclipai/shared";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { notFound, unprocessable } from "../errors.js";
import { aiConnectionService } from "./ai-connections.js";
import { readVerifiedLocalAiCredential } from "./local-ai-credentials.js";
import { logActivity } from "./activity-log.js";
const LOCAL_LOGIN_METHOD = "local_subscription";
const ATTEMPT_DURATION_MS = 30 * 60 * 1000;
function loginHome(id: string) {
return path.join(resolvePaperclipInstanceRoot(), "ai-local-logins", id);
}
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
function presentAttempt(id: string, expiresAt: Date, provider: string): LocalAiLoginAttempt {
const directory = loginHome(id);
return {
sessionId: id, expiresAt: expiresAt.toISOString(),
command: provider === "openai"
? `(export CODEX_HOME=${shellQuote(directory)} && mkdir -p "$CODEX_HOME" && codex -c 'cli_auth_credentials_store="file"' login --device-auth)`
: provider === "anthropic"
? `(export CLAUDE_CONFIG_DIR=${shellQuote(directory)} && mkdir -p "$CLAUDE_CONFIG_DIR" && claude auth login)`
: `(export GROK_HOME=${shellQuote(directory)} && mkdir -p "$GROK_HOME" && grok login --device-auth)`,
};
}
async function prepareHome(id: string, provider: string) {
const directory = loginHome(id);
await mkdir(directory, { recursive: true, mode: 0o700 });
if (provider === "openai") {
// Idempotent: preserve credentials when a user returns to an active attempt.
await writeFile(path.join(directory, "config.toml"), 'cli_auth_credentials_store = "file"\n', { mode: 0o600 });
}
}
function sameTarget(a: AiConnectionLoginIntent, b: AiConnectionLoginIntent) {
return a.provider === b.provider && a.method === b.method && a.ownership === b.ownership &&
a.connectionId === b.connectionId && a.allAgents === b.allAgents &&
JSON.stringify([...a.agentIds].sort()) === JSON.stringify([...b.agentIds].sort());
}
/** Local terminal sign-ins share the durable attempt/credential lifecycle, but
* never seed their home from the operator's rotating CLI credential. */
export function localAiLoginService(db: Db) {
async function reapExpired() {
// Bounded batches use the existing expires-at index. Replaying is safe.
const rows = await db.select({ id: adapterAuthSessions.id }).from(adapterAuthSessions)
.where(and(eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
lte(adapterAuthSessions.expiresAt, new Date())))
.orderBy(adapterAuthSessions.expiresAt).limit(100);
for (const row of rows) {
await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions)
.where(eq(adapterAuthSessions.id, row.id)).for("update");
if (!session || !session.expiresAt || session.expiresAt.getTime() > Date.now()) return;
await rm(loginHome(row.id), { recursive: true, force: true });
await tx.update(adapterAuthSessions).set({
status: session.connectionId ? "authenticated" : "timed_out",
expiresAt: null, finishedAt: session.finishedAt ?? new Date(), updatedAt: new Date(),
}).where(eq(adapterAuthSessions.id, row.id));
});
}
}
async function start(companyId: string, userId: string, intent: AiConnectionLoginIntent, restart = false): Promise<LocalAiLoginAttempt> {
if (intent.provider !== "openai" && intent.provider !== "xai" && intent.provider !== "anthropic")
throw unprocessable("This provider does not use a separate local login home.");
await reapExpired();
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`ai-local-login:${companyId}:${userId}:${intent.provider}`}, 0))`);
const adapterType = intent.provider === "openai" ? "codex_local" : intent.provider === "anthropic" ? "claude_local" : "grok_local";
const [existing] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.companyId, companyId), eq(adapterAuthSessions.startedByUserId, userId),
eq(adapterAuthSessions.adapterType, adapterType),
inArray(adapterAuthSessions.status, [...ADAPTER_AUTH_SESSION_ACTIVE_STATES]),
)).for("update");
if (existing) {
if (!restart && existing.connectionMethod === LOCAL_LOGIN_METHOD && existing.status === "waiting_for_user" &&
existing.aiConnection && sameTarget(existing.aiConnection, intent) &&
existing.expiresAt && existing.expiresAt.getTime() > Date.now()) {
await prepareHome(existing.id, intent.provider);
return presentAttempt(existing.id, existing.expiresAt, intent.provider);
}
if (!restart || existing.connectionMethod !== LOCAL_LOGIN_METHOD)
throw unprocessable("Another sign-in is still open. Finish it, or choose Start sign-in again to replace a local attempt.");
await rm(loginHome(existing.id), { recursive: true, force: true });
await tx.update(adapterAuthSessions).set({ status: "cancelled", finishedAt: new Date(), updatedAt: new Date() })
.where(eq(adapterAuthSessions.id, existing.id));
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled",
entityType: "adapter_auth_session", entityId: existing.id, details: { provider: intent.provider },
});
}
const [environment] = await tx.select().from(environments)
.where(and(eq(environments.driver, "local"), eq(environments.status, "active"))).limit(1);
if (!environment) throw unprocessable("No active local environment is available.");
const id = randomUUID();
const directory = loginHome(id);
const expiresAt = new Date(Date.now() + ATTEMPT_DURATION_MS);
await prepareHome(id, intent.provider);
try {
await tx.insert(adapterAuthSessions).values({
id, publicSessionId: id, companyId, environmentId: environment.id,
adapterType,
startedByUserId: userId, aiConnection: intent,
connectionMethod: LOCAL_LOGIN_METHOD, status: "waiting_for_user", expiresAt,
});
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_started",
entityType: "adapter_auth_session", entityId: id, details: { provider: intent.provider },
});
} catch (error) {
await rm(directory, { recursive: true, force: true });
if ((error as { cause?: { code?: string }; code?: string }).cause?.code === "23505" ||
(error as { code?: string }).code === "23505")
throw unprocessable("Another sign-in is still open. Finish or cancel it before starting again.");
throw error;
}
return presentAttempt(id, expiresAt, intent.provider);
});
}
// Read-only credential detection: never saves a connection or refreshes another
// login. Scope and intent are checked before touching an attempt's directory.
async function check(companyId: string, userId: string, intent: AiConnectionLoginIntent, id?: string): Promise<LocalAiLoginStatus> {
let directory: string | undefined;
if (id || intent.provider !== "anthropic") {
if (!id) throw unprocessable("Start local sign-in before checking this account.");
const [session] = await db.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
));
if (!session?.aiConnection || !sameTarget(session.aiConnection, intent))
throw notFound("Local sign-in attempt not found for this connection.");
if (session.status !== "waiting_for_user" || !session.expiresAt || session.expiresAt.getTime() <= Date.now())
return { status: "expired" };
directory = loginHome(id);
}
try {
await readVerifiedLocalAiCredential(intent.provider, directory);
return { status: "ready" };
} catch {
return { status: "sign_in_required" };
}
}
async function complete(companyId: string, userId: string, id: string, intent: AiConnectionLoginIntent) {
const result = await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
// Successful completion replaces connectionMethod with subscription.
sql`${adapterAuthSessions.providerLeaseId} is null`,
)).for("update");
if (!session || !session.aiConnection || !sameTarget(session.aiConnection, intent))
throw notFound("Local sign-in attempt not found for this connection.");
if (session.connectionId && session.connectionGrantId)
return { connectionId: session.connectionId, grantId: session.connectionGrantId };
if (session.connectionMethod !== LOCAL_LOGIN_METHOD || session.status !== "waiting_for_user" ||
!session.expiresAt || session.expiresAt.getTime() <= Date.now())
throw unprocessable("This sign-in attempt has expired or was cancelled. Start sign-in again.");
const credential = await readVerifiedLocalAiCredential(intent.provider, loginHome(id));
await tx.update(adapterAuthSessions).set({ status: "promoting", updatedAt: new Date() })
.where(eq(adapterAuthSessions.id, id));
// Nested transaction is a savepoint on this same connection. Holding the
// attempt lock makes completion/cancellation/restart retries idempotent.
const saved = await aiConnectionService(tx as unknown as Db)
.save(companyId, userId, intent, credential, id, session.createdAt);
await tx.update(adapterAuthSessions).set({
status: "authenticated", connectionMethod: LOCAL_LOGIN_METHOD,
finishedAt: new Date(), updatedAt: new Date(),
}).where(eq(adapterAuthSessions.id, id));
return saved;
});
// Failed cleanup can be retried by the same completed attempt or reaper.
await rm(loginHome(id), { recursive: true, force: true });
return result;
}
async function cancel(companyId: string, userId: string, id: string) {
await db.transaction(async (tx) => {
const [session] = await tx.select().from(adapterAuthSessions).where(and(
eq(adapterAuthSessions.id, id), eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
eq(adapterAuthSessions.connectionMethod, LOCAL_LOGIN_METHOD),
)).for("update");
if (!session) throw notFound("Local sign-in attempt not found.");
await rm(loginHome(id), { recursive: true, force: true });
if (!session.connectionId && session.status !== "cancelled") {
await tx.update(adapterAuthSessions).set({
status: "cancelled", finishedAt: new Date(), updatedAt: new Date(),
}).where(eq(adapterAuthSessions.id, id));
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId, action: "ai_connection.local_login_cancelled",
entityType: "adapter_auth_session", entityId: id,
details: { provider: session.aiConnection?.provider },
});
}
});
}
return { start, check, complete, cancel, reapExpired };
}
@@ -422,6 +422,7 @@ export async function authorizeChatConversationForBoundRun(
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
].find(
(candidate) =>
source === `chat:${candidate}` || source === `chat:${candidate}:recovery`,
@@ -552,6 +553,7 @@ function externalChatWaitCandidate(
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
].find(
(candidate) =>
source === `chat:${candidate}` || source === `chat:${candidate}:recovery`,
@@ -32,6 +32,7 @@ const EXTERNAL_CHAT_PROVIDERS = new Set([
"discord",
"microsoft-teams",
"telegram",
"imessage-photon",
]);
const ATTACHMENT_OMISSION_REASONS = new Set([
"attachment_limit",
@@ -1,4 +1,5 @@
import { and, eq, inArray, notExists, sql } from "drizzle-orm";
import { photonAnswersMatch } from "../photon/interactions.js";
import { and, or, eq, inArray, notExists, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentWakeupRequests,
@@ -338,7 +339,7 @@ async function resolveQuestionResponseChain(
const provider =
parent?.provider ??
(
["slack", "github", "discord", "microsoft-teams", "telegram"] as const
["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"] as const
).find(
(candidate) =>
sourceContext.source === `chat:${candidate}` ||
@@ -448,7 +449,7 @@ async function resolveQuestionResponseChain(
eq(chatDeliveries.companyId, chatActions.companyId),
eq(chatDeliveries.endpointId, chatActions.endpointId),
eq(chatDeliveries.conversationId, chatActions.conversationId),
eq(chatDeliveries.principalId, chatActions.principalId),
or(eq(chatActions.kind, "photon_interaction"), eq(chatDeliveries.principalId, chatActions.principalId)),
),
)
.innerJoin(
@@ -485,7 +486,7 @@ async function resolveQuestionResponseChain(
.where(
and(
eq(chatActions.companyId, binding.companyId),
inArray(chatActions.kind, ["question_answer", "question_form_submit"]),
inArray(chatActions.kind, ["question_answer", "question_form_submit", "photon_interaction"]),
eq(chatActions.status, "processed"),
sql`${chatActions.payload}->>'interactionId' = ${interaction.id}`,
sql`${chatActions.result}->>'interactionId' = ${interaction.id}`,
@@ -686,6 +687,8 @@ async function resolveQuestionResponseChain(
)
)
return null;
} else if (action.kind === "photon_interaction") {
if (provider !== "imessage-photon" || !photonAnswersMatch(interaction as unknown as AskUserQuestionsInteraction, action.result) || action.payload.version !== 1 || action.payload.sessionGeneration !== conversation.sessionGeneration || typeof action.payload.expiresAt !== "string" || Date.parse(action.payload.expiresAt) <= interaction.resolvedAt.getTime() || !Number.isFinite(Date.parse(action.payload.expiresAt))) return null;
} else if (
!completedQuestionFormMatchesInteraction(
interaction as unknown as AskUserQuestionsInteraction,
@@ -697,12 +700,13 @@ async function resolveQuestionResponseChain(
if (
inbound.state !== "processed" ||
comment.deletedAt !== null ||
comment.authorUserId !== interaction.resolvedByUserId ||
(action?.kind !== "photon_interaction" && comment.authorUserId !== interaction.resolvedByUserId) ||
conversation.issueId !== binding.issueId ||
!["active", "waiting"].includes(conversation.state) ||
endpoint.provider !== provider ||
endpoint.assignedAgentId !== binding.agentId ||
endpoint.status !== "active" ||
(endpoint.status !== "active" &&
!(provider === "imessage-photon" && endpoint.status === "verifying" && record(endpoint.setup).step === "test")) ||
publication.state !== "published" ||
!publication.providerMessageId ||
publication.issueId !== binding.issueId ||
@@ -1,3 +1,4 @@
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { PROCESS_START_REQUESTED } from "../native-local-process-stop.js";
import { remoteLeaseCleanupScope } from "../remote-execution-termination.js";
@@ -6719,6 +6720,8 @@ export async function executePaperclipNativeSession(input: {
preparationSpans?: NativeRunHistoricalSpan[];
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
runnerEnvironment?: NodeJS.ProcessEnv;
/** Private grant materialization; never a user-configured host path. */
managedAiCredentialHome?: string;
runnerExecutionTarget?: AdapterExecutionTarget | null;
/** Resolved per-run authorization; not an independent instance setting. */
runnerIngressAuthorized?: boolean;
@@ -9723,6 +9726,8 @@ export async function createRunnerdBackend(input: {
startedAt: string;
}) => Promise<void>;
runnerEnvironment?: NodeJS.ProcessEnv;
/** Private grant materialization; never a user-configured host path. */
managedAiCredentialHome?: string;
runnerExecutionTarget?: AdapterExecutionTarget | null;
/** Resolved per-run authorization; not an independent instance setting. */
runnerIngressAuthorized?: boolean;
@@ -11820,6 +11825,34 @@ async function createRunnerdBackendWithinSessionClaim(
},
}).transport,
});
const wrapManagedSession = (session: NativeSession): NativeSession => {
if (!input.managedAiCredentialHome || input.execution.provider.kind !== "codex") return session;
const close = session.close.bind(session);
let copied = false;
session.close = async (closeInput) => {
await close(closeInput);
if (copied) return;
copied = true;
const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null;
const localAuth = join(root, "codex-home", "auth.json");
try {
await copyBackCodexAuth({
hostAuthPath: join(input.managedAiCredentialHome!, "auth.json"),
readSandboxAuth: async () => {
if (!remoteAuth || !remoteCommandRunner) return readFileSync(localAuth);
const result = await remoteCommandRunner.execute({ command: "base64", args: [remoteAuth], bypassSession: true, timeoutMs: 10000 });
if (result.exitCode !== 0 || result.timedOut) throw new Error("AI credential copy-back failed");
return Buffer.from(result.stdout, "base64");
},
log: () => {},
});
} finally {
rmSync(localAuth, { force: true });
if (remoteAuth && remoteCommandRunner) await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", remoteAuth], bypassSession: true, timeoutMs: 10000 });
}
};
return session;
};
const priorAuthorityEpoch = sessionToolAuthorityEpochs.get(sessionScopeId);
if (priorAuthorityEpoch && priorAuthorityEpoch !== authorityEpoch) {
priorAuthorityEpoch.revoke();
@@ -11827,14 +11860,11 @@ async function createRunnerdBackendWithinSessionClaim(
sessionToolAuthorityEpochs.set(sessionScopeId, authorityEpoch);
return {
descriptor: () => backend.descriptor(),
openSession: (sessionInput) => backend.openSession(sessionInput),
recoverSession: (snapshot, options) =>
backend.recoverSession
? backend.recoverSession(snapshot, options)
: Promise.resolve({
recovered: false,
reason: "driver does not support recovery",
}),
openSession: async (sessionInput) => wrapManagedSession(await backend.openSession(sessionInput)),
recoverSession: async (snapshot, options) => {
const result = backend.recoverSession ? await backend.recoverSession(snapshot, options) : { recovered: false, reason: "driver does not support recovery" };
return result.session ? { ...result, session: wrapManagedSession(result.session) } : result;
},
openReplacementSession: async (sessionInput) => {
await measureNativeRunnerSpan(
input.trace,
@@ -11842,7 +11872,7 @@ async function createRunnerdBackendWithinSessionClaim(
archiveContinuityState,
{ parentName: "native.session.execute" },
);
return backend.openSession(sessionInput);
return wrapManagedSession(await backend.openSession(sessionInput));
},
} satisfies NativeSessionBackend;
}
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from "vitest";
afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
it("lists and caches public OpenRouter models without sending credentials", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [
{ id: "anthropic/claude-sonnet-4.5", name: "Claude Sonnet" }, { id: 42 },
] }) });
vi.stubGlobal("fetch", fetch);
const { listOpenRouterModels } = await import("./openrouter-models.js");
expect(await listOpenRouterModels()).toEqual([{ id: "openrouter/anthropic/claude-sonnet-4.5", label: "Claude Sonnet" }]);
await listOpenRouterModels();
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("https://openrouter.ai/api/v1/models", { signal: expect.any(AbortSignal) });
await listOpenRouterModels(true);
expect(fetch).toHaveBeenCalledTimes(2);
});
it("allows retry after a public catalog failure", async () => {
const fetch = vi.fn().mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true, json: async () => ({ data: [] }) });
vi.stubGlobal("fetch", fetch);
const { listOpenRouterModels } = await import("./openrouter-models.js");
await expect(listOpenRouterModels()).rejects.toThrow("Retry or enter a model ID manually");
await expect(listOpenRouterModels()).resolves.toEqual([]);
});
+22
View File
@@ -0,0 +1,22 @@
import type { AdapterModel } from "@paperclipai/adapter-utils";
let cached: { until: number; models: AdapterModel[] } | undefined;
let pending: Promise<AdapterModel[]> | undefined;
/** OpenRouter's public catalog does not require access to anyone's credentials. */
export async function listOpenRouterModels(refresh = false): Promise<AdapterModel[]> {
if (!refresh && cached && cached.until > Date.now()) return cached.models;
if (pending) return pending;
pending = (async () => {
const response = await fetch("https://openrouter.ai/api/v1/models", { signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error("Could not load OpenRouter models. Retry or enter a model ID manually.");
const body = await response.json() as { data?: Array<{ id?: unknown; name?: unknown }> };
if (!Array.isArray(body.data)) throw new Error("OpenRouter returned an invalid model catalog.");
const models = body.data.flatMap(model => typeof model.id === "string" && model.id.includes("/")
? [{ id: `openrouter/${model.id}`, label: typeof model.name === "string" ? model.name : model.id }]
: []).sort((a, b) => a.label.localeCompare(b.label));
cached = { until: Date.now() + 60_000, models };
return models;
})();
try { return await pending; } finally { pending = undefined; }
}
+576
View File
@@ -0,0 +1,576 @@
import { PhotonRecoveryTransport } from "./recovery-transport.js";
import { createHash } from "node:crypto";
import {
createGrpcClient,
type GrpcAdvancedIMessage,
type Message as PhotonMessage,
type Chat as PhotonChat,
} from "@photon-ai/advanced-imessage";
import {
Message,
parseMarkdown,
stringifyMarkdown,
type Adapter,
type AdapterPostableMessage,
type ChatInstance,
type FileUpload,
type FormattedContent,
type FetchOptions,
type ThreadInfo,
type Attachment,
} from "chat";
import {
PhotonLineAuthentication,
PhotonError,
photonFailure,
} from "./cloud.js";
import { PhotonState } from "./state.js";
import {
downloadPhotonAttachment,
type PhotonAttachmentLocator,
} from "./attachments.js";
import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js";
export interface PhotonThread {
lineId: string;
chatGuid: string;
isGroup: boolean;
}
interface SendRecord {
schema: 1;
digest: string;
phase: "prepared" | "uploading" | "uploaded" | "sending" | "sent";
attachmentGuid?: string;
messageGuid?: string;
}
export function photonThreadId(value: PhotonThread): string {
return `imessage-photon:${value.lineId}:${value.isGroup ? "g" : "d"}:${Buffer.from(value.chatGuid).toString("base64url")}`;
}
export function parsePhotonThreadId(value: string): PhotonThread {
const match =
/^imessage-photon:([a-zA-Z0-9-]{1,63}):(d|g):([a-zA-Z0-9_-]{1,1024})$/.exec(
value,
);
if (!match) throw new Error("Invalid Photon conversation identity");
const chatGuid = Buffer.from(match[3], "base64url").toString("utf8");
if (!chatGuid || Buffer.from(chatGuid).toString("base64url") !== match[3])
throw new Error("Invalid Photon chat identity");
return { lineId: match[1], chatGuid, isGroup: match[2] === "g" };
}
/** Closed quote context survives durable admission without retaining SDK objects. */
export function photonReplyReference(
raw: unknown,
): { guid: string; part?: string } | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Partial<PhotonMessage>;
const guid = value.replyTargetGuid ?? value.threadOriginatorGuid;
if (typeof guid !== "string" || !guid || guid.length > 512) return null;
return {
guid,
...(typeof value.threadOriginatorPart === "string" &&
value.threadOriginatorPart.length <= 64
? { part: value.threadOriginatorPart }
: {}),
};
}
export function splitPhotonText(text: string, maximum = 4000): string[] {
const points = Array.from(text);
const result: string[] = [];
while (points.length > maximum) {
const candidate = points.slice(0, maximum).join("");
const paragraph = candidate.lastIndexOf("\n\n");
const count =
paragraph >= maximum / 2
? Array.from(candidate.slice(0, paragraph + 2)).length
: maximum;
result.push(points.splice(0, count).join(""));
}
if (points.length) result.push(points.join(""));
return result;
}
function textOf(message: AdapterPostableMessage): string {
if (typeof message === "string") return message;
if ("markdown" in message) return message.markdown;
if ("raw" in message) return message.raw;
if ("ast" in message) return stringifyMarkdown(message.ast);
if ("fallbackText" in message) return message.fallbackText ?? "";
throw new Error("Photon requires a reviewed text or poll publication");
}
export class PhotonChatAdapter implements Adapter<PhotonThread, PhotonMessage> {
readonly name = "imessage-photon";
readonly lockScope = "channel" as const;
readonly botUserId: string;
readonly client: GrpcAdvancedIMessage;
private closed = false;
private recovery?: PhotonRecoveryTransport;
private readonly typing = new Map<string, ReturnType<typeof setTimeout>>();
typingGuard?: (activeThreadId?: string) => Promise<void>;
recoveryStream(sequence?: number) {
this.recovery ??= new PhotonRecoveryTransport(this.authentication);
return this.recovery.catchUp(sequence);
}
constructor(
readonly userName: string,
readonly authentication: PhotonLineAuthentication,
readonly state: PhotonState,
client?: GrpcAdvancedIMessage,
) {
this.botUserId = authentication.identity.phoneNumber;
this.client =
client ??
createGrpcClient({
address: authentication.address,
token: () => authentication.token(),
tls: true,
retry: false,
autoIdempotency: false,
timeout: 25_000,
channelOptions: {
"grpc.max_receive_message_length": MAX_ATTACHMENT_BYTES + 1024 * 1024,
},
});
}
async initialize(_chat: ChatInstance): Promise<void> {
await this.authentication.token();
}
async disconnect(): Promise<void> {
this.closed = true;
for (const timer of this.typing.values()) clearTimeout(timer);
this.typing.clear();
this.authentication.retire();
this.recovery?.close();
await this.client.close();
}
encodeThreadId(value: PhotonThread): string {
if (value.isGroup && this.authentication.identity.allocation === "shared")
throw new PhotonError("rejected", "Photon shared channels support direct messages only");
if (value.lineId !== this.authentication.identity.lineId)
throw new Error("Wrong Photon line");
return photonThreadId(value);
}
decodeThreadId(id: string): PhotonThread {
const value = parsePhotonThreadId(id);
if (value.isGroup && this.authentication.identity.allocation === "shared")
throw new PhotonError("rejected", "Photon shared channels support direct messages only");
if (value.lineId !== this.authentication.identity.lineId)
throw new Error("Wrong Photon line");
return value;
}
channelIdFromThreadId(id: string): string {
this.decodeThreadId(id);
return id;
}
isDM(id: string): boolean {
return !this.decodeThreadId(id).isGroup;
}
async chatInfo(id: string): Promise<PhotonChat> {
if (this.closed) throw new Error("Photon runtime retired");
const identity = this.decodeThreadId(id);
const chat = await this.client.chats
.get(identity.chatGuid)
.catch((error) => {
throw photonFailure(error);
});
if (
chat.guid !== identity.chatGuid ||
chat.isGroup !== identity.isGroup ||
chat.service !== "iMessage"
)
throw new Error("Photon conversation identity changed");
return chat;
}
async fetchThread(id: string): Promise<ThreadInfo> {
const chat = await this.chatInfo(id);
return {
id,
channelId: id,
channelName:
chat.displayName || chat.participants.map((p) => p.address).join(", "),
isDM: !chat.isGroup,
metadata: {
participants: chat.participants.map((p) => ({
address: p.address,
service: p.service,
})),
isArchived: chat.isArchived,
},
};
}
async fetchChannelInfo(id: string) {
const info = await this.fetchThread(id);
return {
id,
name: info.channelName,
isDM: info.isDM,
metadata: info.metadata,
};
}
parseMessage(raw: PhotonMessage): Message<PhotonMessage> {
if (raw.chatGuids.length !== 1)
throw new Error(
"Photon message must have one authenticated conversation",
);
// The receiver supplies the authoritative chat shape; fetch paths set it too.
const isGroup = (raw as PhotonMessage & { paperclipIsGroup?: boolean })
.paperclipIsGroup;
if (typeof isGroup !== "boolean")
throw new Error("Photon message is missing its authenticated chat shape");
const chatGuid = raw.chatGuids[0];
const threadId = this.encodeThreadId({
lineId: this.authentication.identity.lineId,
chatGuid,
isGroup,
});
const address = raw.sender?.address ?? (raw.isFromMe ? this.botUserId : "");
const service = raw.sender?.service;
const authorId = raw.isFromMe ? this.botUserId : `${service}:${address}`;
const attachments: Attachment[] = raw.content.attachments
.filter((a) => !a.isHidden && !a.isSticker)
.map((a) => {
const locator: PhotonAttachmentLocator = {
kind: "photon_attachment",
lineId: this.authentication.identity.lineId,
chatGuid,
messageGuid: raw.guid,
attachmentGuid: a.guid,
};
return {
type: a.mimeType.startsWith("image/")
? "image"
: a.mimeType.startsWith("audio/")
? "audio"
: a.mimeType.startsWith("video/")
? "video"
: "file",
name: a.fileName,
mimeType: a.mimeType,
size: a.totalBytes,
fetchMetadata: {
kind: locator.kind,
lineId: locator.lineId,
chatGuid: locator.chatGuid,
messageGuid: locator.messageGuid,
attachmentGuid: locator.attachmentGuid,
},
fetchData: () =>
downloadPhotonAttachment(
this.client,
this.authentication.identity.lineId,
locator,
this.authentication.identity.allocation,
),
};
});
const text = raw.content.text ?? "";
return new Message({
id: raw.guid,
threadId,
text,
formatted: parseMarkdown(text),
raw,
attachments,
author: {
userId: authorId,
userName: address,
fullName: address,
isBot: false,
isMe: raw.isFromMe,
isSystem:
raw.isSystemMessage ||
raw.isServiceMessage ||
!address ||
(service !== "iMessage" && !raw.isFromMe),
},
metadata: {
dateSent: new Date(raw.dateCreated),
edited: !!raw.dateEdited,
editedAt: raw.dateEdited ? new Date(raw.dateEdited) : undefined,
},
});
}
normalize(raw: PhotonMessage, chat: PhotonChat): Message<PhotonMessage> {
if (!raw.chatGuids.includes(chat.guid))
throw new Error("Photon message belongs to another chat");
return this.parseMessage({
...raw,
chatGuids: [chat.guid],
paperclipIsGroup: chat.isGroup,
} as PhotonMessage);
}
async fetchMessage(id: string, messageId: string) {
const chat = await this.chatInfo(id);
return this.normalize(
await this.client.messages.get(messageId).catch((error) => {
throw photonFailure(error);
}),
chat,
);
}
async fetchMessages(id: string, options?: FetchOptions) {
const chat = await this.chatInfo(id);
const page = await this.client.messages
.listInChat(chat.guid, {
pageSize: Math.min(options?.limit ?? 50, 100),
pageToken: options?.cursor,
})
.catch((error) => {
throw photonFailure(error);
});
return {
messages: page.messages
.map((message) => this.normalize(message, chat))
.sort(
(a, b) =>
a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime(),
),
nextCursor: page.nextPageToken,
};
}
async getUser(userId: string) {
return {
userId,
fullName: userId.replace(/^iMessage:/, ""),
userName: userId.replace(/^iMessage:/, ""),
isBot: false,
};
}
async handleWebhook(): Promise<Response> {
return new Response("Photon uses authenticated streams", { status: 405 });
}
renderFormatted(content: FormattedContent): string {
return stringifyMarkdown(content);
}
async startTyping(id: string): Promise<void> {
await this.refreshTyping(id, false);
}
private async refreshTyping(id: string, refresh: boolean): Promise<void> {
if (this.closed) return;
if (this.typing.has(id)) clearTimeout(this.typing.get(id));
await this.typingGuard?.(refresh ? id : undefined);
await this.client.chats
.setTyping(this.decodeThreadId(id).chatGuid, true)
.catch((error) => {
throw photonFailure(error);
});
if (this.closed) return;
// Refresh while work is running; final/prompt publication or runtime
// retirement clears this timer. Every refresh checks endpoint ownership.
const timer = setTimeout(() => {
this.typing.delete(id);
void this.refreshTyping(id, true).catch(() => {});
}, 8_000);
timer.unref();
this.typing.set(id, timer);
}
async endTyping(id: string): Promise<void> {
if (this.typing.has(id)) clearTimeout(this.typing.get(id));
this.typing.delete(id);
if (!this.closed)
await this.client.chats
.setTyping(this.decodeThreadId(id).chatGuid, false)
.catch(() => {});
}
async postMessage(
_id: string,
_message: AdapterPostableMessage,
): Promise<never> {
throw new Error(
"Photon sends require an immutable Paperclip publication identity",
);
}
async editMessage(
_id: string,
_messageId: string,
_message: AdapterPostableMessage,
): Promise<never> {
throw new Error(
"Photon edits require an immutable Paperclip publication identity",
);
}
async deleteMessage(): Promise<never> {
throw new Error("Photon message deletion is not supported");
}
async addReaction(): Promise<void> {
/* Receipt reactions are intentionally not published. */
}
async removeReaction(): Promise<void> {
/* Reactions never represent approvals. */
}
async publish(
id: string,
publicationId: string,
message: AdapterPostableMessage,
options: {
replyTo?: string;
replaceMessageId?: string;
retryUnknown?: boolean;
assertCurrent(): Promise<void>;
},
): Promise<{ id: string; messageIds: string[] }> {
const chat = await this.chatInfo(id);
if (chat.isArchived) throw new Error("Photon conversation is unavailable");
const text = textOf(message);
const files =
typeof message !== "string" && "files" in message
? (message.files ?? [])
: [];
const parts = splitPhotonText(text);
if (options.replaceMessageId && (parts.length !== 1 || files.length))
throw new Error("Photon cannot edit a multipart publication");
let lastId: string | undefined;
const messageIds: string[] = [];
for (let index = 0; index < parts.length; index++) {
lastId = await this.sendPart(
publicationId,
`text-${index}`,
{
chatGuid: chat.guid,
text: parts[index],
replyTo: options.replyTo,
replaceMessageId: options.replaceMessageId,
},
undefined,
options,
);
messageIds.push(lastId);
}
for (let index = 0; index < files.length; index++) {
const file = files[index];
const bytes =
file.data instanceof Blob
? Buffer.from(await file.data.arrayBuffer())
: Buffer.from(file.data as ArrayBuffer);
if (bytes.length > MAX_ATTACHMENT_BYTES)
throw new Error("Attachment exceeds the configured size limit");
lastId = await this.sendPart(
publicationId,
`file-${index}`,
{
chatGuid: chat.guid,
filename: file.filename,
sha256: createHash("sha256").update(bytes).digest("hex"),
replyTo: options.replyTo,
},
{ ...file, data: bytes },
options,
);
messageIds.push(lastId);
}
if (!lastId) throw new Error("Photon publication is empty");
await this.endTyping(id);
return { id: lastId, messageIds };
}
private async sendPart(
publicationId: string,
part: string,
payload: {
chatGuid: string;
text?: string;
filename?: string;
sha256?: string;
replyTo?: string;
replaceMessageId?: string;
},
file: FileUpload | undefined,
options: { retryUnknown?: boolean; assertCurrent(): Promise<void> },
): Promise<string> {
const key = `send:${publicationId}:${part}`;
const digest = createHash("sha256")
.update(JSON.stringify(payload))
.digest("hex");
let record = await this.state.update<SendRecord>(key, (current) => {
if (current && current.digest !== digest)
throw new Error("Photon publication payload changed after preparation");
return current ?? { schema: 1, digest, phase: "prepared" };
});
if (record.phase === "sent" && record.messageGuid)
return record.messageGuid;
if (
(record.phase === "sending" || record.phase === "uploading") &&
!options.retryUnknown
)
throw new PhotonError(
"delivery_unknown",
"Photon delivery is unknown; resolve this publication before retrying",
);
const save = async (patch: Partial<SendRecord>) => {
record = await this.state.update<SendRecord>(key, (current) => {
if (!current || current.digest !== digest)
throw new Error("Photon send identity changed");
return { ...current, ...patch };
});
};
if (file && !record.attachmentGuid) {
await options.assertCurrent();
await save({ phase: "uploading" });
try {
const uploaded = await this.client.attachments.upload({
fileName: file.filename,
data: file.data as Buffer,
});
if (!uploaded.attachment.guid)
throw new PhotonError(
"delivery_unknown",
"Photon upload receipt is missing",
);
await save({
phase: "uploaded",
attachmentGuid: uploaded.attachment.guid,
});
} catch (error) {
const failure = photonFailure(error, true);
if (failure.code !== "delivery_unknown")
await save({ phase: "prepared" });
throw failure;
}
}
await options.assertCurrent();
if (payload.replaceMessageId) {
const original = await this.client.messages.get(payload.replaceMessageId);
if (
!original.isFromMe ||
!original.chatGuids.includes(payload.chatGuid) ||
Date.now() - new Date(original.dateCreated).getTime() >= 15 * 60_000
)
throw new PhotonError(
"rejected",
"The iMessage edit window expired; stage a correction as a new publication",
);
}
await save({ phase: "sending" });
const clientMessageId = createHash("sha256")
.update(`${this.state.scope.endpointId}:${publicationId}:${part}`)
.digest("hex");
try {
const result = record.attachmentGuid
? await this.client.messages.sendAttachment(
payload.chatGuid,
record.attachmentGuid,
{ clientMessageId, replyTo: payload.replyTo },
)
: payload.replaceMessageId
? await this.client.messages.edit(
payload.chatGuid,
payload.replaceMessageId,
payload.text!,
{ clientMessageId },
)
: await this.client.messages.sendText(
payload.chatGuid,
payload.text!,
{ clientMessageId, replyTo: payload.replyTo },
);
if (!result.guid || !result.chatGuids.includes(payload.chatGuid))
throw new Error("Photon returned an invalid send receipt");
await save({ phase: "sent", messageGuid: result.guid });
return result.guid;
} catch (error) {
const failure = photonFailure(error, true);
if (failure.code !== "delivery_unknown")
await save({ phase: record.attachmentGuid ? "uploaded" : "prepared" });
throw failure;
}
}
}
+208
View File
@@ -0,0 +1,208 @@
import type { Attachment, Message as ChatMessage } from "chat";
import type {
GrpcAdvancedIMessage,
CompanionInfo,
Message as PhotonMessage,
} from "@photon-ai/advanced-imessage";
import { z } from "zod";
import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js";
import { PhotonError, photonFailure } from "./cloud.js";
const guid = z.string().min(1).max(512);
export const photonAttachmentLocatorSchema = z
.object({
kind: z.literal("photon_attachment"),
lineId: guid,
chatGuid: guid,
messageGuid: guid,
attachmentGuid: guid,
partIndex: z.number().int().nonnegative().max(100).optional(),
})
.strict();
export type PhotonAttachmentLocator = z.infer<
typeof photonAttachmentLocatorSchema
>;
type PhotonCompanion =
| { unavailable: true }
| { unavailable: false; data: Buffer; fileName: string; mimeType: string };
const companions = new WeakMap<Buffer, PhotonCompanion>();
/** Bytes are ephemeral. Recovery downloads the source-bound primary and companion again. */
export function takePhotonCompanion(body: Buffer): PhotonCompanion | undefined {
const companion = companions.get(body);
companions.delete(body);
return companion;
}
export function photonAttachmentLocator(
attachment: Attachment,
lineId: string,
chatGuid: string,
message: ChatMessage,
): PhotonAttachmentLocator | null {
const parsed = photonAttachmentLocatorSchema.safeParse(
attachment.fetchMetadata
? {
kind: attachment.fetchMetadata.kind,
lineId: attachment.fetchMetadata.lineId,
chatGuid: attachment.fetchMetadata.chatGuid,
messageGuid: attachment.fetchMetadata.messageGuid,
attachmentGuid: attachment.fetchMetadata.attachmentGuid,
}
: null,
);
if (
!parsed.success ||
parsed.data.lineId !== lineId ||
parsed.data.chatGuid !== chatGuid ||
parsed.data.messageGuid !== message.id
)
return null;
const raw = message.raw as PhotonMessage;
if (
!raw.chatGuids?.includes(chatGuid) ||
!raw.content?.attachments.some(
(candidate) => candidate.guid === parsed.data.attachmentGuid,
)
)
return null;
return parsed.data;
}
export async function downloadPhotonAttachment(
client: GrpcAdvancedIMessage,
lineId: string,
locator: PhotonAttachmentLocator,
allocation: "dedicated" | "shared" = "dedicated",
): Promise<Buffer> {
photonAttachmentLocatorSchema.parse(locator);
if (locator.lineId !== lineId)
throw new Error("Photon attachment belongs to another line");
const source = await client.messages
.get(locator.messageGuid)
.catch((error) => {
throw photonFailure(error);
});
const attachment = source.content.attachments.find(
(candidate) => candidate.guid === locator.attachmentGuid,
);
if (
source.guid !== locator.messageGuid ||
!source.chatGuids.includes(locator.chatGuid) ||
!attachment ||
attachment.isSticker ||
attachment.isHidden
)
throw new Error(
"Photon attachment does not belong to this message and chat",
);
if (
!Number.isSafeInteger(attachment.totalBytes) ||
attachment.totalBytes < 0 ||
attachment.totalBytes > MAX_ATTACHMENT_BYTES
)
throw new Error("Attachment exceeds the configured size limit");
const stream = client.attachments.downloadStream(locator.attachmentGuid);
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
void stream.close();
}, 30_000);
timer.unref();
const chunks: Uint8Array[] = [];
let length = 0;
let header = false;
let companionInfo: CompanionInfo | undefined;
let companionUnavailable = false;
let companionStarted = false;
let companionLength = 0;
const companionChunks: Uint8Array[] = [];
try {
for await (const part of stream) {
if (part.type === "header") {
// The shared gateway rewrites message/metadata attachment IDs to opaque
// project aliases, but streams the native UUID in download headers.
// Ownership comes from the authenticated source-message lookup above
// and this exact alias-addressed RPC, never from matching filenames.
const sharedAlias = allocation === "shared" &&
/^spc-att-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(locator.attachmentGuid);
const matchingSharedHeader = sharedAlias &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part.info.guid) &&
part.info.totalBytes === attachment.totalBytes &&
part.info.mimeType === attachment.mimeType &&
part.info.fileName === attachment.fileName;
if (
header ||
(part.info.guid !== locator.attachmentGuid && !matchingSharedHeader) ||
part.info.isHidden || part.info.isSticker ||
!Number.isSafeInteger(part.info.totalBytes) || part.info.totalBytes < 0 ||
part.info.totalBytes > MAX_ATTACHMENT_BYTES
)
throw new Error("Photon attachment metadata changed");
header = true;
companionInfo = part.companionInfo;
companionUnavailable = Boolean(
companionInfo &&
(companionInfo.kind !== "live-photo-video" ||
!["video/quicktime", "video/mp4"].includes(
companionInfo.mimeType,
) ||
!Number.isSafeInteger(companionInfo.totalBytes) ||
companionInfo.totalBytes <= 0 ||
companionInfo.totalBytes > MAX_ATTACHMENT_BYTES),
);
} else if (part.type === "primaryChunk") {
if (companionStarted)
throw new Error("Photon attachment chunks arrived out of order");
if (!header) throw new Error("Photon attachment header is missing");
length += part.data.length;
if (length > MAX_ATTACHMENT_BYTES)
throw new Error("Attachment exceeds the configured size limit");
chunks.push(part.data);
} else if (part.type === "companionChunk") {
if (!header || !companionInfo)
throw new Error("Photon companion metadata is missing");
companionStarted = true;
companionLength += part.data.length;
if (companionUnavailable || companionLength > MAX_ATTACHMENT_BYTES) {
companionUnavailable = true;
break;
}
companionChunks.push(part.data);
}
}
if (timedOut || !header || !length)
throw new PhotonError(
"attachment_not_ready",
"Photon attachment is still being prepared; retry download",
);
if (attachment.totalBytes > 0 && length !== attachment.totalBytes)
throw new PhotonError(
"attachment_not_ready",
"Photon attachment transfer is incomplete",
);
if (
companionInfo &&
!companionUnavailable &&
companionLength !== companionInfo.totalBytes
)
throw new PhotonError(
"attachment_not_ready",
"Photon Live Photo companion is still being prepared",
);
const body = Buffer.concat(chunks);
if (companionUnavailable) companions.set(body, { unavailable: true });
else if (companionInfo)
companions.set(body, {
unavailable: false,
data: Buffer.concat(companionChunks),
fileName: companionInfo.fileName,
mimeType: companionInfo.mimeType,
});
return body;
} catch (error) {
if (error instanceof Error && !("code" in error)) throw error;
throw photonFailure(error);
} finally {
clearTimeout(timer);
await stream.close();
}
}
+350
View File
@@ -0,0 +1,350 @@
import { createHash } from "node:crypto";
import {
photonLineIdSchema,
photonProjectIdSchema,
type PhotonProjectInspection,
} from "@paperclipai/shared";
import { IMessageError } from "@photon-ai/advanced-imessage";
const CLOUD_ORIGIN = "https://spectrum.photon.codes";
const MAX_RESPONSE_BYTES = 256 * 1024;
export class PhotonError extends Error {
constructor(
readonly code:
| "credentials"
| "line_unavailable"
| "quota"
| "network"
| "history_gap"
| "delivery_unknown"
| "attachment_not_ready"
| "invalid_response"
| "rejected",
message: string,
readonly retryAfterMs?: number,
) {
super(message);
this.name = "PhotonError";
}
}
/** Only documented semantic rejections prove a write did not happen. */
export function photonFailure(error: unknown, writing = false): PhotonError {
if (error instanceof PhotonError) return error;
if (error instanceof IMessageError) {
if (
[
"unauthenticated",
"tokenExpired",
"tokenBlocked",
"unauthorized",
].includes(error.code)
)
return new PhotonError(
"credentials",
"Photon rejected the selected line credentials; reconnect the channel",
);
if (
[
"dailyLimitExceeded",
"recipientLimitExceeded",
"uploadRateExceeded",
"recipientCoolingDown",
"recipientLocked",
"sendReceiveRatioExceeded",
"contentDuplicateExceeded",
].includes(error.code)
)
return new PhotonError(
"quota",
"Photon has temporarily limited this line",
error.retryAfter,
);
if (error.code === "attachmentNotReady")
return new PhotonError(
"attachment_not_ready",
"Photon attachment is still being prepared",
);
if (
[
"chatNotFound",
"messageNotFound",
"attachmentNotFound",
"invalidArgument",
"preconditionFailed",
"operationNotSupported",
].includes(error.code)
)
return new PhotonError(
"rejected",
`Photon rejected the operation (${error.code})`,
);
}
return writing
? new PhotonError(
"delivery_unknown",
"Photon delivery is unknown; reconcile its receipt before retrying",
)
: new PhotonError("network", "Photon connection interrupted; retrying");
}
/** Shared credentials own one project, not any number in the provider pool. */
export function photonSharedIdentity(projectId: string): string {
photonProjectIdSchema.parse(projectId);
return `photon-project:${projectId}`;
}
export function photonSharedScope(projectId: string): string {
photonProjectIdSchema.parse(projectId);
return `shared-${createHash("sha256").update(projectId).digest("hex").slice(0, 48)}`;
}
interface CloudAllocation {
sharedToken?: string;
inspection: PhotonProjectInspection;
tokens: ReadonlyMap<string, string>;
expiresIn: number;
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/** Only this module ever sees a Cloud project secret or minted line tokens. */
export class PhotonCloudClient {
constructor(private readonly fetchImpl: typeof fetch = fetch) {}
private async request(
projectId: string,
projectSecret: string,
suffix: string,
method: string,
): Promise<unknown> {
photonProjectIdSchema.parse(projectId);
if (!projectSecret || projectSecret.length > 4096)
throw new PhotonError("credentials", "Enter a Photon project secret");
let response: Response;
try {
response = await this.fetchImpl(
`${CLOUD_ORIGIN}/projects/${encodeURIComponent(projectId)}/${suffix}`,
{
method,
redirect: "error",
signal: AbortSignal.timeout(15_000),
headers: {
authorization: `Basic ${Buffer.from(`${projectId}:${projectSecret}`).toString("base64")}`,
accept: "application/json",
},
},
);
} catch {
throw new PhotonError(
"network",
"Photon Cloud could not be reached; retry the connection",
);
}
if (response.status === 401 || response.status === 403)
throw new PhotonError(
"credentials",
"Photon rejected this project ID or secret",
);
if (response.status === 429)
throw new PhotonError(
"quota",
"Photon Cloud request limit reached; retry later",
);
if (!response.ok)
throw new PhotonError(
"network",
`Photon Cloud returned HTTP ${response.status}`,
);
const reader = response.body?.getReader();
if (!reader)
throw new PhotonError(
"invalid_response",
"Photon returned an empty response",
);
const chunks: Uint8Array[] = [];
let length = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) break;
length += next.value.length;
if (length > MAX_RESPONSE_BYTES)
throw new PhotonError(
"invalid_response",
"Photon project response exceeds the supported size",
);
chunks.push(next.value);
}
const body: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!record(body) || body.succeed !== true || !record(body.data))
throw new PhotonError(
"invalid_response",
"Photon did not return a valid project response",
);
return body.data;
} catch (error) {
if (error instanceof PhotonError) throw error;
throw new PhotonError(
"invalid_response",
"Photon returned an invalid project response",
);
} finally {
await reader.cancel().catch(() => {});
}
}
async allocation(
projectId: string,
projectSecret: string,
): Promise<CloudAllocation> {
const project = await this.request(projectId, projectSecret, "", "GET");
if (
record(project) &&
typeof project.id === "string" &&
project.id !== projectId
)
throw new PhotonError(
"credentials",
"Photon returned a different project identity",
);
const data = await this.request(
projectId,
projectSecret,
"imessage/tokens",
"POST",
);
if (!record(data))
throw new PhotonError("invalid_response", "Photon allocation is missing");
const projectName =
record(project) && typeof project.name === "string"
? project.name.slice(0, 160)
: projectId;
const inspection: PhotonProjectInspection = {
projectId,
projectName,
allocation: data.type === "dedicated" ? "dedicated" : "shared",
eligible: false,
lines: [],
};
if (data.type === "shared") {
if (typeof data.token !== "string" || !data.token || data.token.length > 16_384 ||
typeof data.expiresIn !== "number" || !Number.isFinite(data.expiresIn) || data.expiresIn < 30)
throw new PhotonError("invalid_response", "Photon returned invalid shared project credentials");
inspection.eligible = true;
return { inspection, sharedToken: data.token, tokens: new Map(), expiresIn: Math.min(data.expiresIn, 86_400) };
}
if (
data.type !== "dedicated" ||
!record(data.auth) ||
!record(data.numbers) ||
typeof data.expiresIn !== "number" ||
!Number.isFinite(data.expiresIn) ||
data.expiresIn < 30
) {
throw new PhotonError(
"line_unavailable",
"Photon did not supply a dedicated line with a stable phone number",
);
}
const tokens = new Map<string, string>();
for (const [lineId, token] of Object.entries(data.auth)) {
if (
!photonLineIdSchema.safeParse(lineId).success ||
typeof token !== "string" ||
!token ||
token.length > 16_384
)
continue;
const phoneNumber = data.numbers[lineId];
if (
typeof phoneNumber !== "string" ||
!/^\+[1-9]\d{6,14}$/.test(phoneNumber)
)
continue;
tokens.set(lineId, token);
inspection.lines.push({ lineId, phoneNumber, eligible: true });
}
if (inspection.lines.length > 256)
throw new PhotonError(
"invalid_response",
"Photon returned too many lines",
);
inspection.eligible = inspection.lines.length > 0;
return { inspection, tokens, expiresIn: Math.min(data.expiresIn, 86_400) };
}
async inspect(
projectId: string,
projectSecret: string,
): Promise<PhotonProjectInspection> {
return (await this.allocation(projectId, projectSecret)).inspection;
}
}
/** A fixed identity; renewal can replace its token, never its project/line/number. */
export class PhotonLineAuthentication {
private current: { token: string; renewAt: number } | undefined;
private renewing: Promise<string> | undefined;
private retired = false;
constructor(
readonly identity: {
allocation?: "shared" | "dedicated";
projectId: string;
lineId: string;
phoneNumber: string;
},
private readonly secret: string,
private readonly cloud = new PhotonCloudClient(),
private readonly now = Date.now,
) {
photonProjectIdSchema.parse(identity.projectId);
photonLineIdSchema.parse(identity.lineId);
if (identity.allocation === "shared" && (identity.lineId !== photonSharedScope(identity.projectId) || identity.phoneNumber !== photonSharedIdentity(identity.projectId)))
throw new PhotonError("credentials", "Photon shared identity must match its project");
}
get address(): string {
return this.identity.allocation === "shared" ? "imessage.spectrum.photon.codes:443" : `${this.identity.lineId}.imsg.photon.codes:443`;
}
retire(): void {
this.retired = true;
this.current = undefined;
}
async token(): Promise<string> {
if (this.retired)
throw new PhotonError("credentials", "Photon runtime has been retired");
if (this.current && this.current.renewAt > this.now())
return this.current.token;
this.renewing ??= this.renew().finally(() => {
this.renewing = undefined;
});
return this.renewing;
}
private async renew(): Promise<string> {
this.current = undefined;
const allocation = await this.cloud.allocation(
this.identity.projectId,
this.secret,
);
if (allocation.inspection.allocation !== (this.identity.allocation ?? "dedicated"))
throw new PhotonError("line_unavailable", "Photon project allocation changed; create a new channel for the new identity");
if (this.identity.allocation === "shared") {
if (!allocation.sharedToken || this.retired)
throw new PhotonError("credentials", "Photon shared credentials are unavailable");
this.current = { token: allocation.sharedToken, renewAt: this.now() + allocation.expiresIn * 800 };
return allocation.sharedToken;
}
const line = allocation.inspection.lines.find(
(candidate) => candidate.lineId === this.identity.lineId,
);
const token = allocation.tokens.get(this.identity.lineId);
if (
!line?.eligible ||
line.phoneNumber !== this.identity.phoneNumber ||
!token
)
throw new PhotonError(
"line_unavailable",
"The selected Photon number is no longer available; reconnect the same line or create a new channel",
);
if (this.retired)
throw new PhotonError("credentials", "Photon runtime has been retired");
this.current = { token, renewAt: this.now() + allocation.expiresIn * 800 };
return token;
}
}
+365
View File
@@ -0,0 +1,365 @@
import { nativeSha256 } from "../native-runtime/canonical.js";
import { createHash } from "node:crypto";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsAnswer,
IssueThreadInteraction,
RequestConfirmationInteraction,
} from "@paperclipai/shared";
import { PhotonChatAdapter } from "./adapter.js";
import { PhotonError, photonFailure } from "./cloud.js";
import { projectSafeChatPublicationText } from "../chat-publication-projection.js";
export class PhotonAnswerValidationError extends Error {}
export interface PhotonInteractionBinding {
version: 1;
reference: string;
interactionId: string;
publicationId: string;
sessionGeneration: number;
expiresAt: string;
}
export interface PhotonPromptReceipt {
schema: 1;
reference: string;
questionIndex: number;
publicationId: string;
promptMessageGuid: string;
promptMessageGuids?: string[];
pollMessageGuid?: string;
options: Record<string, string>;
}
export interface PhotonDraft {
schema: 1;
interactionId: string;
userId: string;
principalId: string;
answers: AskUserQuestionsAnswer[];
decision?: "accept" | "reject";
reason?: string;
lastSequence: number;
}
export function nativePhotonInteraction(
interaction: IssueThreadInteraction,
): interaction is AskUserQuestionsInteraction | RequestConfirmationInteraction {
if (interaction.kind === "ask_user_questions")
return (
interaction.payload.questions.length > 0 &&
interaction.payload.questions.length <= 64
);
return (
interaction.kind === "request_confirmation" &&
!interaction.payload.toolAction &&
!interaction.payload.secretProposal &&
!interaction.payload.connectionAuthorization
);
}
export function photonResponseCommand(text: string): {
command: "answer" | "submit";
reference: string;
questionIndex: number;
value: string;
} | null {
const match =
/^\/(answer|submit)\s+([a-zA-Z0-9_-]{8,24})(?:\.(\d{1,2}))?(?:\s+([\s\S]*))?$/i.exec(
text.trim(),
);
if (!match) return null;
return {
command: match[1].toLowerCase() as "answer" | "submit",
reference: match[2],
questionIndex: Number(match[3] ?? 1) - 1,
value: match[4]?.trim() ?? "",
};
}
export function parsePhotonQuestionAnswer(
interaction: AskUserQuestionsInteraction,
questionIndex: number,
value: string,
): AskUserQuestionsAnswer {
const question = interaction.payload.questions[questionIndex];
if (!question)
throw new PhotonAnswerValidationError("That question does not exist");
if (value.toLowerCase() === "skip") {
if (question.required !== false)
throw new PhotonAnswerValidationError("This question requires an answer");
return { questionId: question.id, optionIds: [] };
}
if (!value || value.length > 100_000)
throw new PhotonAnswerValidationError(
"Enter a nonempty answer within the task’s length limit",
);
const numbers =
/^\d+(?:[ ,]+\d+)*$/.test(value) &&
question.options.some((option) => !option.freeText)
? value.split(/[ ,]+/).map(Number)
: null;
if (numbers) {
if (
new Set(numbers).size !== numbers.length ||
numbers.some((index) => index < 1 || index > question.options.length)
)
throw new PhotonAnswerValidationError(
"Choose valid option numbers without duplicates",
);
if (question.selectionMode === "single" && numbers.length !== 1)
throw new PhotonAnswerValidationError("Choose one option");
if (numbers.some((index) => question.options[index - 1].freeText))
throw new PhotonAnswerValidationError("Write your custom answer as text");
return {
questionId: question.id,
optionIds: numbers.map((index) => question.options[index - 1].id),
};
}
const freeText = question.options.find((option) => option.freeText);
if (question.options.length && question.allowOther === false && !freeText)
throw new PhotonAnswerValidationError(
"Answer with the option number(s) shown in the prompt",
);
return {
questionId: question.id,
optionIds: freeText ? [freeText.id] : [],
otherText: value,
};
}
export function photonAnswersMatch(
interaction: AskUserQuestionsInteraction,
result: unknown,
): boolean {
if (!result || typeof result !== "object") return false;
const record = result as {
code?: unknown;
interactionId?: unknown;
answersSha256?: unknown;
};
return (
record.code === "photon_question_answered" &&
record.interactionId === interaction.id &&
record.answersSha256 === nativeSha256(interaction.result?.answers)
);
}
/** Prompt and poll receipts persist independently, before the outbox is settled.
* A vote arriving before this binding is finalized must wait for this record. */
export async function publishPhotonPrompt(input: {
adapter: PhotonChatAdapter;
threadId: string;
binding: PhotonInteractionBinding;
interaction: AskUserQuestionsInteraction | RequestConfirmationInteraction;
questionIndex: number;
taskUrl?: string | null;
retryUnknown?: boolean;
assertCurrent(): Promise<void>;
}): Promise<PhotonPromptReceipt> {
const { adapter, binding, interaction, questionIndex, assertCurrent } = input;
const key = `prompt:${binding.reference}:${questionIndex}`;
const digest = nativeSha256({
binding,
threadId: input.threadId,
kind: interaction.kind,
payload: interaction.payload,
questionIndex,
taskUrl: input.taskUrl ?? null,
});
await adapter.state.update<{ digest: string }>(
`${key}:identity`,
(current) => {
if (current && current.digest !== digest)
throw new PhotonError(
"rejected",
"Photon prompt changed after preparation",
);
return current ?? { digest };
},
);
const existing = await adapter.state.read<PhotonPromptReceipt>(key);
if (existing) {
for (const guid of existing.promptMessageGuids ?? [
existing.promptMessageGuid,
])
await adapter.state.update<PhotonPromptReceipt>(
`prompt-message:${guid}`,
(current) => current ?? existing,
);
if (existing.pollMessageGuid)
await adapter.state.update<PhotonPromptReceipt>(
`poll-message:${existing.pollMessageGuid}`,
(current) => current ?? existing,
);
return existing;
}
const question =
interaction.kind === "ask_user_questions"
? interaction.payload.questions[questionIndex]
: null;
if (interaction.kind === "ask_user_questions" && !question)
throw new PhotonAnswerValidationError("Photon question index is invalid");
const reference = `${binding.reference}.${questionIndex + 1}`;
const title =
projectSafeChatPublicationText(
question?.prompt ??
(interaction as RequestConfirmationInteraction).payload.prompt,
).trim() || "Input needed";
const choices = question
? question.options.map((option) => ({
id: option.id,
label:
projectSafeChatPublicationText(option.label).trim() ||
`Choice ${question.options.indexOf(option) + 1}`,
}))
: [
{
id: "accept",
label: projectSafeChatPublicationText(
(interaction as RequestConfirmationInteraction).payload
.acceptLabel ?? "Accept",
),
},
{
id: "reject",
label: projectSafeChatPublicationText(
(interaction as RequestConfirmationInteraction).payload
.rejectLabel ?? "Reject",
),
},
];
const nativePoll =
(!question ||
(question.selectionMode === "single" &&
!question.allowOther &&
!question.options.some((o) => o.freeText))) &&
choices.length >= 2 &&
choices.length <= 10;
const text = [
title,
interaction.kind === "request_confirmation" &&
interaction.payload.detailsMarkdown
? projectSafeChatPublicationText(interaction.payload.detailsMarkdown)
: "",
question?.helpText ? projectSafeChatPublicationText(question.helpText) : "",
...choices.map((choice, index) => `${index + 1}. ${choice.label}`),
`Reply to this message, or send /answer ${reference} ${question?.selectionMode === "multi" ? "<numbers separated by commas>" : "<answer>"}.`,
question?.required === false ? `Optional: /answer ${reference} skip` : "",
interaction.kind === "request_confirmation" &&
interaction.payload.rejectRequiresReason
? "To reject, send Reject followed by your reason."
: "",
interaction.kind === "ask_user_questions" &&
interaction.payload.questions.length > 1
? `Answers are saved for you. Finish with /submit ${binding.reference}.`
: "",
input.taskUrl ? `Open this Paperclip task: ${input.taskUrl}` : "",
]
.filter(Boolean)
.join("\n\n");
const prompt = await adapter.publish(
input.threadId,
`${binding.publicationId}:question:${questionIndex}`,
{ markdown: text },
{ assertCurrent, retryUnknown: input.retryUnknown },
);
const receipt: PhotonPromptReceipt = {
schema: 1,
reference: binding.reference,
publicationId: binding.publicationId,
questionIndex,
promptMessageGuid: prompt.id,
promptMessageGuids: prompt.messageIds,
options: {},
};
if (nativePoll) {
const stateKey = `poll:${binding.publicationId}:${questionIndex}`;
type PollSend = {
schema: 1;
phase: "prepared" | "creating" | "created";
receipt?: {
pollMessageGuid: string;
options: Array<{ optionIdentifier: string; text: string }>;
};
};
let saved = await adapter.state.read<PollSend>(stateKey);
if (saved?.phase === "creating" && !input.retryUnknown)
throw new PhotonError(
"delivery_unknown",
"Photon poll creation is unknown; reconcile this publication before retrying",
);
if (!saved || saved.phase !== "created") {
await assertCurrent();
await adapter.state.update<PollSend>(stateKey, (current) => {
if (current?.phase === "creating" && !input.retryUnknown)
throw new PhotonAnswerValidationError("Photon poll already claimed");
return { schema: 1, phase: "creating" };
});
const poll = await adapter.client.polls
.create(
adapter.decodeThreadId(input.threadId).chatGuid,
title,
choices.map((choice) => choice.label),
{
clientMessageId: createHash("sha256")
.update(`${adapter.state.scope.endpointId}:${stateKey}`)
.digest("hex"),
},
)
.catch(async (error) => {
const failure = photonFailure(error, true);
if (failure.code !== "delivery_unknown")
await adapter.state.update<PollSend>(stateKey, () => ({
schema: 1,
phase: "prepared",
}));
throw failure;
});
if (
!poll.pollMessageGuid ||
poll.options.length !== choices.length ||
new Set(poll.options.map((option) => option.optionIdentifier)).size !==
choices.length ||
poll.options.some(
(option, index) =>
!option.optionIdentifier ||
option.text.trim() !== choices[index].label.trim(),
)
)
throw new PhotonError(
"delivery_unknown",
"Photon returned an incomplete poll receipt",
);
saved = await adapter.state.update<PollSend>(stateKey, () => ({
schema: 1,
phase: "created",
receipt: {
pollMessageGuid: poll.pollMessageGuid,
options: poll.options.map((option) => ({
optionIdentifier: option.optionIdentifier,
text: option.text,
})),
},
}));
}
receipt.pollMessageGuid = saved.receipt!.pollMessageGuid;
// Creation response preserves requested option order; labels/titles are never a lookup key.
receipt.options = Object.fromEntries(
saved.receipt!.options.map((option, index) => [
option.optionIdentifier,
choices[index].id,
]),
);
}
await adapter.state.update<PhotonPromptReceipt>(
key,
(current) => current ?? receipt,
);
for (const guid of prompt.messageIds)
await adapter.state.update<PhotonPromptReceipt>(
`prompt-message:${guid}`,
(current) => current ?? receipt,
);
if (receipt.pollMessageGuid)
await adapter.state.update<PhotonPromptReceipt>(
`poll-message:${receipt.pollMessageGuid}`,
(current) => current ?? receipt,
);
return receipt;
}
+140
View File
@@ -0,0 +1,140 @@
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import sharp from "sharp";
import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js";
const require = createRequire(import.meta.url);
const MAX_PIXELS = 50_000_000;
export const HEIF_CONTENT_TYPES = new Set([
"image/heic",
"image/heif",
"image/heic-sequence",
"image/heif-sequence",
]);
/** Validate bounded ISO-BMFF structure before invoking any native decoder. */
export function validateHeifDimensions(body: Buffer): void {
let boxes = 0;
let dimensions = 0;
let totalPixels = 0;
let branded = false;
const visit = (start: number, end: number, depth: number) => {
if (depth > 8) throw new Error("HEIF metadata nesting is too deep");
for (let at = start; at < end; ) {
if (++boxes > 4096 || end - at < 8)
throw new Error("Invalid HEIF box structure");
let size = body.readUInt32BE(at);
const type = body.toString("ascii", at + 4, at + 8);
let header = 8;
if (size === 1) {
if (end - at < 16) throw new Error("Invalid HEIF box length");
const extended = body.readBigUInt64BE(at + 8);
if (extended > BigInt(body.length))
throw new Error("HEIF box exceeds file bounds");
size = Number(extended);
header = 16;
} else if (size === 0) size = end - at;
if (size < header || at + size > end)
throw new Error("HEIF box exceeds file bounds");
const content = at + header;
if (type === "ftyp") {
if (size < header + 8) throw new Error("HEIF file type is missing");
const brands = body.toString("ascii", content, at + size);
branded = /heic|heix|hevc|hevx|mif1|msf1/.test(brands);
} else if (type === "ispe") {
if (size !== header + 12)
throw new Error("Invalid HEIF image dimensions");
const width = body.readUInt32BE(content + 4);
const height = body.readUInt32BE(content + 8);
if (
!width ||
!height ||
width > 16_384 ||
height > 16_384 ||
width * height > MAX_PIXELS
)
throw new Error("HEIF decoded image exceeds the pixel limit");
totalPixels += width * height;
if (totalPixels > MAX_PIXELS * 3 || ++dimensions > 512)
throw new Error("HEIF image collection exceeds the pixel limit");
} else if (["meta", "iprp", "ipco"].includes(type)) {
visit(content + (type === "meta" ? 4 : 0), at + size, depth + 1);
}
at += size;
}
};
if (!body.length || body.length > MAX_ATTACHMENT_BYTES)
throw new Error("HEIF exceeds the attachment byte limit");
visit(0, body.length, 0);
if (!branded || !dimensions)
throw new Error("HEIF dimensions could not be verified");
}
export async function validatePhotonImage(
body: Buffer,
contentType: string,
): Promise<void> {
if (!contentType.startsWith("image/")) return;
if (HEIF_CONTENT_TYPES.has(contentType)) return validateHeifDimensions(body);
const metadata = await sharp(body, {
limitInputPixels: MAX_PIXELS,
failOn: "error",
}).metadata();
if (
!metadata.width ||
!metadata.height ||
metadata.width * metadata.height * (metadata.pages ?? 1) > MAX_PIXELS
)
throw new Error("Decoded image exceeds the pixel limit");
const formats: Record<string, string[]> = {
"image/jpeg": ["jpeg"],
"image/jpg": ["jpeg"],
"image/png": ["png"],
"image/webp": ["webp"],
"image/gif": ["gif"],
};
if (!formats[contentType]?.includes(metadata.format ?? ""))
throw new Error("Image bytes do not match the declared content type");
}
/** Isolate the native converter with a deadline and bounded input/output.
* Native packages exist for macOS, Windows and Linux glibc x64/arm64.
* Unsupported hosts retain the original and report an unavailable preview. */
export async function photonHeifPreview(body: Buffer): Promise<Buffer> {
validateHeifDimensions(body);
const modulePath = require.resolve("heif2jpeg");
const script = `const {heifToJpeg}=require(process.argv[1]);const chunks=[];process.stdin.on('data',c=>chunks.push(c));process.stdin.on('end',async()=>{try{const jpeg=await heifToJpeg(Buffer.concat(chunks),{quality:80});process.stdout.end(jpeg);}catch{process.exitCode=1;}});`;
const jpeg = await new Promise<Buffer>((resolve, reject) => {
const child = spawn(
process.execPath,
["--max-old-space-size=256", "--eval", script, modulePath],
{ stdio: ["pipe", "pipe", "ignore"], windowsHide: true },
);
let length = 0;
const chunks: Buffer[] = [];
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("HEIF preview conversion timed out"));
}, 10_000);
child.stdout.on("data", (chunk: Buffer) => {
length += chunk.length;
if (length > MAX_ATTACHMENT_BYTES) {
child.kill("SIGKILL");
reject(new Error("HEIF preview exceeds the attachment byte limit"));
} else chunks.push(chunk);
});
child.on("error", () => {
clearTimeout(timer);
reject(new Error("HEIF preview converter is unavailable"));
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 && length > 0) resolve(Buffer.concat(chunks));
else reject(new Error("HEIF preview conversion failed on this host"));
});
child.stdin.on("error", () => {});
child.stdin.end(body);
});
await validatePhotonImage(jpeg, "image/jpeg");
return jpeg;
}
+217
View File
@@ -0,0 +1,217 @@
import type { PhotonRecoveryEvent } from "./recovery-transport.js";
import type {
GrpcAdvancedIMessage,
LiveEvent,
TypedEventStream,
} from "@photon-ai/advanced-imessage";
import { PhotonError } from "./cloud.js";
import { PhotonState } from "./state.js";
interface Checkpoint {
schema: 1;
lineId: string;
sequence: number;
}
export interface PhotonReceiverOptions {
client: GrpcAdvancedIMessage;
state: PhotonState;
lineId: string;
intakeAfter: number;
allocation?: "dedicated" | "shared";
catchUp?(sequence?: number): TypedEventStream<PhotonRecoveryEvent>;
/** Renewal verifies both the selected identity and the current endpoint lease. */
assertOwned(): Promise<void>;
admit(event: LiveEvent): Promise<void>;
failure(error: unknown): Promise<void>;
/** Production persists the cursor under the same transaction as its lease fence. */
commitCheckpoint?(sequence: number): Promise<void>;
}
/** Live streams wake a serialized catch-up reader. Only that complete event log
* advances the checkpoint, so cross-stream ordering can never skip an event. */
export class PhotonReceiver {
private stopped = false;
private streams: Array<TypedEventStream<LiveEvent>> = [];
private running?: Promise<void>;
private requested = false;
private catchUpStream?: TypedEventStream<PhotonRecoveryEvent>;
private timer?: ReturnType<typeof setInterval>;
constructor(private readonly options: PhotonReceiverOptions) {}
start(): void {
if (this.stopped || this.timer) return;
const { client } = this.options;
this.streams = [
client.messages.subscribeEvents(),
client.chats.subscribeEvents(),
...(this.options.allocation === "shared" ? [] : [client.groups.subscribeEvents()]),
client.polls.subscribeEvents(),
];
for (const stream of this.streams) {
void (async () => {
try {
for await (const _event of stream) {
if (this.stopped) break;
this.request();
}
if (!this.stopped)
await this.options.failure(
new PhotonError("network", "Photon event stream disconnected"),
);
} catch (error) {
if (!this.stopped) await this.options.failure(error);
}
})().catch(() => {});
}
this.timer = setInterval(() => this.request(), 15_000);
this.timer.unref();
this.request();
}
private request(): void {
if (this.stopped) return;
this.requested = true;
if (this.running) return;
this.running = (async () => {
while (this.requested && !this.stopped) {
this.requested = false;
await this.catchUp();
}
})()
.catch(async (error) => {
if (!this.stopped) await this.options.failure(error);
})
.finally(() => {
this.running = undefined;
});
}
/** Exposed for deterministic recovery tests, never an external route. */
async catchUp(): Promise<void> {
const { state, lineId, client, assertOwned, admit, intakeAfter } =
this.options;
await assertOwned();
const original = await state.read<Checkpoint>("checkpoint");
if (!original && (await state.read("receiver-initialized")))
throw new PhotonError(
"history_gap",
"Photon receiver checkpoint is missing; operator recovery is required",
);
if (
original &&
(original.schema !== 1 ||
original.lineId !== lineId ||
!Number.isSafeInteger(original.sequence) ||
original.sequence < 0)
)
throw new PhotonError(
"history_gap",
"Photon checkpoint is invalid; operator recovery is required",
);
const shared = this.options.allocation === "shared";
let sequence = original?.sequence;
let batchEvents = 0;
const stream =
this.options.catchUp?.(sequence) ?? client.events.catchUp(sequence);
this.catchUpStream = stream;
let completed = false;
try {
for await (const event of stream) {
if (this.stopped) return;
await assertOwned();
if (event.type === "catchup.complete") {
if (
!Number.isSafeInteger(event.headSequence) ||
event.headSequence < 0 ||
(sequence !== undefined && (shared ? event.headSequence < sequence : event.headSequence !== sequence))
)
throw new PhotonError(
"history_gap",
"Photon history has a gap or reset; operator recovery is required",
);
// Shared gateway replay is project-filtered: sequence numbers are
// increasing but not adjacent (the first live project event may be
// > 1 billion). A complete replay barrier covers the filtered tail.
// Commit shared batches only here, after every admission succeeds;
// malformed ordering or interrupted replay keeps the previous cursor.
sequence = shared ? event.headSequence : sequence ?? event.headSequence;
await this.checkpoint(sequence);
completed = true;
break;
}
if (!Number.isSafeInteger(event.sequence) || event.sequence < 1)
throw new PhotonError(
"history_gap",
"Photon returned an invalid event sequence",
);
if (++batchEvents > 100_000)
throw new PhotonError("history_gap", "Photon replay exceeds the supported recovery window");
if (shared && sequence !== undefined && event.sequence < sequence && event.sequence > (original?.sequence ?? -1))
throw new PhotonError("history_gap", "Photon replay arrived out of order; the saved cursor was retained");
if (sequence !== undefined && event.sequence <= sequence) continue;
if (!shared && sequence !== undefined && event.sequence !== sequence + 1)
throw new PhotonError(
"history_gap",
"Photon event history is incomplete; operator recovery is required",
);
// On the first connection, the server may retain only a tail of history.
// Establish that boundary explicitly; it is never allowed after a cursor.
sequence ??= event.sequence - 1;
if (event.type !== "photon.ignored") {
const occurredAt = new Date(event.occurredAt).getTime();
if (!Number.isFinite(occurredAt))
throw new PhotonError(
"invalid_response",
"Photon event timestamp is invalid",
);
if (occurredAt >= intakeAfter) await admit(event);
}
// admission must durably store or classify even irrelevant events.
if (!shared) await this.checkpoint(event.sequence);
sequence = event.sequence;
}
if (!completed && !this.stopped)
throw new PhotonError(
"network",
"Photon catch-up ended before its checkpoint barrier",
);
} finally {
await stream.close();
if (this.catchUpStream === stream) this.catchUpStream = undefined;
}
}
private async checkpoint(sequence: number): Promise<void> {
await this.options.assertOwned();
if (this.options.commitCheckpoint)
return this.options.commitCheckpoint(sequence);
await writePhotonCheckpoint(
this.options.state,
this.options.lineId,
sequence,
);
}
async close(): Promise<void> {
this.stopped = true;
if (this.timer) clearInterval(this.timer);
await this.catchUpStream?.close();
await Promise.allSettled(this.streams.map((stream) => stream.close()));
// client.close interrupts a blocked catch-up RPC during runtime shutdown.
}
}
export async function writePhotonCheckpoint(
state: PhotonState,
lineId: string,
sequence: number,
): Promise<void> {
await state.update<Checkpoint>("checkpoint", (current) => {
if (current && (current.lineId !== lineId || current.sequence > sequence))
throw new PhotonError(
"history_gap",
"Photon checkpoint ownership changed",
);
return { schema: 1, lineId, sequence };
});
await state.update(
"receiver-initialized",
(current) => current ?? { schema: 1, lineId },
);
}
@@ -0,0 +1,178 @@
import { Client, credentials, Metadata, status } from "@grpc/grpc-js";
import {
decodeCatchUpEvent,
TypedEventStream,
type CatchUpEvent,
} from "@photon-ai/advanced-imessage";
import {
PhotonError,
photonFailure,
type PhotonLineAuthentication,
} from "./cloud.js";
import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js";
export type PhotonRecoveryEvent =
| CatchUpEvent
| { type: "photon.ignored"; sequence: number };
export const PHOTON_CATCHUP_PATH =
"/photon.imessage.v1.EventService/CatchUpEvents";
/** Pinned v2.1.0 protobuf envelope. The SDK decoder owns the event graph; this
* reader retains sequence-only/new-variant frames that its public API drops. */
export function photonEnvelopeSequence(bytes: Uint8Array): number | undefined {
let offset = 0;
const integer = () => {
let value = 0n;
for (let shift = 0n; shift < 70n; shift += 7n) {
if (offset >= bytes.length)
throw new PhotonError(
"invalid_response",
"Truncated Photon recovery frame",
);
const byte = bytes[offset++];
value |= BigInt(byte & 127) << shift;
if (!(byte & 128)) {
if (value > BigInt(Number.MAX_SAFE_INTEGER))
throw new PhotonError(
"history_gap",
"Photon recovery sequence exceeds the supported range",
);
return Number(value);
}
}
throw new PhotonError(
"invalid_response",
"Invalid Photon recovery integer",
);
};
let sequence: number | undefined;
while (offset < bytes.length) {
const tag = integer();
if (tag === 8) {
if (sequence !== undefined)
throw new PhotonError(
"invalid_response",
"Duplicate Photon sequence field",
);
sequence = integer();
continue;
}
const wire = tag & 7;
if (wire === 0) integer();
else if (wire === 1) offset += 8;
else if (wire === 2) {
const length = integer();
offset += length;
} else if (wire === 5) offset += 4;
else
throw new PhotonError(
"invalid_response",
"Unsupported Photon recovery frame",
);
if (offset > bytes.length)
throw new PhotonError(
"invalid_response",
"Photon recovery field exceeds frame bounds",
);
}
return sequence;
}
export function photonCatchUpRequest(sequence?: number): Buffer {
if (sequence === undefined) return Buffer.alloc(0);
if (!Number.isSafeInteger(sequence) || sequence < 0)
throw new PhotonError("history_gap", "Invalid Photon recovery cursor");
let remaining = BigInt(sequence);
const bytes = [8];
do {
const byte = Number(remaining & 127n);
remaining >>= 7n;
bytes.push(byte | (remaining ? 128 : 0));
} while (remaining);
return Buffer.from(bytes);
}
export function decodePhotonRecoveryFrame(
bytes: Buffer,
): PhotonRecoveryEvent | null {
const sequence = photonEnvelopeSequence(bytes);
let event: CatchUpEvent | undefined;
try {
event = decodeCatchUpEvent(bytes);
} catch {
throw new PhotonError(
"invalid_response",
"Photon recovery event could not be decoded",
);
}
return (
event ??
(sequence !== undefined ? { type: "photon.ignored", sequence } : null)
);
}
export class PhotonRecoveryTransport {
private readonly client: Client;
constructor(
private readonly authentication: PhotonLineAuthentication,
client?: Client,
) {
this.client =
client ??
new Client(authentication.address, credentials.createSsl(), {
"grpc.max_receive_message_length": MAX_ATTACHMENT_BYTES + 1024 * 1024,
});
}
catchUp(sequence?: number): TypedEventStream<PhotonRecoveryEvent> {
const controller = new AbortController();
const { client, authentication } = this;
async function* receive() {
const metadata = new Metadata();
metadata.set("authorization", `Bearer ${await authentication.token()}`);
if (controller.signal.aborted) return;
const call = client.makeServerStreamRequest(
PHOTON_CATCHUP_PATH,
photonCatchUpRequest,
(bytes: Buffer) => bytes,
sequence,
metadata,
{ deadline: Date.now() + 60_000 },
);
const abort = () => call.cancel();
controller.signal.addEventListener("abort", abort, { once: true });
try {
for await (const bytes of call) {
const frame = decodePhotonRecoveryFrame(bytes as Buffer);
if (frame) yield frame;
}
} catch (error) {
if (controller.signal.aborted) return;
const code = (error as { code?: number }).code;
if (code === status.OUT_OF_RANGE || code === status.FAILED_PRECONDITION)
throw new PhotonError(
"history_gap",
"Photon cannot recover the saved cursor; reconnect after reviewing the history gap",
);
if (
code === status.UNAUTHENTICATED ||
code === status.PERMISSION_DENIED
)
throw new PhotonError(
"credentials",
"Photon rejected the selected line credentials; reconnect the channel",
);
if (code === status.RESOURCE_EXHAUSTED)
throw new PhotonError(
"quota",
"Photon recovery is temporarily rate limited",
);
throw photonFailure(error);
} finally {
controller.signal.removeEventListener("abort", abort);
call.cancel();
}
}
return new TypedEventStream(receive(), async () => controller.abort());
}
close(): void {
this.client.close();
}
}
+39
View File
@@ -0,0 +1,39 @@
import { createHash } from "node:crypto";
import type {
ChatSdkStatePersistence,
ChatSdkStateScope,
} from "../chat-sdk-state.js";
/** Typed provider records share the existing company/endpoint scoped CAS store. */
export class PhotonState {
constructor(
readonly scope: ChatSdkStateScope,
private readonly persistence: ChatSdkStatePersistence,
) {}
private key(key: string): string {
return `photon:${createHash("sha256").update(key).digest("hex")}`;
}
async read<T>(key: string): Promise<T | null> {
const row = await this.persistence.read(this.scope, this.key(key));
return row ? (row.value as T) : null;
}
async update<T>(key: string, update: (current: T | null) => T): Promise<T> {
for (let attempt = 0; attempt < 32; attempt++) {
const row = await this.persistence.read(this.scope, this.key(key));
const value = update(row ? (row.value as T) : null);
if (Buffer.byteLength(JSON.stringify(value)) > 512 * 1024)
throw new Error("Photon state record is too large");
if (
await this.persistence.compareAndSet({
...this.scope,
key: this.key(key),
expectedVersion: row?.version ?? null,
expiresAt: null,
value,
})
)
return value;
}
throw new Error("Photon state changed concurrently; retry");
}
}
+5 -3
View File
@@ -793,9 +793,11 @@ function buildStandaloneBundledPluginInstallArgs(
packageRoot: string,
): string[] {
const packageLockfilePath = path.join(packageRoot, "pnpm-lock.yaml");
return existsSync(packageLockfilePath)
? ["install", "--ignore-workspace", "--frozen-lockfile"]
: ["install", "--ignore-workspace", "--no-lockfile"];
// Never let plugin-supplied workspace settings broaden dependency resolution
// or script execution. When a plugin declares a local install policy, disable
// dependency lifecycle scripts instead of loading that workspace configuration.
const scriptArgs = existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) ? ["--ignore-scripts"] : [];
return ["install", "--ignore-workspace", ...scriptArgs, existsSync(packageLockfilePath) ? "--frozen-lockfile" : "--no-lockfile"];
}
function buildStandaloneBundledPluginInstallCommand(
+3 -1
View File
@@ -2495,7 +2495,9 @@ export function recoveryService(
? "Board operator: repair the project workspace repository URL or clone access, or configure a local checkout cwd, then explicitly retry or reassign."
: "Board operator: repair the source task workspace link, project workspace cwd, or git checkout, then explicitly retry or reassign."
: recoveryCause === "configuration_incomplete"
? readConfigurationIncompletePayload(input.latestRun)
? readConfigurationIncompletePayload(input.latestRun)?.reason === "ai_connection_unavailable"
? "Reconnect the selected AI account or choose an available connection, then continue the task."
: readConfigurationIncompletePayload(input.latestRun)
?.reason === SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON
? `Board operator: the sandbox provider plugin named in the run failure is not ready; ${sandboxProviderPluginRemedy(
readNonEmptyString(
@@ -241,3 +241,12 @@ describe("buildStrandedRecoveryEscalationNotice", () => {
).toBe(false);
});
});
it("names the unavailable AI account instead of suggesting secret bindings", () => {
const notice = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "ai_connection_unavailable", provider: "openai" });
expect(notice.nextAction).toContain("Reconnect the selected AI account");
expect(notice.title).toBe("AI connection needs attention");
expect(notice.body).toContain("Reconnect the account");
expect(notice.body).not.toContain("secret/env");
});
@@ -15,6 +15,7 @@ export type StrandedRecoveryNoticeSeed = {
body: string;
title: string;
tone: IssueCommentPresentation["tone"];
nextAction?: string;
};
export type StrandedRecoveryEscalationNotice = {
@@ -104,6 +105,14 @@ export function sandboxProviderPluginRemedy(pluginStatus: string): string {
export function buildConfigurationIncompleteRecoveryNoticeSeed(
configurationIncomplete?: Record<string, unknown> | null,
): StrandedRecoveryNoticeSeed {
if (readNonEmptyStringField(configurationIncomplete, "reason") === "ai_connection_unavailable") {
return {
title: "AI connection needs attention",
body: "This task paused because its selected AI account is unavailable. Reconnect the account or choose an available connection to continue.",
nextAction: "Reconnect the selected AI account or choose an available connection, then continue the task.",
tone: "danger",
};
}
if (readNonEmptyStringField(configurationIncomplete, "reason") === SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON) {
const pluginKey = readNonEmptyStringField(configurationIncomplete, "pluginKey") ?? "the sandbox provider plugin";
const pluginStatus = readNonEmptyStringField(configurationIncomplete, "pluginStatus") ?? "not ready";
@@ -184,9 +193,9 @@ export function buildStrandedRecoveryEscalationNotice(input: {
),
keyValueRow(
"Next action",
input.recoveryOwner
input.seed?.nextAction ?? (input.recoveryOwner
? "The recovery owner should either restore a live execution path or record the manual resolution on the source issue"
: "Inspect the evidence, then retry the original owner, explicitly reassign, repair the execution path, or record an intentional resolution",
: "Inspect the evidence, then retry the original owner, explicitly reassign, repair the execution path, or record an intentional resolution"),
),
];
+51 -10
View File
@@ -1,5 +1,6 @@
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import {
agentWakeupRequests,
heartbeatRuns,
heartbeatRunEvents,
issueComments,
@@ -10,6 +11,48 @@ import {
} from "@paperclipai/db";
import { conflict, forbidden } from "../errors.js";
import { isUuidLike } from "@paperclipai/shared";
import { queuedCommentIdsFromRunContext, queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
/** Resolve an explicit click from persisted receipts, never caller context or message authors. */
export async function explicitOperatorRunIdentity(
executor: Pick<Db, "select">,
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "contextSnapshot" | "wakeupRequestId">,
) {
const [request] = run.wakeupRequestId ? await executor.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, run.wakeupRequestId), eq(agentWakeupRequests.companyId, run.companyId),
eq(agentWakeupRequests.agentId, run.agentId), eq(agentWakeupRequests.runId, run.id),
)) : [];
if (request?.payload?.manualUserWake === true) {
if (request.requestedByActorType !== "user" || !request.requestedByActorId) {
throw forbidden("Manual wake requires an authenticated user");
}
return { actorId: request.requestedByActorId, cause: "manual_user_wake" };
}
const prefix = "queued-comment-interrupt:";
if (!request?.idempotencyKey?.startsWith(prefix)) return null;
const queueId = request.idempotencyKey.slice(prefix.length);
// The key only locates a candidate. The consumed queue, actor, run, company,
// agent, task, and delivered messages must all independently agree.
if (!isUuidLike(queueId)) {
throw forbidden("Queued-message interrupt authority is unavailable");
}
const [receipt] = await executor.select().from(agentWakeupRequests).where(and(
eq(agentWakeupRequests.id, queueId), eq(agentWakeupRequests.companyId, run.companyId),
eq(agentWakeupRequests.agentId, run.agentId), eq(agentWakeupRequests.runId, run.id),
eq(agentWakeupRequests.status, "coalesced"),
));
const marker = receipt?.payload?.queuedCommentInterrupt;
const actorId = marker && typeof marker === "object" && "actorId" in marker ? marker.actorId : null;
const ids = queuedCommentIdsFromWakePayload(receipt?.payload);
const deliveredIds = queuedCommentIdsFromRunContext(run.contextSnapshot);
if (typeof actorId !== "string" || !actorId || !ids.length ||
receipt?.payload?.issueId !== run.contextSnapshot?.issueId ||
!ids.every(id => deliveredIds.includes(id)) ||
request.requestedByActorType !== "user" || request.requestedByActorId !== actorId) {
throw forbidden("Queued-message interrupt authority is unavailable");
}
return { actorId, cause: "queued_comment_interrupt" };
}
export type RunIdentityContext = typeof runIdentityContexts.$inferSelect;
type Executor = Pick<Db, "select" | "insert" | "update">;
@@ -147,6 +190,7 @@ export async function initializeRunIdentity(
.where(eq(runIdentityContexts.id, run.activeIdentityContextId));
return current!;
}
const operatorIdentity = await explicitOperatorRunIdentity(tx, run);
const [parent] = input.parentRunId
? await tx
.select()
@@ -171,10 +215,9 @@ export async function initializeRunIdentity(
),
)
: [];
const parentId =
interaction?.sourceIdentityContextId ??
input.parentContextId ??
parent?.activeIdentityContextId;
const parentId = operatorIdentity ? null : (
interaction?.sourceIdentityContextId ?? input.parentContextId ?? parent?.activeIdentityContextId
);
const [origin] = parentId
? await tx
.select()
@@ -192,12 +235,10 @@ export async function initializeRunIdentity(
let current = await append(tx, {
companyId: input.companyId,
runId: input.runId,
responsibleUserId: origin
? origin.responsibleUserId
: input.responsibleUserId,
responsibleUserId: operatorIdentity?.actorId ?? (origin ? origin.responsibleUserId : input.responsibleUserId),
parentContextId: origin?.id ?? null,
cause:
origin?.cause === "company_default" ? "company_default" : input.cause,
operatorIdentity ? operatorIdentity.cause : origin?.cause === "company_default" ? "company_default" : input.cause,
correlationId: "dispatch",
});
const ids = [...new Set(input.messageIds ?? [])];
@@ -220,10 +261,10 @@ export async function initializeRunIdentity(
current = await append(tx, {
companyId: input.companyId,
runId: input.runId,
responsibleUserId: comment.authorUserId,
responsibleUserId: operatorIdentity?.actorId ?? comment.authorUserId,
messageId: id,
parentContextId: current.id,
cause: "instruction",
cause: operatorIdentity?.cause ?? "instruction",
correlationId: `message:${id}`,
});
}
@@ -109,6 +109,7 @@ export const SETUP_TOKEN_CANCELLABLE_STATES: readonly SetupTokenSessionState[] =
* per environment.
*/
export interface SetupTokenSessionScope {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
companyId: string;
ownerUserId: string;
// The adapter of the login. It is part of the session identity.
@@ -218,6 +219,7 @@ export interface SetupTokenLeaseManager {
* column on the row.
*/
export interface SetupTokenCleanupRecord {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
sessionId: string;
companyId: string;
ownerUserId: string;
@@ -684,6 +686,7 @@ export interface SetupTokenPromptView {
* response returns the login URL through the confidential transport guard.
*/
export interface SetupTokenSessionDescriptor {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
sessionId: string;
state: SetupTokenSessionState;
environmentId: string;
@@ -906,6 +909,7 @@ export class SetupTokenSessionService {
try {
await this.store.record({
aiConnection: scope.aiConnection,
sessionId,
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
@@ -1118,6 +1122,7 @@ export class SetupTokenSessionService {
environmentId: session.scope.environmentId,
deadline: session.deadline,
loginUrl: session.loginUrl,
...(session.scope.aiConnection ? { aiConnection: session.scope.aiConnection } : {}),
};
}
@@ -1192,6 +1197,7 @@ export class SetupTokenSessionService {
if (!durable) return null;
return {
sessionId: durable.sessionId,
...(durable.aiConnection ? { aiConnection: durable.aiConnection } : {}),
state: durable.state,
environmentId: durable.environmentId,
deadline: durable.deadline,
@@ -1452,6 +1458,7 @@ type AdapterAuthSessionRow = typeof adapterAuthSessions.$inferSelect;
function toCleanupRecord(row: AdapterAuthSessionRow): SetupTokenCleanupRecord {
return {
sessionId: row.publicSessionId,
...(row.aiConnection ? { aiConnection: row.aiConnection } : {}),
companyId: row.companyId,
ownerUserId: row.startedByUserId,
adapterType: row.adapterType,
@@ -1540,6 +1547,7 @@ export function createDbSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore {
// service session id, which the service builds from a CSPRNG at start.
await db.insert(adapterAuthSessions).values({
companyId: record.companyId,
aiConnection: record.aiConnection,
environmentId: record.environmentId,
adapterType: record.adapterType as AgentAdapterType,
startedByUserId: record.ownerUserId,
+196
View File
@@ -0,0 +1,196 @@
import { sql, type SQL } from "drizzle-orm";
import { COMPANY_SEARCH_MAX_QUERY_LENGTH, COMPANY_SEARCH_MAX_TOKENS } from "@paperclipai/shared";
import { visibleIssueCondition } from "./issue-visibility.js";
// Only grammatical filler is ignored, only in multi-term queries, and never
// inside quotes. Keep negation and domain words (API, UI, PR, etc.) meaningful.
const FILLER = new Set(["a", "an", "the", "and", "of", "to", "for", "in", "on", "with"]);
export function escapeTaskSearchPattern(value: string) {
return value.replace(/[\\%_]/g, "\\$&");
}
export function parseTaskSearch(text: string) {
const normalizedQuery = text.slice(0, COMPANY_SEARCH_MAX_QUERY_LENGTH).trim().replace(/\s+/g, " ").toLowerCase();
const parsed = Array.from(normalizedQuery.matchAll(/"([^"]+)"|([^\s"]+)/g), (match) => ({
text: match[1] ?? match[2]!, quoted: match[1] !== undefined,
}));
const meaningful = parsed.filter((term) => term.quoted || !FILLER.has(term.text));
const uniqueTerms = new Map<string, { text: string; quoted: boolean }>();
for (const term of meaningful.length > 0 ? meaningful : parsed) {
uniqueTerms.set(term.text, { ...term, quoted: term.quoted || uniqueTerms.get(term.text)?.quoted === true });
}
const terms = [...uniqueTerms.values()].slice(0, COMPANY_SEARCH_MAX_TOKENS);
const tokens = terms.map((term) => term.text);
const phrase = tokens.join(" ");
// A copied/typed task identifier is navigation, never a fuzzy number match.
const identifier = /^([a-z][a-z0-9]*)[- ](\d+)$/i.exec(normalizedQuery) ?? /^([a-z]+)(\d+)$/i.exec(normalizedQuery);
const identifierQuery = identifier ? `${identifier[1]}-${identifier[2]}` : normalizedQuery;
const patterns = tokens.map((token) => `%${escapeTaskSearchPattern(token)}%`);
const containsPattern = `%${escapeTaskSearchPattern(phrase)}%`;
const startsWithPattern = `${escapeTaskSearchPattern(phrase)}%`;
return { normalizedQuery, terms, tokens, phrase, identifierQuery, patterns, containsPattern, startsWithPattern };
}
export type TaskSearch = ReturnType<typeof parseTaskSearch>;
function taskSearchAny(field: SQL, search: TaskSearch): SQL<boolean> {
return search.patterns.length === 0 ? sql`false`
: sql`(${sql.join(search.patterns.map((pattern) => sql`${field} ILIKE ${pattern}`), sql` OR `)})`;
}
// Short typeahead terms must start a word: UI must not match "build", and
// API must not match "Capistrano". Keep an indexable literal precondition.
export function taskSearchTermMatch(field: SQL, search: TaskSearch, index: number): SQL<boolean> {
const term = search.tokens[index]!;
const literal = sql<boolean>`${field} ILIKE ${search.patterns[index]!}`;
return /^[\p{L}]{1,3}$/u.test(term)
? sql`(${literal} AND ${field} ~* ${`(^|[^[:alnum:]])${term}`})`
: literal;
}
export function taskSearchFieldMatch(field: SQL, search: TaskSearch): SQL<boolean> {
return search.tokens.length === 0 ? sql`false`
: sql`(${sql.join(search.tokens.map((_, index) => taskSearchTermMatch(field, search, index)), sql` OR `)})`;
}
function coverage(matches: SQL[]): SQL<number> {
return matches.length === 0 ? sql`0`
: sql`(${sql.join(matches.map((match) => sql`CASE WHEN ${match} THEN 1 ELSE 0 END`), sql` + `)})`;
}
// Score bands are deliberately disjoint. Incidental comments, repeated terms,
// status and recency cannot outweigh a stronger kind of match.
export function taskSearchScore(search: TaskSearch): SQL<number> {
const n = search.tokens.length;
if (n === 0) return sql`0`;
return sql`(
CASE
WHEN m.ident_exact THEN 8000
WHEN m.ident_starts THEN 7000
WHEN m.title_exact THEN 6000
WHEN m.title_phrase AND m.title_coverage = ${n} THEN 5000
WHEN m.title_coverage = ${n} THEN 4000
WHEN m.issue_coverage = ${n} THEN 3000
WHEN m.token_coverage = ${n} THEN 2000
WHEN m.fuzzy_title THEN 1000
ELSE 0
END
+ m.title_word_coverage * 10
+ CASE WHEN m.title_starts THEN 30 ELSE 0 END
+ CASE m.status WHEN 'done' THEN 0 WHEN 'cancelled' THEN 0 ELSE 10 END
)::double precision`;
}
/** Shared task retrieval for company search and issue-list/command-palette search.
* Uses existing pg_trgm indexes and current rows: no derived corpus or worker.
* The tagged comment/document sets are evaluated once, not once per task.
*/
export function taskSearchCtes(companyId: string, search: TaskSearch, includeContext = true, fallbackFilters?: SQL): SQL {
const n = search.tokens.length;
const comments = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: sql.join(search.patterns.map((_, index) => sql`
SELECT c.issue_id, ${index}::int AS ord FROM issue_comments c
WHERE c.company_id = ${companyId} AND c.deleted_at IS NULL AND ${taskSearchTermMatch(sql`c.body`, search, index)}
GROUP BY c.issue_id
`), sql` UNION ALL `);
const documents = n === 0 || !includeContext ? sql`SELECT NULL::uuid AS issue_id, 0 AS ord WHERE false`
: sql.join(search.patterns.map((_, index) => sql`
SELECT d.issue_id, ${index}::int AS ord FROM issue_documents d
JOIN documents body ON body.id = d.document_id AND body.company_id = d.company_id
WHERE d.company_id = ${companyId} AND (${taskSearchTermMatch(sql`body.title`, search, index)} OR ${taskSearchTermMatch(sql`body.latest_body`, search, index)})
GROUP BY d.issue_id
`), sql` UNION ALL `);
const titleTerms = search.patterns.map((_, index) => taskSearchTermMatch(sql`issues.title`, search, index));
const issueTerms = search.patterns.map((_, index) => sql`(
${titleTerms[index]!} OR ${taskSearchTermMatch(sql`issues.identifier`, search, index)}
OR ${taskSearchTermMatch(sql`issues.description`, search, index)}
)`);
const commentTerms = search.patterns.map((_, index) => sql`issues.id IN (SELECT issue_id FROM comment_matches WHERE ord = ${index})`);
const documentTerms = search.patterns.map((_, index) => sql`issues.id IN (SELECT issue_id FROM document_matches WHERE ord = ${index})`);
const allTerms = issueTerms.map((term, index) => sql`(${term} OR ${commentTerms[index]!} OR ${documentTerms[index]!})`);
const phraseMatch = (field: SQL) => n > 0 ? sql`coalesce(${field} ILIKE ${search.containsPattern}, false)` : sql`false`;
const identExact = n > 0 ? sql`lower(issues.identifier) = ${search.identifierQuery}` : sql`false`;
const identStarts = n > 0 ? sql`issues.identifier ILIKE ${escapeTaskSearchPattern(search.identifierQuery) + "%"}` : sql`false`;
const fuzzyAllowed = !search.terms.some((term) => term.quoted)
&& search.terms.some((term) => /^[\p{L}]{4,255}$/u.test(term.text))
&& !/^[a-z][a-z0-9]*[- ]?\d+$/i.test(search.normalizedQuery);
const fuzzyTerms = search.terms.map((term, index) => {
if (!/^[\p{L}]{4,255}$/u.test(term.text)) return titleTerms[index]!;
// Bound both arguments before calling fuzzystrmatch (255-character limit).
// Cheap length checks prune word pairs before bounded edit-distance work.
const edits = sql`CASE WHEN least(char_length(word), ${Array.from(term.text).length}) >= 6 THEN 2
WHEN least(char_length(word), ${Array.from(term.text).length}) >= 5 THEN 1 ELSE 0 END`;
return sql`(${titleTerms[index]!} OR EXISTS (
SELECT 1 FROM regexp_split_to_table(lower(issues.title), '[^[:alnum:]]+') AS word
WHERE CASE WHEN char_length(word) BETWEEN 4 AND 255
AND abs(char_length(word) - ${Array.from(term.text).length}) <= ${edits}
THEN levenshtein_less_equal(${term.text}, word, ${edits}) <= ${edits}
ELSE false END
))`;
});
const fuzzy = fuzzyAllowed ? sql`CASE WHEN ${coverage(titleTerms)} = ${n} THEN false
ELSE (${sql.join(fuzzyTerms, sql` AND `)}) END` : sql`false`;
const wordTerms = search.tokens.map((token) => {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return sql`issues.title ~* ${`(^|[^[:alnum:]_])${escaped}($|[^[:alnum:]_])`}`;
});
// Carry flags, not potentially large bodies, through materialized stages.
// The search page fetches descriptions only for its result window.
const flags = (fuzzyMatch: SQL) => sql`
SELECT issues.id, issues.identifier, issues.title,
issues.status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id,
issues.project_id, issues.created_at, issues.updated_at,
${identExact} AS ident_exact, ${identStarts} AS ident_starts,
${phraseMatch(sql`issues.identifier`)} AS ident_phrase,
${taskSearchFieldMatch(sql`issues.identifier`, search)} AS ident_token,
${n > 0 ? sql`lower(issues.title) = ${search.phrase}` : sql`false`} AS title_exact,
${n > 0 ? sql`issues.title ILIKE ${search.startsWithPattern}` : sql`false`} AS title_starts,
${phraseMatch(sql`issues.title`)} AS title_phrase,
${taskSearchFieldMatch(sql`issues.title`, search)} AS title_token,
${phraseMatch(sql`issues.description`)} AS desc_phrase,
${taskSearchFieldMatch(sql`issues.description`, search)} AS desc_token,
${coverage(titleTerms)} AS title_coverage,
${coverage(wordTerms)} AS title_word_coverage,
${coverage(issueTerms)} AS issue_coverage,
${coverage(commentTerms)} AS comment_coverage,
${coverage(documentTerms)} AS document_coverage,
${coverage(allTerms)} AS token_coverage,
${fuzzyMatch} AS fuzzy_title,
issues.id IN (SELECT issue_id FROM comment_matches) AS comment_match,
issues.id IN (SELECT issue_id FROM document_matches) AS document_match
FROM issues
`;
return sql`
WITH comment_matches AS MATERIALIZED (${comments}),
document_matches AS MATERIALIZED (${documents}),
literal_candidates AS MATERIALIZED (
SELECT issues.id FROM issues
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()}
AND ${n === 0 ? sql`${search.normalizedQuery.length === 0}` : sql`(
${taskSearchAny(sql`issues.title`, search)}
OR ${taskSearchAny(sql`issues.identifier`, search)}
OR ${taskSearchAny(sql`issues.description`, search)}
OR ${identStarts}
)`}
UNION SELECT issue_id FROM comment_matches
UNION SELECT issue_id FROM document_matches
), search_flags AS MATERIALIZED (
${flags(sql`false`)}
WHERE issues.company_id = ${companyId} AND ${visibleIssueCondition()}
AND issues.id IN (SELECT id FROM literal_candidates)
), literal_matches AS MATERIALIZED (
SELECT * FROM search_flags
WHERE ${n === 0 ? sql`true` : sql`token_coverage = ${n} OR ident_exact OR ident_starts`}
), fuzzy_candidates AS MATERIALIZED (
SELECT issues.id FROM issues
WHERE NOT EXISTS (
SELECT 1 FROM literal_matches literal
JOIN issues ON issues.id = literal.id
${fallbackFilters ? sql`WHERE ${fallbackFilters}` : sql``}
)
AND issues.company_id = ${companyId} AND ${visibleIssueCondition()}
AND ${fuzzy}
), matched AS MATERIALIZED (
SELECT * FROM literal_matches
UNION ALL
${flags(sql`true`)}
WHERE issues.id IN (SELECT id FROM fuzzy_candidates)
)
`;
}
+33 -8
View File
@@ -1,7 +1,6 @@
import {
canBrowseProjectRepositoryGrant,
mergeProjectRepository,
} from "./project-repositories.js";
import { connectionPurposeTransportSchema } from "@paperclipai/shared";
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js";
import { captureRunIdentity } from "./run-identity.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
@@ -1572,9 +1571,8 @@ function assertClass3ToolCredentialRefAllowed(ref: {
}
}
function toConnection(
row: typeof toolConnections.$inferSelect,
): ToolConnection {
function toConnection(row: typeof toolConnections.$inferSelect): ToolConnection {
connectionPurposeTransportSchema.parse(row);
return {
id: row.id,
companyId: row.companyId,
@@ -7419,6 +7417,7 @@ export function toolAccessService(
credentialHeaders?: Record<string, string>,
actor?: ActorInfo,
): Promise<McpToolDescriptor[]> {
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
if (isAgentMailConnection(connection)) {
await validateAgentMailConnection(connection);
return [];
@@ -7433,6 +7432,22 @@ export function toolAccessService(
return localTools(connection);
}
async function annotateAiGrantHealth(connections: ToolConnection[]) {
const aiConnections = connections.filter(connection => connection.connectionPurpose === "ai");
if (!aiConnections.length) return;
const grants = await db.select({ connectionId: connectionGrants.connectionId, status: connectionGrants.status })
.from(connectionGrants).where(and(eq(connectionGrants.companyId, aiConnections[0].companyId),
inArray(connectionGrants.connectionId, aiConnections.map(connection => connection.id))));
for (const connection of aiConnections) {
const identities = grants.filter(grant => grant.connectionId === connection.id);
if (identities.length && identities.every(grant => grant.status === "revoked")) {
connection.healthStatus = "missing_secret";
connection.healthMessage = "This credential was revoked. Reconnect the account to restore access.";
connection.requiresReauthorization = true;
}
}
}
async function annotateGitHubAuthorization(
connections: ToolConnection[],
viewerUserId?: string,
@@ -7510,6 +7525,7 @@ export function toolAccessService(
actor?: ActorInfo,
): Promise<ToolConnectionHealthCheckResult> {
const connection = await getConnectionRow(connectionId);
if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null };
try {
const config = asRecord(connection.config);
const oauth = asRecord(config.oauth);
@@ -7639,6 +7655,7 @@ export function toolAccessService(
} = {},
): Promise<ToolCatalogRefreshResult> {
const connection = await getConnectionRow(connectionId);
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog");
const refreshedAt = now();
let descriptors: McpToolDescriptor[];
try {
@@ -8094,6 +8111,7 @@ export function toolAccessService(
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
ne(toolConnections.transport, "chat_sdk"),
ne(toolConnections.transport, "runtime_auth"),
ne(toolApplications.type, "paperclip_plugin"),
or(isNull(toolConnections.healthCheckedAt), lte(toolConnections.healthCheckedAt, cutoff)),
),
@@ -17427,6 +17445,7 @@ export function toolAccessService(
for (const connection of connections) {
connection.lastUsedAt = lastUsedByConnection.get(connection.id) ?? null;
}
await annotateAiGrantHealth(connections);
await annotateGitHubAuthorization(connections, viewerUserId);
return connections;
},
@@ -17552,6 +17571,7 @@ export function toolAccessService(
connection.id,
connection.companyId,
);
await annotateAiGrantHealth([connection]);
await annotateGitHubAuthorization([connection], viewerUserId);
return connection;
},
@@ -17817,6 +17837,9 @@ export function toolAccessService(
ownerUserId: string,
) => {
const connection = await getConnectionRow(idOrUid);
if (connection.connectionPurpose === "ai") {
throw badRequest("AI credentials use the connection's human access settings, not agent delegation");
}
return db.transaction(async (tx) => {
// Membership removal/suspension takes this same row lock before sweeping
// personal grants. Whichever operation wins is therefore authoritative:
@@ -18304,7 +18327,7 @@ export function toolAccessService(
})),
);
}
if (requested.size > 0) {
if (requested.size > 0 && connection.connectionPurpose !== "ai") {
const profile = await appProfileForConnection(tx, connection);
for (const install of requested.values()) {
const [binding] = await tx
@@ -18384,6 +18407,7 @@ export function toolAccessService(
input: UpdateToolConnection,
): Promise<ToolConnection> => {
const existing = await getConnectionRow(connectionId);
if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed.");
const config = normalizeGoogleSheetsConnectionConfig(
input.config ?? input.transportConfig ?? existing.config,
);
@@ -19493,6 +19517,7 @@ export function toolAccessService(
input.connectionId,
input.companyId,
);
if (connection.connectionPurpose === "ai") throw unprocessable("AI credentials are available only through the runtime resolver");
const application = await getConnectionApplication(connection);
const brokerEnabled = connectionTokenBrokerEnabled(connection);
const path = brokerEnabled