feat: use the responsible person's GitHub for shared agent operations (#13005)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Several people can send instructions to the same agent and task.
> - A fixed GitHub token in the provider process can keep the first
person's access after another person's message is accepted.
> - Task ownership cannot select credentials for each accepted
instruction or preserve the identity of an operation already in
progress.
> - This pull request records ordered execution identity contexts and
resolves credentials when managed Git, gh, or GitHub tools start.
> - The benefit is automatic personal GitHub access for shared agents,
with durable continuation rules and no teammate credential fallback.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: orchestration, connection grants, database, runtime
adapters, native runners, and run details.

**Problem or motivation**

A shared agent must use the person whose instructions it has accepted. A
queued message must retain its author. A retry or approval without new
instructions must retain the originating identity. GitHub must remain
optional for ordinary work.

**Proposed solution**

Persist execution identity separately from task ownership. Give new
processes a run-scoped broker capability and token-free managed
launchers. Capture identity at operation start. Keep an explicit
dedicated-agent grant as an override. Show redacted diagnostics in run
details.

**Alternatives considered**

Per-task ownership, fixed provider tokens, and mutable repository author
configuration do not handle accepted steering or concurrent operations.
A manual account-selection action would add unnecessary setup to each
turn.

**Roadmap alignment**

This completes the existing Multiple Human Users, MCP Tool Gateway &
Apps, Secrets Manager, and Self-healing Runs capabilities. The
implementation follows the maintainer-approved plan.

Related work: Refs #12843, Refs #12907. Existing proposals #4618 and
#8945 cover per-agent or per-worktree author configuration. This change
instead follows the accepted human instruction across runtime types.
Refs #11831 for governed personal connection delegation; this change
preserves connection audience checks and does not use standing
delegation as a personal credential fallback.

## What Changed

- Add durable, ordered identity contexts and active run references.
Preserve message authors through consolidation, steering, retries,
delegation, approvals, routines, and restart.
- Add an authenticated operation-time GitHub credential broker and
local/remote managed git and gh launchers. Keep personal tokens out of
the long-lived provider process.
- Resolve GitHub gateway and server-side Git operations through the same
responsible-person or dedicated-grant selection rules.
- Make absent and unavailable GitHub credentials non-blocking at generic
startup. Clear host and prior-person credentials. Keep anonymous Git
access where supported.
- Add run-detail identity history and the dedicated-account warning.
Keep task ownership and queue-versus-steer decisions unchanged.
- Preserve personal OAuth declarations through connection edits. Retain
exact selected grants in the gateway.
- Fix continuation races found during real acceptance: verify a warm
owner before credential rotation, and wait for bounded durable runner
suspension before the next run starts.
- Make migrations replay-safe. Retain identity through agent/run
deletion, remove it with its company, and clean terminal launcher
directories before releasing execution environments. Document
coordinated release and rollback.

## Verification

- Full workspace typecheck, build, and token gates passed. The complete
local suite passed in its normal test groups: 17,120 passing tests,
including all 143 serialized server suites. After integrating the newly
merged runner API work, full local typecheck and build passed again,
along with 890 focused integration tests. All 31 checks on the
integrated revision passed, including build, browser E2E, release
registry, canary dry run, typecheck, security and all test suites.
Greptile is 5/5 with all review threads resolved.
- Current focused checks passed: 142 native executor tests, 67 runtime
lifecycle tests, 9 durable identity tests, 75 credential/routine tests,
19 low-trust/resumption tests, and the executable migration replay test.
- Authenticated browser acceptance with two Paperclip users and two
GitHub accounts on one shared native agent passed. Real commits and
pushes followed A → B accepted steering → queued A continuation in the
same saved conversation. GitHub commit author and committer identities
matched all three operations. Both runs succeeded and task ownership
stayed unchanged.
- Real GitHub MCP calls switched from A to B after accepted steering. A
delegated subtask retained its originating identity across a server
restart.
- Disabling B's GitHub connection left ordinary work successful. Managed
gh was unauthenticated and the provider had no inherited GH_TOKEN or
GITHUB_TOKEN.
- The browser displayed run-detail diagnostics and the exact
dedicated-account warning. A final controller-restart check followed by
another-person continuation retained the conversation, selected the
correct GitHub login and Git author, and removed each terminal launcher
directory.
- Company-lifetime migration and all five previously failing CI suites
passed locally (167 tests). Same-token gateway A → B → A and six
broker/launcher boundary tests passed.
- Remote callback, launcher, sandbox, and runtime contract tests passed.
Both native and legacy Codex completed actual Daytona executions on the
integrated revision ([campaign
results](https://github.com/paperclipai/paperclip/actions/runs/34155056509)).
The remote package-manager shim staging regression also passed locally.

## Risks

- Deploy the migrations, server broker, launchers, and runner artifacts
together. Existing processes finish with their original contract. New
managed processes need the broker endpoint for GitHub operations.
- Finish or stop new managed executions before rolling application code
back. Keep the additive schema and identity history during rollback.
- Scripts that require a persistent raw GH_TOKEN must use managed git,
gh, or GitHub gateway tools. Run capabilities authorize code executing
within that run to acquire its current identity; this is not
hostile-code isolation within one execution principal. Managed commands
prevent automatic credential carryover; arbitrary code deliberately
copying a credential is outside that boundary.
- Uncertain steering acknowledgement deliberately holds new credential
acquisition until reconciliation. Already-started operations retain
their captured identity.
- GitHub private access and provider outages can still fail the specific
operation that needs them. Dedicated grant failure does not fall back to
personal access.

## Model Used

OpenAI GPT-6 through Codex assisted implementation, review, shell
execution, and browser acceptance. The exact model variant and
context-window size are not exposed in this session. Tool use included
TypeScript and Rust tests, database integration tests, GitHub CLI, and
authenticated browser control.

## 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>
This commit is contained in:
Dotta 2026-09-07 14:32:20 -05:00 committed by GitHub
parent 5bddff0920
commit 1cc45086d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 260590 additions and 263 deletions

View File

@ -1260,3 +1260,7 @@ Networking behavior for this smoke script:
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `npx paperclipai allowed-hostname host.docker.internal` and restart Paperclip
### GitHub identity for shared agents
See [execution GitHub identity](execution-github-identity.md) for the operation-time credential contract, continuation rules, runtime rollout, and acceptance-test requirements.

View File

@ -0,0 +1,53 @@
# GitHub identity during agent execution
Shared agents use the GitHub connection of the person whose accepted instructions they are executing. Task ownership remains unchanged. GitHub is optional: ordinary work can start without a connection; a private checkout, authenticated API call, or commit can fail when that operation needs credentials or author metadata.
## Accepted instructions and continuations
`run_identity_contexts` records ordered revisions, stored message authors, originating causes, parent contexts, acceptance state, and redacted GitHub outcomes. `heartbeat_runs.active_identity_context_id` selects the current revision. Existing historical runs are not backfilled with inferred authorship.
Human messages use their stored authenticated author. Queued messages retain their delivery order. Accepting steering reserves a pending revision before delivery, then activates it after the provider acknowledgement. Rejected delivery leaves the prior revision active. An uncertain acknowledgement holds new credential acquisition; a later acknowledgement or its authenticated native event receipt reconciles the reservation. Replays cannot reactivate an older revision. Activation locks the task before the run, matching task and queue mutations so concurrent status changes cannot deadlock identity initialization.
Delegated work and interactions persist their originating context. Retries retain the originating run's active context. Background continuations carry their source run; dependency wakes use the task's continuation context, independently of its owner. Scheduled and webhook routines use the routine's responsible person; manual invocations use the caller, and edits preserve the routine's responsible person.
## Managed GitHub operations
New executions receive token-free `git` and `gh` launchers and a run-scoped capability. Each launcher invocation requests the active context through the authenticated runtime transport and resolves one eligible credential at operation start. A `gh` command's child Git processes inherit that command's captured identity. Later steering does not change already-started operations. When a subsequent run resumes a settled native conversation, the controller starts a fresh provider process with that runs capability and rebinds its token-free launcher paths. The durable conversation and protected provider settings remain unchanged. Local and remote durable runners complete their bounded suspension before the controller releases the session for the next run, so a queued continuation cannot race unfinished cleanup.
The broker endpoint rejects browser origins and session cookies, validates a distinct signed runtime scope, and rechecks the company, agent, and live run. Sandboxes relay the capability through the existing authenticated callback bridge. Tokens are returned only to the managed command process. They are not persisted in identity history or injected into the long-lived provider process.
Server-side Git operations and GitHub gateway calls follow the same selection rules. Approved gateway operations retain their signed originating identity. Connection audience and tool policies continue to apply to the selected person's connection. Native catalogs remain stable across identity changes, but each invocation resolves the selected grant again. Personal OAuth secret declarations survive connection pauses and metadata edits.
Managed commands disable ambient Git credential helpers, Git global/system configuration, host GitHub CLI configuration, and host SSH identity access. Per-operation GitHub CLI configuration is isolated in a writable configuration directory beneath the managed launcher directory. Missing credentials clear previous author and token values; no teammate, standing delegation, host token, or company-default user's account is substituted. Anonymous/local operations remain available where supported.
Scripts that previously read a persistent `GH_TOKEN` must use managed `git`, `gh`, or GitHub gateway tools. Managed execution skips legacy GitHub token bindings in agent, environment, project, and routine configuration before secret preflight. Configure personal or dedicated access through the GitHub connection instead. Directly invoking an unmanaged executable or retaining a token obtained during an earlier invocation is outside the managed invocation contract.
## Dedicated accounts and diagnostics
An explicit dedicated-agent grant overrides personal selection. Revoked, disabled, unavailable, or ambiguous dedicated grants do not fall back to a person's account. Removing the dedicated configuration restores personal selection.
Connection setup and permissions display: “This agent uses this GitHub account for everyone's work, instead of the person giving instructions.”
Run details show identity revisions and redacted GitHub results: responsible person, selected login when available, personal/dedicated source, and an unavailable reason. Tasks do not receive an additional identity indicator or takeover action.
## Deployment and verification
Deploy the schema, server broker, launcher staging, and runtime environment contract together. Already-running processes retain their original environment; only newly dispatched processes receive the broker contract. Run-scoped capabilities remain valid only while their bound run is active.
Focused coverage lives in `run-identity.test.ts`, `github-operation-credentials.test.ts`, and `github-launcher.test.ts`, alongside the native steering, gateway, routine, and callback-bridge suites. Live acceptance additionally requires two authenticated Paperclip users, two authorized GitHub accounts, and a designated disposable repository for push verification. Local commit metadata and mocked API results do not replace that live push test.
### Release procedure
1. Back up the instance database using the normal deployment procedure.
2. Build and deploy one revision containing migrations 02400245, the server broker, managed launchers, and the runner artifacts. Run the standard pending-migration check before admitting new runs. These additive migrations are safe to replay and do not infer authorship for historical runs.
3. Let pre-rollout executions finish with their original runtime contract. New executions must have an active identity context and the managed launcher capability before provider startup.
4. Check one ordinary run without a GitHub connection, then an authenticated GitHub operation. Inspect the run details for the responsible person and credential outcome. Verify a queued continuation on the same conversation.
5. If rollback is needed, finish or explicitly stop executions using the new broker before removing its endpoint. Keep the additive schema and identity history. Do not drop identity columns or tables to roll back application code.
Remote acceptance uses the existing paid runner workflow with a narrow selection. Run it against the same immutable revision as the release; a successful local test does not qualify a different remote runner artifact.
Identity history survives deletion of the originating agent or run, so surviving
subtasks and approvals retain their responsible person. The company foreign key and company-deletion service remove
these company-scoped records when their company is deleted. Completed runs remove their managed launcher files
before releasing a remote environment; same-run recovery retains them until the
terminal boundary. Cleanup failures are logged and do not change the run result.

View File

@ -1,7 +1,11 @@
import { randomUUID } from "node:crypto";
import { access, readFile } from "node:fs/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as ssh from "./ssh.js";
import * as serverUtils from "./server-utils.js";
import {
cleanupGitHubOperationLaunchers,
prepareGitHubOperationLaunchers,
adapterExecutionTargetUsesManagedHome,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
resolveAdapterExecutionTargetCwd,
@ -401,3 +405,35 @@ describe("resolveAdapterExecutionTargetCwd", () => {
);
});
});
describe("GitHub launcher lifecycle", () => {
it("removes only the completed run's launchers and leaves concurrent runs usable", async () => {
const first = { runId: randomUUID(), target: null };
const second = { runId: randomUUID(), target: null };
try {
const a = await prepareGitHubOperationLaunchers({ ...first, cwd: "/tmp", env: {} });
const b = await prepareGitHubOperationLaunchers({ ...second, cwd: "/tmp", env: {} });
await cleanupGitHubOperationLaunchers(first);
await expect(access(a.PAPERCLIP_GITHUB_LAUNCHER_DIR)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(`${b.PAPERCLIP_GITHUB_LAUNCHER_DIR}/git`, "utf8")).toContain("PAPERCLIP_GITHUB_BROKER_URL");
await cleanupGitHubOperationLaunchers(first); // teardown replay is harmless
} finally {
await cleanupGitHubOperationLaunchers(first);
await cleanupGitHubOperationLaunchers(second);
}
});
it("bounds remote cleanup to one run and rejects traversal", async () => {
const runner = { execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false,
stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() })) };
const target = { kind: "remote" as const, transport: "sandbox" as const,
providerKey: "e2b", remoteCwd: "/remote/workspace", runner };
await cleanupGitHubOperationLaunchers({ runId: "finished-run", target });
expect(runner.execute).toHaveBeenCalledWith({ command: "sh",
args: ["-c", "rm -rf -- '/remote/workspace/.paperclip-runtime/github/finished-run'"],
cwd: "/remote/workspace", timeoutMs: 5_000 });
await expect(cleanupGitHubOperationLaunchers({ runId: "../other", target })).rejects.toThrow("Invalid GitHub launcher run ID");
expect(runner.execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -3,6 +3,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { githubLauncherSource } from "./github-launcher.js";
import type { SshRemoteExecutionSpec } from "./ssh.js";
import {
prepareCommandManagedRuntime,
@ -1494,6 +1495,70 @@ export function runtimeAssetDir(
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
}
type GitHubLauncherLocation = {
runId: string; target: AdapterExecutionTarget | null | undefined;
};
function githubOperationLauncherDirectory(input: GitHubLauncherLocation): string {
// Only controller-generated run IDs may name a removable directory.
if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID");
return input.target?.kind === "remote"
? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "github", input.runId)
: path.join(os.tmpdir(), "paperclip-github-runtime", input.runId);
}
/** Call only after execution settles, before releasing its remote environment lease. */
export async function cleanupGitHubOperationLaunchers(input: GitHubLauncherLocation): Promise<void> {
const directory = githubOperationLauncherDirectory(input);
if (input.target?.kind === "remote") {
const result = await adapterExecutionTargetCommandRunner(input.target).execute({
command: "sh", args: ["-c", `rm -rf -- ${shellQuote(directory)}`],
cwd: input.target.remoteCwd, timeoutMs: 5_000,
});
if (result.exitCode !== 0) throw new Error("Could not clean managed GitHub launchers");
} else {
await fs.rm(directory, { recursive: true, force: true });
}
}
/** Stage token-free launchers next to the execution, not in shared global Git config. */
export async function prepareGitHubOperationLaunchers(input: {
runId: string; target: AdapterExecutionTarget | null | undefined; cwd: string; env: Record<string, string>;
}): Promise<Record<string, string>> {
const remote = input.target?.kind === "remote" ? input.target : null;
const directory = githubOperationLauncherDirectory(input);
const configDirectory = path.posix.join(directory, "gh-config");
const basePath = input.env.PATH || (remote ? "/usr/local/bin:/usr/bin:/bin" : process.env.PATH) || "/usr/bin:/bin";
const managedPath = `${directory}:${basePath}`;
// Login shells may reorder PATH through /etc/profile or path_helper. Restore
// the managed launchers after startup without loading a host user's profile.
const profile = `export PATH=${shellQuote(managedPath)}\n`;
const files: Record<string, string> = Object.fromEntries([
...["git", "gh"].map((name) => [name, githubLauncherSource()] as const),
...[".zshenv", ".zprofile", ".zshrc", ".bash_profile", ".bashrc", ".profile"].map((name) => [name, profile] as const),
]);
if (remote) {
const runner = adapterExecutionTargetCommandRunner(remote);
for (const [program, body] of Object.entries(files)) {
await syncRemoteTextFileWithHashSkip({
runner, remoteCwd: remote.remoteCwd, remoteDir: directory,
remotePath: path.posix.join(directory, program), body,
label: "GitHub operation launcher", action: "stage GitHub operation launcher",
lockDir: path.posix.join(directory, `.${program}.lock`),
timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote),
});
}
const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${shellQuote(directory)}/git ${shellQuote(directory)}/gh && mkdir -p ${shellQuote(configDirectory)}`], cwd: remote.remoteCwd, timeoutMs: 15_000 });
if (permissions.exitCode !== 0) throw new Error("Could not prepare managed GitHub launchers");
} else {
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
await fs.mkdir(configDirectory, { recursive: true, mode: 0o700 });
for (const [program, body] of Object.entries(files)) await fs.writeFile(path.join(directory, program), body, { mode: 0o700 });
}
return { ...input.env, PATH: managedPath, ZDOTDIR: directory, BASH_ENV: `${directory}/.bashrc`,
GH_CONFIG_DIR: configDirectory, PAPERCLIP_GITHUB_LAUNCHER_DIR: directory };
}
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
const out: Record<string, string> = {};
// Keep `x-paperclip-bridge-outcome` in this list. The host marks a

View File

@ -0,0 +1,76 @@
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { githubBrokerEnvironment, githubLauncherSource } from "./github-launcher.js";
const exec = promisify(execFile);
const cleanups: Array<() => Promise<unknown>> = [];
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); });
describe("managed GitHub launchers", () => {
it("captures each command's identity and clears host credentials when the next person has none", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-github-launcher-test-"));
cleanups.push(() => rm(root, { recursive: true, force: true }));
const bin = path.join(root, "managed"), realBin = path.join(root, "real"), repo = path.join(root, "repo");
for (const dir of [bin, realBin, repo, path.join(bin, "gh-config")]) await mkdir(dir, { recursive: true });
for (const name of ["git", "gh"]) await writeFile(path.join(bin, name), githubLauncherSource(), { mode: 0o700 });
await writeFile(path.join(realBin, "gh"), `#!/usr/bin/env node
const {execFileSync}=require('node:child_process');
const identity=execFileSync('git',['var','GIT_AUTHOR_IDENT'],{encoding:'utf8'}).trim();
process.stdout.write(JSON.stringify({identity, token:process.env.GH_TOKEN ?? null, global:process.env.GIT_CONFIG_GLOBAL, config:process.env.GH_CONFIG_DIR}));
`, { mode: 0o700 });
let user: string | null = "A", captures = 0;
let heldCapture: (() => void) | null = null;
let releaseCapture: (() => void) | null = null;
const server = createServer((req, res) => {
captures++;
expect(req.headers.authorization).toBe("Bearer run-capability");
const selected = user;
res.setHeader("content-type", "application/json");
const finish = () => res.end(JSON.stringify(selected ? { status: "available", env: {
GH_TOKEN: `credential-${selected}`, GITHUB_TOKEN: `credential-${selected}`,
GIT_AUTHOR_NAME: selected, GIT_AUTHOR_EMAIL: `${selected}@example.test`,
GIT_COMMITTER_NAME: selected, GIT_COMMITTER_EMAIL: `${selected}@example.test`,
} } : { status: "absent", env: {} }));
if (heldCapture) { const captured = heldCapture; heldCapture = null; releaseCapture = finish; captured(); }
else finish();
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
cleanups.push(() => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())));
const address = server.address() as { port: number };
const env: NodeJS.ProcessEnv = { ...process.env, ...githubBrokerEnvironment({
GH_TOKEN: "ambient-host-token", GIT_AUTHOR_NAME: "Host", GIT_AUTHOR_EMAIL: "host@example.test",
}, { url: `http://127.0.0.1:${address.port}`, token: "run-capability" }), PATH: `${bin}:${realBin}:${process.env.PATH}` };
const git = async (...args: string[]) => (await exec(path.join(bin, "git"), args, { cwd: repo, env })).stdout.trim();
await git("init");
await git("commit", "--allow-empty", "-m", "A");
user = "B";
await git("commit", "--allow-empty", "-m", "B");
user = "A";
await git("commit", "--allow-empty", "-m", "A again");
expect(await git("log", "--format=%an <%ae>|%cn <%ce>" )).toBe("A <A@example.test>|A <A@example.test>\nB <B@example.test>|B <B@example.test>\nA <A@example.test>|A <A@example.test>");
const before = captures;
const gh = JSON.parse((await exec(path.join(bin, "gh"), [], { cwd: repo, env })).stdout);
expect(gh.identity).toContain("A <A@example.test>");
expect(gh.token).toBe("credential-A");
expect(captures - before).toBe(1); // gh's child Git retains the same capture.
const captured = new Promise<void>(resolve => { heldCapture = resolve; });
const operationA = exec(path.join(bin, "gh"), [], { cwd: repo, env });
await captured;
user = "B";
const operationB = JSON.parse((await exec(path.join(bin, "gh"), [], { cwd: repo, env })).stdout);
releaseCapture!();
const completedA = JSON.parse((await operationA).stdout);
expect(completedA.token).toBe("credential-A");
expect(operationB.token).toBe("credential-B");
expect(completedA.config).not.toBe(operationB.config);
user = null;
await expect(git("var", "GIT_AUTHOR_IDENT")).rejects.toThrow();
expect(await git("status", "--porcelain")).toBe(""); // unrelated public/local Git still works
expect(env.GH_TOKEN).toBe("");
expect(env.GIT_AUTHOR_NAME).toBe("");
});
});

View File

