mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The `pi_local` adapter spawns the Pi CLI as a child process for
agent runs, environment tests, and model discovery
> - Each of these three paths builds the child env with `{
...process.env, ...env }`, so the child inherits the full server
environment
> - The server environment can hold server-only `PAPERCLIP_*` secrets
and config paths, such as `PAPERCLIP_AGENT_JWT_SECRET`,
`PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`, and
`PAPERCLIP_SECRETS_MASTER_KEY_FILE`
> - `@paperclipai/adapter-utils` already has
`sanitizeInheritedPaperclipEnv`, and the shared `runChildProcess` path
uses it for its base env. `pi_local` passes its pre-merged env as
`opts.env`, which `runChildProcess` spreads over that sanitized base, so
the inherited server values come back
> - This pull request routes the three `pi_local` merges through
`sanitizeInheritedPaperclipEnv`
> - The benefit is that `pi_local` agent processes no longer receive
server secrets. This is a security hardening change.
## Linked Issues or Issue Description
No issue exists. This is a bug report in the in-PR format.
**What happened?**
A `pi_local` agent process receives the inherited `PAPERCLIP_*`
variables of the Paperclip server process. An env dump from inside a
`pi_local` run showed `PAPERCLIP_AGENT_JWT_SECRET`,
`PAPERCLIP_TOOL_ACTION_SIGNING_SECRET`,
`PAPERCLIP_SECRETS_MASTER_KEY_FILE`, and server config path variables.
The agent process can read these values.
**Expected behavior**
The `pi_local` child env drops inherited server `PAPERCLIP_*` variables,
the same as the shared `runChildProcess` path. Only
`PAPERCLIP_RUNTIME_API_URL`, `PAPERCLIP_LISTEN_HOST`, and
`PAPERCLIP_LISTEN_PORT` stay inherited. The run-scoped variables that
the adapter sets (for example `PAPERCLIP_AGENT_ID` and
`PAPERCLIP_API_KEY`) and the adapter-configured env still reach the
child.
**Steps to reproduce**
1. Start the Paperclip server with `PAPERCLIP_AGENT_JWT_SECRET` set in
its environment.
2. Run a `pi_local` agent whose command writes `process.env` to a file.
3. Read the file. `PAPERCLIP_AGENT_JWT_SECRET` is present.
**Agent adapter(s) involved**
`pi_local` only.
**Additional context**
Other CLI adapters appear to merge `process.env` into the child env in a
similar way (for example in some `execute.ts` and `test.ts` files). This
pull request does not change them. It keeps the scope to `pi_local`.
Related: #3030 and #12870 also touch inherited agent env. This pull
request is independent of #14479.
## What Changed
- `packages/adapters/pi-local/src/server/execute.ts`: the run env and
the runtime env that the adapter logs now start from
`sanitizeInheritedPaperclipEnv(process.env)` instead of `process.env`.
- `packages/adapters/pi-local/src/server/test.ts`: the environment test
env now starts from `sanitizeInheritedPaperclipEnv(process.env)`.
- `packages/adapters/pi-local/src/server/models.ts`: the model discovery
env (`pi --list-models`) now starts from
`sanitizeInheritedPaperclipEnv(process.env)`.
- `server/src/__tests__/pi-local-execute.test.ts`: add a regression
test. The test sets three server secrets on `process.env`, runs
`execute` with a fake Pi command that dumps its env, and checks that the
secrets are absent. It also checks that the adapter-configured env and
the run-scoped `PAPERCLIP_AGENT_ID` and `PAPERCLIP_API_KEY` are present.
## Verification
- `cd server && npx vitest run src/__tests__/pi-local-execute.test.ts
src/__tests__/pi-local-adapter-environment.test.ts
src/__tests__/pi-local-skill-sync.test.ts`: 7 tests pass.
- The new test fails without the source change (the secret assertion
fails) and passes with it.
- `npx vitest run packages/adapters/pi-local`: 42 tests pass.
- `pnpm --filter @paperclipai/adapter-pi-local typecheck`: pass.
- `cd server && tsc --noEmit` (after `pnpm --filter
@paperclipai/plugin-sdk ensure-build-deps`): pass.
- Follow-up coverage observes real child environments from direct model
discovery, agent execution, and `testEnvironment`. Each path omits
inherited secrets and retains the permitted runtime/listen variables and
configured env. Removing either the model-discovery or environment-probe
sanitizer independently makes the regression fail. All 49 scoped tests
and both direct typechecks pass.
## Risks
- Low risk. A `pi_local` setup that relied on an inherited server
`PAPERCLIP_*` variable (other than the three kept keys) no longer gets
it. Such a value can still be set in the adapter `env` config. This is
the same behavior as the shared `runChildProcess` path.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-6.1 Sol, exact runtime model ID
`gpt6sol-cliproxy/gpt-6.1-sol`, with tool use and code execution.
Context-window and thinking-level metadata are not exposed. The prior
model and human-review disclosure was not substantiated by available
runtime metadata.
## 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
- [ ] All Paperclip CI gates are green
- [ ] 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: mdc2122 <259970464+mdc2122@users.noreply.github.com>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { discoverPiModels, execute, testEnvironment } from "@paperclipai/adapter-pi-local/server";
|
|
|
|
async function writeFakePiCommand(commandPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
if (process.argv.includes("--list-models")) {
|
|
console.log("provider model");
|
|
console.log("google gemini-3-flash-preview");
|
|
process.exit(0);
|
|
}
|
|
console.log(JSON.stringify({ type: "agent_start" }));
|
|
console.log(JSON.stringify({ type: "turn_start" }));
|
|
console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: "" }, toolResults: [] }));
|
|
console.log(JSON.stringify({ type: "agent_end", messages: [] }));
|
|
console.log(JSON.stringify({
|
|
type: "auto_retry_end",
|
|
success: false,
|
|
attempt: 3,
|
|
finalError: "Cloud Code Assist API error (429): RESOURCE_EXHAUSTED"
|
|
}));
|
|
process.exit(0);
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
async function writeEnvDumpPiCommand(commandPath: string, envDumpPath: string): Promise<void> {
|
|
const script = `#!/usr/bin/env node
|
|
const fs = require("node:fs");
|
|
if (process.argv.includes("--list-models")) {
|
|
console.log("provider model");
|
|
console.log("google gemini-3-flash-preview");
|
|
process.exit(0);
|
|
}
|
|
fs.writeFileSync(${JSON.stringify(envDumpPath)}, process.env.PATH || "");
|
|
console.log(JSON.stringify({ type: "agent_start" }));
|
|
console.log(JSON.stringify({ type: "turn_start" }));
|
|
console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: "" }, toolResults: [] }));
|
|
console.log(JSON.stringify({ type: "agent_end", messages: [] }));
|
|
process.exit(0);
|
|
`;
|
|
await fs.writeFile(commandPath, script, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
}
|
|
|
|
describe("pi_local execute", () => {
|
|
it("fails the run when Pi exhausts automatic retries despite exiting 0", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-execute-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "pi");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await writeFakePiCommand(commandPath);
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
|
|
try {
|
|
const result = await execute({
|
|
runId: "run-pi-quota-exhausted",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Pi Agent",
|
|
adapterType: "pi_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
model: "google/gemini-3-flash-preview",
|
|
promptTemplate: "Keep working.",
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.errorMessage).toContain("RESOURCE_EXHAUSTED");
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("prepends installed skill bin/ dirs to the spawned Pi child PATH", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-path-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "pi");
|
|
const skillDir = path.join(root, "skills", "demo-skill");
|
|
const skillBinDir = path.join(skillDir, "bin");
|
|
const envDumpPath = path.join(root, "captured-path.txt");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.mkdir(skillBinDir, { recursive: true });
|
|
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# demo-skill\n", "utf8");
|
|
await writeEnvDumpPiCommand(commandPath, envDumpPath);
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
|
|
try {
|
|
await execute({
|
|
runId: "run-pi-skill-path",
|
|
agent: {
|
|
id: "agent-skill-path",
|
|
companyId: "company-skill-path",
|
|
name: "Pi Agent",
|
|
adapterType: "pi_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
model: "google/gemini-3-flash-preview",
|
|
promptTemplate: "Keep working.",
|
|
paperclipRuntimeSkills: [
|
|
{ key: "demo-skill", runtimeName: "demo-skill", source: skillDir },
|
|
],
|
|
paperclipSkillSync: {
|
|
desiredSkills: ["demo-skill"],
|
|
},
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
const capturedPath = await fs.readFile(envDumpPath, "utf8");
|
|
const entries = capturedPath.split(path.delimiter);
|
|
expect(entries[0]).toBe(skillBinDir);
|
|
expect(entries.filter((entry) => entry === skillBinDir)).toHaveLength(1);
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not expose bin/ dirs from skills that are not injected", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-path-neg-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "pi");
|
|
const nonInjectedSkillDir = path.join(root, "skills", "not-injected");
|
|
const nonInjectedBinDir = path.join(nonInjectedSkillDir, "bin");
|
|
const envDumpPath = path.join(root, "captured-path.txt");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.mkdir(nonInjectedBinDir, { recursive: true });
|
|
await fs.writeFile(path.join(nonInjectedSkillDir, "SKILL.md"), "# not-injected\n", "utf8");
|
|
await writeEnvDumpPiCommand(commandPath, envDumpPath);
|
|
|
|
const previousHome = process.env.HOME;
|
|
process.env.HOME = root;
|
|
|
|
try {
|
|
await execute({
|
|
runId: "run-pi-skill-path-neg",
|
|
agent: {
|
|
id: "agent-skill-path-neg",
|
|
companyId: "company-skill-path-neg",
|
|
name: "Pi Agent",
|
|
adapterType: "pi_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
model: "google/gemini-3-flash-preview",
|
|
promptTemplate: "Keep working.",
|
|
// The implicit legacy default applies only to the canonical Paperclip
|
|
// operational skill, so this unrelated skill remains unselected.
|
|
paperclipRuntimeSkills: [
|
|
{ key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir },
|
|
],
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
const capturedPath = await fs.readFile(envDumpPath, "utf8");
|
|
expect(capturedPath.split(path.delimiter)).not.toContain(nonInjectedBinDir);
|
|
} finally {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("isolates server secrets during execution, model discovery, and environment probes", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-env-"));
|
|
const workspace = path.join(root, "workspace");
|
|
const commandPath = path.join(root, "pi");
|
|
const envDumpPath = path.join(root, "captured-env.json");
|
|
await fs.mkdir(workspace, { recursive: true });
|
|
await fs.writeFile(commandPath, `#!/usr/bin/env node
|
|
require("node:fs").appendFileSync(${JSON.stringify(envDumpPath)}, JSON.stringify({ args: process.argv.slice(2), env: process.env }) + "\\n");
|
|
if (process.argv.includes("--list-models")) {
|
|
console.log("provider model");
|
|
console.log("google gemini-3-flash-preview");
|
|
process.exit(0);
|
|
}
|
|
console.log(JSON.stringify({ type: "agent_start" }));
|
|
console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: [{ type: "text", text: "hello" }] }, toolResults: [] }));
|
|
console.log(JSON.stringify({ type: "agent_end", messages: [] }));
|
|
`, "utf8");
|
|
await fs.chmod(commandPath, 0o755);
|
|
|
|
const serverSecrets = {
|
|
PAPERCLIP_AGENT_JWT_SECRET: "server-jwt-secret",
|
|
PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: "server-tool-action-secret",
|
|
PAPERCLIP_SECRETS_MASTER_KEY_FILE: "/server/master.key",
|
|
};
|
|
const permittedEnv = {
|
|
PAPERCLIP_RUNTIME_API_URL: "https://runtime.example.test",
|
|
PAPERCLIP_LISTEN_HOST: "127.0.0.1",
|
|
PAPERCLIP_LISTEN_PORT: "3100",
|
|
};
|
|
const inheritedEnv = { ...serverSecrets, ...permittedEnv };
|
|
const previous = { HOME: process.env.HOME, ...Object.fromEntries(Object.keys(inheritedEnv).map((k) => [k, process.env[k]])) };
|
|
process.env.HOME = root;
|
|
Object.assign(process.env, inheritedEnv);
|
|
|
|
try {
|
|
// Discover directly so execute/testEnvironment cannot pre-sanitize this input.
|
|
await discoverPiModels({ command: commandPath, cwd: workspace, env: { AGENT_SETTING: "kept" } });
|
|
await execute({
|
|
runId: "run-pi-env-leak",
|
|
agent: { id: "agent-env", companyId: "company-env", name: "Pi Agent", adapterType: "pi_local", adapterConfig: {} },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
model: "google/gemini-3-flash-preview",
|
|
promptTemplate: "Keep working.",
|
|
env: { AGENT_SETTING: "kept" },
|
|
},
|
|
context: {},
|
|
authToken: "run-jwt-token",
|
|
onLog: async () => {},
|
|
});
|
|
|
|
const diagnostics = await testEnvironment({
|
|
companyId: "company-env",
|
|
adapterType: "pi_local",
|
|
config: {
|
|
command: commandPath,
|
|
cwd: workspace,
|
|
model: "google/gemini-3-flash-preview",
|
|
env: { AGENT_SETTING: "kept" },
|
|
},
|
|
});
|
|
expect(diagnostics.status).toBe("pass");
|
|
|
|
const captures = (await fs.readFile(envDumpPath, "utf8")).trim().split("\n").map(
|
|
(line) => JSON.parse(line) as { args: string[]; env: Record<string, string> },
|
|
);
|
|
const discovery = captures.find(({ args }) => args.includes("--list-models"));
|
|
const execution = captures.find(({ env }) => env.PAPERCLIP_AGENT_ID === "agent-env");
|
|
const probe = captures.find(({ args }) => args.includes("Respond with hello."));
|
|
expect(discovery).toBeDefined();
|
|
expect(execution).toBeDefined();
|
|
expect(probe).toBeDefined();
|
|
for (const capture of [discovery!, execution!, probe!]) {
|
|
for (const key of Object.keys(serverSecrets)) expect(capture.env).not.toHaveProperty(key);
|
|
expect(capture.env.AGENT_SETTING).toBe("kept");
|
|
expect(capture.env).toMatchObject(permittedEnv);
|
|
}
|
|
expect(execution!.env.PAPERCLIP_API_KEY).toBe("run-jwt-token");
|
|
} finally {
|
|
for (const [key, value] of Object.entries(previous)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|