fix: supply public task links and preserve managed AI sessions (#13699)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat connectors deliver agent replies to external conversations.
> - Agents need a public task link when a user asks to open the task.
> - The prompt and task tools lacked that link, so an agent could invent
an internal address.
> - Managed AI credential directories also changed the session
fingerprint on each run.
> - This change supplies public task URLs and excludes only those
temporary directory values from the fingerprint.
> - Follow-up messages can reuse compatible sessions while real
configuration changes still reset them.

## Linked Issues or Issue Description

Refs #13680 and #13694 for the related Cloud-origin fixes. No duplicate
open PR was found.

**What happened?**

An external chat reply could contain an invented internal task URL. The
publication filter then removed the link. Follow-up runs also lost their
saved provider session because each managed credential home used a
different temporary path.

**Expected behavior**

Agents receive the current public board URL for a task. Temporary
credential directories do not reset an otherwise compatible session.
Account, credential, model, permission, and custom environment changes
still invalidate it.

**Steps to reproduce**

1. Use a chat connector with a managed AI connection.
2. Ask for the current task link.
3. Send a follow-up message with the same agent configuration.
4. Inspect the task URL and the session reset reason.

**Paperclip version or commit**

Reproduced on the source at b193077582.

**Deployment mode**

Cloud staging with a native runner.

## What Changed

- Supply an exact public task URL in fresh and resumed external-chat
context.
- Return a nullable `url` on task records in `get_task_context` and
`search_tasks`, including child tasks.
- Resolve the current claimed Cloud origin at use time. Preserve the
existing safe-URL filter and omit unsafe links.
- Normalize only the exact credential-home paths created by the managed
AI runtime when comparing session configuration. Do not change the
actual execution environment.
- Add regression tests and update the connector playbook.

## Verification

- 255 focused tests passed for public URLs, prompt context, AI
connections, and session configuration.
- 19 native task-tool tests passed, including public URL lookup, origin
changes, and unsafe URL rejection.
- Full workspace typecheck and build passed locally. The full local test
run is still in progress; all CI test lanes passed at the final head.
- All 54 CI checks passed, with two optional jobs skipped. Greptile
scored 5/5 with no review comments.
- After merge, deploy the exact merged commit to the authorized staging
stack. Check a fresh Slack mention, a same-thread reply, the returned
task link, and the measured session reuse and response time.

## Risks

- Existing saved fingerprints can reset once after this change. Future
runs should reuse a compatible session.
- The fingerprint change applies to managed AI connections. Tests
preserve resets for real account, credential, model, permission, and
environment changes.
- A public task URL still requires normal Paperclip access. This change
grants no access and keeps internal URL filtering.
- This change does not remove all runner startup or checkpoint latency.
Staging measurements are still required.
- No schema or migration changes.

## Model Used

OpenAI GPT-6 through Codex, with code editing, tool use, and test
execution. The exact serving model ID and context-window size were not
exposed by this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-19 20:40:05 -05:00
1 parent b193077582
commit 2a99de80ec
10 files changed
+211 -22

No files matched your search

+14
View File
@@ -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. | | 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. | | 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 For chat/email, also verify the applicable UX states in
[Chat connector UX](./CHAT-CONNECTOR-UX.md#apply-and-verify): new connections deny [Chat connector UX](./CHAT-CONNECTOR-UX.md#apply-and-verify): new connections deny
unlinked people by default; linking requires ownership confirmation; nonmembers unlinked people by default; linking requires ownership confirmation; nonmembers
@@ -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");
},
);
});
@@ -2210,6 +2210,46 @@ function sessionParamsWithConfigMetadata(
} }
describe("effective run session config freshness", () => { 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 () => { it("resets when effective adapter config changes after model/profile/env resolution", async () => {
const base = await buildSessionConfigMetadata(); const base = await buildSessionConfigMetadata();
const next = await buildSessionConfigMetadata({ const next = await buildSessionConfigMetadata({
+28 -6
View File
@@ -166,6 +166,32 @@ done`,
} }
} }
function managedAiHomeEnvironment(home: string): Record<string, string> {
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<string, unknown>,
managedHome: string | undefined,
): Record<string, unknown> {
if (!managedHome) return config;
const env = { ...(config.env as Record<string, unknown> | undefined) };
const stable = managedAiHomeEnvironment("<managed-ai-home>");
for (const [key, value] of Object.entries(managedAiHomeEnvironment(managedHome))) {
if (env[key] === value) env[key] = stable[key];
}
return { ...config, env };
}
export async function prepareManagedAiRuntime( export async function prepareManagedAiRuntime(
db: Db, db: Db,
input: { input: {
@@ -237,12 +263,7 @@ export async function prepareManagedAiRuntime(
const env: Record<string, unknown> = { const env: Record<string, unknown> = {
...stripAiAuthBindings(input.config.env), ...stripAiAuthBindings(input.config.env),
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])), ...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
HOME: home, ...managedAiHomeEnvironment(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 = const capability =
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[ AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
@@ -287,6 +308,7 @@ export async function prepareManagedAiRuntime(
accountName: selection.connection.name, accountName: selection.connection.name,
accountOwnerUserId: selection.grant.subjectUserId, accountOwnerUserId: selection.grant.subjectUserId,
identity, identity,
home,
cleanup: async () => { cleanup: async () => {
try { try {
if (subscriptionFile) { if (subscriptionFile) {
@@ -17,9 +17,8 @@ import type {
RequestConfirmationInteraction, RequestConfirmationInteraction,
SafeExternalChatCardAction, SafeExternalChatCardAction,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { readConfigFile } from "../config-file.js";
import { projectSafeChatPublication } from "./chat-publication-projection.js"; import { projectSafeChatPublication } from "./chat-publication-projection.js";
import { safeChatTaskUrl } from "./chat-task-url.js"; import { publicChatTaskUrl } from "./chat-task-url.js";
import { import {
chatQuestionFormActionRecords, chatQuestionFormActionRecords,
createChatQuestionFormDraft, createChatQuestionFormDraft,
@@ -110,16 +109,7 @@ function terminalNativeInteractionCopy(
return null; return null;
} }
export function publicChatInteractionTaskUrl(issueId: string): string | null { export const publicChatInteractionTaskUrl = publicChatTaskUrl;
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);
}
/** /**
* Provider callbacks carry this compact, cryptographically random action id. * Provider callbacks carry this compact, cryptographically random action id.
+15 -1
View File
@@ -1,19 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { readConfigFile } from "../config-file.js"; 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 { safeMilestoneText } from "./chat-run-publications.js";
import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js";
import { publicChatInteractionTaskUrl } from "./chat-interaction-publications.js"; import { publicChatInteractionTaskUrl } from "./chat-interaction-publications.js";
vi.mock("../config-file.js", () => ({ vi.mock("../config-file.js", () => ({
readConfigFile: vi.fn(() => null), readConfigFile: vi.fn(() => null),
})); }));
vi.mock("./cloud-runtime-identity.js", () => ({
runtimeCanonicalOrigin: vi.fn(() => null),
}));
afterEach(() => { afterEach(() => {
vi.mocked(runtimeCanonicalOrigin).mockReturnValue(null);
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.mocked(readConfigFile).mockReturnValue(null); vi.mocked(readConfigFile).mockReturnValue(null);
}); });
describe("external Paperclip task links", () => { 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", () => { it("uses only the safe board origin and canonical issue path", () => {
expect( expect(
safeChatTaskUrl( safeChatTaskUrl(
+15
View File
@@ -1,3 +1,5 @@
import { readConfigFile } from "../config-file.js";
import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js";
import { sanitizeExternalChatUrl } from "./chat-publication-projection.js"; import { sanitizeExternalChatUrl } from "./chat-publication-projection.js";
/** Omit unusable board links without weakening external publication safety. */ /** Omit unusable board links without weakening external publication safety. */
@@ -16,3 +18,16 @@ export function safeChatTaskUrl(
return null; 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);
}
+20 -2
View File
@@ -1,3 +1,4 @@
import { publicChatTaskUrl } from "./chat-task-url.js";
import { readQueuedInteractionResponse } from "./queued-interaction-response.js"; import { readQueuedInteractionResponse } from "./queued-interaction-response.js";
import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.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"; 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 { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
import { admitExplicitNativeContinuation, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js"; import { admitExplicitNativeContinuation, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js";
import { connectionIntentService } from "./connection-intents.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 { aiConnectionBindingSchema } from "@paperclipai/shared";
import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js"; import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js";
import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.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: { export async function buildEffectiveRunSessionConfigMetadata(input: {
adapterType: string; adapterType: string;
effectiveAdapterConfig: Record<string, unknown>; effectiveAdapterConfig: Record<string, unknown>;
managedAiHome?: string;
agentRuntimeConfig: unknown; agentRuntimeConfig: unknown;
issueOverrides: unknown; issueOverrides: unknown;
workspaceConfig: unknown; workspaceConfig: unknown;
@@ -6480,7 +6482,7 @@ export async function buildEffectiveRunSessionConfigMetadata(input: {
); );
const categoryValues = buildSessionConfigCategoryValues({ const categoryValues = buildSessionConfigCategoryValues({
adapterType: input.adapterType, adapterType: input.adapterType,
effectiveAdapterConfig: input.effectiveAdapterConfig, effectiveAdapterConfig: managedAiSessionFingerprintConfig(input.effectiveAdapterConfig, input.managedAiHome),
agentRuntimeConfig: input.agentRuntimeConfig, agentRuntimeConfig: input.agentRuntimeConfig,
instructions, instructions,
issueOverrides: input.issueOverrides, issueOverrides: input.issueOverrides,
@@ -8583,6 +8585,21 @@ export function buildPaperclipTaskMarkdown(input: {
(count, comment) => count + (comment.attachments?.length ?? 0), (count, comment) => count + (comment.attachments?.length ?? 0),
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) { if (input.externalChatProvider && input.nativeRunner) {
lines.push( lines.push(
"", "",
@@ -21233,6 +21250,7 @@ export function heartbeatService(
await buildEffectiveRunSessionConfigMetadata({ await buildEffectiveRunSessionConfigMetadata({
adapterType: agent.adapterType, adapterType: agent.adapterType,
effectiveAdapterConfig: runtimeConfig, effectiveAdapterConfig: runtimeConfig,
managedAiHome: managedAiRuntime?.home,
agentRuntimeConfig: agent.runtimeConfig, agentRuntimeConfig: agent.runtimeConfig,
issueOverrides: issueAssigneeOverrides, issueOverrides: issueAssigneeOverrides,
workspaceConfig: { workspaceConfig: {
@@ -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 { randomUUID } from "node:crypto";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { import {
@@ -158,6 +159,25 @@ describe("PaperclipRunnerToolAuthority", () => {
).rejects.toThrow("paperclip_runner_tool_not_advertised"); ).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 () => { it("exposes existing child tasks on continuation without crossing company boundaries", async () => {
const completedChildId = randomUUID(); const completedChildId = randomUUID();
const activeChildId = randomUUID(); const activeChildId = randomUUID();
@@ -1,3 +1,4 @@
import { publicChatTaskUrl } from "../chat-task-url.js";
import { assertAssignableAgent } from "../agent-assignability.js"; import { assertAssignableAgent } from "../agent-assignability.js";
import { authorizationService } from "../authorization.js"; import { authorizationService } from "../authorization.js";
import { handoffPlanContext } from "./handoff-plan-context.js"; import { handoffPlanContext } from "./handoff-plan-context.js";
@@ -1657,6 +1658,7 @@ function redactedActor(actor: {
function redactedTask(task: typeof issues.$inferSelect) { function redactedTask(task: typeof issues.$inferSelect) {
return { return {
id: task.id, id: task.id,
url: publicChatTaskUrl(task.id),
companyId: task.companyId, companyId: task.companyId,
identifier: task.identifier, identifier: task.identifier,
title: task.title, title: task.title,