@ -0,0 +1,102 @@
/** Standalone source is staged unchanged on local, SSH, and sandbox runtimes. No secrets in files. */
export function githubLauncherSource(): string {
return String.raw`#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { spawn } = require('node:child_process');
const directory = path.dirname(fs.realpathSync(process.argv[1]));
const program = path.basename(process.argv[1]);
const originalPath = (process.env.PATH || '').split(path.delimiter).filter(p => {
try { return fs.realpathSync(p) !== directory; } catch { return true; }
});
const executable = originalPath.map(p => path.join(p, program)).find(p => {
try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; }
});
if (!['git', 'gh'].includes(program) || !executable) {
process.stderr.write('Paperclip: requested GitHub command is not installed.\n');
process.exit(127);
}
async function main() {
let env = { ...process.env };
const configRoot = env.GH_CONFIG_DIR || os.tmpdir();
fs.mkdirSync(configRoot, { recursive: true, mode: 0o700 });
const configDirectory = fs.mkdtempSync(path.join(configRoot, 'paperclip-github-operation-'));
fs.chmodSync(configDirectory, 0o700);
const cleanup = () => fs.rmSync(configDirectory, { recursive: true, force: true });
process.once('exit', cleanup);
{
for (const key of Object.keys(env)) {
if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_AUTHOR_.*|GIT_COMMITTER_.*|GIT_CONFIG_.*|GIT_ASKPASS|SSH_ASKPASS|GIT_SSH.*)$/.test(key)) delete env[key];
}
Object.assign(env, {
GH_CONFIG_DIR: configDirectory,
GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
GIT_TERMINAL_PROMPT: '0',
GIT_AUTHOR_NAME: '', GIT_AUTHOR_EMAIL: '', GIT_COMMITTER_NAME: '', GIT_COMMITTER_EMAIL: '',
GIT_CONFIG_COUNT: '4', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '',
GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_1: 'git@github.com:',
GIT_CONFIG_KEY_2: 'url.https://github.com/.insteadOf', GIT_CONFIG_VALUE_2: 'ssh://git@github.com/',
GIT_CONFIG_KEY_3: 'core.askPass', GIT_CONFIG_VALUE_3: '',
});
const base = env.PAPERCLIP_API_URL || env.PAPERCLIP_GITHUB_BROKER_URL;
let response;
if (base && env.PAPERCLIP_GITHUB_BROKER_TOKEN) {
const url = base.replace(/\/+$/, '').replace(/\/api$/, '') + '/runtime-tools/github/credentials';
for (let attempt = 0; attempt < 30; attempt++) {
response = await fetch(url, {
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
headers: { authorization: 'Bearer ' + (env.PAPERCLIP_GITHUB_BRIDGE_TOKEN || env.PAPERCLIP_API_KEY || env.PAPERCLIP_GITHUB_BROKER_TOKEN),
'x-paperclip-github-capability': env.PAPERCLIP_GITHUB_BROKER_TOKEN, 'content-type': 'application/json' },
body: '{}',
});
if (response.status !== 409) break;
await response.arrayBuffer();
await new Promise(resolve => setTimeout(resolve, 1000));
}
if (!response.ok) throw new Error('GitHub credential context unavailable; retry this operation');
const result = await response.json();
if (result.status === 'available') {
for (const [key, value] of Object.entries(result.env || {})) {
if (/^(GH_TOKEN|GITHUB_TOKEN|PAPERCLIP_GIT_TOKEN|GIT_TERMINAL_PROMPT|GIT_AUTHOR_(NAME|EMAIL)|GIT_COMMITTER_(NAME|EMAIL)|GIT_CONFIG_COUNT|GIT_CONFIG_(KEY|VALUE)_\d+)$/.test(key) && typeof value === 'string') env[key] = value;
}
}
}
}
// Only this invocation and its children inherit the captured credential.
// Its Git children use the real binary, so steering cannot split a gh operation.
env.PATH = originalPath.join(path.delimiter);
// Nested shell aliases must not reload the parent launcher profile and
// recapture a newer identity. All ordinary descendants stay in this operation.
env.ZDOTDIR = configDirectory;
env.BASH_ENV = '/dev/null';
env.GIT_SSH_COMMAND = 'ssh -F /dev/null -o IdentityAgent=none -o IdentitiesOnly=yes -o IdentityFile=none -o BatchMode=yes';
const child = spawn(executable, process.argv.slice(2), { env, stdio: 'inherit' });
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => child.kill(signal));
child.once('error', () => { process.stderr.write('Paperclip: GitHub command could not start.\n'); process.exitCode = 1; });
child.once('exit', (code, signal) => { process.exitCode = code === null ? 128 : code; });
}
main().catch(() => { process.stderr.write('Paperclip: GitHub credential context unavailable; retry this operation.\n'); process.exitCode = 1; });
`;
}
/** Override inherited credentials even when adapters merge the host environment later. */
export function githubBrokerEnvironment(input: Record<string, unknown>, broker: { url: string; token: string }): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) if (typeof value === "string") env[key] = value;
for (const key of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "GIT_CONFIG_COUNT", "PAPERCLIP_GITHUB_OPERATION_ACTIVE"]) env[key] = "";
for (const key of Object.keys(env)) {
if (/^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) env[key] = "";
}
env.GIT_CONFIG_GLOBAL = "/dev/null";
env.GIT_CONFIG_SYSTEM = "/dev/null";
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_TERMINAL_PROMPT = "0";
env.GIT_ASKPASS = "";
env.SSH_ASKPASS = "";
env.GIT_SSH_COMMAND = "ssh -F /dev/null -o IdentityAgent=none -o IdentitiesOnly=yes -o IdentityFile=none -o BatchMode=yes";
env.SSH_AUTH_SOCK = "";
env.PAPERCLIP_GITHUB_BROKER_URL = broker.url;
env.PAPERCLIP_GITHUB_BROKER_TOKEN = broker.token;
return env;
}

View File

