Files
PaperClipAI/packages/adapter-utils/src/acpx-engine/execute-identity.test.ts
T
Dotta 0834a0c1f7 feat(runner): bind ACPX profile boundary (#12387)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Paperclip runner needs a safe boundary before it can launch
ACP-compatible agents.
> - A caller-controlled command, model, environment, or frame could
bypass that boundary.
> - The ACPX transport contract in #12386 defines the allowed messages
but does not bind a runtime profile.
> - This pull request defines closed, versioned profiles and validates
the launch inputs around that contract.
> - The benefit is a small and reviewable trust boundary before any ACPX
process can become available.

## Linked Issues or Issue Description

**Agent or provider**

ACPX sidecar support for the qualified Pi, Claude, and Codex ACP
servers.

**Why this adapter is useful**

The runner needs one bounded process boundary for ACP-compatible
providers. A closed profile prevents an untrusted run from selecting an
arbitrary executable, package version, or model.

**How the agent is invoked**

A later pull request will launch an internal sidecar from an exact
profile. This pull request only validates profiles, environment values,
and protocol frames. It does not add an executable dependency or enable
an adapter.

**Additional context**

This pull request is stacked on #12386. It keeps the existing direct
adapters and the Codex runner path unchanged.

## What Changed

- Add a closed profile table for the qualified Pi, Claude, and Codex ACP
servers.
- Require the exact qualified model and return an isolated profile value
to callers.
- Add an agent-specific environment allowlist with entry and aggregate
size limits.
- Add strict parsing for bounded sidecar requests and structured plan
values.
- Reject unknown fields, unsupported protocol versions, invalid
identifiers, null bytes, cyclic values, and oversized input.

## Verification

- Runner TypeScript typecheck — passed.
- Runner TypeScript tests — 40 files and 362 Vitest tests passed; 11
Node contract tests passed.
- `pnpm -r typecheck` — passed for all applicable workspaces.
- `pnpm build` — passed, including runner binary, server, UI, and
workspace packages.
- Prettier and `git diff --check` — passed.
- The diff contains 6 files and does not change `pnpm-lock.yaml`, a
workflow, a package dependency, or a public export.

## Risks

The main risk is accepting more launch state than the sidecar needs. The
implementation uses an agent-specific allowlist, rejects null bytes, and
enforces per-entry and aggregate bounds. This pull request does not
launch a process or expose a new adapter, so production and
direct-adapter behavior remain unchanged.

## Model Used

OpenAI Codex with GPT-5 and repository tool use.

## 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 linked an existing public item or described the
issue in this PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal task
identifier
- [x] I have run the affected tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have documented the compatibility and security boundary
- [ ] All applicable GitHub Actions are green
- [ ] Greptile is 5/5 with every actionable comment resolved
- [x] I will address all review findings before requesting merge
2026-08-30 12:37:13 -05:00

264 lines
9.8 KiB
TypeScript

// Identity split and launch-environment finalization tests (Phase 17).
//
// Two tests are compile-time: the real assertion is that this file typechecks.
// A `@ts-expect-error` marks an assignment that MUST fail; if it starts to
// compile, tsc reports the unused directive and the typecheck fails. Each
// compile-time assertion lives in a declared, unexecuted function, so vitest
// never runs a synthetic value.
import { describe, expect, it } from "vitest";
import {
buildSessionFingerprint,
finalizeLaunchEnvironment,
projectAcpxInheritedHostEnvironment,
} from "./execute.js";
import type {
LaunchEnvironment,
RunScopedContribution,
RunSiteReuseCandidate,
SessionFingerprintIdentity,
} from "./run-contracts.js";
// A complete fingerprint identity with representative values for the 17 fields.
const SAMPLE_FINGERPRINT_IDENTITY: SessionFingerprintIdentity = {
acpxAgent: "claude",
agentCommand: "claude",
cwd: "/work",
mode: "persistent",
permissionMode: "approve-all",
nonInteractivePermissions: "deny",
requestedModel: "",
requestedThinkingEffort: "",
fastMode: false,
remoteExecutionIdentity: null,
additionalSourcesIdentity: {},
skillsIdentity: {},
skillPromptInstructions: "",
paperclipClaudeSettings: null,
mcpServers: [],
secretManifestHash: "0000",
adapterEnvHash: "0000",
};
describe("acpx identity split and launch environment", () => {
it("test_fingerprint_builder_accepts_only_session_fingerprint_identity", () => {
// The builder accepts a fingerprint identity and returns a stable hash.
const first = buildSessionFingerprint(SAMPLE_FINGERPRINT_IDENTITY);
const second = buildSessionFingerprint(SAMPLE_FINGERPRINT_IDENTITY);
expect(first).toBe(second);
expect(first).toMatch(/^[0-9a-f]{16}$/);
// The compile-time assertions live in an unexecuted function.
function _assertOnlyIdentity(): void {
// An outer-key field is not a fingerprint field, so the builder rejects it.
// @ts-expect-error companyId is not a SessionFingerprintIdentity field.
buildSessionFingerprint({ ...SAMPLE_FINGERPRINT_IDENTITY, companyId: "c" });
// @ts-expect-error taskKey is not a SessionFingerprintIdentity field.
buildSessionFingerprint({ ...SAMPLE_FINGERPRINT_IDENTITY, taskKey: "t" });
}
void _assertOnlyIdentity;
});
it("test_finalize_launch_environment_is_sole_constructor_of_branded_environment", () => {
// The finalizer merges every contribution and freezes the launch env.
const baseEnv: Record<string, string> = { LAUNCH_ENV_TEST_BASE: "base" };
const launchEnvironment = finalizeLaunchEnvironment(
baseEnv,
[{ scope: "session", env: { LAUNCH_ENV_TEST_CONTRIB: "contrib" } }],
{
acpxAgent: "claude",
inheritHostEnvironment: true,
inheritedEnv: {},
},
);
expect(launchEnvironment.env.LAUNCH_ENV_TEST_BASE).toBe("base");
expect(launchEnvironment.env.LAUNCH_ENV_TEST_CONTRIB).toBe("contrib");
expect(Object.isFrozen(launchEnvironment.env)).toBe(true);
// No plain object literal can stand in for the branded environment; it lacks
// the private brand that only `finalizeLaunchEnvironment` sets.
function _assertBrand(): void {
// @ts-expect-error missing the private launch-environment brand.
const _fake: LaunchEnvironment = { env: {} };
void _fake;
}
void _assertBrand;
});
it("test_run_scoped_contribution_is_not_assignable_to_a_reuse_payload", () => {
// A run-scoped contribution and the branded launch environment must never
// enter a reuse payload (Amendment B). The reuse candidate types reject both.
function _assertNotReusable(
contribution: RunScopedContribution,
launchEnvironment: LaunchEnvironment,
): void {
// @ts-expect-error a run-scoped contribution is not a reuse candidate.
const _a: RunSiteReuseCandidate = contribution;
// @ts-expect-error the branded launch environment is not a reuse candidate.
const _b: RunSiteReuseCandidate = launchEnvironment;
void _a;
void _b;
}
void _assertNotReusable;
expect(true).toBe(true);
});
it("inherits only safe host context and the selected provider's credentials", () => {
const inherited = {
PATH: "/usr/bin",
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
OPENAI_API_KEY: "openai-host-secret",
ANTHROPIC_API_KEY: "anthropic-host-secret",
ANTHROPIC_AUTH_TOKEN: "anthropic-auth-host-secret",
CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth-host-secret",
ANTHROPIC_BASE_URL: "https://anthropic.example",
ANTHROPIC_MODEL: "claude-test",
ANTHROPIC_SMALL_FAST_MODEL: "claude-fast-test",
CLAUDE_CONFIG_DIR: "/host/claude",
CLAUDE_CODE_USE_BEDROCK: "true",
ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example",
AWS_BEARER_TOKEN_BEDROCK: "bedrock-host-secret",
OPENROUTER_API_KEY: "openrouter-host-secret",
GOOGLE_GENAI_USE_GCA: "true",
KIMI_MODEL_NAME: "kimi-code/test",
KIMI_MODEL_API_KEY: "kimi-host-secret",
KIMI_MODEL_BASE_URL: "https://kimi.example",
KIMI_MODEL_PROVIDER_TYPE: "openai_legacy",
KIMI_CODE_HOME: "/host/kimi",
PAPERCLIP_NATIVE_MCP_TOKEN: "native-mcp-host-secret",
PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "managed-auth-host-secret",
UNRELATED_SECRET: "unrelated-host-secret",
NODE_OPTIONS: "--require /tmp/host-hook.cjs",
};
expect(projectAcpxInheritedHostEnvironment(inherited, "codex", true)).toEqual({
PATH: "/usr/bin",
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
OPENAI_API_KEY: "openai-host-secret",
});
expect(projectAcpxInheritedHostEnvironment(inherited, "claude", true)).toEqual({
PATH: "/usr/bin",
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
ANTHROPIC_API_KEY: "anthropic-host-secret",
ANTHROPIC_AUTH_TOKEN: "anthropic-auth-host-secret",
CLAUDE_CODE_OAUTH_TOKEN: "claude-oauth-host-secret",
ANTHROPIC_BASE_URL: "https://anthropic.example",
ANTHROPIC_MODEL: "claude-test",
ANTHROPIC_SMALL_FAST_MODEL: "claude-fast-test",
CLAUDE_CONFIG_DIR: "/host/claude",
CLAUDE_CODE_USE_BEDROCK: "true",
ANTHROPIC_BEDROCK_BASE_URL: "https://bedrock.example",
AWS_BEARER_TOKEN_BEDROCK: "bedrock-host-secret",
});
expect(projectAcpxInheritedHostEnvironment(inherited, "pi", true)).toEqual({
PATH: "/usr/bin",
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
OPENROUTER_API_KEY: "openrouter-host-secret",
});
expect(projectAcpxInheritedHostEnvironment(inherited, "gemini", true)).toEqual({
PATH: "/usr/bin",
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
GOOGLE_GENAI_USE_GCA: "true",
});
expect(projectAcpxInheritedHostEnvironment(inherited, "kimi", true)).toEqual({
PATH: "/usr/bin",
LC_ALL: "C.UTF-8",
HTTPS_PROXY: "https://proxy.example",
KIMI_MODEL_NAME: "kimi-code/test",
KIMI_MODEL_API_KEY: "kimi-host-secret",
KIMI_MODEL_BASE_URL: "https://kimi.example",
KIMI_MODEL_PROVIDER_TYPE: "openai_legacy",
KIMI_CODE_HOME: "/host/kimi",
});
});
it("does not project any ambient host environment across a remote boundary", () => {
const inherited = {
PATH: "/host/bin",
OPENAI_API_KEY: "ambient-provider-secret",
PAPERCLIP_NATIVE_MCP_TOKEN: "ambient-native-mcp-secret",
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "ambient-bootstrap-secret",
};
expect(projectAcpxInheritedHostEnvironment(inherited, "codex", false)).toEqual({});
});
it("keeps explicit remote adapter and run contributions while rejecting ambient authority", () => {
const launchEnvironment = finalizeLaunchEnvironment(
{
OPENAI_API_KEY: "explicit-provider-secret",
EXPLICIT_ADAPTER_SECRET: "adapter-secret",
PAPERCLIP_RUNTIME_API_URL: "http://paperclip.internal/api",
},
[
{
scope: "session",
env: { PAPERCLIP_NATIVE_MCP_TOKEN: "explicit-run-contribution" },
},
],
{
acpxAgent: "codex",
inheritHostEnvironment: false,
inheritedEnv: {
PATH: "/host/bin",
OPENAI_API_KEY: "ambient-provider-secret",
PAPERCLIP_NATIVE_MCP_TOKEN: "ambient-native-mcp-secret",
PAPERCLIP_RUNNER_BOOTSTRAP_TICKET: "ambient-bootstrap-secret",
},
},
);
expect(launchEnvironment.env).toMatchObject({
OPENAI_API_KEY: "explicit-provider-secret",
EXPLICIT_ADAPTER_SECRET: "adapter-secret",
PAPERCLIP_RUNTIME_API_URL: "http://paperclip.internal/api",
PAPERCLIP_NATIVE_MCP_TOKEN: "explicit-run-contribution",
});
expect(launchEnvironment.env).not.toHaveProperty("PATH");
expect(launchEnvironment.env).not.toHaveProperty("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET");
});
it("preserves an explicit remote PATH instead of synthesizing a host fallback", () => {
const launchEnvironment = finalizeLaunchEnvironment(
{ PATH: "/sandbox/bin" },
[],
{
acpxAgent: "codex",
inheritHostEnvironment: false,
inheritedEnv: { PATH: "/host/bin" },
},
);
expect(launchEnvironment.env).toEqual({ PATH: "/sandbox/bin" });
});
it("lets explicit Windows env values suppress differently-cased ambient values", () => {
const launchEnvironment = finalizeLaunchEnvironment(
{
openai_api_key: "",
Path: "C:\\explicit\\bin",
},
[],
{
acpxAgent: "codex",
inheritHostEnvironment: true,
inheritedEnv: {
OPENAI_API_KEY: "ambient-provider-secret",
PATH: "C:\\ambient\\bin",
},
platform: "win32",
},
);
expect(launchEnvironment.env).toEqual({
openai_api_key: "",
Path: "C:\\explicit\\bin",
});
});
});