diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 795e6c44667..07651e601a9 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -1454,6 +1454,20 @@ connector proposal or PR. No separate private validation issue is required. | Revoke and reconnect | Revocation blocks subsequent execution; supported reconnect reuses the intended identity/history. | Removal or denial evidence followed by reconnect result. | | Activity and secret handling | Activity explains what occurred and why; stored responses, logs, and artifacts contain no credential values. | Actor, run/issue context, resource, decision, reason code, outcome, plus redaction-check result. | +Chat task links must use the server-resolved public board origin, including the +current claimed Cloud origin. Supply the exact task URL in fresh and resumed +agent context. The native `get_task_context` and `search_tasks` tools also return +a nullable `url` on task records. Keep webhook ingress and internal API addresses separate from +human-facing task links. If no safe public URL is configured, say so instead of +constructing a link. Keep the external-publication URL filter in place. + +When checking chat response speed, measure queue time, runner preparation, +provider turn time, checkpoint/finalization, and provider publication separately. +Check session-reset reasons before attributing slow follow-ups to the model. +Temporary managed-credential homes must not change the session fingerprint; +account, credential, model, permission, and user-configured environment changes +must retain their existing invalidation behavior. + For chat/email, also verify the applicable UX states in [Chat connector UX](./CHAT-CONNECTOR-UX.md#apply-and-verify): new connections deny unlinked people by default; linking requires ownership confirmation; nonmembers diff --git a/server/src/__tests__/heartbeat-chat-task-link.test.ts b/server/src/__tests__/heartbeat-chat-task-link.test.ts new file mode 100644 index 00000000000..bb5e5c93223 --- /dev/null +++ b/server/src/__tests__/heartbeat-chat-task-link.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildPaperclipTaskMarkdown } from "../services/heartbeat.js"; +import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js"; +import { readConfigFile } from "../config-file.js"; + +vi.mock("../services/cloud-runtime-identity.js", () => ({ + runtimeCanonicalOrigin: vi.fn(() => null), +})); +vi.mock("../config-file.js", () => ({ readConfigFile: vi.fn(() => null) })); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(runtimeCanonicalOrigin).mockReturnValue(null); + vi.mocked(readConfigFile).mockReturnValue(null); +}); + +const issue = { id: "task-123", identifier: "TEST-1", title: "Chat request" }; + +describe("external chat task link context", () => { + it.each(["slack", "discord", "telegram", "agentmail"])( + "supplies the public task link for fresh and resumed %s turns", + (externalChatProvider) => { + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://pool.example"); + vi.stubEnv("PAPERCLIP_API_URL", "http://localhost:3100"); + vi.mocked(runtimeCanonicalOrigin).mockReturnValue("https://vanity.example"); + for (const includeDescription of [true, false]) { + for (const nativeRunner of [true, false]) { + const markdown = buildPaperclipTaskMarkdown({ issue, externalChatProvider, nativeRunner, includeDescription }); + expect(markdown).toContain("Public task URL: https://vanity.example/issues/task-123"); + expect(markdown).toContain("use this exact URL"); + expect(markdown).toContain("Opening it still requires Paperclip access"); + expect(markdown).not.toContain("https://pool.example"); + expect(markdown).not.toContain("http://localhost:3100"); + } + } + }, + ); + + it.each(["", "http://localhost:3100", "https://board.internal", "https://user:secret@board.example"])( + "does not invent a task link from missing or unsafe configuration (%s)", + (publicUrl) => { + for (const name of ["PAPERCLIP_AUTH_PUBLIC_BASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_BASE_URL", "PAPERCLIP_PUBLIC_URL", "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL"]) vi.stubEnv(name, ""); + vi.stubEnv("PAPERCLIP_PUBLIC_URL", publicUrl); + vi.stubEnv("PAPERCLIP_API_URL", "https://api.example"); + vi.stubEnv("PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL", "https://ingress.example"); + const markdown = buildPaperclipTaskMarkdown({ issue, externalChatProvider: "slack" }); + expect(markdown).toContain("No public task URL is configured"); + expect(markdown).toContain("do not invent a URL"); + expect(markdown).not.toContain("Public task URL:"); + expect(markdown).not.toContain("https://api.example"); + expect(markdown).not.toContain("https://ingress.example"); + }, + ); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 73a9c1f1af4..d644cb43620 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -2210,6 +2210,46 @@ function sessionParamsWithConfigMetadata( } describe("effective run session config freshness", () => { + it("reuses managed AI sessions across temporary credential homes while preserving configuration boundaries", async () => { + const config = (home: string) => ({ + model: "gpt-5.4-mini", + approvalPolicy: "never", + managedAiConnection: { identity: "account-1:credential-generation-1" }, + env: { + HOME: home, + XDG_CONFIG_HOME: path.join(home, "config"), + XDG_DATA_HOME: path.join(home, "data"), + CODEX_HOME: path.join(home, "provider"), + GROK_HOME: path.join(home, "provider"), + CLAUDE_CONFIG_DIR: path.join(home, "provider"), + CUSTOM_SETTING: "original", + }, + }); + const first = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/tmp/ai-first"), managedAiHome: "/tmp/ai-first" }); + const nextConfig = config("/tmp/ai-next"); + const next = await buildSessionConfigMetadata({ effectiveAdapterConfig: nextConfig, managedAiHome: "/tmp/ai-next" }); + expect(next.fingerprint).toBe(first.fingerprint); + expect(nextConfig.env.HOME).toBe("/tmp/ai-next"); + expect(resolveTaskSessionConfigFreshness({ + hasTaskSession: true, configuredModel: "gpt-5.4-mini", + taskSessionParams: sessionParamsWithConfigMetadata(first), configMetadata: next, + }).reset).toBe(false); + for (const changed of [ + { ...nextConfig, model: "different-model" }, + { ...nextConfig, approvalPolicy: "on-request" }, + { ...nextConfig, managedAiConnection: { identity: "account-2:credential-generation-1" } }, + { ...nextConfig, managedAiConnection: { identity: "account-1:credential-generation-2" } }, + { ...nextConfig, env: { ...nextConfig.env, CUSTOM_SETTING: "changed" } }, + { ...nextConfig, env: { ...nextConfig.env, CODEX_HOME: "/custom/provider" } }, + ]) { + const metadata = await buildSessionConfigMetadata({ effectiveAdapterConfig: changed, managedAiHome: "/tmp/ai-next" }); + expect(metadata.fingerprint).not.toBe(first.fingerprint); + } + const unmanagedFirst = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/custom/first") }); + const unmanagedNext = await buildSessionConfigMetadata({ effectiveAdapterConfig: config("/custom/next") }); + expect(unmanagedFirst.fingerprint).not.toBe(unmanagedNext.fingerprint); + }); + it("resets when effective adapter config changes after model/profile/env resolution", async () => { const base = await buildSessionConfigMetadata(); const next = await buildSessionConfigMetadata({ diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 1ee19dcf082..534f5cf023a 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -166,6 +166,32 @@ done`, } } +function managedAiHomeEnvironment(home: string): Record { + const providerHome = path.join(home, "provider"); + return { + 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, + }; +} + +/** Only the server-created credential home is volatile; retain all other config. */ +export function managedAiSessionFingerprintConfig( + config: Record, + managedHome: string | undefined, +): Record { + if (!managedHome) return config; + const env = { ...(config.env as Record | undefined) }; + const stable = managedAiHomeEnvironment(""); + for (const [key, value] of Object.entries(managedAiHomeEnvironment(managedHome))) { + if (env[key] === value) env[key] = stable[key]; + } + return { ...config, env }; +} + export async function prepareManagedAiRuntime( db: Db, input: { @@ -237,12 +263,7 @@ export async function prepareManagedAiRuntime( const env: Record = { ...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, + ...managedAiHomeEnvironment(home), }; const capability = AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[ @@ -287,6 +308,7 @@ export async function prepareManagedAiRuntime( accountName: selection.connection.name, accountOwnerUserId: selection.grant.subjectUserId, identity, + home, cleanup: async () => { try { if (subscriptionFile) { diff --git a/server/src/services/chat-interaction-publications.ts b/server/src/services/chat-interaction-publications.ts index 49ab8c0b3ff..cddec16dab7 100644 --- a/server/src/services/chat-interaction-publications.ts +++ b/server/src/services/chat-interaction-publications.ts @@ -17,9 +17,8 @@ import type { RequestConfirmationInteraction, SafeExternalChatCardAction, } from "@paperclipai/shared"; -import { readConfigFile } from "../config-file.js"; import { projectSafeChatPublication } from "./chat-publication-projection.js"; -import { safeChatTaskUrl } from "./chat-task-url.js"; +import { publicChatTaskUrl } from "./chat-task-url.js"; import { chatQuestionFormActionRecords, createChatQuestionFormDraft, @@ -110,16 +109,7 @@ function terminalNativeInteractionCopy( return null; } -export function publicChatInteractionTaskUrl(issueId: string): string | null { - const configured = - process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || - process.env.BETTER_AUTH_URL?.trim() || - process.env.BETTER_AUTH_BASE_URL?.trim() || - process.env.PAPERCLIP_PUBLIC_URL?.trim() || - readConfigFile()?.auth?.publicBaseUrl?.trim() || - process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim(); - return safeChatTaskUrl(configured, issueId); -} +export const publicChatInteractionTaskUrl = publicChatTaskUrl; /** * Provider callbacks carry this compact, cryptographically random action id. diff --git a/server/src/services/chat-task-url.test.ts b/server/src/services/chat-task-url.test.ts index 964a0a6d8c3..79f10a840a0 100644 --- a/server/src/services/chat-task-url.test.ts +++ b/server/src/services/chat-task-url.test.ts @@ -1,19 +1,33 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { readConfigFile } from "../config-file.js"; -import { safeChatTaskUrl } from "./chat-task-url.js"; +import { publicChatTaskUrl, safeChatTaskUrl } from "./chat-task-url.js"; import { safeMilestoneText } from "./chat-run-publications.js"; +import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { publicChatInteractionTaskUrl } from "./chat-interaction-publications.js"; vi.mock("../config-file.js", () => ({ readConfigFile: vi.fn(() => null), })); +vi.mock("./cloud-runtime-identity.js", () => ({ + runtimeCanonicalOrigin: vi.fn(() => null), +})); + afterEach(() => { + vi.mocked(runtimeCanonicalOrigin).mockReturnValue(null); vi.unstubAllEnvs(); vi.mocked(readConfigFile).mockReturnValue(null); }); describe("external Paperclip task links", () => { + it("resolves a newly claimed Cloud origin at use time ahead of pool configuration", () => { + vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://pool.example"); + expect(publicChatTaskUrl("issue-1")).toBe("https://pool.example/issues/issue-1"); + vi.mocked(runtimeCanonicalOrigin).mockReturnValue("https://vanity.example"); + expect(publicChatTaskUrl("issue-1")).toBe("https://vanity.example/issues/issue-1"); + expect(publicChatInteractionTaskUrl("issue-1")).toBe("https://vanity.example/issues/issue-1"); + }); + it("uses only the safe board origin and canonical issue path", () => { expect( safeChatTaskUrl( diff --git a/server/src/services/chat-task-url.ts b/server/src/services/chat-task-url.ts index 904548f47a0..a6926b7dc6f 100644 --- a/server/src/services/chat-task-url.ts +++ b/server/src/services/chat-task-url.ts @@ -1,3 +1,5 @@ +import { readConfigFile } from "../config-file.js"; +import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { sanitizeExternalChatUrl } from "./chat-publication-projection.js"; /** Omit unusable board links without weakening external publication safety. */ @@ -16,3 +18,16 @@ export function safeChatTaskUrl( return null; } } + +/** Resolve at use time so a claimed Cloud instance never advertises its pool URL. */ +export function publicChatTaskUrl(issueId: string): string | null { + const configured = + runtimeCanonicalOrigin() || + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || + process.env.BETTER_AUTH_URL?.trim() || + process.env.BETTER_AUTH_BASE_URL?.trim() || + process.env.PAPERCLIP_PUBLIC_URL?.trim() || + readConfigFile()?.auth?.publicBaseUrl?.trim() || + process.env.PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL?.trim(); + return safeChatTaskUrl(configured, issueId); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 50ad68d2669..e572e0a4fac 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,4 @@ +import { publicChatTaskUrl } from "./chat-task-url.js"; import { readQueuedInteractionResponse } from "./queued-interaction-response.js"; import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js"; import { PROCESS_IDENTITY_RECORDED, recordNativeLocalProcessStop } from "./native-local-process-stop.js"; @@ -8,7 +9,7 @@ import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminati import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js"; import { admitExplicitNativeContinuation, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js"; import { connectionIntentService } from "./connection-intents.js"; -import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js"; +import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, 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"; @@ -6463,6 +6464,7 @@ function buildSessionConfigCategoryValues(input: { export async function buildEffectiveRunSessionConfigMetadata(input: { adapterType: string; effectiveAdapterConfig: Record; + managedAiHome?: string; agentRuntimeConfig: unknown; issueOverrides: unknown; workspaceConfig: unknown; @@ -6480,7 +6482,7 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { ); const categoryValues = buildSessionConfigCategoryValues({ adapterType: input.adapterType, - effectiveAdapterConfig: input.effectiveAdapterConfig, + effectiveAdapterConfig: managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome), agentRuntimeConfig: input.agentRuntimeConfig, instructions, issueOverrides: input.issueOverrides, @@ -8583,6 +8585,21 @@ export function buildPaperclipTaskMarkdown(input: { (count, comment) => count + (comment.attachments?.length ?? 0), 0, ); + if (input.externalChatProvider && issue) { + const taskUrl = publicChatTaskUrl(issue.id); + lines.push( + "", + "Paperclip task link (server-provided):", + ...(taskUrl + ? [ + `- Public task URL: ${taskUrl}`, + "When asked for this task's link, use this exact URL. Do not construct a URL from task IDs, localhost, an API address, or a sandbox address. Opening it still requires Paperclip access.", + ] + : [ + "No public task URL is configured. If asked for a link, explain that a public Paperclip URL must be configured; do not invent a URL or expose an internal API or sandbox address.", + ]), + ); + } if (input.externalChatProvider && input.nativeRunner) { lines.push( "", @@ -21233,6 +21250,7 @@ export function heartbeatService( await buildEffectiveRunSessionConfigMetadata({ adapterType: agent.adapterType, effectiveAdapterConfig: runtimeConfig, + managedAiHome: managedAiRuntime?.home, agentRuntimeConfig: agent.runtimeConfig, issueOverrides: issueAssigneeOverrides, workspaceConfig: { diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 3ea436f3bba..1c8484b2e14 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -1,4 +1,5 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import * as cloudIdentity from "../cloud-runtime-identity.js"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; import { eq, inArray } from "drizzle-orm"; import { @@ -158,6 +159,25 @@ describe("PaperclipRunnerToolAuthority", () => { ).rejects.toThrow("paperclip_runner_tool_not_advertised"); }); + it("returns public task URLs from the current claimed origin in context and search results", async () => { + const origin = vi.spyOn(cloudIdentity, "runtimeCanonicalOrigin").mockReturnValue("https://board.example"); + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + try { + expect(await authority.execute({ tool: "get_task_context", callId: "public-task-url", arguments: {} })) + .toMatchObject({ activeTask: { id: issueId, url: `https://board.example/issues/${issueId}` } }); + expect(await authority.execute({ tool: "search_tasks", callId: "search-public-task-url", arguments: { query: "Exercise real runner tools" } })) + .toMatchObject({ tasks: expect.arrayContaining([expect.objectContaining({ id: issueId, url: `https://board.example/issues/${issueId}` })]) }); + origin.mockReturnValue("https://renamed.example"); + expect(await authority.execute({ tool: "get_task_context", callId: "renamed-task-url", arguments: {} })) + .toMatchObject({ activeTask: { url: `https://renamed.example/issues/${issueId}` } }); + origin.mockReturnValue("https://localhost"); + expect(await authority.execute({ tool: "get_task_context", callId: "unsafe-task-url", arguments: {} })) + .toMatchObject({ activeTask: { url: null } }); + } finally { + origin.mockRestore(); + } + }); + it("exposes existing child tasks on continuation without crossing company boundaries", async () => { const completedChildId = randomUUID(); const activeChildId = randomUUID(); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 855073b82ca..edfd54ffbf0 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,3 +1,4 @@ +import { publicChatTaskUrl } from "../chat-task-url.js"; import { assertAssignableAgent } from "../agent-assignability.js"; import { authorizationService } from "../authorization.js"; import { handoffPlanContext } from "./handoff-plan-context.js"; @@ -1657,6 +1658,7 @@ function redactedActor(actor: { function redactedTask(task: typeof issues.$inferSelect) { return { id: task.id, + url: publicChatTaskUrl(task.id), companyId: task.companyId, identifier: task.identifier, title: task.title,