mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
1 parent
b193077582
commit
2a99de80ec
10 files changed
+211
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
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({
|
||||
|
||||
@@ -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(
|
||||
db: Db,
|
||||
input: {
|
||||
@@ -237,12 +263,7 @@ export async function prepareManagedAiRuntime(
|
||||
const env: Record<string, unknown> = {
|
||||
...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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user