mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - The native Runner keeps a live provider process between task turns. > - Managed GitHub access used a token tied to one run. > - A new run forced Paperclip to replace that process to replace its token. > - This PR gives the session a stable credential transport and binds each operation to the active run. > - The agent can keep its process while Paperclip checks current identity and grants. ## Linked Issues or Issue Description Follow-up to #13738. Related credential-rotation work: #11770 and #8208 use process replacement for other adapter credentials; this change applies to managed GitHub access in the native Runner. **What happened?** A configured GitHub connection forced a warm native provider process to close at each new run. The saved conversation survived, but the live process did not. **Expected behavior** Keep the warm provider process. Resolve GitHub access for the current run when each command starts. Deny access while idle or after the run ends. **Steps to reproduce** 1. Configure managed GitHub access for a native Runner agent with a warm session. 2. Complete a turn, then send another message to the same task. 3. Observe the provider process close with the reason `warm native session configuration changed`. **Paperclip version or commit** Reproduced on master `8326e33ad`. Rebased onto `e3d8fb087` before submission. **Deployment mode** Local and remote native execution, including the sandbox callback bridge. ## What Changed - Move configured native GitHub transport and launcher ownership from the run to the provider session. - Bind the broker only after the executor acquires session ownership. Clear that binding when the run exits. - Keep the shared live-run, identity, grant, and trust-policy checks for each credential request. - Reject wrong scopes, idle requests, and credential responses that arrive after their run binding changes. - Retire transport and launcher files with the provider session. Keep anonymous commands available if bridge startup fails. - Add red/green executor tests, real subprocess and callback-bridge tests, and database checks. Update the runtime documentation. ## Verification - Before the fix, both new local and remote warm-session reuse tests failed. - After the fix, 435 targeted tests passed across the executor, broker, launcher, token, and database suites. - A real long-lived test process kept the same PID and original environment across two runs, including through the production callback bridge on local test processes. - Server typecheck and TypeScript compilation passed. - Full workspace typecheck and build passed. Server typecheck passed again after the review fix. - The fallback-logging regression failed before the fix; all 9 broker tests pass afterward. - The exact chat sidebar browser scenario passed locally. The initial CI timeout showed failed Vite module downloads; all eight browser shards pass on the latest commit. - All 53 latest-head checks passed, including the full CI test matrix and security checks (two unrelated conditional checks skipped). - The duplicate full local test run was stopped after CI passed; it is not claimed as a completed local pass. Targeted local tests, workspace typecheck/build, and the browser scenario passed. - Greptile reviewed the latest commit at 5/5 with no unresolved findings. - No fresh paid provider or Daytona campaign has run for this change. ## Risks - The broker now lives as long as the provider session. Tests cover idle denial, late cleanup, late responses, shutdown, and failed startup. - Its in-memory authority does not survive a controller restart. Existing checkpoint and process-recovery rules still apply. - Raw GitHub credentials remain confined to individual command processes. The session transport token cannot select a different task, agent, company, or run. - No database migration or public API change. ## Model Used OpenAI Codex, GPT-6, with reasoning, terminal tools, and code execution. The exact serving model ID and context-window size are not exposed in 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>
117 lines
7.8 KiB
TypeScript
117 lines
7.8 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { cleanupGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js";
|
|
|
|
const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" };
|
|
|
|
describe("heartbeat GitHub launcher lifetime", () => {
|
|
it.each([target, null])("defers native managed authorization/staging to the session owner (%j)", async (target) => {
|
|
const prepare = vi.fn();
|
|
const mint = vi.fn();
|
|
const result = await prepareHeartbeatGitHubLaunchers({
|
|
native: true, githubConfigured: true, agentId: "agent-a", runId: "run-a", target,
|
|
cwd: "/workspace", env: { GH_TOKEN: "ambient" }, brokerUrl: "https://paperclip.test", createBrokerToken: mint,
|
|
}, prepare);
|
|
expect(prepare).not.toHaveBeenCalled();
|
|
expect(mint).not.toHaveBeenCalled();
|
|
expect(result.cleanupLocation).toBeNull();
|
|
expect(result.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "" });
|
|
});
|
|
it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => {
|
|
const createBrokerToken = vi.fn(() => "run-secret");
|
|
const prepareLaunchers = vi.fn(async (input) => input.env);
|
|
const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target,
|
|
cwd: "/workspace", env: { GH_TOKEN: "ambient-secret" }, brokerUrl: "https://paperclip.test", createBrokerToken };
|
|
const first = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-one" }, prepareLaunchers);
|
|
const second = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-two" }, prepareLaunchers);
|
|
expect(createBrokerToken).not.toHaveBeenCalled();
|
|
expect(prepareLaunchers.mock.calls.map(([input]) => input.runId)).toEqual([expect.stringMatching(/^anonymous-agent-a-/), prepareLaunchers.mock.calls[0]?.[0].runId]);
|
|
expect(first.cleanupLocation).toBeNull();
|
|
expect(second.cleanupLocation).toBeNull();
|
|
expect(first.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "", PAPERCLIP_GITHUB_BROKER_URL: "" });
|
|
});
|
|
it.each([false, true])("cleans partial run-scoped staging and preserves its error (cleanup fails: %s)", async (cleanupFails) => {
|
|
const stagingError = new Error("remote launcher staging failed");
|
|
const prepareLaunchers = vi.fn(async () => { throw stagingError; });
|
|
const cleanupLaunchers = vi.fn(async () => {
|
|
if (cleanupFails) throw new Error("cleanup unavailable");
|
|
});
|
|
await expect(prepareHeartbeatGitHubLaunchers({
|
|
native: false, githubConfigured: true, agentId: "agent-a", target,
|
|
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
|
createBrokerToken: () => "current-run-secret",
|
|
}, prepareLaunchers, cleanupLaunchers)).rejects.toBe(stagingError);
|
|
expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target });
|
|
});
|
|
|
|
it("does not remove shared anonymous wrappers when staging a later run fails", async () => {
|
|
const stagingError = new Error("remote launcher staging failed");
|
|
const cleanupLaunchers = vi.fn(async () => undefined);
|
|
await expect(prepareHeartbeatGitHubLaunchers({
|
|
native: true, githubConfigured: false, agentId: "agent-a", target,
|
|
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
|
createBrokerToken: () => { throw new Error("must not mint a capability"); },
|
|
}, async () => { throw stagingError; }, cleanupLaunchers)).rejects.toBe(stagingError);
|
|
expect(cleanupLaunchers).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{ native: false, githubConfigured: true, target },
|
|
{ native: false, githubConfigured: false, target },
|
|
{ native: true, githubConfigured: false, target: null },
|
|
])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => {
|
|
const createBrokerToken = vi.fn(() => "current-run-secret");
|
|
const prepareLaunchers = vi.fn(async (input) => input.env);
|
|
const result = await prepareHeartbeatGitHubLaunchers({ ...mode, agentId: "agent-a", runId: "run-one",
|
|
cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test", createBrokerToken }, prepareLaunchers);
|
|
expect(createBrokerToken).toHaveBeenCalledOnce();
|
|
expect(prepareLaunchers.mock.calls[0]?.[0].runId).toBe("run-one");
|
|
expect(result.cleanupLocation).toEqual({ runId: "run-one", target: mode.target });
|
|
expect(result.env.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("current-run-secret");
|
|
});
|
|
});
|
|
|
|
|
|
it("keeps anonymous wrappers usable after run cleanup and excludes sandbox image credentials", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "native-anonymous-github-"));
|
|
try {
|
|
const bin = path.join(root, "bin");
|
|
await mkdir(bin);
|
|
await mkdir(path.join(root, ".config", "gh"), { recursive: true });
|
|
await writeFile(path.join(root, ".config", "gh", "hosts.yml"), "ambient-image-secret");
|
|
await writeFile(path.join(bin, "gh"), `#!${process.execPath}
|
|
const fs = require('node:fs');
|
|
process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 });
|
|
const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" };
|
|
const execute: CommandManagedRuntimeRunner["execute"] = async (input) => {
|
|
const startedAt = new Date().toISOString();
|
|
const child = promisify(execFile)(input.command, input.args ?? [], { cwd: input.cwd ?? root, env: { ...imageEnv, ...input.env }, timeout: 15_000 });
|
|
child.child.stdin?.on("error", () => undefined);
|
|
child.child.stdin?.end(input.stdin ?? "");
|
|
const result = await child;
|
|
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
|
|
};
|
|
const sandboxTarget = { ...target, remoteCwd: root, runner: { execute } };
|
|
const base = { native: true, githubConfigured: false, agentId: "agent-a", target: sandboxTarget,
|
|
cwd: root, env: imageEnv, brokerUrl: "https://unused.invalid", createBrokerToken: () => { throw new Error("must not mint a capability"); } };
|
|
const first = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-one" });
|
|
await cleanupGitHubOperationLaunchers({ runId: "run-one", target: sandboxTarget });
|
|
const second = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-two" });
|
|
expect(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).toBe(second.env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
|
expect(await readFile(path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain("ambient-token");
|
|
// A still-live provider uses its first-turn environment, not the new one.
|
|
const command = await execute({ command: path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), env: first.env });
|
|
expect(JSON.parse(command.stdout)).toEqual({ token: "", githubToken: "", ssh: "", global: "/dev/null", imageConfig: false });
|
|
const changedPath = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-three", env: { ...imageEnv, PATH: `${imageEnv.PATH}:/extra` } });
|
|
expect(changedPath.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).not.toBe(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 20_000);
|