mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +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>
147 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|