mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## 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>
120 lines
4.1 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|