Files
PaperClipAI/server/src/services/chat-task-url.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

120 lines
4.1 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { readConfigFile } from "../config-file.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(
"https://board.example/path?secret=ignored#fragment",
"issue-1",
),
).toBe("https://board.example/issues/issue-1");
expect(safeChatTaskUrl("https://board.example", "a/b?c")).toBe(
"https://board.example/issues/a%2Fb%3Fc",
);
});
it.each([
undefined,
null,
"",
"not a URL",
"http://board.example",
"http://127.0.0.1:3103",
"https://localhost",
"https://10.0.0.1",
"https://board.internal",
"https://user:secret@board.example",
])(
"omits unsafe or private board links before generating recovery copy (%s)",
(baseUrl) => {
expect(safeChatTaskUrl(baseUrl, "issue-1")).toBeNull();
expect(
safeMilestoneText({
agentName: "Maya",
milestone: "failed",
issueId: "issue-1",
publicBaseUrl: baseUrl,
}),
).toBe(
"Maya stopped before completing this turn. Open the task in Paperclip for details.",
);
},
);
it("does not use webhook ingress as the question or confirmation destination", () => {
for (const name of [
"PAPERCLIP_PUBLIC_URL",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
"PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL",
])
vi.stubEnv(name, "");
vi.stubEnv("PAPERCLIP_CHAT_WEBHOOK_PUBLIC_URL", "https://ingress.example");
expect(publicChatInteractionTaskUrl("issue-1")).toBeNull();
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://127.0.0.1:3103");
expect(publicChatInteractionTaskUrl("issue-1")).toBeNull();
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://board.example");
expect(publicChatInteractionTaskUrl("issue-1")).toBe(
"https://board.example/issues/issue-1",
);
vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", "https://canonical.example");
expect(publicChatInteractionTaskUrl("issue-1")).toBe(
"https://canonical.example/issues/issue-1",
);
});
it("uses the configured board origin ahead of the managed-runtime fallback", () => {
for (const name of [
"PAPERCLIP_PUBLIC_URL",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
])
vi.stubEnv(name, "");
vi.stubEnv(
"PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL",
"https://managed.example",
);
vi.mocked(readConfigFile).mockReturnValue({
auth: { publicBaseUrl: "https://configured.example" },
} as ReturnType<typeof readConfigFile>);
expect(publicChatInteractionTaskUrl("issue-1")).toBe(
"https://configured.example/issues/issue-1",
);
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://environment.example");
expect(publicChatInteractionTaskUrl("issue-1")).toBe(
"https://environment.example/issues/issue-1",
);
});
});