Files
PaperClipAI/server/src/services/native-runtime/native-github-access.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

147 lines
6.1 KiB
TypeScript

import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import path from "node:path";
import {
cleanupGitHubOperationLaunchers,
prepareGitHubOperationLaunchers,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
type Binding = { companyId: string; agentId: string; issueId: string; runId: string };
type LauncherInput = Parameters<typeof prepareGitHubOperationLaunchers>[0];
export type NativeGitHubAccess = Awaited<ReturnType<typeof createNativeGitHubAccess>>;
/** A process-lifetime transport, with authority only while its controller owns a run.
* No run token or GitHub credential is stored in the provider's environment/files.
* The resolver still checks the active run and current identity/grants per operation.
*/
export async function createNativeGitHubAccess(input: {
scope: Omit<Binding, "runId">;
target: LauncherInput["target"];
cwd: string;
env: NodeJS.ProcessEnv;
resolveCredentials: (binding: Binding) => Promise<unknown>;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}, startBridge = startAdapterExecutionTargetPaperclipBridge) {
const token = randomBytes(32).toString("hex");
const location = { runId: `native-session-${randomUUID()}`, target: input.target };
let active: Binding | null = null;
let stopped = false;
let ready = true;
let stopping: Promise<void> | undefined;
let bridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
const server = createServer(async (req, res) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Type", "application/json");
req.resume();
const reply = (status: number, body: unknown) => {
res.writeHead(status);
res.end(JSON.stringify(body));
};
const bearer = req.headers.authorization ?? "";
const expected = `Bearer ${token}`;
if (req.headers.origin || req.headers.cookie || req.headers["sec-fetch-site"] ||
Buffer.byteLength(bearer) !== Buffer.byteLength(expected) ||
!timingSafeEqual(Buffer.from(bearer), Buffer.from(expected))) {
reply(403, { error: "GitHub session authentication required" });
return;
}
if (req.method !== "POST" || req.url !== "/runtime-tools/github/credentials") {
reply(404, { error: "Unknown GitHub session operation" });
return;
}
const binding = active;
if (!binding || stopped) {
reply(403, { error: "No active GitHub run" });
return;
}
try {
// Bind at receipt; body/headers cannot select a run or responsible user.
const result = await input.resolveCredentials({ ...binding });
if (active !== binding || stopped) {
reply(403, { error: "GitHub run ended during credential acquisition" });
return;
}
reply(200, result);
} catch (error) {
const status = (error as { status?: number })?.status;
// Never leak secret-store/provider errors. Preserve steering's retry signal.
reply(status === 409 ? 409 : status === 403 ? 403 : 503,
{ error: "GitHub credentials unavailable" });
}
});
server.requestTimeout = 15_000;
server.headersTimeout = 10_000;
const stop = () => stopping ??= (async () => {
stopped = true;
active = null;
server.closeAllConnections();
const results = await Promise.allSettled([
bridge?.stop(),
new Promise<void>(resolve => server.close(() => resolve())),
cleanupGitHubOperationLaunchers(location),
]);
if (results.some(result => result.status === "rejected")) {
await input.onLog?.("stderr", "[paperclip] GitHub session cleanup incomplete.\n").catch(() => undefined);
}
})();
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("GitHub broker did not listen");
const url = `http://127.0.0.1:${address.port}`;
try {
bridge = await startBridge({
...location,
runtimeRootDir: input.target?.kind === "remote"
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", location.runId)
: null,
adapterKey: "native-github",
hostApiToken: token,
hostApiUrl: url,
onLog: input.onLog,
});
if (input.target?.kind === "remote" && !bridge) {
throw new Error("GitHub session requires a remote callback bridge");
}
} catch {
// GitHub remains optional. Stage anonymous wrappers for this session;
// the supervisor retries transport setup on the next run.
ready = false;
await new Promise<void>(resolve => server.close(() => resolve()));
await input.onLog?.("stderr", "[paperclip] GitHub runtime transport unavailable; continuing without managed GitHub access.\n").catch(() => undefined);
}
const env = await prepareGitHubOperationLaunchers({
...location, cwd: input.cwd,
env: {
...githubBrokerEnvironment({ PATH: input.env.PATH }, {
url: ready ? bridge?.env.PAPERCLIP_API_URL ?? url : "",
token: ready ? bridge?.env.PAPERCLIP_API_KEY ?? token : "",
}),
// Never retain an old run's bridge authentication override.
PAPERCLIP_GITHUB_BRIDGE_TOKEN: ready ? bridge?.env.PAPERCLIP_API_KEY ?? token : "",
},
});
return {
env,
ready,
activate(binding: Binding) {
if (stopped || active) throw new Error("GitHub session is closed or busy");
if (binding.companyId !== input.scope.companyId || binding.agentId !== input.scope.agentId ||
binding.issueId !== input.scope.issueId) throw new Error("GitHub session scope mismatch");
const owner = { ...binding };
active = owner;
return () => { if (active === owner) active = null; };
},
stop,
};
} catch (error) {
await stop().catch(() => undefined);
throw error;
}
}