@ -31,6 +31,13 @@ afterEach(async () => {
});
describe("local process sandbox", () => {
it.runIf(process.platform !== "linux")("rejects sandbox scopes on unsupported hosts", async () => {
await expect(buildLocalProcessSandboxSpawnTarget({
executable: process.execPath, args: ["-e", "process.exit(0)"], cwd: process.cwd(),
options: { workspaceDir: process.cwd(), networkScope: "deny" },
})).rejects.toThrow("supported only on Linux");
});
it("parses read-only and writable extra paths", () => {
expect(parseLocalProcessSandboxExtraPaths(["/opt/cache", { path: "/var/lib/tool", access: "rw" }])).toEqual([
{ path: "/opt/cache", access: "ro" },
@ -52,7 +59,7 @@ describe("local process sandbox", () => {
expect(() => parseLocalProcessNetworkScope("public")).toThrow('"deny" or "allowlist"');
});
it("describes every valid allowlist input when no proxy rules remain", async () => {
it.runIf(process.platform === "linux")("describes every valid allowlist input when no proxy rules remain", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-rules-"));
cleanup.push(workspace);
@ -69,7 +76,7 @@ describe("local process sandbox", () => {
})).rejects.toThrow("valid networkAllowlist hostname or HTTP(S) networkTrustedUrl");
});
it("builds a fresh-root bubblewrap command with workspace access", async () => {
it.runIf(process.platform === "linux")("builds a fresh-root bubblewrap command with workspace access", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-sandbox-"));
cleanup.push(root);
const workspace = path.join(root, "workspace");
@ -96,7 +103,7 @@ describe("local process sandbox", () => {
expect(target.args.slice(-3)).toEqual([process.execPath, "-e", "console.log('ok')"]);
});
it("binds a confined absolute alias to the synchronized workspace", async () => {
it.runIf(process.platform === "linux")("binds a confined absolute alias to the synchronized workspace", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-alias-"));
cleanup.push(root);
const workspace = path.join(root, "workspace");
@ -116,7 +123,7 @@ describe("local process sandbox", () => {
expect(target.args).toEqual(expect.arrayContaining(["--bind", workspace, "/app"]));
});
it("rejects writable out-of-tree paths without an outbound restore mapping", async () => {
it.runIf(process.platform === "linux")("rejects writable out-of-tree paths without an outbound restore mapping", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-outbound-"));
cleanup.push(root);
const workspace = path.join(root, "workspace");
@ -136,7 +143,7 @@ describe("local process sandbox", () => {
})).rejects.toThrow("has no outbound restore mapping");
});
it("builds a network-only namespace without changing filesystem visibility", async () => {
it.runIf(process.platform === "linux")("builds a network-only namespace without changing filesystem visibility", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-"));
cleanup.push(workspace);
const target = await buildLocalProcessSandboxSpawnTarget({
@ -152,7 +159,7 @@ describe("local process sandbox", () => {
expect(target.env?.HTTP_PROXY).toBeUndefined();
});
it("forwards allowed proxy targets with a deep TMPDIR and rejects other hosts", async () => {
it.runIf(process.platform === "linux")("forwards allowed proxy targets with a deep TMPDIR and rejects other hosts", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-proxy-"));
cleanup.push(workspace);
const deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment"));
@ -228,7 +235,7 @@ describe("local process sandbox", () => {
}
});
it("always permits trusted Paperclip control-plane URLs", async () => {
it.runIf(process.platform === "linux")("always permits trusted Paperclip control-plane URLs", async () => {
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-trusted-"));
cleanup.push(workspace);
const server = http.createServer((_request, response) => response.end("control-plane-response"));

View File

@ -169,7 +169,7 @@ describe("sandbox callback bridge", () => {
client: createFileSystemSandboxCallbackBridgeQueueClient(),
queueDir,
authorizeRequest: async (request) =>
request.path === "/api/agents/me" ? null : `Route not allowed: ${request.method} ${request.path}`,
["/api/agents/me", "/runtime-tools/github/credentials"].includes(request.path) ? null : `Route not allowed: ${request.method} ${request.path}`,
handleRequest: async (request) => {
seenRequests.push({
method: request.method,
@ -265,6 +265,23 @@ describe("sandbox callback bridge", () => {
expect(seenRequests[0]?.headers.authorization).toBeUndefined();
expect(seenRequests[0]?.headers["x-paperclip-run-id"]).toBeUndefined();
const githubResponse = await fetch(`${bridge.baseUrl}/runtime-tools/github/credentials`, {
method: "POST",
headers: {
authorization: `Bearer ${bridgeToken}`,
"content-type": "application/json",
"x-paperclip-github-capability": "test-run-scoped-capability",
},
body: "{}",
});
expect(githubResponse.status).toBe(200);
await githubResponse.arrayBuffer();
expect(seenRequests[1]).toMatchObject({
method: "POST", path: "/runtime-tools/github/credentials", body: "{}",
headers: { "x-paperclip-github-capability": "test-run-scoped-capability" },
});
expect(seenRequests[1]?.headers.authorization).toBeUndefined();
});
it("denies non-allowlisted requests by default", async () => {
@ -1301,6 +1318,7 @@ describe("sandbox callback bridge", () => {
it("permits the documented heartbeat surface and denies unrelated routes", () => {
const allowed: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/runtime-tools/github/credentials" },
{ method: "GET", path: "/api/agents/me" },
{ method: "GET", path: "/api/agents/me/inbox-lite" },
{ method: "GET", path: "/api/agents/me/inbox/mine" },

View File

@ -98,6 +98,8 @@ export interface SandboxCallbackBridgeRouteRule {
// reverse bridge. Keep this in sync with the Paperclip skill in
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
// Runtime capability authentication is independently checked by the controller.
{ method: "POST", path: /^\/runtime-tools\/github\/credentials$/ },
// Identity, inbox, agent self-management
{ method: "GET", path: /^\/api\/agents\/me$/ },
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
@ -186,6 +188,7 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
"content-type",
"if-match",
"if-none-match",
"x-paperclip-github-capability",
] as const;
export interface SandboxCallbackBridgeRequest {

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS "run_identity_contexts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"revision" integer NOT NULL,
"responsible_user_id" text,
"message_id" uuid,
"parent_context_id" uuid,
"cause" text NOT NULL,
"correlation_id" text NOT NULL,
"status" text DEFAULT 'accepted' NOT NULL,
"accepted_at" timestamp with time zone,
"github" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "active_identity_context_id" uuid;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'run_identity_contexts_company_id_companies_id_fk' AND conrelid = 'public.run_identity_contexts'::regclass) THEN
ALTER TABLE "run_identity_contexts" ADD CONSTRAINT "run_identity_contexts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "run_identity_contexts_run_revision_idx" ON "run_identity_contexts" USING btree ("run_id","revision");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "run_identity_contexts_run_correlation_idx" ON "run_identity_contexts" USING btree ("run_id","correlation_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "run_identity_contexts_company_run_idx" ON "run_identity_contexts" USING btree ("company_id","run_id");

View File

@ -0,0 +1 @@
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "origin_identity_context_id" uuid;

View File

@ -0,0 +1 @@
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "source_identity_context_id" uuid;

View File

@ -0,0 +1 @@
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "continuation_identity_context_id" uuid;

View File

@ -0,0 +1 @@
ALTER TABLE "run_identity_contexts" DROP CONSTRAINT IF EXISTS "run_identity_contexts_run_id_heartbeat_runs_id_fk";

View File

@ -0,0 +1,3 @@
ALTER TABLE "run_identity_contexts" DROP CONSTRAINT IF EXISTS "run_identity_contexts_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "run_identity_contexts" ADD CONSTRAINT "run_identity_contexts_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1667,6 +1667,48 @@
"when": 1788557575279,
"tag": "0239_sturdy_santa_claus",
"breakpoints": true
},
{
"idx": 240,
"version": "7",
"when": 1788796987307,
"tag": "0240_pink_fantastic_four",
"breakpoints": true
},
{
"idx": 241,
"version": "7",
"when": 1788797555060,
"tag": "0241_conscious_adam_destine",
"breakpoints": true
},
{
"idx": 242,
"version": "7",
"when": 1788798139203,
"tag": "0242_wide_lightspeed",
"breakpoints": true
},
{
"idx": 243,
"version": "7",
"when": 1788798652047,
"tag": "0243_sleepy_metal_master",
"breakpoints": true
},
{
"idx": 244,
"version": "7",
"when": 1788804288225,
"tag": "0244_organic_meltdown",
"breakpoints": true
},
{
"idx": 245,
"version": "7",
"when": 1788804969749,
"tag": "0245_misty_nightshade",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,58 @@
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const migrations = [
"0240_pink_fantastic_four.sql", "0241_conscious_adam_destine.sql",
"0242_wide_lightspeed.sql", "0243_sleepy_metal_master.sql", "0244_organic_meltdown.sql", "0245_misty_nightshade.sql",
].map((name) => readFileSync(new URL(`./migrations/${name}`, import.meta.url), "utf8"));
(support.supported ? describe : describe.skip)("execution identity migration", () => {
it("can replay without inventing historical authorship or losing accepted contexts", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-identity-migration-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
const companyId = randomUUID(), agentId = randomUUID(), historicalRunId = randomUUID(), runId = randomUUID(), contextId = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Identity migration', 'MIG')`;
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type) VALUES (${agentId}, ${companyId}, 'Shared', 'engineer', 'codex_local')`;
await sql`INSERT INTO heartbeat_runs (id, company_id, agent_id, status) VALUES
(${historicalRunId}, ${companyId}, ${agentId}, 'succeeded'), (${runId}, ${companyId}, ${agentId}, 'running')`;
await sql`INSERT INTO run_identity_contexts (id, company_id, run_id, revision, responsible_user_id, cause, correlation_id, accepted_at)
VALUES (${contextId}, ${companyId}, ${runId}, 1, 'person-a', 'instruction', 'dispatch', now())`;
await sql`UPDATE heartbeat_runs SET active_identity_context_id = ${contextId}, responsible_user_id = 'person-a' WHERE id = ${runId}`;
for (let pass = 0; pass < 2; pass++) {
for (const migration of migrations) {
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await sql.unsafe(statement);
}
}
}
const [historical] = await sql`SELECT active_identity_context_id, responsible_user_id FROM heartbeat_runs WHERE id = ${historicalRunId}`;
expect(historical).toEqual({ active_identity_context_id: null, responsible_user_id: null });
const contexts = await sql`SELECT id, responsible_user_id FROM run_identity_contexts WHERE company_id = ${companyId}`;
expect(contexts).toEqual([{ id: contextId, responsible_user_id: 'person-a' }]);
const [active] = await sql`SELECT active_identity_context_id FROM heartbeat_runs WHERE id = ${runId}`;
expect(active.active_identity_context_id).toBe(contextId);
// Agent removal deletes its run rows, but surviving task continuations
// must retain attribution. Even replaying the migration set must be safe.
await sql`DELETE FROM heartbeat_runs WHERE id = ${runId}`;
for (const migration of migrations) {
for (const statement of migration.split('--> statement-breakpoint')) {
if (statement.trim()) await sql.unsafe(statement);
}
}
const [retained] = await sql`SELECT run_id, responsible_user_id FROM run_identity_contexts WHERE id = ${contextId}`;
expect(retained).toEqual({ run_id: runId, responsible_user_id: 'person-a' });
await sql`DELETE FROM heartbeat_runs WHERE company_id = ${companyId}`;
await sql`DELETE FROM agents WHERE company_id = ${companyId}`;
await sql`DELETE FROM companies WHERE id = ${companyId}`;
expect(await sql`SELECT id FROM run_identity_contexts WHERE id = ${contextId}`).toHaveLength(0);
} finally {
await sql.end();
await database.cleanup();
}
}, 30_000);
});

View File

@ -26,6 +26,8 @@ export const heartbeatRuns = pgTable(
triggerDetail: text("trigger_detail"),
status: text("status").notNull().default("queued"),
responsibleUserId: text("responsible_user_id"),
// The service validates the company/run boundary; avoid a cyclic schema import.
activeIdentityContextId: uuid("active_identity_context_id"),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
error: text("error"),

View File

@ -183,3 +183,4 @@ export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
export { pluginLogs } from "./plugin_logs.js";
export { runIdentityContexts } from "./run_identity_contexts.js";

View File

@ -40,6 +40,7 @@ export const issueThreadInteractions = pgTable(
.default("requested"),
idempotencyKey: text("idempotency_key"),
sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
sourceIdentityContextId: uuid("source_identity_context_id"),
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
title: text("title"),
summary: text("summary"),

View File

@ -53,6 +53,8 @@ export const issues = pgTable(
originKind: text("origin_kind").notNull().default("manual"),
originId: text("origin_id"),
originRunId: text("origin_run_id"),
originIdentityContextId: uuid("origin_identity_context_id"),
continuationIdentityContextId: uuid("continuation_identity_context_id"),
originFingerprint: text("origin_fingerprint").notNull().default("default"),
requestDepth: integer("request_depth").notNull().default(0),
billingCode: text("billing_code"),

View File

@ -0,0 +1,25 @@
import { pgTable, uuid, text, integer, timestamp, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
/** Immutable attribution records. Only acceptance state and redacted diagnostics advance. */
export const runIdentityContexts = pgTable("run_identity_contexts", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
// Retain attribution after an agent and its runs are deleted: surviving tasks
// and approvals still reference these contexts. The original run UUID is archival.
runId: uuid("run_id").notNull(),
revision: integer("revision").notNull(),
responsibleUserId: text("responsible_user_id"),
messageId: uuid("message_id"),
parentContextId: uuid("parent_context_id"),
cause: text("cause").notNull(),
correlationId: text("correlation_id").notNull(),
status: text("status").notNull().default("accepted"),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
github: jsonb("github").$type<{ status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string }>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
revisionIdx: uniqueIndex("run_identity_contexts_run_revision_idx").on(t.runId, t.revision),
correlationIdx: uniqueIndex("run_identity_contexts_run_correlation_idx").on(t.runId, t.correlationId),
companyRunIdx: index("run_identity_contexts_company_run_idx").on(t.companyId, t.runId),
}));

View File

@ -551,6 +551,22 @@ pub struct CodexProvider {
// entry from this static ceiling, but cannot introduce another environment
// variable by changing GIT_CONFIG_COUNT.
const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[
"ZDOTDIR",
"BASH_ENV",
"PAPERCLIP_GITHUB_BROKER_URL",
"PAPERCLIP_GITHUB_BROKER_TOKEN",
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
"GH_CONFIG_DIR",
"GH_ENTERPRISE_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_NOSYSTEM",
"GIT_ASKPASS",
"SSH_ASKPASS",
"SSH_AUTH_SOCK",
"GIT_SSH_COMMAND",
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"PAPERCLIP_GIT_TOKEN",
@ -3127,8 +3143,13 @@ mod tests {
#[test]
fn github_credentials_cross_only_the_bounded_provider_environment() {
assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 73);
assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 89);
for key in [
"PAPERCLIP_GITHUB_BROKER_URL",
"PAPERCLIP_GITHUB_BROKER_TOKEN",
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
"GH_CONFIG_DIR",
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
"GH_TOKEN",
"GITHUB_TOKEN",
"PAPERCLIP_GIT_TOKEN",

View File

@ -1726,6 +1726,36 @@ impl CodexCommandExecutor {
.ok_or_else(|| DurableRunnerError::invalid("Codex provider is unavailable"))
}
// Only the authenticated controller can rebind these run-scoped launch
// settings. Executable, model, instructions, approval mode, and all other
// flags remain part of the immutable durable provider profile.
fn stable_launch_args(args: &[String]) -> Vec<String> {
let mut stable = Vec::new();
let mut index = 0;
while index < args.len() {
if args[index] == "-c" && index + 1 < args.len() {
let key = args[index + 1].split('=').next().unwrap_or("");
if matches!(
key,
"permissions.paperclip-runner-workspace-only.filesystem"
| "permissions.paperclip-runner-workspace-read-only.filesystem"
| "permissions.paperclip-runner-workspace-only.network.enabled"
| "permissions.paperclip-runner-workspace-read-only.network.enabled"
| "shell_environment_policy.inherit"
| "shell_environment_policy.ignore_default_excludes"
| "shell_environment_policy.include_only"
| "shell_environment_policy.set"
) {
index += 2;
continue;
}
}
stable.push(args[index].clone());
index += 1;
}
stable
}
fn attach_run(&mut self, payload: &Value) -> Result<(), DurableRunnerError> {
let mut next_state = self
.state
@ -1752,20 +1782,53 @@ impl CodexCommandExecutor {
"run.attach requires a settled Codex provider session with no pending events",
));
}
let runtime_launch_args: Option<Vec<String>> = payload
.get("runtimeLaunchArgs")
.map(|value| serde_json::from_value(value.clone()))
.transpose()
.map_err(|_| {
DurableRunnerError::invalid("run.attach runtime launch arguments are invalid")
})?;
if let Some(provider) = payload.get("provider") {
let config: CodexProviderConfig =
serde_json::from_value(provider.clone()).map_err(|error| {
let mut config: CodexProviderConfig = serde_json::from_value(provider.clone())
.map_err(|error| {
DurableRunnerError::invalid(format!("run.attach provider is invalid: {error}"))
})?;
config
.validate()
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
if runtime_launch_args.is_some()
&& config.provider == "codex"
&& Self::stable_launch_args(&config.args)
== Self::stable_launch_args(&next_state.config.args)
{
config.args = next_state.config.args.clone();
}
if config != next_state.config {
return Err(DurableRunnerError::invalid(
"run.attach cannot change the durable Codex provider profile",
));
}
}
let runtime_launch_changed = if let Some(args) = runtime_launch_args {
if next_state.config.provider != "codex"
|| Self::stable_launch_args(&args)
!= Self::stable_launch_args(&next_state.config.args)
{
return Err(DurableRunnerError::invalid(
"run.attach cannot change protected launch arguments",
));
}
let changed = args != next_state.config.args;
next_state.config.args = args;
next_state
.config
.validate()
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
changed
} else {
false
};
let completion_contract = completion_contract(payload)?;
let tool_set = authorized_tool_set(payload)?;
next_state
@ -1791,21 +1854,22 @@ impl CodexCommandExecutor {
let provider = self.provider.as_mut().ok_or_else(|| {
DurableRunnerError::invalid("run.attach requires the restored Codex provider process")
})?;
let retained_provider = provider
.attach_run_in_place(
next_state.tool_bridge.authorized_tools().cloned(),
next_state.completion_contract.as_ref().map(|contract| {
(
contract.revision.as_str(),
contract.criterion_ids.as_slice(),
)
}),
)
.map_err(|error| {
DurableRunnerError::invalid(format!(
"failed to retain Codex for warm run attachment: {error}"
))
})?;
let retained_provider = !runtime_launch_changed
&& provider
.attach_run_in_place(
next_state.tool_bridge.authorized_tools().cloned(),
next_state.completion_contract.as_ref().map(|contract| {
(
contract.revision.as_str(),
contract.criterion_ids.as_slice(),
)
}),
)
.map_err(|error| {
DurableRunnerError::invalid(format!(
"failed to retain Codex for warm run attachment: {error}"
))
})?;
if !retained_provider {
provider.shutdown().map_err(|error| {
DurableRunnerError::invalid(format!(
@ -3260,6 +3324,46 @@ impl CommandExecutor for CodexCommandExecutor {
#[cfg(test)]
mod tests {
#[test]
fn runtime_launch_rebinding_preserves_protected_arguments() {
let before: Vec<String> = vec![
"-c",
"default_permissions=\"paperclip-runner-workspace-only\"",
"-c",
"shell_environment_policy.set={PATH=\"/run/a\"}",
"--disable",
"image_generation",
"app-server",
]
.into_iter()
.map(str::to_owned)
.collect();
let after: Vec<String> = vec![
"-c",
"default_permissions=\"paperclip-runner-workspace-only\"",
"-c",
"shell_environment_policy.set={PATH=\"/run/b\"}",
"-c",
"shell_environment_policy.include_only=[\"PAPERCLIP_GITHUB_BROKER_TOKEN\"]",
"--disable",
"image_generation",
"app-server",
]
.into_iter()
.map(str::to_owned)
.collect();
assert_eq!(
CodexCommandExecutor::stable_launch_args(&before),
CodexCommandExecutor::stable_launch_args(&after)
);
let mut unsafe_args = after;
unsafe_args.push("--dangerously-bypass-approvals-and-sandbox".to_owned());
assert_ne!(
CodexCommandExecutor::stable_launch_args(&before),
CodexCommandExecutor::stable_launch_args(&unsafe_args)
);
}
use super::*;
fn opencode_result_state() -> CodexProviderState {

View File

@ -79,6 +79,29 @@ describe("Codex security configuration", () => {
expect(serialized).not.toContain("!trusted-helper");
});
it("isolates managed launcher profiles and never serializes broker capabilities or host API credentials", () => {
const serialized = createIsolatedCodexAppServerArgs({
HOME: "/isolated/provider", CODEX_HOME: "/isolated/provider",
PATH: "/runtime/run-B:/safe/bin",
PAPERCLIP_GITHUB_LAUNCHER_DIR: "/runtime/run-B",
PAPERCLIP_GITHUB_BROKER_TOKEN: "private-run-capability",
PAPERCLIP_GITHUB_BRIDGE_TOKEN: "private-bridge-capability",
PAPERCLIP_API_KEY: "forbidden-agent-token",
GH_CONFIG_DIR: "/runtime/run-B/gh-config",
}).join("\n");
expect(serialized).toContain('"/isolated/provider"="none"');
expect(serialized).toContain('"/runtime/run-B"="read"');
expect(serialized).toContain('"/runtime/run-B/gh-config"="write"');
expect(serialized).toContain('HOME="/runtime/run-B"');
expect(serialized).toContain('ZDOTDIR="/runtime/run-B"');
expect(serialized).toContain('BASH_ENV="/runtime/run-B/.bashrc"');
expect(serialized).toContain('"PAPERCLIP_GITHUB_BRIDGE_TOKEN"');
expect(serialized).not.toContain("PAPERCLIP_API_KEY");
expect(serialized).not.toContain("private-run-capability");
expect(serialized).not.toContain("private-bridge-capability");
expect(serialized).not.toContain("forbidden-agent-token");
});
it("uses a read-only permission profile for plan mode", () => {
expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({
cwd: "/workspace",

View File

@ -42,6 +42,11 @@ export function codexCommandEnvironment(
const value = source[key];
if (value !== undefined) environment[key] = value;
}
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) {
environment.HOME = source.PAPERCLIP_GITHUB_LAUNCHER_DIR;
environment.ZDOTDIR = source.PAPERCLIP_GITHUB_LAUNCHER_DIR;
environment.BASH_ENV = `${source.PAPERCLIP_GITHUB_LAUNCHER_DIR}/.bashrc`;
}
return environment;
}
@ -80,6 +85,7 @@ export function createIsolatedCodexAppServerArgs(
const hasGitHubCredential = hasGitHubCredentialEnvironment(source);
const externalRunnerSandbox = usesExternalRunnerSandbox(source);
const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source);
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
const deniedHostRoots = [
...new Set(
[source.HOME, source.CODEX_HOME]
@ -96,6 +102,8 @@ export function createIsolatedCodexAppServerArgs(
`":tmpdir"="none"`,
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="write"}`,
].join(",");
const planningFilesystemRules = [
@ -104,6 +112,8 @@ export function createIsolatedCodexAppServerArgs(
`":tmpdir"="none"`,
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="read"}`,
].join(",");
const commandEnv = Object.entries(codexCommandEnvironment(source))

View File

@ -149,6 +149,7 @@ describe("runtime context materialization", () => {
const config = await readFile(join(codexHome, "config.toml"), "utf8");
expect(config).toContain("shell_snapshot = false");
expect(config).toContain("paperclip-assigned");
expect(config).toContain('default_tools_approval_mode = "approve"');
expect(config).toContain("Bearer ");
expect(config).not.toContain("unassigned");
});

View File

@ -331,6 +331,10 @@ export async function prepareIsolatedCodexHome(input: {
? [
`[mcp_servers.${JSON.stringify(input.nativeMcp.name)}]`,
`url = ${JSON.stringify(input.nativeMcp.url)}`,
// This endpoint is Paperclip's authenticated policy gateway, not an
// upstream server. It enforces connection grants and human approvals
// for every operation; the provider must deliver calls to that gate.
'default_tools_approval_mode = "approve"',
`http_headers = { Authorization = ${JSON.stringify(`Bearer ${input.nativeMcp.token}`)} }`,
"",
]

View File

@ -1,4 +1,20 @@
const STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS = [
"ZDOTDIR",
"BASH_ENV",
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
"PAPERCLIP_GITHUB_BROKER_URL",
"PAPERCLIP_GITHUB_BROKER_TOKEN",
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
"GH_CONFIG_DIR",
"GH_ENTERPRISE_TOKEN",
"GITHUB_ENTERPRISE_TOKEN",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_CONFIG_NOSYSTEM",
"GIT_ASKPASS",
"SSH_ASKPASS",
"SSH_AUTH_SOCK",
"GIT_SSH_COMMAND",
"GH_TOKEN",
"GITHUB_TOKEN",
"PAPERCLIP_GIT_TOKEN",
@ -61,6 +77,7 @@ export function hasGitHubCredentialEnvironment(
source: NodeJS.ProcessEnv,
): boolean {
return [
source.PAPERCLIP_GITHUB_BROKER_TOKEN,
source.GH_TOKEN,
source.GITHUB_TOKEN,
source.PAPERCLIP_GIT_TOKEN,

View File

@ -2723,9 +2723,12 @@ it("cold-restores a suspended provider session under its durable run binding", a
stateDirectory,
"--include-skill-instructions",
"--durable-turn-ids",
"-c",
'shell_environment_policy.set={PATH="/run/A"}',
),
stateDirectory,
environment: {
PAPERCLIP_GITHUB_BROKER_TOKEN: "test-run-A-capability",
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(64 * 1024 * 1024),
},
@ -2798,6 +2801,8 @@ it("cold-restores a suspended provider session under its durable run binding", a
};
const rotated = createCapabilityRunnerdCodexTransport({
...options,
environment: { ...options.environment, PAPERCLIP_GITHUB_BROKER_TOKEN: "test-run-B-capability" },
codexArgs: options.codexArgs.map((arg) => arg.replace('/run/A', '/run/B')),
resumeDynamicTools: dynamicTools,
resumeCompletionContract: {
revision: "contract-second",
@ -2811,6 +2816,9 @@ it("cold-restores a suspended provider session under its durable run binding", a
}));
try {
const read = await rotated.transport.request("thread/read", {});
const persistedProvider = JSON.parse(await readFile(join(stateDirectory, "runner", "codex-provider-state.json"), "utf8"));
expect(persistedProvider.config.args.join("\n")).toContain('/run/B');
expect(JSON.stringify(persistedProvider)).not.toContain("test-run-B-capability");
expect(read.thread).toMatchObject({
id: firstProviderThread.id,
sessionId: firstProviderThread.sessionId,

View File

@ -3561,6 +3561,22 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#authorizedTools,
this.options.resumeCompletionContract,
);
if (provider === "codex" && this.options.environment?.PAPERCLIP_GITHUB_BROKER_TOKEN) {
// These controller-owned, token-free paths belong to the new run.
// Keep the durable provider profile and thread identity unchanged.
runAttachTemplate.runtimeLaunchArgs = this.options.codexArgs ?? createRunnerdCodexAppServerArgs({
environment: this.options.environment,
codexHome,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(this.options.environment),
...(runtimeContext ? [
resolve(codexHome, "skills"),
runtimeContext.instructions.bundle.rootPath,
...runtimeContext.skills.map((skill) => skill.bundle.rootPath),
] : []),
],
});
}
this.#runAttachTemplate = structuredClone(runAttachTemplate);
core.queueCommand("run.attach", runAttachTemplate);
}

View File

@ -166,6 +166,12 @@ export interface HeartbeatRun {
triggerDetail: WakeupTriggerDetail | null;
status: HeartbeatRunStatus;
responsibleUserId: string | null;
activeIdentityContextId?: string | null;
identityHistory?: Array<{
id: string; revision: number; responsibleUserId: string | null; messageId: string | null;
parentContextId: string | null; cause: string; status: string; acceptedAt: Date | string | null;
github: { status: "available" | "absent" | "unavailable"; login?: string; source?: "personal" | "dedicated"; reason?: string } | null;
}>;
startedAt: Date | null;
finishedAt: Date | null;
error: string | null;

View File

@ -811,6 +811,8 @@ export interface Issue {
originKind?: IssueOriginKind;
originId?: string | null;
originRunId?: string | null;
originIdentityContextId?: string | null;
continuationIdentityContextId?: string | null;
originFingerprint?: string | null;
requestDepth: number;
billingCode: string | null;
@ -1058,6 +1060,7 @@ export interface IssueCommentMetadataSection {
export interface IssueCommentMetadata {
version: 1;
sourceRunId?: string | null;
sourceIdentityContextId?: string | null;
authorizationReason?: string | null;
sections: IssueCommentMetadataSection[];
}
@ -1471,6 +1474,7 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF
idempotencyKey?: string | null;
sourceCommentId?: string | null;
sourceRunId?: string | null;
sourceIdentityContextId?: string | null;
addresseeAgentId?: string | null;
addresseeUserId?: string | null;
title?: string | null;

View File

@ -771,11 +771,8 @@ describe("agent live run routes", () => {
);
expect(res.status, JSON.stringify(res.body)).toBe(202);
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
// caller actually supplied so empty-body callers (e.g. e2e suites) match
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
// caller supplies reason / payload / forceFreshSession those are
// forwarded; idempotencyKey is omitted unless explicitly set.
// Optional wake fields retain their existing shape; execution identity
// always comes from the authenticated caller.
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
source: "on_demand",
triggerDetail: "manual",
@ -790,6 +787,8 @@ describe("agent live run routes", () => {
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
responsibleUserId: "local-board",
originIdentityContextId: null,
forceFreshSession: true,
},
});
@ -813,6 +812,8 @@ describe("agent live run routes", () => {
contextSnapshot: {
triggeredBy: "board",
actorId: "local-board",
responsibleUserId: "local-board",
originIdentityContextId: null,
},
});
});

View File

@ -0,0 +1,113 @@
import express from "express";
import request from "supertest";
import { runtimeConnectionIntentRoutes } from "../routes/connection-intents.js";
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
import { errorHandler } from "../middleware/index.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, companyMemberships, companySecrets, connectionGrants, createDb, heartbeatRuns, issueComments, issues, runIdentityContexts, toolApplications, toolConnectionInstalls, toolConnections, userSecretDefinitions } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, acceptSteeredIdentity } from "../services/run-identity.js";
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
const vault = vi.hoisted(() => ({
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
}));
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("operation-time GitHub credential resolution", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
beforeAll(async () => { vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "test-github-broker-signing-secret"); database = await startEmbeddedPostgresTestDatabase("paperclip-github-operation-"); db = createDb(database.connectionString); }, 30_000);
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); }, 60_000);
async function seed() {
const companyId=randomUUID(), agentId=randomUUID(), runId=randomUUID(), issueId=randomUUID();
await db.insert(companies).values({ id:companyId, name:companyId, issuePrefix:companyId.slice(0,8) });
await db.insert(agents).values({ id:agentId, companyId, name:"Shared", role:"engineer", adapterType:"codex_local" });
await db.insert(issues).values({ id:issueId, companyId, title:"Identity test" });
await db.insert(heartbeatRuns).values({ id:runId, companyId, agentId, status:"running", contextSnapshot:{issueId} });
await db.insert(companyMemberships).values(["A","B"].map(principalId => ({companyId, principalType:"user", principalId, status:"active", membershipRole:"member"})));
await initializeRunIdentity(db,{companyId,runId,responsibleUserId:"A",cause:"instruction"});
return {companyId,agentId,runId,issueId};
}
async function grant(input: Awaited<ReturnType<typeof seed>>, user: string, dedicated=false) {
const applicationId=randomUUID(), connectionId=randomUUID(), secretId=randomUUID(), definitionId=randomUUID(), id=randomUUID();
await db.insert(toolApplications).values({id:applicationId, companyId:input.companyId, name:applicationId,type:"mcp_http"});
await db.insert(toolConnections).values({id:connectionId,companyId:input.companyId,applicationId,name:connectionId,uid:connectionId,transport:"mcp_remote",status:"active",enabled:true,credentialPolicy:dedicated?"per_agent":"per_user",config:{sourceTemplateKey:"github"}});
await db.insert(toolConnectionInstalls).values({companyId:input.companyId,connectionId,targetType:"agent",targetId:input.agentId});
if (!dedicated) await db.insert(userSecretDefinitions).values({id:definitionId,companyId:input.companyId,key:definitionId,name:"Test GitHub"});
await db.insert(companySecrets).values({id:secretId,companyId:input.companyId,key:secretId,name:"Test token",scope:dedicated?"company":"user",ownerUserId:dedicated?null:user,userSecretDefinitionId:dedicated?null:definitionId});
await db.insert(connectionGrants).values({id,companyId:input.companyId,connectionId,kind:dedicated?"agent":"user",subjectUserId:dedicated?null:user,subjectAgentId:dedicated?input.agentId:null,status:"active",credentialSecretRefs:[{secretId,configPath:"oauth.access_token",versionSelector:"latest"}],providerTenant:{github:{userId:user,login:user,installationCount:1,repositoryCount:1,repositorySelection:"selected",installationIds:["1"],installationOwnerLogins:[user]}}});
return {id,connectionId};
}
async function switchTo(input: Awaited<ReturnType<typeof seed>>, user:string) {
const id=randomUUID();
await db.insert(issueComments).values({id,companyId:input.companyId,issueId:input.issueId,authorUserId:user,body:"Next instruction"});
const context=await reserveSteeredIdentity(db,{...input,messageId:id});
await acceptSteeredIdentity(db,context!);
}
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
const input=await seed(); await grant(input,"A"); await grant(input,"B");
for (const user of ["A","B","A"]) {
await switchTo(input,user);
const result=await resolveGitHubOperationCredentials(db,input);
expect(result).toMatchObject({status:"available",login:user,source:"personal"});
expect(result.env.GH_TOKEN).toBe(`test-token-${user}`);
expect(result.env.GIT_AUTHOR_EMAIL).toBe(`${user}+${user}@users.noreply.github.com`);
}
const history=await db.select().from(runIdentityContexts).where(eq(runIdentityContexts.runId,input.runId));
expect(JSON.stringify(history)).not.toContain("test-token-");
});
it("returns no credential for unconnected users, removed membership, or ambiguous personal accounts", async () => {
const input=await seed(); await grant(input,"A");
await switchTo(input,"B");
expect((await resolveGitHubOperationCredentials(db,input)).env).toEqual({});
await switchTo(input,"A");
await db.update(companyMemberships).set({status:"inactive"}).where(eq(companyMemberships.companyId,input.companyId));
expect((await resolveGitHubOperationCredentials(db,input)).status).toBe("unavailable");
await db.update(companyMemberships).set({status:"active"}).where(eq(companyMemberships.companyId,input.companyId));
await grant(input,"A");
expect((await resolveGitHubOperationCredentials(db,input)).reason).toMatch(/More than one/);
await expect(resolveGitHubOperationCredentials(db,{...input,companyId:randomUUID()})).rejects.toThrow();
});
it("honors dedicated overrides and never substitutes personal credentials when revoked or disabled", async () => {
const input=await seed(); await grant(input,"A"); const dedicated=await grant(input,"robot",true);
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"available",source:"dedicated",login:"robot"});
await db.update(connectionGrants).set({status:"revoked"}).where(eq(connectionGrants.id,dedicated.id));
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"unavailable",source:"dedicated",env:{}});
await db.update(connectionGrants).set({status:"active"}).where(eq(connectionGrants.id,dedicated.id));
await db.update(toolConnections).set({enabled:false}).where(eq(toolConnections.id,dedicated.connectionId));
expect(await resolveGitHubOperationCredentials(db,input)).toMatchObject({status:"unavailable",source:"dedicated",env:{}});
});
it("does not resolve the company default person's GitHub", async () => {
const input=await seed(); await grant(input,"A");
await db.update(runIdentityContexts).set({cause:"company_default"}).where(eq(runIdentityContexts.runId,input.runId));
expect((await resolveGitHubOperationCredentials(db,input)).env).toEqual({});
});
it("requires a run-scoped runtime capability and never accepts browser authentication or supplied identities", async () => {
const input = await seed(); await grant(input, "A");
const app = express(); app.use(express.json()); app.use(runtimeConnectionIntentRoutes(db)); app.use(errorHandler);
const tokenInput = {...input, responsibleUserId: "A", scope: "github_credentials" as const};
const token = createRuntimeToolsToken(tokenInput)!.token;
const post = () => request(app).post("/runtime-tools/github/credentials");
const a = await post().set("Authorization", `Bearer ${token}`).send({responsibleUserId: "B"});
expect((await post().set("Authorization", `Bearer ${token}`).set("Sec-Fetch-Mode", "cors")).status).toBe(200);
expect(a.status).toBe(200); expect(a.body.login).toBe("A"); expect(a.headers["cache-control"]).toBe("no-store");
for (const [header, value] of [["Origin", "http://127.0.0.1"], ["Cookie", "session=test"], ["Sec-Fetch-Site", "same-origin"]]) {
expect((await post().set("Authorization", `Bearer ${token}`).set(header!,value!)).status).toBe(403);
}
const wrongScope = createRuntimeToolsToken({...tokenInput, scope: "connection_intents"})!.token;
expect((await post().set("Authorization", `Bearer ${wrongScope}`)).status).toBe(401);
const wrongAgent = createRuntimeToolsToken({...tokenInput, agentId: randomUUID()})!.token;
expect((await post().set("Authorization", `Bearer ${wrongAgent}`)).status).toBe(403);
// The runner bridge replaces Authorization, but forwards the separate run capability.
expect((await post().set("Authorization", "Bearer bridge-host-token").set("x-paperclip-github-capability",token)).status).toBe(200);
await switchTo(input,"B");
const b = await post().set("Authorization", `Bearer ${token}`);
expect(b.status).toBe(200); expect(b.body.env).toEqual({});
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id,input.runId));
expect((await post().set("Authorization", `Bearer ${token}`)).status).toBe(403);
});
});

View File

@ -21,6 +21,32 @@ import type { AuthorizationActor, AuthorizationDecision } from "../services/auth
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("does not preflight or resolve legacy GitHub token bindings for managed executions", async () => {
const assertNoGitHubBinding = (env: Record<string, unknown>) => {
for (const key of Object.keys(env)) if (/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|PAPERCLIP_GIT_TOKEN)$/.test(key)) {
throw new Error("Unavailable legacy GitHub secret must not be resolved at startup");
}
};
const missing = { type: "user_secret_ref", key: "old-github-token", required: true };
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1", agentId: "agent-1", environmentId: "environment-1",
projectId: "project-1", routineId: "routine-1", managedGitHubCredentials: true,
executionRunConfig: { env: { GH_TOKEN: missing, AGENT_VALUE: "ok" } },
environmentEnv: { GITHUB_TOKEN: missing, ENVIRONMENT_VALUE: "ok" },
projectEnv: { GH_ENTERPRISE_TOKEN: missing, PROJECT_VALUE: "ok" },
routineEnv: { GITHUB_ENTERPRISE_TOKEN: missing, PAPERCLIP_GIT_TOKEN: missing, ROUTINE_VALUE: "ok" },
secretsSvc: {
collectMissingRuntimeBindings: vi.fn(async (_companyId, env) => { assertNoGitHubBinding(env); return []; }),
resolveAdapterConfigForRuntime: vi.fn(async (_companyId, config) => {
assertNoGitHubBinding(config.env); return { config, secretKeys: new Set(), manifest: [] };
}),
resolveEnvBindings: vi.fn(async (_companyId, env) => {
assertNoGitHubBinding(env); return { env, secretKeys: new Set(), manifest: [] };
}),
} as any,
});
expect(result.resolvedConfig.env).toEqual({ AGENT_VALUE: "ok", ENVIRONMENT_VALUE: "ok", PROJECT_VALUE: "ok", ROUTINE_VALUE: "ok" });
});
it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: {

View File

@ -284,6 +284,7 @@ describe("openapi routes", () => {
const { spec } = loadSpecRoutes();
expect(spec.paths["/api/openapi.json"].get.security).toEqual([]);
expect(spec.paths["/runtime-tools/github/credentials"].post.security).toEqual([{ RuntimeToolsBearerAuth: [] }]);
expect(spec.paths["/api/plugins/install"].post.security).toEqual([
{ BoardSessionAuth: [] },
{ BoardApiKeyAuth: [] },

View File

@ -30,6 +30,9 @@ describe("opencode_local environment diagnostics", () => {
it("treats an empty OPENAI_API_KEY override as missing", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-empty-key-"));
// This case tests environment precedence, not model-discovery retry delays.
const fakeOpencode = path.join(cwd, "opencode");
await fs.writeFile(fakeOpencode, "#!/bin/sh\necho openai/test-model\n", { mode: 0o755 });
const originalOpenAiKey = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-host-value";
@ -38,7 +41,7 @@ describe("opencode_local environment diagnostics", () => {
companyId: "company-1",
adapterType: "opencode_local",
config: {
command: process.execPath,
command: fakeOpencode,
cwd,
env: {
OPENAI_API_KEY: "",

View File

@ -0,0 +1,188 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { acceptSteeredIdentity, captureRunIdentity, initializeRunIdentity, listRunIdentityContexts, rejectSteeredIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("durable execution identity", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-run-identity-");
db = createDb(database.connectionString);
}, 30_000);
afterAll(async () => { await database?.cleanup(); }, 60_000);
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Identity tests", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Shared agent", role: "engineer", adapterType: "codex_local" });
await db.insert(issues).values({ id: issueId, companyId, title: "Shared task", responsibleUserId: "owner" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { issueId } });
const messageIds: string[] = [];
for (const author of ["A", "B", "A"]) {
const id = randomUUID();
await db.insert(issueComments).values({ id, companyId, issueId, authorUserId: author, body: "Instruction" });
messageIds.push(id);
}
return { companyId, agentId, issueId, runId, messageIds };
}
it("retains mixed-author delivery order without rewriting task ownership", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, responsibleUserId: "owner", cause: "queued" });
const history = await listRunIdentityContexts(db, input.companyId, input.runId);
expect(history.map((row) => row.responsibleUserId)).toEqual(["owner", "A", "B", "A"]);
expect(history.map((row) => row.revision)).toEqual([1, 2, 3, 4]);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
const [issue] = await db.select().from(issues).where(eq(issues.id, input.issueId));
expect(issue.responsibleUserId).toBe("owner");
await initializeRunIdentity(db, { ...input, responsibleUserId: "B", cause: "restart" });
expect(await listRunIdentityContexts(db, input.companyId, input.runId)).toHaveLength(4);
});
it("holds acquisition during uncertain steering, preserves snapshots, and never rewinds on replay", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [input.messageIds[0]], responsibleUserId: "A", cause: "instruction" });
const startedUnderA = await captureRunIdentity(db, input);
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
expect(b).not.toBeNull();
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
await expect(reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] })).rejects.toThrow(/reconciled/);
await db.transaction(async tx => {
await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId)).for("update");
await acceptSteeredIdentity(tx, b!);
});
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
expect(startedUnderA.context?.responsibleUserId).toBe("A");
const a = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[2] });
await acceptSteeredIdentity(db, a!);
await acceptSteeredIdentity(db, b!);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
});
it("keeps rejected steering unchanged and preserves the originating identity on retry", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await rejectSteeredIdentity(db, b!);
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("A");
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
const retry = await initializeRunIdentity(db, { companyId: input.companyId, runId, parentRunId: input.runId, responsibleUserId: "B", cause: "retry" });
expect(retry.responsibleUserId).toBe("A");
expect(retry.parentContextId).toBe((await captureRunIdentity(db, input)).context?.id);
await expect(captureRunIdentity(db, { ...input, companyId: randomUUID() })).rejects.toThrow();
await expect(captureRunIdentity(db, { ...input, agentId: randomUUID() })).rejects.toThrow();
});
it("preserves an explicitly absent identity through a continuation initiated by another person", async () => {
const input = await seed();
const origin = await initializeRunIdentity(db, {
...input, messageIds: [], responsibleUserId: null, cause: "routine",
});
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId, companyId: input.companyId, agentId: input.agentId,
status: "running", responsibleUserId: "B",
});
const continued = await initializeRunIdentity(db, {
companyId: input.companyId, runId, parentContextId: origin.id,
responsibleUserId: "B", cause: "retry",
});
expect(continued.responsibleUserId).toBeNull();
const captured = await captureRunIdentity(db, { ...input, runId });
expect(captured.run.responsibleUserId).toBeNull();
expect(captured.context?.parentContextId).toBe(origin.id);
});
it("recovers an uncertain acknowledgement from the authenticated native event journal", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const pending = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await expect(captureRunIdentity(db, input)).rejects.toThrow(/reconciled/);
await db.insert(heartbeatRunEvents).values({ companyId: input.companyId, runId: input.runId,
agentId: input.agentId, seq: 1, eventType: "item.completed", sourceEventId: randomUUID(),
payload: { prpEvent: { turnId: "turn-1", itemId: `turn-1:steer:${pending!.messageId}`, payload: {kind: "steering_acknowledgement"} } },
});
expect((await captureRunIdentity(db, input)).context?.responsibleUserId).toBe("B");
await db.update(heartbeatRuns).set({status: "succeeded"}).where(eq(heartbeatRuns.id, input.runId));
expect((await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] }))?.status).toBe("accepted");
});
it("preserves an operation's origin through approval and delegation after another person steers", async () => {
const input = await seed();
const origin = await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: input.companyId,
issueId: input.issueId, kind: "request_confirmation", sourceRunId: input.runId,
sourceIdentityContextId: origin.id, payload: { prompt: "Continue?" } as never,
});
const b = await reserveSteeredIdentity(db, { ...input, messageId: input.messageIds[1] });
await acceptSteeredIdentity(db, b!);
for (const approval of [true, false]) {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
const continued = await initializeRunIdentity(db, { companyId: input.companyId, runId, issueId: input.issueId,
...(approval ? { interactionId } : { parentContextId: origin.id }),
responsibleUserId: "B", cause: approval ? "approval" : "delegation",
});
expect(continued.responsibleUserId).toBe("A");
expect(continued.parentContextId).toBe(origin.id);
}
});
it("retains continuation and approval attribution after deleting the originating agent and runs", async () => {
const input = await seed();
const origin = await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId: input.companyId,
issueId: input.issueId, kind: "request_confirmation", sourceRunId: input.runId,
sourceIdentityContextId: origin.id, payload: { prompt: "Continue?" } as never,
});
await db.update(issues).set({ originIdentityContextId: origin.id }).where(eq(issues.id, input.issueId));
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, input.agentId));
await db.delete(agents).where(eq(agents.id, input.agentId));
const agentId = randomUUID();
await db.insert(agents).values({ id: agentId, companyId: input.companyId, name: "Replacement", role: "engineer", adapterType: "codex_local" });
const [task] = await db.select().from(issues).where(eq(issues.id, input.issueId));
expect(task.continuationIdentityContextId).toBe(origin.id);
for (const source of [{ parentContextId: task.originIdentityContextId }, { parentContextId: task.continuationIdentityContextId }, { interactionId }]) {
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId, status: "running" });
const continued = await initializeRunIdentity(db, { companyId: input.companyId, runId,
issueId: input.issueId, ...source, responsibleUserId: "B", cause: "continuation" });
expect(continued.responsibleUserId).toBe("A");
expect(continued.parentContextId).toBe(origin.id);
}
});
it("does not deadlock identity initialization against a task mutation that also updates the run", async () => {
const input = await seed();
let initialization!: ReturnType<typeof initializeRunIdentity>;
await db.transaction(async (tx) => {
await tx.select().from(issues).where(eq(issues.id, input.issueId)).for("update");
const [backend] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
initialization = initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
// Wait until initialization is blocked by this task mutation, rather than
// relying on timing to decide whether it has acquired its first lock.
let waiting = false;
for (let attempt = 0; attempt < 100; attempt++) {
const [state] = await db.execute(sql`select exists (
select 1 from pg_stat_activity where ${backend.pid} = any(pg_blocking_pids(pid))
) as waiting`) as unknown as Array<{ waiting: boolean }>;
if (state.waiting) { waiting = true; break; }
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(waiting).toBe(true);
await tx.execute(sql`set local lock_timeout = '1s'`);
await tx.update(heartbeatRuns).set({ updatedAt: new Date() }).where(eq(heartbeatRuns.id, input.runId));
});
await expect(initialization).resolves.toMatchObject({ responsibleUserId: "A" });
});
it("does not turn a company-default fallback into personal consent on continuation", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "company_default" });
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: input.companyId, agentId: input.agentId, status: "running" });
const retry = await initializeRunIdentity(db, { companyId: input.companyId, runId, parentRunId: input.runId, responsibleUserId: "A", cause: "retry" });
expect(retry.cause).toBe("company_default");
});
});

View File

@ -38,6 +38,8 @@ import {
toolRuntimeMetricCounters,
toolRuntimeSlots,
toolStdioCommandTemplates,
userSecretDefinitions,
userSecretDeclarations,
} from "@paperclipai/db";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
@ -5001,6 +5003,36 @@ describeEmbeddedPostgres("tool access service", () => {
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual(["app_paused"]);
});
it("preserves all active personal OAuth declarations through pause, resume, and metadata edits", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const [application] = await db.insert(toolApplications).values({ companyId: company.id, name: "GitHub", type: "mcp_http" }).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id, applicationId: application.id, name: "GitHub", uid: randomUUID(),
transport: "mcp_remote", status: "active", enabled: true, credentialPolicy: "per_user",
config: { url: "https://api.githubcopilot.com/mcp/", sourceTemplateKey: "github" },
}).returning();
const [sharedDefinition] = await db.insert(userSecretDefinitions).values({ companyId: company.id, key: randomUUID(), name: "OAuth access token" }).returning();
const definitionIds = [sharedDefinition.id];
for (const user of ["A", "B", "revoked"]) {
const definition = user === "revoked"
? (await db.insert(userSecretDefinitions).values({ companyId: company.id, key: randomUUID(), name: "Revoked identity" }).returning())[0]
: sharedDefinition;
const [secret] = await db.insert(companySecrets).values({ companyId: company.id, key: randomUUID(), name: user,
scope: "user", ownerUserId: user, userSecretDefinitionId: definition.id }).returning();
await db.insert(connectionGrants).values({ companyId: company.id, connectionId: connection.id, kind: "user",
subjectUserId: user, status: user === "revoked" ? "revoked" : "active",
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token", versionSelector: "latest" }],
});
}
for (const edit of [{ enabled: false }, { enabled: true }, { name: "Renamed GitHub" }]) {
await service.updateConnection(connection.id, edit);
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection.id));
expect(declarations.map((row) => row.userSecretDefinitionId).sort()).toEqual([...definitionIds].sort());
expect(declarations.every((row) => row.configPath === "oauth.access_token")).toBe(true);
}
});
it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);

View File

@ -8,6 +8,9 @@ import {
companies,
companySecretBindings,
companySecrets,
companyMemberships,
toolConnectionInstalls,
issueComments,
connectionGrants,
createDb,
heartbeatRuns,
@ -26,6 +29,7 @@ import {
} from "@paperclipai/db";
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
import type { VercelConnectClient } from "../services/vercel-connect.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../services/run-identity.js";
import { secretService } from "../services/secrets.js";
import {
createToolGatewayService,
@ -1203,6 +1207,53 @@ describeEmbeddedPostgres("tool gateway service", () => {
expect(storedGrant?.externalCredential).toMatchObject({ tokenId: "stk_fresh" });
});
it("keeps managed GitHub personal identity even under a legacy shared policy", async () => {
const { company, agent, issue, run } = await createRunFixture(db);
const { connection } = await createRemoteMcpToolFixture(db, company.id);
await db.update(toolConnections).set({ authKind: "oauth", credentialSource: "paperclip_vault",
config: { ...connection.config, sourceTemplateKey: "github" },
}).where(eq(toolConnections.id, connection.id));
await db.insert(toolConnectionInstalls).values({ companyId: company.id,
connectionId: connection.id, targetType: "agent", targetId: agent.id });
await db.insert(companyMemberships).values(["A", "B"].map(principalId => ({ companyId: company.id,
principalType: "user", principalId, status: "active", membershipRole: "member" })));
const grants = await db.insert(connectionGrants).values(["A", "B"].map(subjectUserId => ({
companyId: company.id, connectionId: connection.id, kind: "user", subjectUserId,
status: "active", credentialSecretRefs: [],
}))).returning();
await initializeRunIdentity(db, { companyId: company.id, runId: run.id, issueId: issue.id,
responsibleUserId: "A", cause: "instruction" });
await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads",
policyType: "allow", selectors: { riskLevel: "read" } });
const resolvedGrants: string[] = [];
const gateway = createTestToolGatewayService(db, {
oauthGrantRefresher: async ({ grantId }) => {
resolvedGrants.push(grantId);
return db.select().from(connectionGrants).where(eq(connectionGrants.id, grantId)).then(rows => rows[0]!);
},
remoteHttpRequest: async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0",
id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "ok" }] },
}), { status: 200, headers: { "content-type": "application/json" } }),
});
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
const tool = (await gateway.listToolsForSession(session.token)).find(t => t.providerType === "mcp_remote_http")!;
for (const [index, user] of ["A", "B", "A"].entries()) {
if (index > 0) {
const [message] = await db.insert(issueComments).values({ companyId: company.id, issueId: issue.id,
authorUserId: user, body: "Next instruction" }).returning();
const pending = await reserveSteeredIdentity(db, { companyId: company.id, runId: run.id,
issueId: issue.id, messageId: message.id });
await reconcileSteeredIdentity(db, pending!);
}
expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).status).toBe("completed");
}
expect(resolvedGrants).toEqual([grants[0].id, grants[1].id, grants[0].id]);
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, company.id));
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
.rejects.toMatchObject({ reasonCode: "grant_owner_membership_inactive" });
expect(resolvedGrants).toHaveLength(3);
});
it("refreshes a customer OAuth grant once and retries after an upstream 401", async () => {
const { company, agent, run } = await createRunFixture(db);
const { connection } = await createRemoteMcpToolFixture(db, company.id);
@ -1222,6 +1273,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
await db.update(toolConnections).set({
authKind: "oauth",
credentialSource: "paperclip_vault",
credentialRefs: [{ name: "oauth.access_token", placement: "header", key: "Authorization", prefix: "Bearer ", secretId: accessSecret.id, versionSelector: "latest" }],
config: {
url: "https://example.invalid/mcp",
oauth: {

View File

@ -7737,7 +7737,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
const reservePort = async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const probe = net.createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
// macOS can allocate an entire ephemeral range above 55535. Pick a
// bounded candidate so the test's HMR companion remains a valid port.
await new Promise<void>((resolve) => {
probe.once("error", () => resolve());
probe.listen(20_000 + Math.floor(Math.random() * 20_000), "127.0.0.1", resolve);
});
if (!probe.listening) continue;
const address = probe.address();
const port = typeof address === "object" && address ? address.port : null;
await new Promise<void>((resolve, reject) => {
@ -8078,7 +8084,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
const reservePort = async () => {
for (let attempt = 0; attempt < 100; attempt += 1) {
const probe = net.createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
// macOS can allocate an entire ephemeral range above 55535. Pick a
// bounded candidate so the test's HMR companion remains a valid port.
await new Promise<void>((resolve) => {
probe.once("error", () => resolve());
probe.listen(20_000 + Math.floor(Math.random() * 20_000), "127.0.0.1", resolve);
});
if (!probe.listening) continue;
const address = probe.address();
const candidate = typeof address === "object" && address ? address.port : null;
await new Promise<void>((resolve, reject) => {

View File

@ -24,6 +24,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "./logger.js";
import { captureRunIdentity } from "../services/run-identity.js";
import { boardAuthService } from "../services/board-auth.js";
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
@ -379,7 +380,18 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
return;
}
const onBehalfOfUserId = claims.responsible_user_id !== undefined
const [identityRun] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
identityRun.activeIdentityContextId = captured.context?.id ?? null;
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
}
const onBehalfOfUserId = identityRun?.activeIdentityContextId
? identityRun.responsibleUserId
: claims.responsible_user_id !== undefined
? normalizeOptionalString(claims.responsible_user_id)
: await resolveLegacyRunResponsibleUserId(db, {
companyId: claims.company_id,
@ -399,6 +411,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
runId: claims.run_id,
onBehalfOfUserId,
identityContextId: identityRun?.activeIdentityContextId ?? null,
onBehalfOfMemberships,
source: "agent_jwt",
};

View File

@ -5205,12 +5205,16 @@ export function agentRoutes(
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
payload: req.body.payload ?? null,
payload: req.actor.type === "agent" && req.body.payload
? { ...req.body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined }
: req.body.payload ?? null,
idempotencyKey: req.body.idempotencyKey ?? null,
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
contextSnapshot: {
triggeredBy: req.actor.type,
originIdentityContextId: req.actor.identityContextId ?? null,
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
forceFreshSession: req.body.forceFreshSession === true,
...(req.body.reason === "rerun_with_provider_trace" &&
@ -5312,6 +5316,8 @@ export function agentRoutes(
}>;
const contextSnapshot: Record<string, unknown> = {
triggeredBy: req.actor.type,
originIdentityContextId: req.actor.identityContextId ?? null,
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
};
if (body.forceFreshSession === true) {
@ -5336,7 +5342,9 @@ export function agentRoutes(
wakeOpts.reason = body.reason;
}
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
wakeOpts.payload = body.payload as Record<string, unknown>;
wakeOpts.payload = req.actor.type === "agent"
? { ...body.payload, commentId: undefined, wakeCommentId: undefined, wakeCommentIds: undefined }
: body.payload as Record<string, unknown>;
}
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
wakeOpts.idempotencyKey = body.idempotencyKey;
@ -6036,7 +6044,7 @@ export function agentRoutes(
run.companyId,
run.id,
redactCurrentUserValue(
{ ...decoratedRun, retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
{ ...decoratedRun, identityHistory: await listRunIdentityContexts(db, run.companyId, run.id), retryExhaustedReason, outputSilence: await heartbeat.buildRunOutputSilence(run) },
await getCurrentUserRedactionOptions(),
),
));
@ -6673,3 +6681,4 @@ export function agentRoutes(
return router;
}
import { listRunIdentityContexts } from "../services/run-identity.js";

View File

@ -15,6 +15,7 @@ import { logActivity } from "../services/activity-log.js";
import { accessService } from "../services/access.js";
import type { heartbeatService } from "../services/heartbeat.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
function bearer(req: Request) {
const value = req.header("authorization") ?? "";
@ -61,6 +62,19 @@ export function runtimeConnectionIntentRoutes(db: Db) {
const router = Router();
const service = connectionIntentService(db);
router.post("/runtime-tools/github/credentials", async (req, res) => {
// This capability is never accepted as board/session authentication.
// Node fetch sends Sec-Fetch-Mode too; browsers additionally send Origin or Sec-Fetch-Site.
if (req.headers.origin || req.headers.cookie || req.headers["sec-fetch-site"]) throw forbidden("GitHub credentials require runtime authentication");
const claims = verifyRuntimeToolsToken(typeof req.headers["x-paperclip-github-capability"] === "string"
? req.headers["x-paperclip-github-capability"] : bearer(req), "github_credentials");
if (!claims) throw unauthorized("Invalid GitHub runtime capability");
res.setHeader("Cache-Control", "no-store");
res.json(await resolveGitHubOperationCredentials(db, {
companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id,
}));
});
router.get("/mcp/runtime-tools", async (req, res) => {
await service.validate(runtimeClaims(req));
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });

View File

@ -1,3 +1,4 @@
import { storedSteeringAcknowledgement, reconcileSteeredIdentity, reserveSteeredIdentity, acceptSteeredIdentity, rejectSteeredIdentity } from "../services/run-identity.js";
import { createHash, randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
@ -9227,6 +9228,7 @@ export function issueRoutes(
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
originRunId: createBody.originRunId ?? actor.runId,
originIdentityContextId: req.actor.identityContextId ?? null,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
@ -11963,6 +11965,10 @@ export function issueRoutes(
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
const actor = getActorInfo(req);
const steeringIdentity = await reserveSteeredIdentity(db, {
companyId: issue.companyId, runId: req.body.targetRunId, issueId: issue.id, messageId: commentId,
});
let steeringDeliveryAttempted = false;
let acknowledgedTurnId: string | null = null;
let duplicate = false;
let queue: IssueQueuedCommentQueue;
@ -12075,11 +12081,14 @@ export function issueRoutes(
});
}
const acknowledgement = await steerNativeSession({
steeringDeliveryAttempted = true;
const acknowledgement = (steeringIdentity ? await storedSteeringAcknowledgement(tx, steeringIdentity) : null) ?? await steerNativeSession({
runId: locked.activeRun.id,
message: entry.comment.body,
correlationId: commentId,
onAcknowledged: steeringIdentity ? () => reconcileSteeredIdentity(db, steeringIdentity) : undefined,
});
if (steeringIdentity) await acceptSteeredIdentity(tx, steeringIdentity);
acknowledgedTurnId = acknowledgement.turnId;
const remainingIds = locked.queue.entries
.map((candidate) => candidate.comment.id)
@ -12130,6 +12139,10 @@ export function issueRoutes(
});
});
} catch (error) {
const uncertain = steeringDeliveryAttempted && (!(error instanceof NativeSessionSteeringError)
|| error.code === "steering_timeout");
if (steeringIdentity && !uncertain) await rejectSteeredIdentity(db, steeringIdentity);
if (error instanceof NativeSessionSteeringError) {
throw conflict(error.message, { code: error.code, retryable: true });
}
@ -12226,6 +12239,7 @@ export function issueRoutes(
...req.body,
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
}, {
identityContextId: req.actor.identityContextId,
agentId: actor.agentId,
userId: actor.actorType === "user" ? actor.actorId : null,
});

View File

@ -821,6 +821,7 @@ const RUNTIME_TOOLS_SECURITY: Array<Record<string, string[]>> = [
];
const RUNTIME_TOOLS_OPERATIONS = new Set([
"POST /runtime-tools/github/credentials",
"GET /mcp/runtime-tools",
"POST /mcp/runtime-tools",
"POST /runtime-tools/connections/search",
@ -1150,7 +1151,7 @@ function applyDocumentFixups(document: any): any {
scheme: "bearer",
bearerFormat: "Heartbeat-bound runtime tools token",
description:
"Short-lived token bound to an active heartbeat run and presented in the Authorization bearer header.",
"Scoped token bound to an active heartbeat run and presented in the Authorization bearer header. The GitHub credential endpoint requires the distinct github_credentials scope.",
},
};
document.security = AUTHENTICATED_SECURITY;
@ -7436,6 +7437,14 @@ for (const route of [
// --- Connection intents ------------------------------------------------------
registerCurrentRoute({
method: "post",
path: "/runtime-tools/github/credentials",
tags: ["connection-intents"],
summary: "Resolve operation credentials using a run capability with github_credentials scope; browser sessions are rejected",
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
});
registerCurrentRoute({
method: "get",
path: "/mcp/runtime-tools",

View File

@ -6,7 +6,7 @@ export interface RuntimeToolsTokenClaims {
company_id: string;
run_id: string;
responsible_user_id: string;
scope: "connection_intents";
scope: "connection_intents" | "github_credentials";
iat: number;
exp: number;
instance_id: string;
@ -44,6 +44,7 @@ export function createRuntimeToolsToken(input: {
companyId: string;
runId: string;
responsibleUserId: string;
scope?: RuntimeToolsTokenClaims["scope"];
}) {
if (!secret()) return null;
const now = Math.floor(Date.now() / 1000);
@ -53,9 +54,10 @@ export function createRuntimeToolsToken(input: {
company_id: input.companyId,
run_id: input.runId,
responsible_user_id: input.responsibleUserId,
scope: "connection_intents",
scope: input.scope ?? "connection_intents",
iat: now,
exp: now + TOKEN_TTL_SECONDS,
// Broker tokens remain scoped to a live run, which is rechecked on every use.
exp: now + (input.scope === "github_credentials" ? 30 * 24 * 60 * 60 : TOKEN_TTL_SECONDS),
instance_id: instanceId,
};
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
@ -65,7 +67,7 @@ export function createRuntimeToolsToken(input: {
: null;
}
export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims | null {
export function verifyRuntimeToolsToken(token: string, scope: RuntimeToolsTokenClaims["scope"] = "connection_intents"): RuntimeToolsTokenClaims | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
let header: Record<string, unknown>;
@ -86,7 +88,7 @@ export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims
typeof claims.sub !== "string"
|| typeof claims.run_id !== "string"
|| typeof claims.responsible_user_id !== "string"
|| claims.scope !== "connection_intents"
|| claims.scope !== scope
|| typeof claims.iat !== "number"
|| typeof claims.exp !== "number"
|| claims.exp <= Math.floor(Date.now() / 1000)

View File

@ -14,6 +14,7 @@ import {
projects,
goals,
heartbeatRuns,
runIdentityContexts,
heartbeatRunEvents,
costEvents,
financeEvents,
@ -536,6 +537,7 @@ export function companyService(db: Db) {
}
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
await tx.delete(activityLog).where(eq(activityLog.companyId, id));
await tx.delete(runIdentityContexts).where(eq(runIdentityContexts.companyId, id));
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));

View File

@ -126,6 +126,7 @@ export function connectionIntentService(db: Db) {
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
responsibleUserId: heartbeatRuns.responsibleUserId,
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
@ -135,7 +136,7 @@ export function connectionIntentService(db: Db) {
!run
|| run.companyId !== claims.company_id
|| run.agentId !== claims.sub
|| run.responsibleUserId !== claims.responsible_user_id
|| (!run.activeIdentityContextId && run.responsibleUserId !== claims.responsible_user_id)
) throw forbidden("Runtime tool token does not match its heartbeat run");
if (run.status !== "running") throw forbidden("Runtime tool token is no longer active");
const snapshot = record(run.contextSnapshot);

View File

@ -1,5 +1,6 @@
import {
companySecrets,
heartbeatRuns,
companyMemberships,
connectionGrantDelegations,
connectionGrants,
@ -48,6 +49,7 @@ export type GitCredential = {
/** The company-secret name the token came from; null for a server-environment token. */
secretName: string | null;
githubIdentity?: { userId: string; login: string };
identitySource?: "personal" | "dedicated";
};
/** A prepared, credential-bearing git invocation: config args plus the env that carries the token. */
@ -260,6 +262,23 @@ export function createGitRemoteAuthProvider(
return async (remoteUrl: string) => {
if (!isSupportedGitHubRemoteUrl(remoteUrl)) return null;
if (db && context?.heartbeatRunId && context.agentId) {
const [run] = await db.select({ contextId: heartbeatRuns.activeIdentityContextId }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, context.heartbeatRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, context.agentId),
));
if (run?.contextId) {
const { resolveGitHubOperationCredentials } = await import("./github-operation-credentials.js");
const result = await resolveGitHubOperationCredentials(db, {
companyId, runId: context.heartbeatRunId, agentId: context.agentId,
});
const anonymous = buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null });
return { ...anonymous, env: {
...anonymous.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null",
GIT_AUTHOR_NAME: "", GIT_AUTHOR_EMAIL: "", GIT_COMMITTER_NAME: "", GIT_COMMITTER_EMAIL: "",
...result.env,
} };
}
}
credentialPromise ??= resolveCredential();
const credential = await credentialPromise;
if (!credential) return null;
@ -273,16 +292,16 @@ export async function resolveManagedGitHubIdentitySelection(
context: {
responsibleUserId?: string | null;
agentId?: string | null;
allowStandingDelegation?: boolean;
},
): Promise<{
configured: boolean;
identitySource?: "personal" | "dedicated";
grant?: typeof connectionGrants.$inferSelect;
error?: string;
}> {
const connections = await db.select().from(toolConnections).where(and(
eq(toolConnections.companyId, companyId),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
));
const githubConnections = connections.filter((connection) => {
const config = connection.config && typeof connection.config === "object" ? connection.config as Record<string, unknown> : {};
@ -300,7 +319,7 @@ export async function resolveManagedGitHubIdentitySelection(
));
const eligibleConnectionIds = new Set(githubConnections.filter((connection) => installs.some((install) =>
install.connectionId === connection.id && (
install.targetType === "company"
(install.targetType === "company" && install.targetId === companyId)
|| (install.targetType === "agent" && install.targetId === context.agentId)
)
)).map((connection) => connection.id));
@ -323,7 +342,7 @@ export async function resolveManagedGitHubIdentitySelection(
const personal = context.responsibleUserId
? grants.filter((grant) => grant.kind === "user" && grant.subjectUserId === context.responsibleUserId)
: [];
const delegated = !context.responsibleUserId && context.agentId
const delegated = context.allowStandingDelegation !== false && !context.responsibleUserId && context.agentId
? await db.select({ grantId: connectionGrantDelegations.grantId }).from(connectionGrantDelegations).where(and(
eq(connectionGrantDelegations.companyId, companyId),
eq(connectionGrantDelegations.agentId, context.agentId),
@ -334,17 +353,22 @@ export async function resolveManagedGitHubIdentitySelection(
})
: [];
const candidates = dedicated.length > 0 ? dedicated : personal.length > 0 ? personal : delegated;
const identitySource = dedicated.length > 0 ? "dedicated" as const : "personal" as const;
if (candidates.length !== 1) {
return {
configured: true,
configured: true, identitySource,
error: candidates.length === 0
? "No managed GitHub identity is available for this run"
: "More than one managed GitHub identity matches this run",
};
}
const grant = candidates[0]!;
if (grant.status !== "active") return { configured: true, error: "The managed GitHub identity must be reconnected" };
return { configured: true, grant };
const connection = githubConnections.find((candidate) => candidate.id === grant.connectionId);
if (!connection?.enabled || connection.status !== "active") {
return { configured: true, identitySource, error: "The managed GitHub connection is unavailable" };
}
if (grant.status !== "active") return { configured: true, identitySource, error: "The managed GitHub identity must be reconnected" };
return { configured: true, identitySource, grant };
}
export async function filterResolvedGitHubConnectionsForRun<T extends {
@ -379,7 +403,7 @@ export async function filterResolvedGitHubConnectionsForRun<T extends {
);
}
async function resolveManagedGitHubCredential(
export async function resolveManagedGitHubCredential(
db: Db,
secrets: GitCredentialSecretsDeps,
companyId: string,
@ -388,20 +412,21 @@ async function resolveManagedGitHubCredential(
heartbeatRunId?: string | null;
responsibleUserId?: string | null;
agentId?: string | null;
allowStandingDelegation?: boolean;
},
): Promise<{ configured: boolean; credential?: GitCredential; error?: string }> {
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
if (!selection.configured) return { configured: false };
if (!selection.grant) return { configured: true, error: selection.error };
if (!selection.grant) return { configured: true, identitySource: selection.identitySource, error: selection.error };
let grant = selection.grant;
if (grant.kind === "user" && grant.subjectUserId) {
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
const [membership] = await db.select({ id: companyMemberships.id, role: companyMemberships.membershipRole }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, grant.subjectUserId),
eq(companyMemberships.status, "active"),
)).limit(1);
if (!membership) return { configured: true, error: "The managed GitHub identity owner is not an active company member" };
if (!membership || membership.role === "viewer") return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity owner is not an authorized company member" };
}
const expiresAt = grant.providerTenant?.oauth?.accessTokenExpiresAt;
const refreshedAt = grant.providerTenant?.oauth?.refreshedAt;
@ -423,9 +448,9 @@ async function resolveManagedGitHubCredential(
}
const accessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
const github = grant.providerTenant?.github;
if (!accessRef || !github) return { configured: true, error: "The managed GitHub identity is incomplete" };
if (!accessRef || !github) return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity is incomplete" };
if (github.installationCount < 1 || github.repositoryCount < 1) {
return { configured: true, error: "The managed GitHub identity no longer has repository access" };
return { configured: true, identitySource: selection.identitySource, error: "The managed GitHub identity no longer has repository access" };
}
const accessContext = {
consumerType: "system" as const,
@ -439,7 +464,7 @@ async function resolveManagedGitHubCredential(
let token: string;
if (grant.kind === "user") {
if (!grant.subjectUserId || !secrets.resolveUserSecretValue) {
return { configured: true, error: "The personal GitHub credential cannot be resolved" };
return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential cannot be resolved" };
}
const [secret] = await db.select({
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
@ -448,25 +473,26 @@ async function resolveManagedGitHubCredential(
eq(companySecrets.id, accessRef.secretId),
eq(companySecrets.ownerUserId, grant.subjectUserId),
)).limit(1);
if (!secret?.userSecretDefinitionId) return { configured: true, error: "The personal GitHub credential is invalid" };
if (!secret?.userSecretDefinitionId) return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential is invalid" };
const resolved = await secrets.resolveUserSecretValue(companyId, {
definitionId: secret.userSecretDefinitionId,
responsibleUserId: grant.subjectUserId,
version: accessRef.versionSelector ?? "latest",
required: true,
}, accessContext);
if (!resolved) return { configured: true, error: "The personal GitHub credential is missing" };
if (!resolved) return { configured: true, identitySource: selection.identitySource, error: "The personal GitHub credential is missing" };
token = resolved.value;
} else {
token = await secrets.resolveSecretValue(companyId, accessRef.secretId, accessRef.versionSelector ?? "latest", { accessContext });
}
return {
configured: true,
configured: true, identitySource: selection.identitySource,
credential: {
token,
source: "managed_connection",
secretName: null,
githubIdentity: { userId: github.userId, login: github.login },
identitySource: grant.kind === "agent" ? "dedicated" : "personal",
},
};
}

View File

@ -0,0 +1,42 @@
import { eq } from "drizzle-orm";
import { runIdentityContexts, type Db } from "@paperclipai/db";
import { forbidden } from "../errors.js";
import { captureRunIdentity } from "./run-identity.js";
import { buildGitAuthInvocation, resolveManagedGitHubCredential } from "./git-credentials.js";
import { secretService } from "./secrets.js";
export type GitHubCredentialSummary = {
status: "available" | "absent" | "unavailable";
source?: "personal" | "dedicated";
login?: string;
reason?: string;
};
/** No company secrets or ambient credentials are consulted by this path. */
export async function resolveGitHubOperationCredentials(db: Db, input: {
companyId: string; agentId: string; runId: string;
}) {
const { run, context } = await captureRunIdentity(db, input);
if (!context) throw forbidden("This run predates managed GitHub credentials");
let summary: GitHubCredentialSummary;
let env: Record<string, string> = {};
try {
const resolved = await resolveManagedGitHubCredential(db, secretService(db), input.companyId, {
agentId: input.agentId, heartbeatRunId: input.runId,
allowStandingDelegation: false,
responsibleUserId: context?.cause === "company_default" ? null : context?.responsibleUserId ?? null,
issueId: typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null,
});
if (resolved.credential) {
summary = { status: "available", source: resolved.credential.identitySource, login: resolved.credential.githubIdentity?.login };
env = buildGitAuthInvocation(resolved.credential).env;
} else {
summary = { status: resolved.configured ? "unavailable" : "absent", source: resolved.identitySource ?? "personal", reason: resolved.error ?? "No GitHub identity connected" };
}
} catch {
// Provider/secret errors can contain sensitive response bodies. Never persist them.
summary = { status: "unavailable", reason: "GitHub credentials are temporarily unavailable" };
}
if (context) await db.update(runIdentityContexts).set({ github: summary }).where(eq(runIdentityContexts.id, context.id));
return { identityContextId: context?.id ?? null, revision: context?.revision ?? null, ...summary, env };
}

View File

@ -1,3 +1,6 @@
import { initializeRunIdentity } from "./run-identity.js";
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, startAdapterExecutionTargetPaperclipBridge } from "@paperclipai/adapter-utils/execution-target";
import fs from "node:fs/promises";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
@ -103,7 +106,6 @@ import {
createGitRemoteAuthProvider,
describeGitAuthFailure,
filterResolvedGitHubConnectionsForRun,
GIT_CREDENTIAL_TOKEN_ENV_KEY,
scrubGitCredentialText,
type GitRemoteAuthProvider,
} from "./git-credentials.js";
@ -1211,14 +1213,19 @@ const LOW_TRUST_SENSITIVE_ENV_KEY_RE =
// 3. Any other PAPERCLIP_*-named binding is user data and flows through to
// the run env like any non-prefixed binding.
const FORBIDDEN_ENV_BINDING_KEYS = new Set(["PAPERCLIP_API_KEY"]);
const MANAGED_GITHUB_TOKEN_KEYS = new Set([
"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN", "PAPERCLIP_GIT_TOKEN",
]);
function stripForbiddenEnvBindings(
envValue: unknown,
managedGitHubCredentials = false,
): Record<string, unknown> | null {
const record = parseObject(envValue);
const filtered = Object.fromEntries(
Object.entries(record).filter(
([key]) => !FORBIDDEN_ENV_BINDING_KEYS.has(key),
([key]) => !FORBIDDEN_ENV_BINDING_KEYS.has(key)
&& !(managedGitHubCredentials && MANAGED_GITHUB_TOKEN_KEYS.has(key)),
),
);
return Object.keys(filtered).length > 0 ? filtered : null;
@ -1226,11 +1233,12 @@ function stripForbiddenEnvBindings(
function stripForbiddenEnvFromAdapterConfig(
config: Record<string, unknown>,
managedGitHubCredentials = false,
): Record<string, unknown> {
if (!Object.prototype.hasOwnProperty.call(config, "env")) return config;
return {
...config,
env: stripForbiddenEnvBindings(config.env) ?? {},
env: stripForbiddenEnvBindings(config.env, managedGitHubCredentials) ?? {},
};
}
@ -1281,16 +1289,18 @@ export async function resolveExecutionRunAdapterConfig(input: {
reason: string;
remediation: string;
};
/** Managed GitHub tokens are resolved only when operations start. */
managedGitHubCredentials?: boolean;
/** Audited class-3 values resolved by an internal credential broker. */
trustedEnvProjection?: Record<string, string>;
trustedEnvSecretKeys?: string[];
}) {
const executionRunConfig = stripForbiddenEnvFromAdapterConfig(
input.executionRunConfig,
input.executionRunConfig, input.managedGitHubCredentials,
);
const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv);
const projectEnv = stripForbiddenEnvBindings(input.projectEnv);
const routineEnv = stripForbiddenEnvBindings(input.routineEnv);
const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv, input.managedGitHubCredentials);
const projectEnv = stripForbiddenEnvBindings(input.projectEnv, input.managedGitHubCredentials);
const routineEnv = stripForbiddenEnvBindings(input.routineEnv, input.managedGitHubCredentials);
const agentEnv = parseObject(executionRunConfig.env);
const lowTrustAllowedBindingIds =
input.trustPreset?.kind === "low_trust_review"
@ -4089,7 +4099,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
input.agent.id,
);
const [runIdentity] = await input.db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns)
.where(
and(
@ -4099,8 +4109,8 @@ export async function buildPaperclipRuntimeMcpServers(input: {
),
)
.limit(1);
const resolvedInstalledConnections =
await filterResolvedGitHubConnectionsForRun({
const resolvedInstalledConnections = runIdentity?.activeIdentityContextId
? effective.installedConnections : await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
@ -4146,7 +4156,8 @@ export async function buildPaperclipRuntimeMcpServers(input: {
permittedConnectionIds.has(connection.id) &&
connection.status === "active" &&
connection.enabled &&
!isToolConnectionAttentionHealth(connection.healthStatus) &&
(Boolean(runIdentity?.activeIdentityContextId) && (connection.config?.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|| !isToolConnectionAttentionHealth(connection.healthStatus)) &&
(connection.transport === "mcp_remote" ||
connection.transport === "local_stdio"),
);
@ -4226,7 +4237,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
if (!profile) {
const fullConnectionIds = new Set(
effective.entries
.filter((entry) => entry.effect === "include" && entry.connectionId)
.filter((entry) => entry.effect === "include" && entry.selectorType === "connection" && entry.connectionId)
.map((entry) => entry.connectionId!),
);
const entries = [
@ -4575,7 +4586,7 @@ export async function createManagedMcpRunConfig(input: {
),
);
const [runIdentity] = await input.db
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns)
.where(
and(
@ -4585,7 +4596,9 @@ export async function createManagedMcpRunConfig(input: {
),
)
.limit(1);
const resolvedAvailableInstalls = await filterResolvedGitHubConnectionsForRun(
const resolvedAvailableInstalls = runIdentity?.activeIdentityContextId
? installRows.filter((install) => install.enabled && install.status === "active").map((install) => ({ id: install.connectionId }))
: await filterResolvedGitHubConnectionsForRun(
{
db: input.db,
companyId: input.agent.companyId,
@ -8995,6 +9008,8 @@ export function heartbeatService(
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
originIdentityContextId: issues.originIdentityContextId,
continuationIdentityContextId: issues.continuationIdentityContextId,
updatedAt: issues.updatedAt,
})
.from(issues)
@ -9109,6 +9124,7 @@ export function heartbeatService(
routineId: issueContext.originId,
env: snapshot.routine.env ?? null,
responsibleUserId:
routineRun?.responsibleUserId ??
revision?.responsibleUserId ??
snapshot.routine.responsibleUserId ??
null,
@ -9229,6 +9245,27 @@ export function heartbeatService(
input.requestedByActorType === "user"
? readNonEmptyString(input.requestedByActorId)
: null;
const messageIds = Array.isArray(input.contextSnapshot.wakeCommentIds)
? input.contextSnapshot.wakeCommentIds.filter((id): id is string => typeof id === "string")
: readNonEmptyString(input.contextSnapshot.wakeCommentId) ? [String(input.contextSnapshot.wakeCommentId)] : [];
if (input.issueContext && messageIds.length && !input.contextSnapshot.retryOfRunId) {
const messages = await db.select({ id: issueComments.id, authorUserId: issueComments.authorUserId })
.from(issueComments).where(and(eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, input.issueContext.id), inArray(issueComments.id, messageIds)));
for (const id of [...messageIds].reverse()) {
const author = messages.find((message) => message.id === id)?.authorUserId;
if (author) {
delete input.contextSnapshot.executionIdentityCause;
return author;
}
}
}
const retryOfRunId = readNonEmptyString(input.contextSnapshot.retryOfRunId);
if (retryOfRunId) {
const [origin] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, retryOfRunId)));
if (origin?.responsibleUserId) return origin.responsibleUserId;
}
if (contextResponsibleUserId) return contextResponsibleUserId;
if (input.existingRunResponsibleUserId)
return input.existingRunResponsibleUserId;
@ -9242,9 +9279,8 @@ export function heartbeatService(
input.issueContext?.parentId,
);
if (parentResponsibleUserId) return parentResponsibleUserId;
if (input.issueContext)
return resolveCompanyDefaultResponsibleUserId(input.companyId);
if (requestedUserId) return requestedUserId;
if (!input.issueContext && requestedUserId) return requestedUserId;
input.contextSnapshot.executionIdentityCause = "company_default";
return resolveCompanyDefaultResponsibleUserId(input.companyId);
}
@ -10483,6 +10519,7 @@ export function heartbeatService(
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId:
readNonEmptyString(context.responsibleUserId) ??
readNonEmptyString(context.responsible_user_id),
@ -10744,6 +10781,7 @@ export function heartbeatService(
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId:
readNonEmptyString(context.responsibleUserId) ??
readNonEmptyString(context.responsible_user_id),
@ -11266,7 +11304,7 @@ export function heartbeatService(
triggerDetail: "system",
reason: RUN_LIVENESS_CONTINUATION_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
contextSnapshot: { ...decision.contextSnapshot, originIdentityContextId: null, parentRunId: run.id },
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
@ -11638,7 +11676,7 @@ export function heartbeatService(
triggerDetail: "system",
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
payload: decision.payload,
contextSnapshot: decision.contextSnapshot,
contextSnapshot: { ...decision.contextSnapshot, originIdentityContextId: null, parentRunId: run.id },
idempotencyKey: decision.idempotencyKey,
requestedByActorType: "system",
requestedByActorId: "heartbeat",
@ -18034,6 +18072,7 @@ export function heartbeatService(
activeRunExecutions.add(run.id);
let runScratch: HeartbeatRunScratch | null = null;
let githubLauncherLocation: Parameters<typeof cleanupGitHubOperationLaunchers>[0] | null = null;
let nativeSessionResumeScheduled = false;
let nativeWorkspaceFinalizeScheduled = false;
let nativeWorkspaceSync: Awaited<
@ -18306,19 +18345,30 @@ export function heartbeatService(
agent.companyId,
issueContext,
);
const responsibleUserId = await resolveResponsibleUserIdForRun({
let responsibleUserId: string | null = await resolveResponsibleUserIdForRun({
run,
contextSnapshot: context,
issueContext,
routineEnvContext,
});
if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
await db
.update(heartbeatRuns)
.set({ responsibleUserId, updatedAt: new Date() })
.where(eq(heartbeatRuns.id, run.id));
run = { ...run, responsibleUserId };
}
const identityContext = await initializeRunIdentity(db, {
companyId: agent.companyId, runId: run.id, responsibleUserId,
interactionId: readNonEmptyString(context.interactionId),
issueId, messageIds: run.retryOfRunId || context.retryOfRunId ? [] : queuedCommentIdsFromRunContext(context).length
? queuedCommentIdsFromRunContext(context)
: Array.isArray(context.wakeCommentIds) ? context.wakeCommentIds.filter((id): id is string => typeof id === "string")
: wakeCommentId ? [wakeCommentId] : [],
parentContextId: run.retryOfRunId || context.retryOfRunId ? null
: readNonEmptyString(context.originIdentityContextId)
?? (run.triggerDetail === "manual" || context.parentRunId ? null : issueContext?.continuationIdentityContextId ?? issueContext?.originIdentityContextId),
parentRunId: run.retryOfRunId ?? readNonEmptyString(context.retryOfRunId) ?? readNonEmptyString(context.parentRunId),
cause: readNonEmptyString(context.executionIdentityCause) ?? readNonEmptyString(context.wakeReason) ?? "dispatch",
});
// Initialization has persisted the active context, including an explicit
// absence of identity inherited from an automatic continuation.
responsibleUserId = identityContext.responsibleUserId;
run = { ...run, activeIdentityContextId: identityContext.id, responsibleUserId };
context.executionIdentityRunId = run.id;
if (
responsibleUserId &&
issueContext &&
@ -18832,23 +18882,9 @@ export function heartbeatService(
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
: runScopedMentionedSkillKeys;
const pushCapabilityPreflightRequired = requiresPushCapabilityPreflight({
adapterType: agent.adapterType,
issueId,
explicitRunScopedSkillKeys: runScopedMentionedSkillKeys,
});
const githubRunAuth = await createGitRemoteAuthProvider(
db,
agent.companyId,
{
issueId,
heartbeatRunId: run.id,
responsibleUserId,
agentId: agent.id,
},
)("https://github.com/paperclipai/credential-probe.git");
const { resolvedConfig, secretKeys, secretManifest } =
await resolveExecutionRunAdapterConfig({
managedGitHubCredentials: true,
companyId: agent.companyId,
agentId: agent.id,
adapterType: agent.adapterType,
@ -18865,25 +18901,6 @@ export function heartbeatService(
routineEnv: routineEnvContext.env,
secretsSvc,
trustPreset,
...(githubRunAuth
? {
trustedEnvProjection: githubRunAuth.env,
trustedEnvSecretKeys: [
"GH_TOKEN",
"GITHUB_TOKEN",
GIT_CREDENTIAL_TOKEN_ENV_KEY,
],
}
: {}),
requiredScopedEnvBinding: pushCapabilityPreflightRequired
? {
keys: [...PUSH_CAPABILITY_ENV_KEYS],
consumerScopes: ["agent", "project"],
reason: "push_write_credential_missing",
remediation:
"GitHub PR workflow requires GH_TOKEN or GITHUB_TOKEN bound at project or agent scope.",
}
: undefined,
});
if (secretManifest.length > 0) {
context.paperclipSecrets = {
@ -19187,9 +19204,8 @@ export function heartbeatService(
: null,
issueId,
});
// One credential provider per run: base-ref refreshes during workspace realization and
// restore authenticate against private GitHub remotes with the same company-secret token
// the managed clone uses.
// The run-scoped provider resolves the active identity at each Git operation,
// including base-ref refreshes, workspace realization, and restore.
const workspaceGitAuthProvider = createGitRemoteAuthProvider(
db,
agent.companyId,
@ -19855,6 +19871,19 @@ export function heartbeatService(
} else {
delete context.paperclipScratch;
}
const githubBrokerToken = createRuntimeToolsToken({
agentId: agent.id, companyId: agent.companyId, runId: run.id,
responsibleUserId: responsibleUserId ?? "", scope: "github_credentials",
});
const githubBrokerEnv = githubBrokerEnvironment(parseObject(runtimeConfig.env), {
url: configuredPaperclipApiBaseUrl() ?? "", token: githubBrokerToken?.token ?? "",
});
githubLauncherLocation = { runId: run.id, target: executionTarget };
runtimeConfig = { ...runtimeConfig, env: await prepareGitHubOperationLaunchers({
runId: run.id, target: executionTarget, cwd: executionWorkspace.cwd,
env: githubBrokerEnv,
}) };
secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN");
context.paperclipEnvironment = {
id: selectedEnvironment.id,
name: selectedEnvironment.name,
@ -20336,20 +20365,8 @@ export function heartbeatService(
environmentDriver: selectedEnvironment.driver,
leaseMetadata: activeEnvironmentLease.lease.metadata,
});
await assertPushCapabilityCheckoutValid({
enabled:
pushCapabilityPreflightRequired &&
executionTarget?.kind === "local",
issue: issueRef
? {
id: issueRef.id,
identifier: issueRef.identifier,
}
: null,
cwd: executionWorkspace.cwd,
});
const adapterEnv = Object.fromEntries(
Object.entries(parseObject(resolvedConfig.env)).filter(
Object.entries(parseObject(runtimeConfig.env)).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" && typeof entry[1] === "string",
),
@ -21355,77 +21372,104 @@ export function heartbeatService(
endedAtMs: nativeDispatchAtMs,
},
);
const guardedDispatch =
await dispatchResolvedInteractionContinuationWithAtomicGate(
(markDispatchStarted) =>
executePaperclipNativeSession({
db,
execution: nativeExecution,
runnerInstanceId: nativeRunnerInstanceId,
leaseOwner: runOptions.nativeLeaseOwner,
restartRecovery: runOptions.nativeRestartRecovery,
backend:
options.nativeSessionBackendFactory?.(nativeExecution),
useRunnerd: agent.adapterType === "paperclip_runner",
onLog,
onEvent: onAdapterEvent,
preparationSpans: nativeRunnerPreparationSpans,
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
// workspace boundary authoritative.
runnerEnvironment: {
...buildNativeProviderEnvironment(
adapterEnv,
process.env,
executionWorkspace.cwd,
// Native Git/gh uses the same authenticated remote callback
// transport as managed adapters. A bridge failure must not make
// GitHub a prerequisite for otherwise unrelated native work.
let nativeGitHubBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTarget?.kind === "remote" && adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN) {
try {
nativeGitHubBridge = await startAdapterExecutionTargetPaperclipBridge({
runId: run.id,
target: executionTarget,
runtimeRootDir: path.posix.join(executionTarget.remoteCwd, ".paperclip-runtime", "github", run.id),
adapterKey: "native-github",
hostApiToken: adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN,
hostApiUrl: adapterEnv.PAPERCLIP_GITHUB_BROKER_URL,
onLog,
});
} catch {
await onLog("stderr", "[paperclip] GitHub runtime transport unavailable; continuing without managed GitHub access.\n");
}
}
try {
const guardedDispatch =
await dispatchResolvedInteractionContinuationWithAtomicGate(
(markDispatchStarted) =>
executePaperclipNativeSession({
db,
execution: nativeExecution,
runnerInstanceId: nativeRunnerInstanceId,
leaseOwner: runOptions.nativeLeaseOwner,
restartRecovery: runOptions.nativeRestartRecovery,
backend:
options.nativeSessionBackendFactory?.(nativeExecution),
useRunnerd: agent.adapterType === "paperclip_runner",
onLog,
onEvent: onAdapterEvent,
preparationSpans: nativeRunnerPreparationSpans,
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
// workspace boundary authoritative.
runnerEnvironment: {
...buildNativeProviderEnvironment(
adapterEnv,
process.env,
executionWorkspace.cwd,
),
...(nativeGitHubBridge ? {
PAPERCLIP_GITHUB_BROKER_URL: nativeGitHubBridge.env.PAPERCLIP_API_URL,
PAPERCLIP_GITHUB_BRIDGE_TOKEN: nativeGitHubBridge.env.PAPERCLIP_API_KEY,
} : {}),
...(nativeMcpServer
? {
PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
}
: {}),
...(providerTraceCapture
? {
PAPERCLIP_PROVIDER_TRACE_PATH:
providerTraceCapture.path,
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
PROVIDER_TRACE_MAX_BYTES,
),
}
: {}),
},
runnerExecutionTarget: executionTarget,
runnerIngressAuthorized: isRunnerIngressAuthorized(
nativeRuntimeResolution,
),
...(nativeMcpServer
? {
PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
}
: {}),
...(providerTraceCapture
? {
PAPERCLIP_PROVIDER_TRACE_PATH:
providerTraceCapture.path,
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
PROVIDER_TRACE_MAX_BYTES,
),
}
: {}),
},
runnerExecutionTarget: executionTarget,
runnerIngressAuthorized: isRunnerIngressAuthorized(
nativeRuntimeResolution,
),
runnerPublicUrl:
runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath:
runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
null,
runnerRemoteBinaryPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
null,
runnerRemoteCodexPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
null,
runnerRemoteCodexNpmSpec:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
null,
runnerRemoteProviderPackPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
null,
enqueueWakeup,
onSpawn: async (meta) => {
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
},
}),
);
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
runnerPublicUrl:
runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath:
runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
null,
runnerRemoteBinaryPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
null,
runnerRemoteCodexPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
null,
runnerRemoteCodexNpmSpec:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
null,
runnerRemoteProviderPackPath:
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
null,
enqueueWakeup,
onSpawn: async (meta) => {
markDispatchStarted();
await persistRunProcessMetadata(run.id, meta);
},
}),
);
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
} finally {
await nativeGitHubBridge?.stop();
}
} else {
const interactionId = readNonEmptyString(context.interactionId);
const legacyQuestionResponse =
@ -22820,6 +22864,13 @@ export function heartbeatService(
latestRun?.status,
);
if (!nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled) {
// Keep launchers during same-run recovery. At a terminal boundary all
// operations have settled; clean before the remote lease can be stopped.
if (githubLauncherLocation && latestRun && isHeartbeatRunTerminalStatus(latestRun.status)) {
await cleanupGitHubOperationLaunchers(githubLauncherLocation).catch((err) => {
logger.warn({ err, runId: run.id }, "failed to clean managed GitHub launchers");
});
}
await releaseEnvironmentLeasesForRun({
runId: run.id,
companyId: run.companyId,

View File

@ -7,6 +7,7 @@ import {
companies,
documents,
heartbeatRuns,
runIdentityContexts,
issueComments,
issueDocuments,
issueQuestionResponseDeliveries,
@ -101,6 +102,7 @@ export { extractGitHubPullRequestReferences } from "./github-pull-request-merge.
export type { GitHubPullRequestReference } from "./github-pull-request-merge.js";
type InteractionActor = {
identityContextId?: string | null;
agentId?: string | null;
runId?: string | null;
userId?: string | null;
@ -2707,10 +2709,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
}
}
let sourceIdentityContextId: string | null = null;
if (data.sourceRunId) {
const sourceRun = await db
.select({
companyId: heartbeatRuns.companyId,
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
})
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, data.sourceRunId))
@ -2718,6 +2722,14 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
if (!sourceRun || sourceRun.companyId !== issue.companyId) {
throw unprocessable("sourceRunId must belong to the same company");
}
sourceIdentityContextId = actor.identityContextId ?? sourceRun.activeIdentityContextId;
if (sourceIdentityContextId) {
const [origin] = await db.select({id: runIdentityContexts.id}).from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, sourceIdentityContextId), eq(runIdentityContexts.companyId, issue.companyId),
eq(runIdentityContexts.runId, data.sourceRunId), eq(runIdentityContexts.status, "accepted"),
));
if (!origin) throw unprocessable("Interaction execution identity is unavailable");
}
}
const requiresCurrentTarget =
@ -2770,6 +2782,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
idempotencyKey: data.idempotencyKey ?? null,
sourceCommentId: data.sourceCommentId ?? null,
sourceRunId: data.sourceRunId ?? null,
sourceIdentityContextId,
title: data.title ?? null,
summary: data.summary ?? null,
createdByAgentId: actor.agentId ?? null,
@ -3018,6 +3031,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
billingCode: task.billingCode ?? null,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
originIdentityContextId: interaction.sourceIdentityContextId ?? null,
originRunId: interaction.sourceRunId ?? null,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.userId ?? null,
} as Parameters<ReturnType<typeof issueService>["createChild"]>[1]);

View File

@ -3266,6 +3266,8 @@ const issueListSelect = {
originKind: issues.originKind,
originId: issues.originId,
originRunId: issues.originRunId,
originIdentityContextId: issues.originIdentityContextId,
continuationIdentityContextId: issues.continuationIdentityContextId,
originFingerprint: issues.originFingerprint,
requestDepth: issues.requestDepth,
billingCode: issues.billingCode,

View File

@ -4,6 +4,7 @@ import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
symlink,
writeFile,
@ -180,6 +181,7 @@ import {
nativeSessionRecoveryProjection,
nativeGovernedWaitResult,
parseRemoteExecutableCandidate,
buildRemoteCodexLauncherCommand,
mayUsePreinstalledRunnerArtifact,
nativeUsageCostUsd,
normalizeNativeUsage,
@ -1601,6 +1603,32 @@ describe("remote provider checkpoint restores", () => {
});
describe("remote preinstalled executable discovery", () => {
it("stages a relative-path CLI shim without changing its installation or losing arguments", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-codex-shim-"));
try {
const installation = join(root, "image install's bin");
const target = join(root, "workspace", "bin", "codex");
const source = join(installation, "codex");
await mkdir(installation, { recursive: true });
await mkdir(join(root, "workspace", "bin"), { recursive: true });
const shim = '#!/bin/sh\ncat "$(dirname "$0")/version.txt"\nprintf "%s\\n" "$@"\n';
await writeFile(source, shim, { mode: 0o755 });
await writeFile(join(installation, "version.txt"), "codex-cli 0.153.4\n");
// Existing deployments may already have the old symlink. Never write
// through it into the shared installation while upgrading the launcher.
await symlink(source, target);
for (let pass = 0; pass < 2; pass++) {
execFileSync("sh", ["-c", buildRemoteCodexLauncherCommand(source, target)]);
expect(execFileSync(target, ["--version", "argument with 'quotes'"], { encoding: "utf8" }))
.toBe("codex-cli 0.153.4\n--version\nargument with 'quotes'\n");
expect(await readFile(source, "utf8")).toBe(shim);
}
expect(await readdir(join(root, "workspace", "bin"))).toEqual(["codex"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("accepts one normalized absolute executable path", () => {
expect(
parseRemoteExecutableCandidate(
@ -3319,7 +3347,7 @@ describe("native warm session supervision", () => {
expect(close).not.toHaveBeenCalled();
});
it("reattaches a live runnerd warm session under a fresh run authority", async () => {
it.each([false, true])("verifies a live warm owner before refreshing run authority (broker: %s)", async (useBroker) => {
const stateBase = await mkdtemp(
join(tmpdir(), "paperclip-runnerd-warm-authority-"),
);
@ -3391,8 +3419,13 @@ describe("native warm session supervision", () => {
return result;
})
.mockImplementationOnce(async (options) => {
expect(options.existingSession).toBe(firstSession);
expect(options.persistedSession).toBeUndefined();
if (useBroker) {
expect(options.existingSession).toBeUndefined();
expect(options.persistedSession?.providerSessionId).toBe("provider-runnerd-warm");
} else {
expect(options.existingSession).toBe(firstSession);
expect(options.persistedSession).toBeUndefined();
}
return result;
});
@ -3400,6 +3433,7 @@ describe("native warm session supervision", () => {
await executePaperclipNativeSession({
db: leaseDb(first),
execution: first,
runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "first-run-capability" } : undefined,
runnerInstanceId: "runner-runnerd-warm",
useRunnerd: true,
runnerExecutionTarget: remoteTarget,
@ -3441,20 +3475,26 @@ describe("native warm session supervision", () => {
await executePaperclipNativeSession({
db: continuationDb,
execution: second,
runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "second-run-capability" } : undefined,
runnerInstanceId: "runner-runnerd-warm",
useRunnerd: true,
runnerExecutionTarget: remoteTarget,
});
expect(firstClose).not.toHaveBeenCalled();
await vi.waitFor(
() =>
expect(firstClose).toHaveBeenCalledWith({
reason: "warm native session idle timeout",
}),
{
timeout: 1_500,
},
);
if (useBroker) {
expect(firstClose).toHaveBeenCalledOnce();
expect(firstClose).toHaveBeenCalledWith({ reason: "warm native session configuration changed" });
} else {
expect(firstClose).not.toHaveBeenCalled();
await vi.waitFor(
() =>
expect(firstClose).toHaveBeenCalledWith({
reason: "warm native session idle timeout",
}),
{
timeout: 1_500,
},
);
}
} finally {
if (previousStateDirectory === undefined) {
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
@ -4013,6 +4053,11 @@ describe("runnerd provider runtime wiring", () => {
useRunnerd: true,
});
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
// Durable local runner state must settle before releasing this scope to
// another run, just like a remote runner's checkpoint.
expect(state.execute).toHaveBeenCalledWith(expect.objectContaining({
requireSessionCloseBeforeReturn: true,
}));
await expect(
executePaperclipNativeSession({
db: leaseDb(second),
@ -6040,7 +6085,7 @@ describe("runnerd provider runtime wiring", () => {
stdout = script.includes("/opt/paperclip-runner/bin/codex")
? "/opt/paperclip-runner/bin/codex\n"
: "/usr/local/bin/codex\n";
} else if (!script.includes("ln -sfn")) {
} else if (!script.includes("ln -sfn") && !script.includes("paperclip_codex_launcher_tmp")) {
throw new Error(`unexpected command: ${command.command}`);
}
return {

View File

@ -263,6 +263,7 @@ function clearNativeRuntimeRequestResolutions(runId: string): void {
}
type WarmNativeSession = {
credentialRunId?: string;
session: NativeSession;
ownerToken: symbol;
configDigest: string;
@ -1880,6 +1881,8 @@ function hasIdleWarmNativeSessionOwner(input: {
if (input.execution.session.lifecyclePolicy.mode !== "warm") return false;
const entry = warmNativeSessions.get(nativeSessionScopeKey(input.execution));
if (!entry || entry.busy) return false;
// A verified idle owner proves the checkpoint belongs to this session even
// when its process must later rotate to a new run-scoped broker capability.
const environmentId =
input.runnerExecutionTarget?.kind === "remote"
? (input.runnerExecutionTarget.environmentId ?? null)
@ -2924,12 +2927,20 @@ export async function getNativeSessionSteeringState(
};
}
// Receipts live as long as this controller process. Durable identity reservations
// hold credential acquisition after a restart until a provider receipt is known.
const steeringDeliveries = new Map<string, Promise<{ turnId: string }>>();
function clearSteeringDeliveries(runId: string) {
for (const key of steeringDeliveries.keys()) if (key.startsWith(`${runId}:`)) steeringDeliveries.delete(key);
}
/** Dispatches a true same-turn steering message and resolves only after ack. */
export async function steerNativeSession(input: {
runId: string;
message: string;
correlationId: string;
timeoutMs?: number;
onAcknowledged?: () => Promise<void>;
}): Promise<{ turnId: string }> {
const active = activeNativeSessions.get(input.runId);
if (!active) {
@ -2954,14 +2965,24 @@ export async function steerNativeSession(input: {
);
}
const deliveryKey = `${input.runId}:${input.correlationId}`;
let delivery = steeringDeliveries.get(deliveryKey);
if (!delivery) {
delivery = active.session.steer({
turnId,
message: { role: "user", text: input.message },
correlationId: input.correlationId,
}).then(() => ({ turnId }));
steeringDeliveries.set(deliveryKey, delivery);
void delivery.catch(() => { steeringDeliveries.delete(deliveryKey); });
}
// Do not await the persistence callback here: the route holds the run lock
// until acknowledgement. After a timeout this callback can acquire that lock.
if (input.onAcknowledged) void delivery.then(input.onAcknowledged).catch(() => undefined);
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
active.session.steer({
turnId,
message: { role: "user", text: input.message },
correlationId: input.correlationId,
}),
const acknowledged = await Promise.race([
delivery,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() =>
@ -2975,7 +2996,7 @@ export async function steerNativeSession(input: {
);
}),
]);
return { turnId };
return acknowledged;
} catch (error) {
if (error instanceof NativeSessionSteeringError) throw error;
const message = error instanceof Error ? error.message : String(error);
@ -4267,7 +4288,11 @@ async function executePaperclipNativeSessionWithinScope(
if (warmSessionId !== null && warmConfigDigest !== null) {
const entry = warmNativeSessions.get(warmSessionId);
if (entry) {
if (entry.configDigest !== warmConfigDigest) {
// Run-scoped broker capabilities must rotate with the process, while the
// settled provider checkpoint retains the conversation across runs.
const credentialRunChanged = Boolean(input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN)
&& entry.credentialRunId !== input.execution.binding.runId;
if (entry.configDigest !== warmConfigDigest || credentialRunChanged) {
if (entry.busy) throw new Error("native_session_supervisor_busy");
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
warmNativeSessions.delete(warmSessionId);
@ -4424,9 +4449,9 @@ async function executePaperclipNativeSessionWithinScope(
warmSessionId === null
? undefined
: NATIVE_WARM_SEMANTIC_RESULT_TERMINAL_GRACE_MS,
requireSessionCloseBeforeReturn:
runnerdBackend !== null &&
input.runnerExecutionTarget?.kind === "remote",
// Every durable runner must finish its bounded suspension before
// the next run verifies and rotates the saved authority.
requireSessionCloseBeforeReturn: runnerdBackend !== null,
onCheckpoint:
warmSessionId !== null && warmConfigDigest !== null
? async (snapshot) =>
@ -4489,6 +4514,8 @@ async function executePaperclipNativeSessionWithinScope(
existing.session = session;
} else
warmNativeSessions.set(warmSessionId, {
credentialRunId: input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN
? input.execution.binding.runId : undefined,
session,
ownerToken: warmSessionOwnerToken,
configDigest: warmConfigDigest,
@ -4518,6 +4545,7 @@ async function executePaperclipNativeSessionWithinScope(
});
else {
activeNativeSessions.delete(input.execution.binding.runId);
clearSteeringDeliveries(input.execution.binding.runId);
clearNativeRuntimeRequestResolutions(
input.execution.binding.runId,
);
@ -4541,6 +4569,7 @@ async function executePaperclipNativeSessionWithinScope(
endedAtMs: Date.now(),
});
activeNativeSessions.delete(input.execution.binding.runId);
clearSteeringDeliveries(input.execution.binding.runId);
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
} catch (error) {
await leaseRenewal.stop().catch(() => undefined);
@ -4572,6 +4601,7 @@ async function executePaperclipNativeSessionWithinScope(
}
trace.activate(taskSettleScope);
activeNativeSessions.delete(input.execution.binding.runId);
clearSteeringDeliveries(input.execution.binding.runId);
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
await releaseWarmNativeSession(
@ -5006,6 +5036,22 @@ function processEnvironment(
);
}
/** Preserve package-manager shims that resolve dependencies relative to argv[0]. */
export function buildRemoteCodexLauncherCommand(sourcePath: string, targetPath: string): string {
if (sourcePath === targetPath) throw new Error("runner_remote_preinstalled_source_conflict");
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
const launcher = `#!/bin/sh\nexec ${quote(sourcePath)} "$@"\n`;
// Replace atomically: writing through an existing symlink would corrupt the
// image's shared CLI, and another run may be executing this launcher already.
return `umask 077; mkdir -p ${quote(posix.dirname(targetPath))} && ` +
`[ ! -d ${quote(targetPath)} ] && ` +
`paperclip_codex_launcher_tmp=$(mktemp ${quote(targetPath + ".tmp.XXXXXX")}) && ` +
`trap 'rm -f "$paperclip_codex_launcher_tmp"' 0 && ` +
`printf '%s' ${quote(launcher)} > "$paperclip_codex_launcher_tmp" && ` +
`chmod 700 "$paperclip_codex_launcher_tmp" && ` +
`mv -f "$paperclip_codex_launcher_tmp" ${quote(targetPath)}`;
}
export function parseRemoteExecutableCandidate(stdout: string): string | null {
const lines = stdout
.split(/\r?\n/)
@ -6498,8 +6544,10 @@ async function createRunnerdBackendWithinSessionClaim(
command: "sh",
args: [
"-c",
`umask 077; mkdir -p '${escapedDirectory}' && ` +
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
targetPath === remoteCodexBinary
? buildRemoteCodexLauncherCommand(sourcePath, targetPath)
: `umask 077; mkdir -p '${escapedDirectory}' && ` +
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
],
cwd: remoteTarget.remoteCwd,
bypassSession: true,

View File

@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, approvals, companies, createDb, documents, heartbeatRuns, issueApprovals, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
@ -603,6 +604,46 @@ describe("PaperclipRunnerToolAuthority", () => {
.toHaveLength(0);
});
it("captures delegation and approval origins before steering and preserves replay identity", async () => {
const issueId = "00000000-0000-4000-8000-000000000120";
const runId = "00000000-0000-4000-8000-000000000121";
await db.insert(issues).values({ id: issueId, companyId, title: "Identity delegation",
status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId,
status: "running", runtimeMode: "native", nativeIssueId: issueId,
invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const origin = await initializeRunIdentity(db, {
companyId, runId, issueId, responsibleUserId: "person-a", cause: "instruction",
});
const [message] = await db.insert(issueComments).values({
companyId, issueId, body: "New instruction", authorUserId: "person-b",
}).returning();
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
const call = { tool: "create_task", callId: "identity-child", arguments: {
idempotencyKey: "identity-child", title: "Keep the initiating identity",
responsibleUserId: "forged-user", originIdentityContextId: "forged-context",
} };
const first = await authority.execute(call) as { task: { id: string } };
const pending = await reserveSteeredIdentity(db, { companyId, runId, issueId, messageId: message.id });
await reconcileSteeredIdentity(db, pending!);
await expect(authority.execute({ ...call, callId: "identity-child-replay" })).resolves.toEqual(first);
const [child] = await db.select().from(issues).where(eq(issues.id, first.task.id));
expect(child).toMatchObject({ originRunId: runId, originIdentityContextId: origin.id,
continuationIdentityContextId: origin.id });
const second = await authority.execute({ ...call, callId: "identity-child-b", arguments: {
idempotencyKey: "identity-child-b", title: "Use the next instruction identity",
} }) as { task: { id: string } };
const [nextChild] = await db.select().from(issues).where(eq(issues.id, second.task.id));
expect(nextChild.originIdentityContextId).toBe(pending!.id);
await authority.execute({ tool: "request_human_input", callId: "identity-approval", arguments: {
idempotencyKey: "identity-approval", interactionKind: "confirmation", title: "Approve continuation",
prompt: "Continue?", continuationPolicy: "wake_assignee", payload: {},
} });
const interactions = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
expect(interactions.find((row) => row.title === "Approve continuation")?.sourceIdentityContextId).toBe(pending!.id);
});
it("fails closed once the run is no longer active", async () => {
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });

View File

@ -29,6 +29,7 @@ import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { captureRunIdentity } from "../run-identity.js";
const IMPLEMENTED_OPERATIONS = new Set([
"search_api", "call_api",
@ -189,8 +190,10 @@ export class PaperclipRunnerToolAuthority {
return { approval, tasks: tasks.map((row) => row.issue) };
}
case "report_progress": return this.#reportProgress(input);
case "request_human_input": return this.#requestHumanInput(input);
case "create_task": return this.#createTask(input);
case "request_human_input": return this.#requestHumanInput(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "create_task": return this.#createTask(input,
(await captureRunIdentity(this.db, this.binding)).context?.id ?? null);
case "set_dependencies": return this.#setDependencies(input);
default: throw new Error("paperclip_runner_tool_not_bound");
}
@ -427,7 +430,7 @@ export class PaperclipRunnerToolAuthority {
return result;
}
async #createTask(input: Record<string, unknown>): Promise<unknown> {
async #createTask(input: Record<string, unknown>, identityContextId: string | null): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined
? this.binding.agentId
@ -489,6 +492,9 @@ export class PaperclipRunnerToolAuthority {
createdByAgentId: this.binding.agentId,
originKind: "manual",
originId: durableIdempotencyKey,
originRunId: this.binding.runId,
originIdentityContextId: identityContextId,
continuationIdentityContextId: identityContextId,
originFingerprint: inputFingerprint,
actorAgentId: this.binding.agentId,
actorRunId: this.binding.runId,
@ -693,6 +699,10 @@ export class PaperclipRunnerToolAuthority {
issue: typeof issues.$inferSelect;
actor: typeof agents.$inferSelect;
}> {
// Match identity activation and queue mutations before locking the run.
await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, this.binding.issueId), eq(issues.companyId, this.binding.companyId),
)).for("update");
// Authorization for writes is intentionally re-read only after the
// transaction starts. Locking the run and issue in the same statement
// closes the gap between the discovery-time check and the mutation: a
@ -733,7 +743,7 @@ export class PaperclipRunnerToolAuthority {
return context;
}
async #requestHumanInput(input: Record<string, unknown>): Promise<unknown> {
async #requestHumanInput(input: Record<string, unknown>, identityContextId: string | null): Promise<unknown> {
const interactionKind = requiredString(input.interactionKind);
const interactionKinds = {
confirmation: "request_confirmation",
@ -802,7 +812,7 @@ export class PaperclipRunnerToolAuthority {
supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? true,
} : {}),
},
} as never, { agentId: this.binding.agentId, userId: null });
} as never, { agentId: this.binding.agentId, userId: null, identityContextId });
const activity = await persistActivity(tx, {
companyId: this.binding.companyId,
actorType: "agent",

View File

@ -23,7 +23,8 @@ vi.mock("../tool-access.js", () => ({
}),
}));
import { buildNativeRuntimeContext } from "./runtime-context.js";
import { createHash } from "node:crypto";
import { buildNativeRuntimeContext, resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
const temporaryRoots: string[] = [];
let previousPaperclipHome: string | undefined;
@ -297,3 +298,26 @@ describe("buildNativeRuntimeContext", () => {
expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]);
});
});
it("pins both permitted GitHub tool catalogs even when another users health probe reports missing credentials", async () => {
const connections = ["github-A", "github-B"].map((id) => ({
id, status: "active", enabled: true, healthStatus: "missing_secret", transport: "mcp_remote",
config: { sourceTemplateKey: "github" }, transportConfig: {},
}));
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({
entries: connections.map((connection) => ({ effect: "include", connectionId: connection.id })),
installedConnections: connections,
allowedTools: connections.map((connection) => ({ id: `${connection.id}-get_me`, connectionId: connection.id })),
});
const limit = vi.fn(async () => [{ responsibleUserId: "A", activeIdentityContextId: "context-A" }]);
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
const snapshot = await resolveNativeRuntimeMcpSnapshot({ db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" });
const expected = createHash("sha256").update(JSON.stringify({
version: 1, agentId: "agent-1", connections: ["github-A", "github-B"],
tools: ["github-A-get_me", "github-B-get_me"],
})).digest("hex");
expect(snapshot.digest).toBe(expected);
expect(snapshot.bindingId).toBe("native-mcp:run-1");
expect(limit).toHaveBeenCalledOnce();
});

View File

@ -178,7 +178,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
});
const [runIdentity] = hasGitHubConnection
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, input.runId),
@ -187,7 +187,10 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
))
.limit(1)
: [];
const resolvedInstalledConnections = await filterResolvedGitHubConnectionsForRun({
// Broker runs pin the permitted tool catalog, not one persons credential.
// Selection happens at each operation and can change through steering.
const resolvedInstalledConnections = runIdentity?.activeIdentityContextId
? effective.installedConnections : await filterResolvedGitHubConnectionsForRun({
db: input.db,
companyId: input.agent.companyId,
agentId: input.agent.id,
@ -200,7 +203,8 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
permitted.has(connection.id)
&& connection.status === "active"
&& connection.enabled
&& !isToolConnectionAttentionHealth(connection.healthStatus)
&& (Boolean(runIdentity?.activeIdentityContextId) && (connection.config?.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|| !isToolConnectionAttentionHealth(connection.healthStatus))
&& ["mcp_remote", "local_stdio"].includes(connection.transport)
).map((connection) => connection.id));
const assignment = {

View File

@ -0,0 +1,217 @@
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import { heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues, runIdentityContexts, type Db } from "@paperclipai/db";
import { conflict, forbidden } from "../errors.js";
export type RunIdentityContext = typeof runIdentityContexts.$inferSelect;
type Executor = Pick<Db, "select" | "insert" | "update">;
/** Match task mutation ordering: lock the task before the run, never the reverse. */
async function lockIdentityTask(executor: Pick<Db, "select">, companyId: string, runId: string) {
const [run] = await executor.select({ context: heartbeatRuns.contextSnapshot, issueId: heartbeatRuns.nativeIssueId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)));
const issueId = run?.context?.issueId ?? run?.context?.taskId ?? run?.issueId;
if (typeof issueId === "string") await executor.select({ id: issues.id }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))).for("update");
}
async function append(executor: Executor, input: {
companyId: string; runId: string; responsibleUserId: string | null;
messageId?: string | null; parentContextId?: string | null; cause: string;
correlationId: string; status?: "accepted" | "pending";
}) {
const [existing] = await executor.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, input.runId), eq(runIdentityContexts.correlationId, input.correlationId),
));
if (existing) return existing;
const [last] = await executor.select().from(runIdentityContexts)
.where(eq(runIdentityContexts.runId, input.runId)).orderBy(desc(runIdentityContexts.revision)).limit(1);
const [created] = await executor.insert(runIdentityContexts).values({
...input, revision: (last?.revision ?? 0) + 1,
acceptedAt: input.status === "pending" ? null : new Date(),
}).returning();
if (!created) throw new Error("Failed to persist run identity");
if (created.status === "accepted") await activate(executor, created);
return created;
}
async function activate(executor: Executor, context: RunIdentityContext) {
const [run] = await executor.update(heartbeatRuns).set({
responsibleUserId: context.responsibleUserId,
activeIdentityContextId: context.id, updatedAt: new Date(),
}).where(and(eq(heartbeatRuns.id, context.runId), eq(heartbeatRuns.companyId, context.companyId))).returning();
const issueId = run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId;
if (typeof issueId === "string") await executor.update(issues).set({ continuationIdentityContextId: context.id })
.where(and(eq(issues.id, issueId), eq(issues.companyId, context.companyId)));
}
/** Called only for newly dispatched runs. Historical rows are never backfilled. */
export async function initializeRunIdentity(db: Db, input: {
companyId: string; runId: string; responsibleUserId: string | null;
messageIds?: string[]; issueId?: string | null; interactionId?: string | null; parentRunId?: string | null; parentContextId?: string | null; cause: string;
}) {
return db.transaction(async (tx) => {
await lockIdentityTask(tx, input.companyId, input.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
)).for("update");
if (!run) throw forbidden("Run identity does not belong to this company");
if (run.activeIdentityContextId) {
const [current] = await tx.select().from(runIdentityContexts).where(eq(runIdentityContexts.id, run.activeIdentityContextId));
return current!;
}
const [parent] = input.parentRunId ? await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.parentRunId), eq(heartbeatRuns.companyId, input.companyId),
)) : [];
const [interaction] = input.interactionId && input.issueId ? await tx.select().from(issueThreadInteractions).where(and(
eq(issueThreadInteractions.id, input.interactionId), eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.issueId, input.issueId),
)) : [];
const parentId = interaction?.sourceIdentityContextId ?? input.parentContextId ?? parent?.activeIdentityContextId;
const [origin] = parentId ? await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, parentId), eq(runIdentityContexts.companyId, input.companyId),
eq(runIdentityContexts.status, "accepted"),
)) : [];
if (parentId && !origin) throw forbidden("Originating execution identity is unavailable");
let current = await append(tx, {
companyId: input.companyId, runId: input.runId,
responsibleUserId: origin ? origin.responsibleUserId : input.responsibleUserId,
parentContextId: origin?.id ?? null,
cause: origin?.cause === "company_default" ? "company_default" : input.cause, correlationId: "dispatch",
});
const ids = [...new Set(input.messageIds ?? [])];
const comments = ids.length && input.issueId ? await tx.select().from(issueComments).where(and(
eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), inArray(issueComments.id, ids),
)) : [];
for (const id of ids) {
const comment = comments.find((c) => c.id === id);
if (!comment?.authorUserId) continue;
current = await append(tx, {
companyId: input.companyId, runId: input.runId, responsibleUserId: comment.authorUserId,
messageId: id, parentContextId: current.id, cause: "instruction", correlationId: `message:${id}`,
});
}
return current;
});
}
/** Caller holds the task and run row locks, in that order. Reserve before delivery so acquisitions cannot guess. */
export async function prepareSteeredIdentity(executor: Executor, input: {
companyId: string; runId: string; messageId: string; issueId: string;
}) {
const [run] = await executor.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
));
const [comment] = await executor.select().from(issueComments).where(and(
eq(issueComments.id, input.messageId), eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId),
));
if (!run || !comment?.authorUserId) throw forbidden("Steering requires an authenticated message author");
if (run.status !== "running" || (run.contextSnapshot?.issueId !== input.issueId && run.contextSnapshot?.taskId !== input.issueId)) {
throw conflict("Steering targets a different or inactive execution");
}
return append(executor, {
companyId: input.companyId, runId: input.runId, responsibleUserId: comment.authorUserId,
messageId: comment.id, parentContextId: run.activeIdentityContextId,
cause: "steering", correlationId: `message:${comment.id}`, status: "pending",
});
}
export async function reserveSteeredIdentity(db: Db, input: Parameters<typeof prepareSteeredIdentity>[1]) {
return db.transaction(async (tx) => {
await lockIdentityTask(tx, input.companyId, input.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId),
)).for("update");
// Processes started before the broker rollout keep their original environment.
if (!run?.activeIdentityContextId) return null;
const [pending] = await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending"),
)).limit(1);
if (pending && pending.messageId !== input.messageId) {
if (await storedSteeringAcknowledgement(tx, pending)) await acceptSteeredIdentity(tx, pending);
else throw conflict("A prior steering acknowledgement must be reconciled first");
}
const [existing] = await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.messageId, input.messageId),
eq(runIdentityContexts.status, "accepted"),
));
if (existing) return existing;
const context = await prepareSteeredIdentity(tx, input);
if (context.status === "rejected") {
await tx.update(runIdentityContexts).set({ status: "pending" }).where(eq(runIdentityContexts.id, context.id));
return { ...context, status: "pending" };
}
return context;
});
}
export async function rejectSteeredIdentity(db: Db, context: RunIdentityContext) {
await db.update(runIdentityContexts).set({ status: "rejected" }).where(and(
eq(runIdentityContexts.id, context.id), eq(runIdentityContexts.status, "pending"),
));
}
export async function acceptSteeredIdentity(executor: Executor, context: RunIdentityContext) {
// A replay must not reactivate a historical identity after later instructions.
const [accepted] = await executor.update(runIdentityContexts)
.set({ status: "accepted", acceptedAt: new Date() }).where(and(
eq(runIdentityContexts.id, context.id), eq(runIdentityContexts.status, "pending"),
)).returning();
if (accepted) await activate(executor, accepted);
}
export async function captureRunIdentity(db: Db, input: { companyId: string; runId: string; agentId: string }) {
// Lock acquisition serializes with steering delivery and its durable acknowledgement.
return db.transaction(async (tx) => {
await lockIdentityTask(tx, input.companyId, input.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.agentId, input.agentId),
)).for("update");
if (!run || run.status !== "running") throw forbidden("Credential acquisition requires this agent's active run");
const [pending] = await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.runId, run.id), eq(runIdentityContexts.status, "pending"),
)).limit(1);
if (pending) {
const acknowledgement = await storedSteeringAcknowledgement(tx, pending);
if (!acknowledgement) throw conflict("Message acceptance is being reconciled; retry credential acquisition");
await acceptSteeredIdentity(tx, pending);
run.activeIdentityContextId = pending.id;
run.responsibleUserId = pending.responsibleUserId;
}
const [context] = run.activeIdentityContextId ? await tx.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, run.activeIdentityContextId), eq(runIdentityContexts.runId, run.id),
)) : [];
return { run, context: context ?? null };
});
}
export async function listRunIdentityContexts(db: Db, companyId: string, runId: string) {
return db.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.companyId, companyId), eq(runIdentityContexts.runId, runId),
)).orderBy(asc(runIdentityContexts.revision));
}
/** Late native acknowledgements settle reservations even when the HTTP caller timed out. */
export async function reconcileSteeredIdentity(db: Db, context: RunIdentityContext) {
await db.transaction(async (tx) => {
await lockIdentityTask(tx, context.companyId, context.runId);
const [run] = await tx.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, context.runId), eq(heartbeatRuns.companyId, context.companyId),
)).for("update");
if (!run) return;
await acceptSteeredIdentity(tx, context);
});
}
/** Only events validated and persisted by the native control-plane transport count. */
export async function storedSteeringAcknowledgement(executor: Pick<Db, "select">, context: RunIdentityContext) {
if (!context.messageId) return null;
const [receipt] = await executor.select({ payload: heartbeatRunEvents.payload }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, context.companyId), eq(heartbeatRunEvents.runId, context.runId),
eq(heartbeatRunEvents.eventType, "item.completed"),
sql`${heartbeatRunEvents.sourceEventId} is not null`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind' = 'steering_acknowledgement'`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'itemId' like ${`%:steer:${context.messageId}`}`,
)).limit(1);
const event = receipt?.payload?.prpEvent as { turnId?: string } | undefined;
return typeof event?.turnId === "string" ? { turnId: event.turnId } : null;
}

