Files
PaperClipAI/server/src/services/heartbeat-github-launchers.test.ts
T
DottaandPaperclip 3d78e3a4ec fix(runner): keep warm sessions alive with managed GitHub access (#13815)
## 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>
2026-09-22 13:07:13 -05:00

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);