Files
PaperClipAI/server/src/services/native-runtime/runtime-context.test.ts
T
Nicky LeachandPaperclip fa9f3f8a08 feat(runner): support preconfigured user homes for Codex and ACPX (#16486)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Runner launches the selected provider and keeps durable session
state.
> - Runner currently prepares private provider homes for each session.
> - Operators can already have working login, skills, and MCP
configuration on the execution machine.
> - This PR adds an explicit mode that uses that existing setup.
> - Runner keeps its task bridge and session ownership while leaving the
user's configuration in place.

## Linked Issues or Issue Description

**Problem or motivation:** Rebuilding a provider home is unnecessary
when the execution account is already configured.

**Proposed solution:** Add `homeMode: "preconfigured"` to Runner adapter
config and runnerd transport options. Keep `"isolated"` as the default.
Initially support native Codex and ACPX Claude.

**Alternatives considered:** Continue copying user configuration into a
private home, or add an approval workflow. Both add work outside this
feature's scope.

**Roadmap alignment:** This extends the existing cross-provider Runner.
It does not add another runtime.

Related work: #14056 covers managed Codex credentials. #14474 covers
command environment delivery. This mode uses user-owned credentials and
retains the existing launch boundaries.

## What Changed

- Add a closed v7 launch contract for the selected home mode. Existing
contracts retain their behavior.
- Keep homeMode under operator control. Reject task overrides and
agent-authenticated changes, including removal through replacements,
adapter changes, or rollback. Ignore stored task values and exclude it
from automatic hire inheritance.
- Skip managed AI login, assigned-skill materialization, and external
MCP setup in preconfigured mode. Preserve the Runner task/completion
bridge.
- Keep protocol state and provider lifetime fences scoped to the Runner
session. Different sessions can share a home.
- Fail preconfigured ACPX planning/read-only launches before provider
admission until a read-only user-home launch is qualified. Use isolated
mode or native Codex for planning.
- Reject ACPX approval requests. Keep native Codex on `never`. Local
permission rules and hooks remain effective. Initially require the
default Runner ACPX `approve-all` setting; other Runner policies are
unqualified in this mode and fail before launch.
- Keep the internal ACPX bridge in a stable session namespace,
preserving an existing user MCP server named `paperclip`.
- Reject preconfigured ACPX Codex before launch until a session
permission profile can enforce Runner-state denial. Remove the
unprotected full-access mode switch; use native Codex or isolated mode
for that model family.
- Bump Pi to profile 23 because its attestation covers shared Runner
source changed here. Preserve profile 22 for historical recovery, and
update Docker/package verification inputs.
- Fence recovery on mode and home bindings. Fail missing
provider-history recovery without starting a replacement session.
Exclude the external Codex home from backups and credential cleanup.
- Discover remote HOME on the execution machine. Do not project
controller HOME or XDG paths onto it.
- Separate task instructions from Codex ACP isolation and update the
generated provider digest.
- Keep remote configured-environment manifests consistent when dropping
controller paths. Preserve exact Claude model IDs through per-launch
environment settings, without editing the user home or patching the
provider.

## Verification

- Targeted local checks cover native contracts and launch allowlists,
user-config preservation, provider admission, permissions, session
recovery, remote environment projection, heartbeat preparation, and
operator-only configuration. Runner, shared, and server TypeScript
checks and the Rust binary build pass; generated ACPX profiles are
current.
- Following the latest master integration, all 757 server
configuration/executor tests and 115 Runner admission/recovery tests
passed. Additional review-fix checks passed: 103 planning contract/host
tests, 78 provider-backend tests, 95 route/preparation/inheritance
tests, 43 task-validator tests, and 43 workspace-policy tests. Counts
describe separate runs and can overlap.
- The real pinned Claude ACP/SDK regression reproduces exact-model
aliasing without the fix (`claude-sonnet-5-5` becomes `sonnet`) and
passes with the fix. All 89 adapter tests pass. User settings remain
byte-for-byte unchanged.
- Localhost-only Claude SDK probes with synthetic credentials verify the
private bridge runs, local `ask` reaches the existing reject callback,
local `deny` blocks without a callback, and a user MCP server named
`paperclip` remains available. No user model allowlist is required.
- Review regressions cover fail-closed provider admission, saved-mode
recovery before routing, controller-home path removal without
configured-environment manifest drift, pre-launch rejection of
unqualified read-only ACPX launches, and denial of task/agent changes to
the operator-owned home mode.
- Before rebase, `d4d4ee2aebe4cb88c4f4d9ef8e22fed4b36b87be` received
standard Greptile 5/5 with no unresolved threads. Prior Apex findings
are fixed and covered by regressions. No manual Greptile retrigger is
requested for this rebase, per reviewer direction.
- The prior PR head and master hit the same GitHub runner encoding crash
in workspace-b on three PR attempts, including diagnostic logging
([prior PR
CI](https://github.com/paperclipai/paperclip/actions/runs/38818474563/job/116714538405),
[prior master
CI](https://github.com/paperclipai/paperclip/actions/runs/38819528748/job/116712423193)).
Rebased onto `origin/master` at `c3ffa3f0dd` to pull in its bridge-test
index correction. The rebased head
`edd153db410bea063b645e0000aa68649f287b00` passes all CI checks ([CI
run](https://github.com/paperclipai/paperclip/actions/runs/38824676914)),
including workspace-b. Greptile's automatic standard review is 5/5 on
this exact head, with no unresolved threads; no manual review was
requested.
- Local workspace-b investigation passed 911 shared tests and 3,868
tests across the remaining 15 workspace projects. The catalog run
separately hit an existing ignored
`.agents/skills/design-guide/SKILL.md` description-budget violation in
this checkout; that file is untracked, outside the PR, and left
untouched. Repeating shared tests with CI reporting enabled also passed.
- Rebase verification: the final Git tree exactly matches the previously
reviewed branch combined with current master. All 192 Runner
admission/contract tests, 61 server preparation/provider tests, and 68
bridge tests pass, as do Runner/server TypeScript and generated-profile
checks. The real Claude adapter fixture hit an `ENOTEMPTY` cleanup race
on its initial run; all 89 adapter tests passed on retry.
- Apex on rebased head `edd153db` returned 2/5: ACPX Codex selected full
access without enforcing Runner-state denial, and agent configuration
replacement could remove the operator mode. Both are fixed in
`26e14dbbf`. Preconfigured ACPX Codex is temporarily unqualified;
re-enabling it requires session permission enforcement on thread/turn
operations and a real filesystem-denial probe. The config guard covers
effective replacements, adapter changes, and rollback while allowing
ordinary agent merges and operator removal. All 190 focused Runner
tests, 103 server tests, both typechecks, and generated-profile checks
pass. Standard review and CI passed before the next Apex request.
- CI on `26e14dbbf` passed 1,967 Runner-shard tests but hit the
previously observed `ENOTEMPTY` fixture cleanup race after successful
Claude model admission. `eb83284fb` adds bounded filesystem retries to
that fixture's teardown; all 87 adapter tests pass. Assertions and
production cleanup behavior are unchanged. Head
`eb83284fb42ee19a15d9a93b106c1bbb07e1695e` passes all 59 CI checks ([CI
run](https://github.com/paperclipai/paperclip/actions/runs/38829500857))
and standard Greptile at 5/5 with no unresolved threads. Apex also
completed at **5/5** on that exact head ([Apex
result](https://github.com/paperclipai/paperclip/pull/16486#issuecomment-6201559527)).
All review threads are resolved, the branch has no merge conflicts, and
the PR remains unmerged.
- No live paid-provider or remote-machine smoke was performed. The
lockfile remains unchanged under the repository's refresh-bot policy;
local dependency installation resolved the changed patch hash.

## Risks

This explicit opt-in grants Codex tools access to the execution home
(read-only in planning), including credentials. Native Codex separately
denies Runner-owned protocol state. ACPX Claude retains its existing
provider trust boundary, and the isolated default is unchanged.
Providers may refresh credentials and update their own metadata.

User configuration can block tools or the completion reporter. Existing
blocked results and failure diagnostics remain the recovery path. Edit
the local settings and retry; no new approval workflow is added.

External provider history remains on the execution machine and is not
backed up by Runner. Remote HOME discovery requires `getent` and fails
explicitly when unavailable. Other harnesses need separate
qualification.

The Codex ACP bundle digest changes. Existing sessions tied to the old
bundle may need a fresh session. No database migration is required.

## Model Used

OpenAI Codex, GPT-6, with reasoning and code execution. The exact
runtime model ID was not exposed.

## 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 described the issue in-PR following the feature issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run targeted 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 risks above

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-15 17:57:58 -07:00

489 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdtemp, mkdir, readFile, readdir, chmod, lstat, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import {
PAPERCLIP_OPERATIONAL_SKILL_KEY,
type PaperclipSkillEntry,
} from "@paperclipai/adapter-utils/server-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const serviceMocks = vi.hoisted(() => ({
exportFiles: vi.fn(),
readCommittedForRuntime: vi.fn(),
getEffectiveProfilesForAgent: vi.fn(),
githubBotConnectionIdsForRun: vi.fn(),
spekoToolsForSession: vi.fn(),
}));
vi.mock("../voice/speko-agent-tools.js", () => ({ spekoToolsForSession: serviceMocks.spekoToolsForSession }));
vi.mock("../chat-github-tools.js", () => ({
githubBotConnectionIdsForRun: serviceMocks.githubBotConnectionIdsForRun,
}));
vi.mock("../agent-instructions.js", () => ({
agentInstructionsService: () => ({ exportFiles: serviceMocks.exportFiles }),
agentInstructionsBundleMode: (agent: { adapterConfig?: { instructionsBundleMode?: string } }) => agent.adapterConfig?.instructionsBundleMode ?? null,
}));
vi.mock("../agent-instruction-revisions.js", () => ({
agentInstructionRevisionService: () => ({ readCommittedForRuntime: serviceMocks.readCommittedForRuntime }),
}));
vi.mock("../tool-access.js", () => ({
toolAccessService: () => ({
getEffectiveProfilesForAgent: serviceMocks.getEffectiveProfilesForAgent,
}),
}));
import { createHash } from "node:crypto";
import { context as otelContext, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { InMemorySpanExporter, SimpleSpanProcessor, TracerProvider } from "@opentelemetry/sdk-trace";
import { withRunExecutionTiming } from "../execution-preparation-timing.js";
import { buildNativeRuntimeContext, resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
const temporaryRoots: string[] = [];
let previousPaperclipHome: string | undefined;
let previousInstanceId: string | undefined;
async function makeTreeWritable(target: string): Promise<void> {
const metadata = await lstat(target).catch(() => null);
if (!metadata) return;
if (metadata.isDirectory()) {
await chmod(target, 0o700);
for (const entry of await readdir(target)) {
await makeTreeWritable(path.join(target, entry));
}
} else {
await chmod(target, 0o600);
}
}
beforeEach(async () => {
vi.clearAllMocks();
serviceMocks.readCommittedForRuntime.mockResolvedValue(null);
serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set());
serviceMocks.spekoToolsForSession.mockResolvedValue([]);
previousPaperclipHome = process.env.PAPERCLIP_HOME;
previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const root = await mkdtemp(path.join(tmpdir(), "paperclip-native-context-"));
temporaryRoots.push(root);
process.env.PAPERCLIP_HOME = root;
process.env.PAPERCLIP_INSTANCE_ID = "runtime_context_test";
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [{ effect: "include", connectionId: "connection-1" }],
bindings: [],
allowedTools: [{ id: "tool-1", connectionId: "connection-1" }],
allowedToolNames: ["issues.read"],
installedConnections: [{
id: "connection-1",
transport: "mcp_remote",
enabled: true,
status: "active",
healthStatus: "healthy",
}],
});
});
afterEach(async () => {
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId;
// A rejected Promise.all does not cancel the other materializers. Let their
// bounded local writes settle before removing the read-only asset tree.
await new Promise((resolve) => setTimeout(resolve, 25));
while (temporaryRoots.length) {
const root = temporaryRoots.pop()!;
await makeTreeWritable(root);
await rm(root, { recursive: true, force: true });
}
});
describe("buildNativeRuntimeContext", () => {
it("uses committed instructions when the disk projection is stale", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: { "AGENTS.md": "Old disk projection\n", "reference.md": "Sibling reference\n" },
});
serviceMocks.readCommittedForRuntime.mockResolvedValue({
revision: { entryFile: "AGENTS.md" }, content: "Saved canonical instructions\n",
});
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: { instructionsBundleMode: "managed" } },
runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [],
});
expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
.toBe("Saved canonical instructions\n");
expect(await readFile(path.join(context.instructions.bundle.rootPath, "reference.md"), "utf8"))
.toBe("Sibling reference\n");
expect(serviceMocks.readCommittedForRuntime).toHaveBeenCalledWith({ companyId: "company-1", agentId: "agent-1" });
});
it("does not substitute canonical managed instructions into an external bundle", async () => {
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "External instructions\n" } });
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterConfig: { instructionsBundleMode: "external" } },
runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [],
});
expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
.toBe("External instructions\n");
expect(serviceMocks.readCommittedForRuntime).not.toHaveBeenCalled();
});
it("keeps the pinned prompt immutable while exposing the registered editable instruction copy", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "instructions/CHARter.md",
files: { "instructions/CHARter.md": "Original instructions.\n" },
});
const workingRoot = path.join(temporaryRoots.at(-1)!, "private-run-copy");
await mkdir(path.join(workingRoot, "instructions"), { recursive: true });
await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Original instructions.\n");
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: {} },
runId: "run-1",
runtimeConfig: {},
runtimeSkillEntries: [],
instructionWorkingCopy: { rootPath: workingRoot, entryPath: "instructions/CHARter.md" },
});
expect(context.instructions.workingCopy).toEqual({ rootPath: workingRoot, entryPath: "instructions/CHARter.md" });
await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Persist this next time.\n");
expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
.toBe("Original instructions.\n");
});
it.each(["disabled", "degraded"] as const)(
"omits an unavailable native MCP connection when it is %s without aborting runtime context creation",
async (unavailableState) => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: { "AGENTS.md": "Continue work without unavailable apps.\n" },
});
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [{ effect: "include", connectionId: "connection-1" }],
bindings: [],
allowedTools: [{ id: "tool-1", connectionId: "connection-1" }],
allowedToolNames: ["issues.read"],
installedConnections: [{
id: "connection-1",
transport: "mcp_remote",
enabled: unavailableState !== "disabled",
status: unavailableState === "disabled" ? "disabled" : "active",
healthStatus: unavailableState === "degraded" ? "degraded" : "healthy",
}],
});
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
runtimeConfig: {},
runtimeSkillEntries: [],
});
expect(context.mcp.bindingId).toBeNull();
expect(context.mcp.assignmentSetId).toMatch(/^sha256:[a-f0-9]{64}$/);
},
);
it("keeps healthy native MCP connections when another assigned connection is unavailable", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: { "AGENTS.md": "Continue work with the apps that are available.\n" },
});
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
agentId: "agent-1",
profiles: [],
entries: [
{ effect: "include", connectionId: "connection-expired" },
{ effect: "include", connectionId: "connection-healthy" },
],
bindings: [],
allowedTools: [
{ id: "tool-expired", connectionId: "connection-expired" },
{ id: "tool-healthy", connectionId: "connection-healthy" },
],
allowedToolNames: ["expired.read", "healthy.read"],
installedConnections: [
{
id: "connection-expired",
transport: "mcp_remote",
enabled: true,
status: "active",
healthStatus: "degraded",
},
{
id: "connection-healthy",
transport: "mcp_remote",
enabled: true,
status: "active",
healthStatus: "healthy",
},
],
});
const context = await buildNativeRuntimeContext({
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
runtimeConfig: {},
runtimeSkillEntries: [],
});
expect(context.mcp.bindingId).toBe("native-mcp:run-1");
expect(context.mcp.assignmentSetId).toMatch(/^sha256:[a-f0-9]{64}$/);
});
it("materializes every instruction and selected-skill file as immutable, content-addressed context", async () => {
serviceMocks.exportFiles.mockResolvedValue({
entryFile: "AGENTS.md",
files: {
"AGENTS.md": "Follow the agent instructions.\n",
"references/policy.md": "Company policy sibling.\n",
},
});
const sourceRoot = await mkdtemp(path.join(tmpdir(), "paperclip-native-skill-"));
temporaryRoots.push(sourceRoot);
const selectedRoot = path.join(sourceRoot, "reviewer");
const unselectedRoot = path.join(sourceRoot, "unused");
await mkdir(path.join(selectedRoot, "references"), { recursive: true });
await mkdir(unselectedRoot, { recursive: true });
await writeFile(path.join(selectedRoot, "SKILL.md"), "# Reviewer\nUse the checklist.\n");
await writeFile(path.join(selectedRoot, "references", "checklist.md"), "- Verify tests\n");
await writeFile(path.join(unselectedRoot, "SKILL.md"), "# Not selected\n");
const entries: PaperclipSkillEntry[] = [
{ key: "company-1/reviewer", runtimeName: "reviewer", source: selectedRoot, versionId: "version-1" },
{ key: "company-1/unused", runtimeName: "unused", source: unselectedRoot, versionId: "version-2" },
];
const input = {
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
runtimeConfig: { paperclipSkillSync: { desiredSkills: ["company-1/reviewer"] } },
runtimeSkillEntries: entries,
};
const context = await buildNativeRuntimeContext(input);
const repeated = await buildNativeRuntimeContext(input);
expect(serviceMocks.exportFiles).toHaveBeenCalledWith(input.agent, { rejectSymlinks: true });
expect(serviceMocks.getEffectiveProfilesForAgent).toHaveBeenCalledWith("company-1", "agent-1");
expect(context.instructions.entryPath).toBe("AGENTS.md");
expect(await readFile(path.join(context.instructions.bundle.rootPath, "AGENTS.md"), "utf8"))
.toBe("Follow the agent instructions.\n");
expect(await readFile(path.join(context.instructions.bundle.rootPath, "references", "policy.md"), "utf8"))
.toBe("Company policy sibling.\n");
const unselected = await buildNativeRuntimeContext({ ...input, runtimeConfig: {} });
expect(unselected.skills).toEqual([]);
expect(context.skills).toHaveLength(1);
expect(context.skills[0]).toMatchObject({
key: "company-1/reviewer",
runtimeName: "reviewer",
versionId: "version-1",
});
expect(await readFile(path.join(context.skills[0]!.bundle.rootPath, "SKILL.md"), "utf8"))
.toContain("Use the checklist");
expect(await readFile(path.join(context.skills[0]!.bundle.rootPath, "references", "checklist.md"), "utf8"))
.toBe("- Verify tests\n");
expect((await stat(context.instructions.bundle.rootPath)).mode & 0o222).toBe(0);
expect((await stat(path.join(context.instructions.bundle.rootPath, "AGENTS.md"))).mode & 0o222).toBe(0);
expect((await stat(context.skills[0]!.bundle.rootPath)).mode & 0o222).toBe(0);
expect(context.mcp).toMatchObject({
bindingId: "native-mcp:run-1",
assignmentSetId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
digest: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(repeated.aggregateDigest).toBe(context.aggregateDigest);
expect(repeated.instructions.bundle.rootPath).toBe(context.instructions.bundle.rootPath);
expect(repeated.skills[0]!.bundle.rootPath).toBe(context.skills[0]!.bundle.rootPath);
});
it("does not materialize assigned skills or external MCP in preconfigured mode", async () => {
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "Test\n" } });
const context = await buildNativeRuntimeContext({ db: {} as Db,
agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: {} },
runId: "run-1", runtimeConfig: { homeMode: "preconfigured", paperclipSkillSync: { desiredSkills: ["missing"] } },
runtimeSkillEntries: [{ key: "missing", runtimeName: "missing", source: "/does/not/exist", sourceStatus: "missing" }],
});
expect(context.skills).toEqual([]);
expect(context.mcp.bindingId).toBeNull();
expect(serviceMocks.getEffectiveProfilesForAgent).not.toHaveBeenCalled();
});
it("fails closed for a missing assigned skill and omits a stale legacy operational skill", async () => {
serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "Test\n" } });
const base = {
db: {} as Db,
agent: {
id: "agent-1",
companyId: "company-1",
name: "Reviewer",
adapterType: "paperclip_runner",
adapterConfig: {},
},
runId: "run-1",
};
await expect(buildNativeRuntimeContext({
...base,
runtimeConfig: { paperclipSkillSync: { desiredSkills: ["company-1/missing"] } },
runtimeSkillEntries: [{
key: "company-1/missing",
runtimeName: "missing",
source: "/does/not/exist",
sourceStatus: "missing",
missingDetail: "assigned skill checkout is unavailable",
}],
})).rejects.toThrow("assigned skill checkout is unavailable");
const supportedRoot = await mkdtemp(path.join(tmpdir(), "paperclip-native-supported-skill-"));
temporaryRoots.push(supportedRoot);
await writeFile(path.join(supportedRoot, "SKILL.md"), "# Supported\n");
const context = await buildNativeRuntimeContext({
...base,
runtimeConfig: {
paperclipSkillSync: {
desiredSkills: [PAPERCLIP_OPERATIONAL_SKILL_KEY, "company-1/supported"],
},
},
runtimeSkillEntries: [
{
key: PAPERCLIP_OPERATIONAL_SKILL_KEY,
runtimeName: "paperclip",
source: "/unused",
},
{
key: "company-1/supported",
runtimeName: "supported",
source: supportedRoot,
},
],
});
expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]);
});
});
it("pins both permitted GitHub tool catalogs even when another user’s health probe reports missing credentials", async () => {
const connections = ["github-A", "github-B"].map((id) => ({
id, status: "active", enabled: true, healthStatus: "missing_secret", transport: "mcp_remote",
config: { sourceTemplateKey: "github" }, transportConfig: {},
}));
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
entries: connections.map((connection) => ({ effect: "include", connectionId: connection.id })),
installedConnections: connections,
allowedTools: connections.map((connection) => ({ id: `${connection.id}-get_me`, connectionId: connection.id })),
});
const limit = vi.fn(async () => [{ responsibleUserId: "A", activeIdentityContextId: "context-A" }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" });
const expected = createHash("sha256").update(JSON.stringify({
version: 1, agentId: "agent-1", connections: ["github-A", "github-B"],
tools: ["github-A-get_me", "github-B-get_me"],
})).digest("hex");
expect(snapshot.digest).toBe(expected);
expect(snapshot.bindingId).toBe("native-mcp:run-1");
expect(limit).toHaveBeenCalledOnce();
});
it("pins channel tools only when the current task run is bound to that bot", async () => {
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
entries: [{ effect: "include", connectionId: "bot-connection" }],
installedConnections: [{
id: "bot-connection", status: "active", enabled: true, healthStatus: "healthy",
transport: "chat_sdk", config: { sourceTemplateKey: "github-chat" }, transportConfig: {},
}],
allowedTools: [{ id: "bot-read-pr", connectionId: "bot-connection" }],
});
const input = { db: {} as Db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" };
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBeNull();
serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set(["bot-connection"]));
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBe("native-mcp:run-1");
expect(serviceMocks.githubBotConnectionIdsForRun).toHaveBeenLastCalledWith(input.db, "company-1", "agent-1", "run-1");
});
it("pins a voice tool only when the current requester can call through that assigned endpoint", async () => {
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] });
const limit = vi.fn().mockResolvedValue([{ contextSnapshot: { issueId: "task-1" }, activeIdentityContextId: "identity-1" }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const input = { db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" };
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBeNull();
serviceMocks.spekoToolsForSession.mockResolvedValue([{ connectionId: "voice-connection" }]);
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBe("native-mcp:run-1");
expect(serviceMocks.spekoToolsForSession).toHaveBeenLastCalledWith(db, { companyId: "company-1", agentId: "agent-1", runId: "run-1", issueId: "task-1", identityContextId: "identity-1" });
});
it("keeps instructions, selected skills, and MCP concurrent and distinguishes cold materialization from warm verification", async () => {
vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector.test");
const exporter = new InMemorySpanExporter();
const provider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] });
const manager = new AsyncLocalStorageContextManager().enable();
expect(otelContext.setGlobalContextManager(manager)).toBe(true);
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
serviceMocks.exportFiles.mockImplementation(async () => {
await gate;
return { entryFile: "AGENTS.md", files: { "AGENTS.md": "Private fixture instructions" } };
});
const source = path.join(temporaryRoots.at(-1)!, "selected-skill");
await mkdir(source);
await writeFile(path.join(source, "SKILL.md"), "Private skill fixture");
const input = {
db: {} as Db, agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterConfig: {} },
runId: "concurrent-context", runtimeConfig: { paperclipSkillSync: { desiredSkills: ["selected"] } },
runtimeSkillEntries: [{ key: "selected", runtimeName: "selected", source }],
};
const pending = withRunExecutionTiming(input.runId, () => buildNativeRuntimeContext(input));
try {
await vi.waitFor(() => {
expect(exporter.getFinishedSpans().map(span => span.name)).toEqual(expect.arrayContaining(["runtime_context.skills", "runtime_context.mcp"]));
});
expect(exporter.getFinishedSpans().some(span => span.name === "runtime_context.instructions")).toBe(false);
release();
const cold = await pending;
expect(cold.skills).toHaveLength(1);
expect(exporter.getFinishedSpans().some(span => span.name === "runtime_context.asset_materialize")).toBe(true);
exporter.reset();
const warm = await withRunExecutionTiming(input.runId, () => buildNativeRuntimeContext(input));
expect(warm.aggregateDigest).toBe(cold.aggregateDigest);
const spans = exporter.getFinishedSpans();
expect(spans.some(span => span.name === "runtime_context.asset_verify")).toBe(true);
expect(spans.some(span => span.name === "runtime_context.asset_materialize")).toBe(false);
expect(JSON.stringify(spans.map(span => span.attributes))).not.toContain("Private");
const siblings = spans.filter(span => ["runtime_context.instructions", "runtime_context.skills", "runtime_context.mcp"].includes(span.name));
expect(siblings).toHaveLength(3);
expect(new Set(siblings.map(span => span.parentSpanContext?.spanId)).size).toBe(1);
} finally {
release();
await pending.catch(() => undefined);
trace.disable(); otelContext.disable(); manager.disable();
await provider.shutdown();
vi.unstubAllEnvs();
}
});