View File

@ -1,3 +1,4 @@
import { captureRunIdentity } from "./run-identity.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, max, ne, sql } from "drizzle-orm";
@ -2832,7 +2833,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
async function loadBrokerRunContext(input: { companyId: string; agentId: string; runId: string }) {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
const [initialRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
const run = initialRun?.activeIdentityContextId ? (await captureRunIdentity(db, input)).run : initialRun;
if (!run || run.companyId !== input.companyId || run.agentId !== input.agentId) {
throw forbidden("Agent run context does not match the authenticated actor");
}
@ -2841,7 +2843,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
const snapshot = asRecord(run.contextSnapshot);
const paperclipIssue = asRecord(snapshot.paperclipIssue);
const responsibleUserId = runSnapshotString(snapshot, "responsibleUserId", "responsible_user_id")
const responsibleUserId = run.activeIdentityContextId ? run.responsibleUserId : runSnapshotString(snapshot, "responsibleUserId", "responsible_user_id")
?? runSnapshotString(paperclipIssue, "responsibleUserId", "responsible_user_id")
?? run.responsibleUserId;
if (!responsibleUserId) {
@ -4195,6 +4197,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
eq(companySecretBindings.targetId, connection.id),
),
);
// A metadata edit or pause/resume must retain declarations for every
// active personal/dedicated grant, not just connection-owned credentials.
const activeGrants = await dbClient.select({ refs: connectionGrants.credentialSecretRefs })
.from(connectionGrants).where(and(
eq(connectionGrants.companyId, connection.companyId),
eq(connectionGrants.connectionId, connection.id),
eq(connectionGrants.status, "active"),
));
const rawBindings = [
...connection.credentialRefs.map((ref) => ({
secretId: ref.secretId,
@ -4204,7 +4214,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
required: true,
label: null,
})),
...[...connection.credentialSecretRefs, ...grantSecretRefs].map((ref) => ({
...[...connection.credentialSecretRefs, ...grantSecretRefs, ...activeGrants.flatMap((grant) => grant.refs)].map((ref) => ({
secretId: ref.secretId,
configPath: ref.configPath,
projectionClass: ref.projectionClass ?? "unclassified",
@ -13880,6 +13890,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
);
}
if (runContext.run.activeIdentityContextId && (
connection.config.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github"
)) {
await fail(409, "Use managed git, gh, or GitHub tools for this run", "denied", "managed_github_invocation_required");
}
const requestedSubject = input.body.subject;
if (requestedSubject?.type === "user" && requestedSubject.userId !== runContext.responsibleUserId) {
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {

View File

@ -86,6 +86,7 @@ export function signToolArguments(args: {
canonicalArguments: string;
approvalSnapshot?: unknown;
executionOnApprove?: boolean;
identityContextId?: string;
signingSecret?: string;
}) {
const payloadValue: Record<string, unknown> = {
@ -93,6 +94,7 @@ export function signToolArguments(args: {
toolName: args.toolName,
canonicalArguments: args.canonicalArguments,
};
if (args.identityContextId) payloadValue.identityContextId = args.identityContextId;
if (args.executionOnApprove === true) {
payloadValue.executionOnApprove = true;
}
@ -111,6 +113,7 @@ export function verifyToolArgumentsSignature(input: {
canonicalArguments: string;
approvalSnapshot?: unknown;
executionOnApprove?: boolean;
identityContextId?: string;
signingSecret?: string;
}) {
if (!input.signedArguments) return false;
@ -127,6 +130,7 @@ export function verifyToolArgumentsSignature(input: {
toolName: input.toolName,
canonicalArguments: input.canonicalArguments,
};
if (input.identityContextId) expectedPayloadValue.identityContextId = input.identityContextId;
if (input.executionOnApprove !== undefined) {
expectedPayloadValue.executionOnApprove = input.executionOnApprove;
}
@ -146,7 +150,7 @@ export function readSignedToolArgumentsPayload(input: {
invocationId: string;
toolName: string;
signingSecret?: string;
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean } | null {
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean; identityContextId?: string } | null {
if (!input.signedArguments) return null;
let parsed: { payload?: unknown };
try {
@ -161,6 +165,7 @@ export function readSignedToolArgumentsPayload(input: {
canonicalArguments?: unknown;
approvalSnapshot?: unknown;
executionOnApprove?: unknown;
identityContextId?: unknown;
};
try {
payload = JSON.parse(parsed.payload);
@ -176,6 +181,7 @@ export function readSignedToolArgumentsPayload(input: {
canonicalArguments: payload.canonicalArguments,
approvalSnapshot: payload.approvalSnapshot,
executionOnApprove: payload.executionOnApprove === true ? true : undefined,
identityContextId: typeof payload.identityContextId === "string" ? payload.identityContextId : undefined,
signingSecret: input.signingSecret,
})) {
return null;
@ -185,6 +191,7 @@ export function readSignedToolArgumentsPayload(input: {
arguments: JSON.parse(payload.canonicalArguments) as unknown,
...(payload.approvalSnapshot !== undefined ? { approvalSnapshot: payload.approvalSnapshot } : {}),
...(payload.executionOnApprove === true ? { executionOnApprove: true } : {}),
...(typeof payload.identityContextId === "string" ? { identityContextId: payload.identityContextId } : {}),
};
} catch {
return null;

View File

@ -1,3 +1,6 @@
import { runIdentityContexts } from "@paperclipai/db";
import { captureRunIdentity } from "./run-identity.js";
import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js";
import { spawn } from "node:child_process";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
@ -257,6 +260,8 @@ export interface ToolGatewaySession {
actorId?: string | null;
/** Human whose personal connection grant applies to this execution. */
responsibleUserId?: string | null;
/** Captured by the controller for this request, never accepted from tool arguments. */
identityContextId?: string | null;
createdAt: Date;
expiresAt: Date;
}
@ -1306,6 +1311,7 @@ export function createToolGatewayService(
projectId: input.session?.projectId ?? null,
runId: input.runId,
gatewaySessionId: input.session?.id ?? null,
identityContextId: input.session?.identityContextId ?? null,
gatewayId: input.session?.gatewayId ?? null,
gatewayPublicId: input.session?.gatewayPublicId ?? null,
gatewayName: input.session?.gatewayName ?? null,
@ -1450,7 +1456,21 @@ export function createToolGatewayService(
.set({ lastUsedAt: now, updatedAt: now })
.where(eq(toolGatewaySessions.id, row.id));
return gatewaySessionFromRow({ ...row, lastUsedAt: now, updatedAt: now });
const session = gatewaySessionFromRow({ ...row, lastUsedAt: now, updatedAt: now });
return captureSessionIdentity(session);
}
async function captureSessionIdentity(session: ToolGatewaySession): Promise<ToolGatewaySession> {
// Authentication creates a fresh operation snapshot on every invocation.
// Never trust a previously attached context on a reusable transport session.
// Approved operations restore their signed origin after authentication.
if (!session.runId || !session.agentId) return session;
const [run] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, session.runId), eq(heartbeatRuns.companyId, session.companyId)));
if (!run?.activeIdentityContextId) return session;
const captured = await captureRunIdentity(db, { companyId: session.companyId, agentId: session.agentId, runId: session.runId });
return { ...session, identityContextId: captured.context?.id,
responsibleUserId: captured.context?.cause === "company_default" ? null : captured.context?.responsibleUserId };
}
function normalizeGatewayTokenActions(value: unknown): ToolMcpGatewayTokenAction[] {
@ -1523,9 +1543,10 @@ export function createToolGatewayService(
resultHash: input.resultSummary?.sha256 ?? null,
resultSummary: input.resultSummary ?? null,
resultSizeBytes: input.resultSummary?.sizeBytes ?? null,
metadata: Object.keys(metadata).length > 0 || input.metadata || input.session.projectId
metadata: Object.keys(metadata).length > 0 || input.metadata || input.session.projectId || input.session.identityContextId
? {
...metadata,
identityContextId: input.session.identityContextId ?? null,
gatewayId: input.session.gatewayId ?? null,
gatewayName: input.session.gatewayName ?? null,
projectId: input.session.projectId ?? null,
@ -1709,6 +1730,7 @@ export function createToolGatewayService(
canonicalArguments,
approvalSnapshot: approvalSnapshot ?? undefined,
executionOnApprove: true,
identityContextId: input.session.identityContextId ?? undefined,
signingSecret: options.toolActionSigningSecret,
});
} catch (error) {
@ -2017,6 +2039,21 @@ export function createToolGatewayService(
if (!tool) {
throw new ToolGatewayHttpError(404, `Tool "${toolName}" not found`, "tool_not_found", { tool: toolName });
}
if (session.identityContextId && session.agentId && tool.connectionId) {
const [connection] = await db.select().from(toolConnections).where(and(
eq(toolConnections.id, tool.connectionId), eq(toolConnections.companyId, session.companyId),
));
if (connection?.config.sourceTemplateKey === "github" || connection?.transportConfig?.sourceTemplateKey === "github") {
const selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, {
agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false,
});
if (!selected.grant) throw new ToolGatewayHttpError(409, selected.error ?? "No GitHub identity connected", "github_identity_unavailable");
const target = connectedTools.find((candidate) => candidate.connectionId === selected.grant!.connectionId
&& candidate.upstreamToolName === tool.upstreamToolName && candidate.providerType === tool.providerType);
if (!target) throw new ToolGatewayHttpError(404, "This GitHub tool is unavailable for the responsible person", "github_tool_unavailable");
return target;
}
}
return tool;
}
@ -2773,6 +2810,28 @@ export function createToolGatewayService(
}
async function resolveCredentialHeaders(
session: ToolGatewaySession, connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect, resolveOptions: { forceRefresh?: boolean } = {},
): Promise<Record<string, string>> {
const tracked = session.identityContextId && (connection.config.sourceTemplateKey === "github"
|| connection.transportConfig?.sourceTemplateKey === "github");
try {
const headers = await resolveCredentialHeadersUnrecorded(session, connection, grant, resolveOptions);
if (tracked) await db.update(runIdentityContexts).set({ github: {
status: "available", login: grant.providerTenant?.github?.login,
source: grant.kind === "agent" ? "dedicated" : "personal",
} }).where(and(eq(runIdentityContexts.id, session.identityContextId!), eq(runIdentityContexts.companyId, session.companyId)));
return headers;
} catch (error) {
if (tracked) await db.update(runIdentityContexts).set({ github: {
status: "unavailable", reason: "GitHub authorization is unavailable",
source: grant.kind === "agent" ? "dedicated" : "personal",
} }).where(and(eq(runIdentityContexts.id, session.identityContextId!), eq(runIdentityContexts.companyId, session.companyId)));
throw error;
}
}
async function resolveCredentialHeadersUnrecorded(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
grant: typeof connectionGrants.$inferSelect,
@ -2913,7 +2972,10 @@ export function createToolGatewayService(
connection,
grant,
grantRef,
`credentials.${ref.name}`,
// OAuth grants declare their canonical oauth.* path. Treating
// this header projection as a generic credentials.* binding loses
// the personal secret declaration created by the OAuth callback.
grantRef.configPath.startsWith("oauth.") ? grantRef.configPath : `credentials.${ref.name}`,
);
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
} catch {
@ -3114,7 +3176,7 @@ export function createToolGatewayService(
session: ToolGatewaySession,
connection: typeof toolConnections.$inferSelect,
): Promise<typeof connectionGrants.$inferSelect> {
const [run] = session.runId
const [run] = session.runId && !session.identityContextId
? await db.select({
responsibleUserId: heartbeatRuns.responsibleUserId,
invocationSource: heartbeatRuns.invocationSource,
@ -3123,8 +3185,30 @@ export function createToolGatewayService(
eq(heartbeatRuns.companyId, session.companyId),
)).limit(1)
: [];
const actingUserId = run?.responsibleUserId ?? session.responsibleUserId ?? null;
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
const actingUserId = session.identityContextId ? session.responsibleUserId ?? null : run?.responsibleUserId ?? session.responsibleUserId ?? null;
if (session.identityContextId && session.agentId && (
connection.config.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github"
)) {
const selected = await resolveManagedGitHubIdentitySelection(db, session.companyId, {
agentId: session.agentId, responsibleUserId: session.responsibleUserId, allowStandingDelegation: false,
});
if (!selected.grant || selected.grant.connectionId !== connection.id) {
throw new ToolGatewayHttpError(409, selected.error ?? "GitHub identity changed; retry through the managed tool", "github_identity_unavailable");
}
if (selected.grant.kind === "user") {
const [member] = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships).where(and(
eq(companyMemberships.companyId, session.companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, selected.grant.subjectUserId!), eq(companyMemberships.status, "active"),
)).limit(1);
if (!member || member.role === "viewer") {
throw new ToolGatewayHttpError(403, "The personal grant owner is not an authorized company member", "grant_owner_membership_inactive");
}
}
// Managed GitHub selection is final: a legacy shared policy cannot replace
// the captured person's grant with an organization or teammate's account.
return selected.grant;
}
const autonomous = !session.identityContextId && (run?.invocationSource === "automation" || run?.invocationSource === "timer");
const findUserGrant = async () => {
if (!actingUserId) return undefined;
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
@ -4898,7 +4982,7 @@ export function createToolGatewayService(
expiresAt: row.token.expiresAt ?? new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
};
await assertNamedGatewayProtocolLimit(session, input.protocolMethod, clientMetadata);
return session;
return captureSessionIdentity(session);
}
/**
@ -5510,6 +5594,17 @@ export function createToolGatewayService(
createdAt: new Date(),
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
};
if (signedPayload.identityContextId) {
const [origin] = await db.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, signedPayload.identityContextId),
eq(runIdentityContexts.companyId, session.companyId),
eq(runIdentityContexts.runId, session.runId!),
eq(runIdentityContexts.status, "accepted"),
));
if (!origin) throw new ToolGatewayHttpError(409, "Approved action identity is unavailable", "identity_context_unavailable");
session.identityContextId = origin.id;
session.responsibleUserId = origin.cause === "company_default" ? null : origin.responsibleUserId;
}
let tool: ToolGatewayDescriptor;
let liveApprovalSnapshot: Awaited<ReturnType<typeof connectedRemoteApprovalSnapshot>>;
try {
@ -6771,6 +6866,30 @@ export function createToolGatewayService(
let invocationId = String(randomUUID());
const startedAt = Date.now();
// A retry carries the signed originating operation, even if steering has
// since accepted instructions from someone else in this same run.
if (input.approvedActionRequestId) {
const [request] = await db.select().from(toolActionRequests).where(and(
eq(toolActionRequests.id, input.approvedActionRequestId), eq(toolActionRequests.companyId, session.companyId),
));
const [invocation] = request ? await db.select().from(toolInvocations).where(and(
eq(toolInvocations.id, request.invocationId), eq(toolInvocations.companyId, session.companyId),
eq(toolInvocations.runId, session.runId!), eq(toolInvocations.agentId, session.agentId!),
)) : [];
const payload = request && invocation ? readSignedToolArgumentsPayload({
signedArguments: request.signedArguments, invocationId: invocation.id,
toolName: invocation.toolName, signingSecret: options.toolActionSigningSecret,
}) : null;
if (payload?.identityContextId) {
const [origin] = await db.select().from(runIdentityContexts).where(and(
eq(runIdentityContexts.id, payload.identityContextId), eq(runIdentityContexts.companyId, session.companyId),
eq(runIdentityContexts.runId, session.runId!), eq(runIdentityContexts.status, "accepted"),
));
if (!origin) throw new ToolGatewayHttpError(409, "Approved action identity is unavailable", "identity_context_unavailable");
session.identityContextId = origin.id;
session.responsibleUserId = origin.cause === "company_default" ? null : origin.responsibleUserId;
}
}
let tool = await findToolForSession(session, input.tool);
let virtualToolName: string | null = null;
let requestedParameters: unknown = input.parameters ?? {};
@ -7109,6 +7228,7 @@ export function createToolGatewayService(
canonicalArguments: storedCanonical,
approvalSnapshot: signedPayload.approvalSnapshot,
executionOnApprove: signedPayload.executionOnApprove,
identityContextId: signedPayload.identityContextId,
signingSecret: options.toolActionSigningSecret,
})
) {

View File

@ -48,7 +48,7 @@ afterEach(async () => {
function serviceCommand() {
// Answers `/api/health` the way a real Paperclip dev runtime does: managed
// publication requires semantic health, not just a 200 (PAP-17572).
return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((rq,r)=>{if(rq.url==="/api/health"){r.setHeader("content-type","application/json");r.end(JSON.stringify({status:"ok"}));return}r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`;
return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000].filter(q=>q<65536))http.createServer((rq,r)=>{if(rq.url==="/api/health"){r.setHeader("content-type","application/json");r.end(JSON.stringify({status:"ok"}));return}r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`;
}
/**
@ -612,7 +612,8 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
expect(calls).toEqual(["reserve", "expose", "remove"]);
}, 20_000);
it("reaches a terminal failure naming the port and address when a guest still binds the wildcard", async () => {
// This acceptance fixture inspects live Linux /proc socket tables.
it.skipIf(process.platform !== "linux")("reaches a terminal failure naming the port and address when a guest still binds the wildcard", async () => {
const { broker, calls } = createBroker();
installDeps({ broker });
@ -630,7 +631,8 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
expect(calls).toEqual(["reserve", "remove"]);
}, 20_000);
it("explains rather than only coding the failure, so the next operator can act", async () => {
// This acceptance fixture inspects live Linux /proc socket tables.
it.skipIf(process.platform !== "linux")("explains rather than only coding the failure, so the next operator can act", async () => {
const { broker } = createBroker();
installDeps({ broker });
@ -676,7 +678,9 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
serviceName: "paperclip-dev",
command: declared,
expose: { ...LEGACY_HTTP_EXPOSE, tailscaleHttps: false },
port: { type: "auto", envKey: "PORT" },
// This guest opens a second listener at appPort + 10000. An arbitrary
// ephemeral app port can overflow the valid TCP port range.
port: await findFreeExposureAppPort(RUNTIME_EXPOSURE_APP_PORT_MIN),
}));
expect(calls).toEqual([]);
@ -726,7 +730,8 @@ describe("readiness probes loopback for an exposed runtime (PAP-17256)", () => {
});
describe("the deployed failure shape: loopback app port, wildcard HMR (PAP-17256)", () => {
it("fails terminally naming the HMR port, because forcing the bind cannot reach Vite's own listener", async () => {
// This acceptance fixture inspects live Linux /proc socket tables.
it.skipIf(process.platform !== "linux")("fails terminally naming the HMR port, because forcing the bind cannot reach Vite's own listener", async () => {
// Plain master's app.ts passes Vite `hmr.port` without `hmr.server` or
// `server.host`, so the HMR websocket binds `::` no matter what the bind mode
// is. The argv rewrite fixes the app port; only the preflight catches this.

View File

@ -29,6 +29,7 @@ declare global {
keyScope?: AgentApiKeyScope;
runId?: string;
onBehalfOfUserId?: string | null;
identityContextId?: string | null;
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
};
}

View File

@ -3688,6 +3688,9 @@ export function AccessStep({
<div className="divide-y divide-border">
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
{githubIdentity && grantKind === "agent" ? (
<p className="mt-2 text-sm text-muted-foreground">This agent uses this GitHub account for everyones work, instead of the person giving instructions.</p>
) : null}
{identityLoading ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
<Skeleton className="h-20 w-full rounded-md" />

View File

@ -3579,6 +3579,29 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
</span>
</div>
)}
{Boolean(run.identityHistory?.length) && (
<details className="text-xs text-muted-foreground" data-testid="run-identity-history">
<summary className="cursor-pointer">GitHub identity history</summary>
<ol className="mt-2 space-y-2">
{run.identityHistory!.map((identity) => {
const person = userDirectory?.users.find((entry) => entry.principalId === identity.responsibleUserId);
return (
<li key={identity.id}>
<span className="text-foreground">{person?.user?.name ?? person?.user?.email ?? identity.responsibleUserId ?? "No responsible person"}</span>
{" · "}{identity.cause}{" · "}{identity.status}
{identity.github ? (
<span className="block">
{identity.github.login ? `@${identity.github.login} · ` : ""}
{identity.github.source ? `${identity.github.source} · ` : ""}
{identity.github.status}{identity.github.reason ? `: ${identity.github.reason}` : ""}
</span>
) : <span className="block">No GitHub operation recorded</span>}
</li>
);
})}
</ol>
</details>
)}
{resumeRun.isError && (
<div className="text-xs text-destructive">
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}

View File

@ -179,6 +179,7 @@ export function IdentitiesSection({
return (
<section className="space-y-5">
<h2 className="text-sm font-semibold text-foreground">GitHub identity</h2>
<p className="text-sm text-muted-foreground">This agent uses this GitHub account for everyones work, instead of the person giving instructions.</p>
<IdentityRow
title={github ? `@${github.login}` : "Dedicated GitHub account"}
status={agentGrant?.status ?? null}