Files
PaperClipAI/server/src/__tests__/heartbeat-chat-task-link.test.ts
T
DottaandPaperclip 2a99de80ec 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>
2026-09-19 20:40:05 -05:00

55 lines
2.7 KiB
TypeScript

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");
},
);
});