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:
parent
5bddff0920
commit
1cc45086d3
|
|
@ -1260,3 +1260,7 @@ Networking behavior for this smoke script:
|
||||||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
- 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`)
|
- 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
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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 run’s 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 0240–0245, 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.
|
||||||
|
|
@ -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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as ssh from "./ssh.js";
|
import * as ssh from "./ssh.js";
|
||||||
import * as serverUtils from "./server-utils.js";
|
import * as serverUtils from "./server-utils.js";
|
||||||
import {
|
import {
|
||||||
|
cleanupGitHubOperationLaunchers,
|
||||||
|
prepareGitHubOperationLaunchers,
|
||||||
adapterExecutionTargetUsesManagedHome,
|
adapterExecutionTargetUsesManagedHome,
|
||||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||||
resolveAdapterExecutionTargetCwd,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { githubLauncherSource } from "./github-launcher.js";
|
||||||
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||||
import {
|
import {
|
||||||
prepareCommandManagedRuntime,
|
prepareCommandManagedRuntime,
|
||||||
|
|
@ -1494,6 +1495,70 @@ export function runtimeAssetDir(
|
||||||
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
|
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> {
|
function buildBridgeResponseHeaders(response: Response): Record<string, string> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
// Keep `x-paperclip-bridge-outcome` in this list. The host marks a
|
// Keep `x-paperclip-bridge-outcome` in this list. The host marks a
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,13 @@ afterEach(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("local process sandbox", () => {
|
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", () => {
|
it("parses read-only and writable extra paths", () => {
|
||||||
expect(parseLocalProcessSandboxExtraPaths(["/opt/cache", { path: "/var/lib/tool", access: "rw" }])).toEqual([
|
expect(parseLocalProcessSandboxExtraPaths(["/opt/cache", { path: "/var/lib/tool", access: "rw" }])).toEqual([
|
||||||
{ path: "/opt/cache", access: "ro" },
|
{ path: "/opt/cache", access: "ro" },
|
||||||
|
|
@ -52,7 +59,7 @@ describe("local process sandbox", () => {
|
||||||
expect(() => parseLocalProcessNetworkScope("public")).toThrow('"deny" or "allowlist"');
|
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-"));
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-rules-"));
|
||||||
cleanup.push(workspace);
|
cleanup.push(workspace);
|
||||||
|
|
||||||
|
|
@ -69,7 +76,7 @@ describe("local process sandbox", () => {
|
||||||
})).rejects.toThrow("valid networkAllowlist hostname or HTTP(S) networkTrustedUrl");
|
})).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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-sandbox-"));
|
||||||
cleanup.push(root);
|
cleanup.push(root);
|
||||||
const workspace = path.join(root, "workspace");
|
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')"]);
|
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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-alias-"));
|
||||||
cleanup.push(root);
|
cleanup.push(root);
|
||||||
const workspace = path.join(root, "workspace");
|
const workspace = path.join(root, "workspace");
|
||||||
|
|
@ -116,7 +123,7 @@ describe("local process sandbox", () => {
|
||||||
expect(target.args).toEqual(expect.arrayContaining(["--bind", workspace, "/app"]));
|
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-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-outbound-"));
|
||||||
cleanup.push(root);
|
cleanup.push(root);
|
||||||
const workspace = path.join(root, "workspace");
|
const workspace = path.join(root, "workspace");
|
||||||
|
|
@ -136,7 +143,7 @@ describe("local process sandbox", () => {
|
||||||
})).rejects.toThrow("has no outbound restore mapping");
|
})).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-"));
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-"));
|
||||||
cleanup.push(workspace);
|
cleanup.push(workspace);
|
||||||
const target = await buildLocalProcessSandboxSpawnTarget({
|
const target = await buildLocalProcessSandboxSpawnTarget({
|
||||||
|
|
@ -152,7 +159,7 @@ describe("local process sandbox", () => {
|
||||||
expect(target.env?.HTTP_PROXY).toBeUndefined();
|
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-"));
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-proxy-"));
|
||||||
cleanup.push(workspace);
|
cleanup.push(workspace);
|
||||||
const deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment"));
|
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-"));
|
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-trusted-"));
|
||||||
cleanup.push(workspace);
|
cleanup.push(workspace);
|
||||||
const server = http.createServer((_request, response) => response.end("control-plane-response"));
|
const server = http.createServer((_request, response) => response.end("control-plane-response"));
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ describe("sandbox callback bridge", () => {
|
||||||
client: createFileSystemSandboxCallbackBridgeQueueClient(),
|
client: createFileSystemSandboxCallbackBridgeQueueClient(),
|
||||||
queueDir,
|
queueDir,
|
||||||
authorizeRequest: async (request) =>
|
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) => {
|
handleRequest: async (request) => {
|
||||||
seenRequests.push({
|
seenRequests.push({
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
|
@ -265,6 +265,23 @@ describe("sandbox callback bridge", () => {
|
||||||
expect(seenRequests[0]?.headers.authorization).toBeUndefined();
|
expect(seenRequests[0]?.headers.authorization).toBeUndefined();
|
||||||
expect(seenRequests[0]?.headers["x-paperclip-run-id"]).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 () => {
|
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", () => {
|
it("permits the documented heartbeat surface and denies unrelated routes", () => {
|
||||||
const allowed: Array<{ method: string; path: string }> = [
|
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" },
|
||||||
{ method: "GET", path: "/api/agents/me/inbox-lite" },
|
{ method: "GET", path: "/api/agents/me/inbox-lite" },
|
||||||
{ method: "GET", path: "/api/agents/me/inbox/mine" },
|
{ method: "GET", path: "/api/agents/me/inbox/mine" },
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,8 @@ export interface SandboxCallbackBridgeRouteRule {
|
||||||
// reverse bridge. Keep this in sync with the Paperclip skill in
|
// reverse bridge. Keep this in sync with the Paperclip skill in
|
||||||
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
|
// `skills/paperclip/SKILL.md` and `references/api-reference.md`.
|
||||||
export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [
|
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
|
// Identity, inbox, agent self-management
|
||||||
{ method: "GET", path: /^\/api\/agents\/me$/ },
|
{ method: "GET", path: /^\/api\/agents\/me$/ },
|
||||||
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
|
{ method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ },
|
||||||
|
|
@ -186,6 +188,7 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [
|
||||||
"content-type",
|
"content-type",
|
||||||
"if-match",
|
"if-match",
|
||||||
"if-none-match",
|
"if-none-match",
|
||||||
|
"x-paperclip-github-capability",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface SandboxCallbackBridgeRequest {
|
export interface SandboxCallbackBridgeRequest {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "origin_identity_context_id" uuid;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "issue_thread_interactions" ADD COLUMN IF NOT EXISTS "source_identity_context_id" uuid;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "continuation_identity_context_id" uuid;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "run_identity_contexts" DROP CONSTRAINT IF EXISTS "run_identity_contexts_run_id_heartbeat_runs_id_fk";
|
||||||
|
|
@ -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
|
|
@ -1667,6 +1667,48 @@
|
||||||
"when": 1788557575279,
|
"when": 1788557575279,
|
||||||
"tag": "0239_sturdy_santa_claus",
|
"tag": "0239_sturdy_santa_claus",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -26,6 +26,8 @@ export const heartbeatRuns = pgTable(
|
||||||
triggerDetail: text("trigger_detail"),
|
triggerDetail: text("trigger_detail"),
|
||||||
status: text("status").notNull().default("queued"),
|
status: text("status").notNull().default("queued"),
|
||||||
responsibleUserId: text("responsible_user_id"),
|
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 }),
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||||
error: text("error"),
|
error: text("error"),
|
||||||
|
|
|
||||||
|
|
@ -183,3 +183,4 @@ export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js
|
||||||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||||
export { pluginLogs } from "./plugin_logs.js";
|
export { pluginLogs } from "./plugin_logs.js";
|
||||||
|
export { runIdentityContexts } from "./run_identity_contexts.js";
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ export const issueThreadInteractions = pgTable(
|
||||||
.default("requested"),
|
.default("requested"),
|
||||||
idempotencyKey: text("idempotency_key"),
|
idempotencyKey: text("idempotency_key"),
|
||||||
sourceCommentId: uuid("source_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
|
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" }),
|
sourceRunId: uuid("source_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
summary: text("summary"),
|
summary: text("summary"),
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ export const issues = pgTable(
|
||||||
originKind: text("origin_kind").notNull().default("manual"),
|
originKind: text("origin_kind").notNull().default("manual"),
|
||||||
originId: text("origin_id"),
|
originId: text("origin_id"),
|
||||||
originRunId: text("origin_run_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"),
|
originFingerprint: text("origin_fingerprint").notNull().default("default"),
|
||||||
requestDepth: integer("request_depth").notNull().default(0),
|
requestDepth: integer("request_depth").notNull().default(0),
|
||||||
billingCode: text("billing_code"),
|
billingCode: text("billing_code"),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}));
|
||||||
|
|
@ -551,6 +551,22 @@ pub struct CodexProvider {
|
||||||
// entry from this static ceiling, but cannot introduce another environment
|
// entry from this static ceiling, but cannot introduce another environment
|
||||||
// variable by changing GIT_CONFIG_COUNT.
|
// variable by changing GIT_CONFIG_COUNT.
|
||||||
const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[
|
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",
|
"GH_TOKEN",
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
"PAPERCLIP_GIT_TOKEN",
|
"PAPERCLIP_GIT_TOKEN",
|
||||||
|
|
@ -3127,8 +3143,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn github_credentials_cross_only_the_bounded_provider_environment() {
|
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 [
|
for key in [
|
||||||
|
"PAPERCLIP_GITHUB_BROKER_URL",
|
||||||
|
"PAPERCLIP_GITHUB_BROKER_TOKEN",
|
||||||
|
"PAPERCLIP_GITHUB_LAUNCHER_DIR",
|
||||||
|
"GH_CONFIG_DIR",
|
||||||
|
"PAPERCLIP_GITHUB_BRIDGE_TOKEN",
|
||||||
"GH_TOKEN",
|
"GH_TOKEN",
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
"PAPERCLIP_GIT_TOKEN",
|
"PAPERCLIP_GIT_TOKEN",
|
||||||
|
|
|
||||||
|
|
@ -1726,6 +1726,36 @@ impl CodexCommandExecutor {
|
||||||
.ok_or_else(|| DurableRunnerError::invalid("Codex provider is unavailable"))
|
.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> {
|
fn attach_run(&mut self, payload: &Value) -> Result<(), DurableRunnerError> {
|
||||||
let mut next_state = self
|
let mut next_state = self
|
||||||
.state
|
.state
|
||||||
|
|
@ -1752,20 +1782,53 @@ impl CodexCommandExecutor {
|
||||||
"run.attach requires a settled Codex provider session with no pending events",
|
"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") {
|
if let Some(provider) = payload.get("provider") {
|
||||||
let config: CodexProviderConfig =
|
let mut config: CodexProviderConfig = serde_json::from_value(provider.clone())
|
||||||
serde_json::from_value(provider.clone()).map_err(|error| {
|
.map_err(|error| {
|
||||||
DurableRunnerError::invalid(format!("run.attach provider is invalid: {error}"))
|
DurableRunnerError::invalid(format!("run.attach provider is invalid: {error}"))
|
||||||
})?;
|
})?;
|
||||||
config
|
config
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(|error| DurableRunnerError::invalid(error.to_string()))?;
|
.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 {
|
if config != next_state.config {
|
||||||
return Err(DurableRunnerError::invalid(
|
return Err(DurableRunnerError::invalid(
|
||||||
"run.attach cannot change the durable Codex provider profile",
|
"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 completion_contract = completion_contract(payload)?;
|
||||||
let tool_set = authorized_tool_set(payload)?;
|
let tool_set = authorized_tool_set(payload)?;
|
||||||
next_state
|
next_state
|
||||||
|
|
@ -1791,21 +1854,22 @@ impl CodexCommandExecutor {
|
||||||
let provider = self.provider.as_mut().ok_or_else(|| {
|
let provider = self.provider.as_mut().ok_or_else(|| {
|
||||||
DurableRunnerError::invalid("run.attach requires the restored Codex provider process")
|
DurableRunnerError::invalid("run.attach requires the restored Codex provider process")
|
||||||
})?;
|
})?;
|
||||||
let retained_provider = provider
|
let retained_provider = !runtime_launch_changed
|
||||||
.attach_run_in_place(
|
&& provider
|
||||||
next_state.tool_bridge.authorized_tools().cloned(),
|
.attach_run_in_place(
|
||||||
next_state.completion_contract.as_ref().map(|contract| {
|
next_state.tool_bridge.authorized_tools().cloned(),
|
||||||
(
|
next_state.completion_contract.as_ref().map(|contract| {
|
||||||
contract.revision.as_str(),
|
(
|
||||||
contract.criterion_ids.as_slice(),
|
contract.revision.as_str(),
|
||||||
)
|
contract.criterion_ids.as_slice(),
|
||||||
}),
|
)
|
||||||
)
|
}),
|
||||||
.map_err(|error| {
|
)
|
||||||
DurableRunnerError::invalid(format!(
|
.map_err(|error| {
|
||||||
"failed to retain Codex for warm run attachment: {error}"
|
DurableRunnerError::invalid(format!(
|
||||||
))
|
"failed to retain Codex for warm run attachment: {error}"
|
||||||
})?;
|
))
|
||||||
|
})?;
|
||||||
if !retained_provider {
|
if !retained_provider {
|
||||||
provider.shutdown().map_err(|error| {
|
provider.shutdown().map_err(|error| {
|
||||||
DurableRunnerError::invalid(format!(
|
DurableRunnerError::invalid(format!(
|
||||||
|
|
@ -3260,6 +3324,46 @@ impl CommandExecutor for CodexCommandExecutor {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
fn opencode_result_state() -> CodexProviderState {
|
fn opencode_result_state() -> CodexProviderState {
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,29 @@ describe("Codex security configuration", () => {
|
||||||
expect(serialized).not.toContain("!trusted-helper");
|
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", () => {
|
it("uses a read-only permission profile for plan mode", () => {
|
||||||
expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({
|
expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ export function codexCommandEnvironment(
|
||||||
const value = source[key];
|
const value = source[key];
|
||||||
if (value !== undefined) environment[key] = value;
|
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;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +85,7 @@ export function createIsolatedCodexAppServerArgs(
|
||||||
const hasGitHubCredential = hasGitHubCredentialEnvironment(source);
|
const hasGitHubCredential = hasGitHubCredentialEnvironment(source);
|
||||||
const externalRunnerSandbox = usesExternalRunnerSandbox(source);
|
const externalRunnerSandbox = usesExternalRunnerSandbox(source);
|
||||||
const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source);
|
const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source);
|
||||||
|
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
|
||||||
const deniedHostRoots = [
|
const deniedHostRoots = [
|
||||||
...new Set(
|
...new Set(
|
||||||
[source.HOME, source.CODEX_HOME]
|
[source.HOME, source.CODEX_HOME]
|
||||||
|
|
@ -96,6 +102,8 @@ export function createIsolatedCodexAppServerArgs(
|
||||||
`":tmpdir"="none"`,
|
`":tmpdir"="none"`,
|
||||||
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
||||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
...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"}`,
|
`":workspace_roots"={"."="write"}`,
|
||||||
].join(",");
|
].join(",");
|
||||||
const planningFilesystemRules = [
|
const planningFilesystemRules = [
|
||||||
|
|
@ -104,6 +112,8 @@ export function createIsolatedCodexAppServerArgs(
|
||||||
`":tmpdir"="none"`,
|
`":tmpdir"="none"`,
|
||||||
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
|
||||||
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
|
...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"}`,
|
`":workspace_roots"={"."="read"}`,
|
||||||
].join(",");
|
].join(",");
|
||||||
const commandEnv = Object.entries(codexCommandEnvironment(source))
|
const commandEnv = Object.entries(codexCommandEnvironment(source))
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ describe("runtime context materialization", () => {
|
||||||
const config = await readFile(join(codexHome, "config.toml"), "utf8");
|
const config = await readFile(join(codexHome, "config.toml"), "utf8");
|
||||||
expect(config).toContain("shell_snapshot = false");
|
expect(config).toContain("shell_snapshot = false");
|
||||||
expect(config).toContain("paperclip-assigned");
|
expect(config).toContain("paperclip-assigned");
|
||||||
|
expect(config).toContain('default_tools_approval_mode = "approve"');
|
||||||
expect(config).toContain("Bearer ");
|
expect(config).toContain("Bearer ");
|
||||||
expect(config).not.toContain("unassigned");
|
expect(config).not.toContain("unassigned");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,10 @@ export async function prepareIsolatedCodexHome(input: {
|
||||||
? [
|
? [
|
||||||
`[mcp_servers.${JSON.stringify(input.nativeMcp.name)}]`,
|
`[mcp_servers.${JSON.stringify(input.nativeMcp.name)}]`,
|
||||||
`url = ${JSON.stringify(input.nativeMcp.url)}`,
|
`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}`)} }`,
|
`http_headers = { Authorization = ${JSON.stringify(`Bearer ${input.nativeMcp.token}`)} }`,
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,20 @@
|
||||||
const STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS = [
|
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",
|
"GH_TOKEN",
|
||||||
"GITHUB_TOKEN",
|
"GITHUB_TOKEN",
|
||||||
"PAPERCLIP_GIT_TOKEN",
|
"PAPERCLIP_GIT_TOKEN",
|
||||||
|
|
@ -61,6 +77,7 @@ export function hasGitHubCredentialEnvironment(
|
||||||
source: NodeJS.ProcessEnv,
|
source: NodeJS.ProcessEnv,
|
||||||
): boolean {
|
): boolean {
|
||||||
return [
|
return [
|
||||||
|
source.PAPERCLIP_GITHUB_BROKER_TOKEN,
|
||||||
source.GH_TOKEN,
|
source.GH_TOKEN,
|
||||||
source.GITHUB_TOKEN,
|
source.GITHUB_TOKEN,
|
||||||
source.PAPERCLIP_GIT_TOKEN,
|
source.PAPERCLIP_GIT_TOKEN,
|
||||||
|
|
|
||||||
|
|
@ -2723,9 +2723,12 @@ it("cold-restores a suspended provider session under its durable run binding", a
|
||||||
stateDirectory,
|
stateDirectory,
|
||||||
"--include-skill-instructions",
|
"--include-skill-instructions",
|
||||||
"--durable-turn-ids",
|
"--durable-turn-ids",
|
||||||
|
"-c",
|
||||||
|
'shell_environment_policy.set={PATH="/run/A"}',
|
||||||
),
|
),
|
||||||
stateDirectory,
|
stateDirectory,
|
||||||
environment: {
|
environment: {
|
||||||
|
PAPERCLIP_GITHUB_BROKER_TOKEN: "test-run-A-capability",
|
||||||
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
|
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
|
||||||
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(64 * 1024 * 1024),
|
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({
|
const rotated = createCapabilityRunnerdCodexTransport({
|
||||||
...options,
|
...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,
|
resumeDynamicTools: dynamicTools,
|
||||||
resumeCompletionContract: {
|
resumeCompletionContract: {
|
||||||
revision: "contract-second",
|
revision: "contract-second",
|
||||||
|
|
@ -2811,6 +2816,9 @@ it("cold-restores a suspended provider session under its durable run binding", a
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const read = await rotated.transport.request("thread/read", {});
|
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({
|
expect(read.thread).toMatchObject({
|
||||||
id: firstProviderThread.id,
|
id: firstProviderThread.id,
|
||||||
sessionId: firstProviderThread.sessionId,
|
sessionId: firstProviderThread.sessionId,
|
||||||
|
|
|
||||||
|
|
@ -3561,6 +3561,22 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||||
this.#authorizedTools,
|
this.#authorizedTools,
|
||||||
this.options.resumeCompletionContract,
|
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);
|
this.#runAttachTemplate = structuredClone(runAttachTemplate);
|
||||||
core.queueCommand("run.attach", runAttachTemplate);
|
core.queueCommand("run.attach", runAttachTemplate);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,12 @@ export interface HeartbeatRun {
|
||||||
triggerDetail: WakeupTriggerDetail | null;
|
triggerDetail: WakeupTriggerDetail | null;
|
||||||
status: HeartbeatRunStatus;
|
status: HeartbeatRunStatus;
|
||||||
responsibleUserId: string | null;
|
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;
|
startedAt: Date | null;
|
||||||
finishedAt: Date | null;
|
finishedAt: Date | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,8 @@ export interface Issue {
|
||||||
originKind?: IssueOriginKind;
|
originKind?: IssueOriginKind;
|
||||||
originId?: string | null;
|
originId?: string | null;
|
||||||
originRunId?: string | null;
|
originRunId?: string | null;
|
||||||
|
originIdentityContextId?: string | null;
|
||||||
|
continuationIdentityContextId?: string | null;
|
||||||
originFingerprint?: string | null;
|
originFingerprint?: string | null;
|
||||||
requestDepth: number;
|
requestDepth: number;
|
||||||
billingCode: string | null;
|
billingCode: string | null;
|
||||||
|
|
@ -1058,6 +1060,7 @@ export interface IssueCommentMetadataSection {
|
||||||
export interface IssueCommentMetadata {
|
export interface IssueCommentMetadata {
|
||||||
version: 1;
|
version: 1;
|
||||||
sourceRunId?: string | null;
|
sourceRunId?: string | null;
|
||||||
|
sourceIdentityContextId?: string | null;
|
||||||
authorizationReason?: string | null;
|
authorizationReason?: string | null;
|
||||||
sections: IssueCommentMetadataSection[];
|
sections: IssueCommentMetadataSection[];
|
||||||
}
|
}
|
||||||
|
|
@ -1471,6 +1474,7 @@ export interface IssueThreadInteractionBase extends IssueThreadInteractionActorF
|
||||||
idempotencyKey?: string | null;
|
idempotencyKey?: string | null;
|
||||||
sourceCommentId?: string | null;
|
sourceCommentId?: string | null;
|
||||||
sourceRunId?: string | null;
|
sourceRunId?: string | null;
|
||||||
|
sourceIdentityContextId?: string | null;
|
||||||
addresseeAgentId?: string | null;
|
addresseeAgentId?: string | null;
|
||||||
addresseeUserId?: string | null;
|
addresseeUserId?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -771,11 +771,8 @@ describe("agent live run routes", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||||
// The legacy /heartbeat/invoke endpoint forwards only the wake fields the
|
// Optional wake fields retain their existing shape; execution identity
|
||||||
// caller actually supplied so empty-body callers (e.g. e2e suites) match
|
// always comes from the authenticated caller.
|
||||||
// the original fixed-arg `heartbeat.invoke()` shape exactly. When the
|
|
||||||
// caller supplies reason / payload / forceFreshSession those are
|
|
||||||
// forwarded; idempotencyKey is omitted unless explicitly set.
|
|
||||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, {
|
||||||
source: "on_demand",
|
source: "on_demand",
|
||||||
triggerDetail: "manual",
|
triggerDetail: "manual",
|
||||||
|
|
@ -790,6 +787,8 @@ describe("agent live run routes", () => {
|
||||||
contextSnapshot: {
|
contextSnapshot: {
|
||||||
triggeredBy: "board",
|
triggeredBy: "board",
|
||||||
actorId: "local-board",
|
actorId: "local-board",
|
||||||
|
responsibleUserId: "local-board",
|
||||||
|
originIdentityContextId: null,
|
||||||
forceFreshSession: true,
|
forceFreshSession: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -813,6 +812,8 @@ describe("agent live run routes", () => {
|
||||||
contextSnapshot: {
|
contextSnapshot: {
|
||||||
triggeredBy: "board",
|
triggeredBy: "board",
|
||||||
actorId: "local-board",
|
actorId: "local-board",
|
||||||
|
responsibleUserId: "local-board",
|
||||||
|
originIdentityContextId: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
@ -21,6 +21,32 @@ import type { AuthorizationActor, AuthorizationDecision } from "../services/auth
|
||||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
|
import { resolveManagedProjectWorkspaceDir } from "../home-paths.ts";
|
||||||
|
|
||||||
describe("resolveExecutionRunAdapterConfig", () => {
|
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 () => {
|
it("overlays environment, project, and routine env on top of agent env and unions secret keys", async () => {
|
||||||
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
|
||||||
config: {
|
config: {
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,7 @@ describe("openapi routes", () => {
|
||||||
const { spec } = loadSpecRoutes();
|
const { spec } = loadSpecRoutes();
|
||||||
|
|
||||||
expect(spec.paths["/api/openapi.json"].get.security).toEqual([]);
|
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([
|
expect(spec.paths["/api/plugins/install"].post.security).toEqual([
|
||||||
{ BoardSessionAuth: [] },
|
{ BoardSessionAuth: [] },
|
||||||
{ BoardApiKeyAuth: [] },
|
{ BoardApiKeyAuth: [] },
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ describe("opencode_local environment diagnostics", () => {
|
||||||
|
|
||||||
it("treats an empty OPENAI_API_KEY override as missing", async () => {
|
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-"));
|
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;
|
const originalOpenAiKey = process.env.OPENAI_API_KEY;
|
||||||
process.env.OPENAI_API_KEY = "sk-host-value";
|
process.env.OPENAI_API_KEY = "sk-host-value";
|
||||||
|
|
||||||
|
|
@ -38,7 +41,7 @@ describe("opencode_local environment diagnostics", () => {
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
adapterType: "opencode_local",
|
adapterType: "opencode_local",
|
||||||
config: {
|
config: {
|
||||||
command: process.execPath,
|
command: fakeOpencode,
|
||||||
cwd,
|
cwd,
|
||||||
env: {
|
env: {
|
||||||
OPENAI_API_KEY: "",
|
OPENAI_API_KEY: "",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
@ -38,6 +38,8 @@ import {
|
||||||
toolRuntimeMetricCounters,
|
toolRuntimeMetricCounters,
|
||||||
toolRuntimeSlots,
|
toolRuntimeSlots,
|
||||||
toolStdioCommandTemplates,
|
toolStdioCommandTemplates,
|
||||||
|
userSecretDefinitions,
|
||||||
|
userSecretDeclarations,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
|
@ -5001,6 +5003,36 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||||
expect(activity.lifecycleEvents.map((event) => event.type)).toEqual(["app_paused"]);
|
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 () => {
|
it("allows same-company Google Sheets updates and derives the env mirror from the allowlist", async () => {
|
||||||
const company = await createCompany(db);
|
const company = await createCompany(db);
|
||||||
const service = createTestToolAccessService(db);
|
const service = createTestToolAccessService(db);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import {
|
||||||
companies,
|
companies,
|
||||||
companySecretBindings,
|
companySecretBindings,
|
||||||
companySecrets,
|
companySecrets,
|
||||||
|
companyMemberships,
|
||||||
|
toolConnectionInstalls,
|
||||||
|
issueComments,
|
||||||
connectionGrants,
|
connectionGrants,
|
||||||
createDb,
|
createDb,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
|
@ -26,6 +29,7 @@ import {
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
|
||||||
import type { VercelConnectClient } from "../services/vercel-connect.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 { secretService } from "../services/secrets.js";
|
||||||
import {
|
import {
|
||||||
createToolGatewayService,
|
createToolGatewayService,
|
||||||
|
|
@ -1203,6 +1207,53 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||||
expect(storedGrant?.externalCredential).toMatchObject({ tokenId: "stk_fresh" });
|
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 () => {
|
it("refreshes a customer OAuth grant once and retries after an upstream 401", async () => {
|
||||||
const { company, agent, run } = await createRunFixture(db);
|
const { company, agent, run } = await createRunFixture(db);
|
||||||
const { connection } = await createRemoteMcpToolFixture(db, company.id);
|
const { connection } = await createRemoteMcpToolFixture(db, company.id);
|
||||||
|
|
@ -1222,6 +1273,7 @@ describeEmbeddedPostgres("tool gateway service", () => {
|
||||||
await db.update(toolConnections).set({
|
await db.update(toolConnections).set({
|
||||||
authKind: "oauth",
|
authKind: "oauth",
|
||||||
credentialSource: "paperclip_vault",
|
credentialSource: "paperclip_vault",
|
||||||
|
credentialRefs: [{ name: "oauth.access_token", placement: "header", key: "Authorization", prefix: "Bearer ", secretId: accessSecret.id, versionSelector: "latest" }],
|
||||||
config: {
|
config: {
|
||||||
url: "https://example.invalid/mcp",
|
url: "https://example.invalid/mcp",
|
||||||
oauth: {
|
oauth: {
|
||||||
|
|
|
||||||
|
|
@ -7737,7 +7737,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
const reservePort = async () => {
|
const reservePort = async () => {
|
||||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
const probe = net.createServer();
|
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 address = probe.address();
|
||||||
const port = typeof address === "object" && address ? address.port : null;
|
const port = typeof address === "object" && address ? address.port : null;
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
@ -8078,7 +8084,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
const reservePort = async () => {
|
const reservePort = async () => {
|
||||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||||
const probe = net.createServer();
|
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 address = probe.address();
|
||||||
const candidate = typeof address === "object" && address ? address.port : null;
|
const candidate = typeof address === "object" && address ? address.port : null;
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||||
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared";
|
||||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
|
import { captureRunIdentity } from "../services/run-identity.js";
|
||||||
import { boardAuthService } from "../services/board-auth.js";
|
import { boardAuthService } from "../services/board-auth.js";
|
||||||
|
|
||||||
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
|
const CLOUD_TENANT_WRITE_DEBOUNCE_MS = 5_000;
|
||||||
|
|
@ -379,7 +380,18 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||||
return;
|
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)
|
? normalizeOptionalString(claims.responsible_user_id)
|
||||||
: await resolveLegacyRunResponsibleUserId(db, {
|
: await resolveLegacyRunResponsibleUserId(db, {
|
||||||
companyId: claims.company_id,
|
companyId: claims.company_id,
|
||||||
|
|
@ -399,6 +411,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||||
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
|
keyScope: normalizeAgentApiKeyScope(claims.key_scope),
|
||||||
runId: claims.run_id,
|
runId: claims.run_id,
|
||||||
onBehalfOfUserId,
|
onBehalfOfUserId,
|
||||||
|
identityContextId: identityRun?.activeIdentityContextId ?? null,
|
||||||
onBehalfOfMemberships,
|
onBehalfOfMemberships,
|
||||||
source: "agent_jwt",
|
source: "agent_jwt",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5205,12 +5205,16 @@ export function agentRoutes(
|
||||||
source: opts.source,
|
source: opts.source,
|
||||||
triggerDetail: req.body.triggerDetail ?? "manual",
|
triggerDetail: req.body.triggerDetail ?? "manual",
|
||||||
reason: req.body.reason ?? null,
|
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,
|
idempotencyKey: req.body.idempotencyKey ?? null,
|
||||||
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
||||||
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||||
contextSnapshot: {
|
contextSnapshot: {
|
||||||
triggeredBy: req.actor.type,
|
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,
|
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||||
forceFreshSession: req.body.forceFreshSession === true,
|
forceFreshSession: req.body.forceFreshSession === true,
|
||||||
...(req.body.reason === "rerun_with_provider_trace" &&
|
...(req.body.reason === "rerun_with_provider_trace" &&
|
||||||
|
|
@ -5312,6 +5316,8 @@ export function agentRoutes(
|
||||||
}>;
|
}>;
|
||||||
const contextSnapshot: Record<string, unknown> = {
|
const contextSnapshot: Record<string, unknown> = {
|
||||||
triggeredBy: req.actor.type,
|
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,
|
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||||
};
|
};
|
||||||
if (body.forceFreshSession === true) {
|
if (body.forceFreshSession === true) {
|
||||||
|
|
@ -5336,7 +5342,9 @@ export function agentRoutes(
|
||||||
wakeOpts.reason = body.reason;
|
wakeOpts.reason = body.reason;
|
||||||
}
|
}
|
||||||
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
|
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) {
|
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
|
||||||
wakeOpts.idempotencyKey = body.idempotencyKey;
|
wakeOpts.idempotencyKey = body.idempotencyKey;
|
||||||
|
|
@ -6036,7 +6044,7 @@ export function agentRoutes(
|
||||||
run.companyId,
|
run.companyId,
|
||||||
run.id,
|
run.id,
|
||||||
redactCurrentUserValue(
|
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(),
|
await getCurrentUserRedactionOptions(),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
@ -6673,3 +6681,4 @@ export function agentRoutes(
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
import { listRunIdentityContexts } from "../services/run-identity.js";
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { logActivity } from "../services/activity-log.js";
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
import type { heartbeatService } from "../services/heartbeat.js";
|
import type { heartbeatService } from "../services/heartbeat.js";
|
||||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||||
|
import { resolveGitHubOperationCredentials } from "../services/github-operation-credentials.js";
|
||||||
|
|
||||||
function bearer(req: Request) {
|
function bearer(req: Request) {
|
||||||
const value = req.header("authorization") ?? "";
|
const value = req.header("authorization") ?? "";
|
||||||
|
|
@ -61,6 +62,19 @@ export function runtimeConnectionIntentRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const service = connectionIntentService(db);
|
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) => {
|
router.get("/mcp/runtime-tools", async (req, res) => {
|
||||||
await service.validate(runtimeClaims(req));
|
await service.validate(runtimeClaims(req));
|
||||||
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });
|
res.json({ name: "paperclip-runtime-tools", protocolVersion: "2025-03-26" });
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { storedSteeringAcknowledgement, reconcileSteeredIdentity, reserveSteeredIdentity, acceptSteeredIdentity, rejectSteeredIdentity } from "../services/run-identity.js";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
|
|
@ -9227,6 +9228,7 @@ export function issueRoutes(
|
||||||
...(taskBridgeOriginForActor(req) ?? {}),
|
...(taskBridgeOriginForActor(req) ?? {}),
|
||||||
id: issueId,
|
id: issueId,
|
||||||
originRunId: createBody.originRunId ?? actor.runId,
|
originRunId: createBody.originRunId ?? actor.runId,
|
||||||
|
originIdentityContextId: req.actor.identityContextId ?? null,
|
||||||
executionPolicy,
|
executionPolicy,
|
||||||
...(sourceTrust ? { sourceTrust } : {}),
|
...(sourceTrust ? { sourceTrust } : {}),
|
||||||
createdByAgentId: actor.agentId,
|
createdByAgentId: actor.agentId,
|
||||||
|
|
@ -11963,6 +11965,10 @@ export function issueRoutes(
|
||||||
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||||
if (!issue) return;
|
if (!issue) return;
|
||||||
const actor = getActorInfo(req);
|
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 acknowledgedTurnId: string | null = null;
|
||||||
let duplicate = false;
|
let duplicate = false;
|
||||||
let queue: IssueQueuedCommentQueue;
|
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,
|
runId: locked.activeRun.id,
|
||||||
message: entry.comment.body,
|
message: entry.comment.body,
|
||||||
correlationId: commentId,
|
correlationId: commentId,
|
||||||
|
onAcknowledged: steeringIdentity ? () => reconcileSteeredIdentity(db, steeringIdentity) : undefined,
|
||||||
});
|
});
|
||||||
|
if (steeringIdentity) await acceptSteeredIdentity(tx, steeringIdentity);
|
||||||
acknowledgedTurnId = acknowledgement.turnId;
|
acknowledgedTurnId = acknowledgement.turnId;
|
||||||
const remainingIds = locked.queue.entries
|
const remainingIds = locked.queue.entries
|
||||||
.map((candidate) => candidate.comment.id)
|
.map((candidate) => candidate.comment.id)
|
||||||
|
|
@ -12130,6 +12139,10 @@ export function issueRoutes(
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const uncertain = steeringDeliveryAttempted && (!(error instanceof NativeSessionSteeringError)
|
||||||
|
|| error.code === "steering_timeout");
|
||||||
|
if (steeringIdentity && !uncertain) await rejectSteeredIdentity(db, steeringIdentity);
|
||||||
|
|
||||||
if (error instanceof NativeSessionSteeringError) {
|
if (error instanceof NativeSessionSteeringError) {
|
||||||
throw conflict(error.message, { code: error.code, retryable: true });
|
throw conflict(error.message, { code: error.code, retryable: true });
|
||||||
}
|
}
|
||||||
|
|
@ -12226,6 +12239,7 @@ export function issueRoutes(
|
||||||
...req.body,
|
...req.body,
|
||||||
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
|
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
|
||||||
}, {
|
}, {
|
||||||
|
identityContextId: req.actor.identityContextId,
|
||||||
agentId: actor.agentId,
|
agentId: actor.agentId,
|
||||||
userId: actor.actorType === "user" ? actor.actorId : null,
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -821,6 +821,7 @@ const RUNTIME_TOOLS_SECURITY: Array<Record<string, string[]>> = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const RUNTIME_TOOLS_OPERATIONS = new Set([
|
const RUNTIME_TOOLS_OPERATIONS = new Set([
|
||||||
|
"POST /runtime-tools/github/credentials",
|
||||||
"GET /mcp/runtime-tools",
|
"GET /mcp/runtime-tools",
|
||||||
"POST /mcp/runtime-tools",
|
"POST /mcp/runtime-tools",
|
||||||
"POST /runtime-tools/connections/search",
|
"POST /runtime-tools/connections/search",
|
||||||
|
|
@ -1150,7 +1151,7 @@ function applyDocumentFixups(document: any): any {
|
||||||
scheme: "bearer",
|
scheme: "bearer",
|
||||||
bearerFormat: "Heartbeat-bound runtime tools token",
|
bearerFormat: "Heartbeat-bound runtime tools token",
|
||||||
description:
|
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;
|
document.security = AUTHENTICATED_SECURITY;
|
||||||
|
|
@ -7436,6 +7437,14 @@ for (const route of [
|
||||||
|
|
||||||
// --- Connection intents ------------------------------------------------------
|
// --- 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({
|
registerCurrentRoute({
|
||||||
method: "get",
|
method: "get",
|
||||||
path: "/mcp/runtime-tools",
|
path: "/mcp/runtime-tools",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export interface RuntimeToolsTokenClaims {
|
||||||
company_id: string;
|
company_id: string;
|
||||||
run_id: string;
|
run_id: string;
|
||||||
responsible_user_id: string;
|
responsible_user_id: string;
|
||||||
scope: "connection_intents";
|
scope: "connection_intents" | "github_credentials";
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
|
|
@ -44,6 +44,7 @@ export function createRuntimeToolsToken(input: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
responsibleUserId: string;
|
responsibleUserId: string;
|
||||||
|
scope?: RuntimeToolsTokenClaims["scope"];
|
||||||
}) {
|
}) {
|
||||||
if (!secret()) return null;
|
if (!secret()) return null;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
@ -53,9 +54,10 @@ export function createRuntimeToolsToken(input: {
|
||||||
company_id: input.companyId,
|
company_id: input.companyId,
|
||||||
run_id: input.runId,
|
run_id: input.runId,
|
||||||
responsible_user_id: input.responsibleUserId,
|
responsible_user_id: input.responsibleUserId,
|
||||||
scope: "connection_intents",
|
scope: input.scope ?? "connection_intents",
|
||||||
iat: now,
|
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,
|
instance_id: instanceId,
|
||||||
};
|
};
|
||||||
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
|
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
|
||||||
|
|
@ -65,7 +67,7 @@ export function createRuntimeToolsToken(input: {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims | null {
|
export function verifyRuntimeToolsToken(token: string, scope: RuntimeToolsTokenClaims["scope"] = "connection_intents"): RuntimeToolsTokenClaims | null {
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) return null;
|
||||||
let header: Record<string, unknown>;
|
let header: Record<string, unknown>;
|
||||||
|
|
@ -86,7 +88,7 @@ export function verifyRuntimeToolsToken(token: string): RuntimeToolsTokenClaims
|
||||||
typeof claims.sub !== "string"
|
typeof claims.sub !== "string"
|
||||||
|| typeof claims.run_id !== "string"
|
|| typeof claims.run_id !== "string"
|
||||||
|| typeof claims.responsible_user_id !== "string"
|
|| typeof claims.responsible_user_id !== "string"
|
||||||
|| claims.scope !== "connection_intents"
|
|| claims.scope !== scope
|
||||||
|| typeof claims.iat !== "number"
|
|| typeof claims.iat !== "number"
|
||||||
|| typeof claims.exp !== "number"
|
|| typeof claims.exp !== "number"
|
||||||
|| claims.exp <= Math.floor(Date.now() / 1000)
|
|| claims.exp <= Math.floor(Date.now() / 1000)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
projects,
|
projects,
|
||||||
goals,
|
goals,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
runIdentityContexts,
|
||||||
heartbeatRunEvents,
|
heartbeatRunEvents,
|
||||||
costEvents,
|
costEvents,
|
||||||
financeEvents,
|
financeEvents,
|
||||||
|
|
@ -536,6 +537,7 @@ export function companyService(db: Db) {
|
||||||
}
|
}
|
||||||
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
await tx.delete(agentTaskSessions).where(eq(agentTaskSessions.companyId, id));
|
||||||
await tx.delete(activityLog).where(eq(activityLog.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(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
|
||||||
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
|
||||||
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ export function connectionIntentService(db: Db) {
|
||||||
agentId: heartbeatRuns.agentId,
|
agentId: heartbeatRuns.agentId,
|
||||||
status: heartbeatRuns.status,
|
status: heartbeatRuns.status,
|
||||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||||
|
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
||||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||||
})
|
})
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
|
|
@ -135,7 +136,7 @@ export function connectionIntentService(db: Db) {
|
||||||
!run
|
!run
|
||||||
|| run.companyId !== claims.company_id
|
|| run.companyId !== claims.company_id
|
||||||
|| run.agentId !== claims.sub
|
|| 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");
|
) throw forbidden("Runtime tool token does not match its heartbeat run");
|
||||||
if (run.status !== "running") throw forbidden("Runtime tool token is no longer active");
|
if (run.status !== "running") throw forbidden("Runtime tool token is no longer active");
|
||||||
const snapshot = record(run.contextSnapshot);
|
const snapshot = record(run.contextSnapshot);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
companySecrets,
|
companySecrets,
|
||||||
|
heartbeatRuns,
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
connectionGrantDelegations,
|
connectionGrantDelegations,
|
||||||
connectionGrants,
|
connectionGrants,
|
||||||
|
|
@ -48,6 +49,7 @@ export type GitCredential = {
|
||||||
/** The company-secret name the token came from; null for a server-environment token. */
|
/** The company-secret name the token came from; null for a server-environment token. */
|
||||||
secretName: string | null;
|
secretName: string | null;
|
||||||
githubIdentity?: { userId: string; login: string };
|
githubIdentity?: { userId: string; login: string };
|
||||||
|
identitySource?: "personal" | "dedicated";
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A prepared, credential-bearing git invocation: config args plus the env that carries the token. */
|
/** 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) => {
|
return async (remoteUrl: string) => {
|
||||||
if (!isSupportedGitHubRemoteUrl(remoteUrl)) return null;
|
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();
|
credentialPromise ??= resolveCredential();
|
||||||
const credential = await credentialPromise;
|
const credential = await credentialPromise;
|
||||||
if (!credential) return null;
|
if (!credential) return null;
|
||||||
|
|
@ -273,16 +292,16 @@ export async function resolveManagedGitHubIdentitySelection(
|
||||||
context: {
|
context: {
|
||||||
responsibleUserId?: string | null;
|
responsibleUserId?: string | null;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
|
allowStandingDelegation?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
identitySource?: "personal" | "dedicated";
|
||||||
grant?: typeof connectionGrants.$inferSelect;
|
grant?: typeof connectionGrants.$inferSelect;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
const connections = await db.select().from(toolConnections).where(and(
|
const connections = await db.select().from(toolConnections).where(and(
|
||||||
eq(toolConnections.companyId, companyId),
|
eq(toolConnections.companyId, companyId),
|
||||||
eq(toolConnections.enabled, true),
|
|
||||||
eq(toolConnections.status, "active"),
|
|
||||||
));
|
));
|
||||||
const githubConnections = connections.filter((connection) => {
|
const githubConnections = connections.filter((connection) => {
|
||||||
const config = connection.config && typeof connection.config === "object" ? connection.config as Record<string, unknown> : {};
|
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) =>
|
const eligibleConnectionIds = new Set(githubConnections.filter((connection) => installs.some((install) =>
|
||||||
install.connectionId === connection.id && (
|
install.connectionId === connection.id && (
|
||||||
install.targetType === "company"
|
(install.targetType === "company" && install.targetId === companyId)
|
||||||
|| (install.targetType === "agent" && install.targetId === context.agentId)
|
|| (install.targetType === "agent" && install.targetId === context.agentId)
|
||||||
)
|
)
|
||||||
)).map((connection) => connection.id));
|
)).map((connection) => connection.id));
|
||||||
|
|
@ -323,7 +342,7 @@ export async function resolveManagedGitHubIdentitySelection(
|
||||||
const personal = context.responsibleUserId
|
const personal = context.responsibleUserId
|
||||||
? grants.filter((grant) => grant.kind === "user" && grant.subjectUserId === 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(
|
? await db.select({ grantId: connectionGrantDelegations.grantId }).from(connectionGrantDelegations).where(and(
|
||||||
eq(connectionGrantDelegations.companyId, companyId),
|
eq(connectionGrantDelegations.companyId, companyId),
|
||||||
eq(connectionGrantDelegations.agentId, context.agentId),
|
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 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) {
|
if (candidates.length !== 1) {
|
||||||
return {
|
return {
|
||||||
configured: true,
|
configured: true, identitySource,
|
||||||
error: candidates.length === 0
|
error: candidates.length === 0
|
||||||
? "No managed GitHub identity is available for this run"
|
? "No managed GitHub identity is available for this run"
|
||||||
: "More than one managed GitHub identity matches this run",
|
: "More than one managed GitHub identity matches this run",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const grant = candidates[0]!;
|
const grant = candidates[0]!;
|
||||||
if (grant.status !== "active") return { configured: true, error: "The managed GitHub identity must be reconnected" };
|
const connection = githubConnections.find((candidate) => candidate.id === grant.connectionId);
|
||||||
return { configured: true, grant };
|
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 {
|
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,
|
db: Db,
|
||||||
secrets: GitCredentialSecretsDeps,
|
secrets: GitCredentialSecretsDeps,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|
@ -388,20 +412,21 @@ async function resolveManagedGitHubCredential(
|
||||||
heartbeatRunId?: string | null;
|
heartbeatRunId?: string | null;
|
||||||
responsibleUserId?: string | null;
|
responsibleUserId?: string | null;
|
||||||
agentId?: 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);
|
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
|
||||||
if (!selection.configured) return { configured: false };
|
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;
|
let grant = selection.grant;
|
||||||
if (grant.kind === "user" && grant.subjectUserId) {
|
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.companyId, companyId),
|
||||||
eq(companyMemberships.principalType, "user"),
|
eq(companyMemberships.principalType, "user"),
|
||||||
eq(companyMemberships.principalId, grant.subjectUserId),
|
eq(companyMemberships.principalId, grant.subjectUserId),
|
||||||
eq(companyMemberships.status, "active"),
|
eq(companyMemberships.status, "active"),
|
||||||
)).limit(1);
|
)).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 expiresAt = grant.providerTenant?.oauth?.accessTokenExpiresAt;
|
||||||
const refreshedAt = grant.providerTenant?.oauth?.refreshedAt;
|
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 accessRef = grant.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token");
|
||||||
const github = grant.providerTenant?.github;
|
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) {
|
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 = {
|
const accessContext = {
|
||||||
consumerType: "system" as const,
|
consumerType: "system" as const,
|
||||||
|
|
@ -439,7 +464,7 @@ async function resolveManagedGitHubCredential(
|
||||||
let token: string;
|
let token: string;
|
||||||
if (grant.kind === "user") {
|
if (grant.kind === "user") {
|
||||||
if (!grant.subjectUserId || !secrets.resolveUserSecretValue) {
|
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({
|
const [secret] = await db.select({
|
||||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||||
|
|
@ -448,25 +473,26 @@ async function resolveManagedGitHubCredential(
|
||||||
eq(companySecrets.id, accessRef.secretId),
|
eq(companySecrets.id, accessRef.secretId),
|
||||||
eq(companySecrets.ownerUserId, grant.subjectUserId),
|
eq(companySecrets.ownerUserId, grant.subjectUserId),
|
||||||
)).limit(1);
|
)).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, {
|
const resolved = await secrets.resolveUserSecretValue(companyId, {
|
||||||
definitionId: secret.userSecretDefinitionId,
|
definitionId: secret.userSecretDefinitionId,
|
||||||
responsibleUserId: grant.subjectUserId,
|
responsibleUserId: grant.subjectUserId,
|
||||||
version: accessRef.versionSelector ?? "latest",
|
version: accessRef.versionSelector ?? "latest",
|
||||||
required: true,
|
required: true,
|
||||||
}, accessContext);
|
}, 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;
|
token = resolved.value;
|
||||||
} else {
|
} else {
|
||||||
token = await secrets.resolveSecretValue(companyId, accessRef.secretId, accessRef.versionSelector ?? "latest", { accessContext });
|
token = await secrets.resolveSecretValue(companyId, accessRef.secretId, accessRef.versionSelector ?? "latest", { accessContext });
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
configured: true,
|
configured: true, identitySource: selection.identitySource,
|
||||||
credential: {
|
credential: {
|
||||||
token,
|
token,
|
||||||
source: "managed_connection",
|
source: "managed_connection",
|
||||||
secretName: null,
|
secretName: null,
|
||||||
githubIdentity: { userId: github.userId, login: github.login },
|
githubIdentity: { userId: github.userId, login: github.login },
|
||||||
|
identitySource: grant.kind === "agent" ? "dedicated" : "personal",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFile as execFileCallback } from "node:child_process";
|
import { execFile as execFileCallback } from "node:child_process";
|
||||||
|
|
@ -103,7 +106,6 @@ import {
|
||||||
createGitRemoteAuthProvider,
|
createGitRemoteAuthProvider,
|
||||||
describeGitAuthFailure,
|
describeGitAuthFailure,
|
||||||
filterResolvedGitHubConnectionsForRun,
|
filterResolvedGitHubConnectionsForRun,
|
||||||
GIT_CREDENTIAL_TOKEN_ENV_KEY,
|
|
||||||
scrubGitCredentialText,
|
scrubGitCredentialText,
|
||||||
type GitRemoteAuthProvider,
|
type GitRemoteAuthProvider,
|
||||||
} from "./git-credentials.js";
|
} 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
|
// 3. Any other PAPERCLIP_*-named binding is user data and flows through to
|
||||||
// the run env like any non-prefixed binding.
|
// the run env like any non-prefixed binding.
|
||||||
const FORBIDDEN_ENV_BINDING_KEYS = new Set(["PAPERCLIP_API_KEY"]);
|
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(
|
function stripForbiddenEnvBindings(
|
||||||
envValue: unknown,
|
envValue: unknown,
|
||||||
|
managedGitHubCredentials = false,
|
||||||
): Record<string, unknown> | null {
|
): Record<string, unknown> | null {
|
||||||
const record = parseObject(envValue);
|
const record = parseObject(envValue);
|
||||||
const filtered = Object.fromEntries(
|
const filtered = Object.fromEntries(
|
||||||
Object.entries(record).filter(
|
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;
|
return Object.keys(filtered).length > 0 ? filtered : null;
|
||||||
|
|
@ -1226,11 +1233,12 @@ function stripForbiddenEnvBindings(
|
||||||
|
|
||||||
function stripForbiddenEnvFromAdapterConfig(
|
function stripForbiddenEnvFromAdapterConfig(
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>,
|
||||||
|
managedGitHubCredentials = false,
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
if (!Object.prototype.hasOwnProperty.call(config, "env")) return config;
|
if (!Object.prototype.hasOwnProperty.call(config, "env")) return config;
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
env: stripForbiddenEnvBindings(config.env) ?? {},
|
env: stripForbiddenEnvBindings(config.env, managedGitHubCredentials) ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1281,16 +1289,18 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||||
reason: string;
|
reason: string;
|
||||||
remediation: string;
|
remediation: string;
|
||||||
};
|
};
|
||||||
|
/** Managed GitHub tokens are resolved only when operations start. */
|
||||||
|
managedGitHubCredentials?: boolean;
|
||||||
/** Audited class-3 values resolved by an internal credential broker. */
|
/** Audited class-3 values resolved by an internal credential broker. */
|
||||||
trustedEnvProjection?: Record<string, string>;
|
trustedEnvProjection?: Record<string, string>;
|
||||||
trustedEnvSecretKeys?: string[];
|
trustedEnvSecretKeys?: string[];
|
||||||
}) {
|
}) {
|
||||||
const executionRunConfig = stripForbiddenEnvFromAdapterConfig(
|
const executionRunConfig = stripForbiddenEnvFromAdapterConfig(
|
||||||
input.executionRunConfig,
|
input.executionRunConfig, input.managedGitHubCredentials,
|
||||||
);
|
);
|
||||||
const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv);
|
const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv, input.managedGitHubCredentials);
|
||||||
const projectEnv = stripForbiddenEnvBindings(input.projectEnv);
|
const projectEnv = stripForbiddenEnvBindings(input.projectEnv, input.managedGitHubCredentials);
|
||||||
const routineEnv = stripForbiddenEnvBindings(input.routineEnv);
|
const routineEnv = stripForbiddenEnvBindings(input.routineEnv, input.managedGitHubCredentials);
|
||||||
const agentEnv = parseObject(executionRunConfig.env);
|
const agentEnv = parseObject(executionRunConfig.env);
|
||||||
const lowTrustAllowedBindingIds =
|
const lowTrustAllowedBindingIds =
|
||||||
input.trustPreset?.kind === "low_trust_review"
|
input.trustPreset?.kind === "low_trust_review"
|
||||||
|
|
@ -4089,7 +4099,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
||||||
input.agent.id,
|
input.agent.id,
|
||||||
);
|
);
|
||||||
const [runIdentity] = await input.db
|
const [runIdentity] = await input.db
|
||||||
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|
@ -4099,8 +4109,8 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const resolvedInstalledConnections =
|
const resolvedInstalledConnections = runIdentity?.activeIdentityContextId
|
||||||
await filterResolvedGitHubConnectionsForRun({
|
? effective.installedConnections : await filterResolvedGitHubConnectionsForRun({
|
||||||
db: input.db,
|
db: input.db,
|
||||||
companyId: input.agent.companyId,
|
companyId: input.agent.companyId,
|
||||||
agentId: input.agent.id,
|
agentId: input.agent.id,
|
||||||
|
|
@ -4146,7 +4156,8 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
||||||
permittedConnectionIds.has(connection.id) &&
|
permittedConnectionIds.has(connection.id) &&
|
||||||
connection.status === "active" &&
|
connection.status === "active" &&
|
||||||
connection.enabled &&
|
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 === "mcp_remote" ||
|
||||||
connection.transport === "local_stdio"),
|
connection.transport === "local_stdio"),
|
||||||
);
|
);
|
||||||
|
|
@ -4226,7 +4237,7 @@ export async function buildPaperclipRuntimeMcpServers(input: {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
const fullConnectionIds = new Set(
|
const fullConnectionIds = new Set(
|
||||||
effective.entries
|
effective.entries
|
||||||
.filter((entry) => entry.effect === "include" && entry.connectionId)
|
.filter((entry) => entry.effect === "include" && entry.selectorType === "connection" && entry.connectionId)
|
||||||
.map((entry) => entry.connectionId!),
|
.map((entry) => entry.connectionId!),
|
||||||
);
|
);
|
||||||
const entries = [
|
const entries = [
|
||||||
|
|
@ -4575,7 +4586,7 @@ export async function createManagedMcpRunConfig(input: {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const [runIdentity] = await input.db
|
const [runIdentity] = await input.db
|
||||||
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|
@ -4585,7 +4596,9 @@ export async function createManagedMcpRunConfig(input: {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.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,
|
db: input.db,
|
||||||
companyId: input.agent.companyId,
|
companyId: input.agent.companyId,
|
||||||
|
|
@ -8995,6 +9008,8 @@ export function heartbeatService(
|
||||||
originKind: issues.originKind,
|
originKind: issues.originKind,
|
||||||
originId: issues.originId,
|
originId: issues.originId,
|
||||||
originRunId: issues.originRunId,
|
originRunId: issues.originRunId,
|
||||||
|
originIdentityContextId: issues.originIdentityContextId,
|
||||||
|
continuationIdentityContextId: issues.continuationIdentityContextId,
|
||||||
updatedAt: issues.updatedAt,
|
updatedAt: issues.updatedAt,
|
||||||
})
|
})
|
||||||
.from(issues)
|
.from(issues)
|
||||||
|
|
@ -9109,6 +9124,7 @@ export function heartbeatService(
|
||||||
routineId: issueContext.originId,
|
routineId: issueContext.originId,
|
||||||
env: snapshot.routine.env ?? null,
|
env: snapshot.routine.env ?? null,
|
||||||
responsibleUserId:
|
responsibleUserId:
|
||||||
|
routineRun?.responsibleUserId ??
|
||||||
revision?.responsibleUserId ??
|
revision?.responsibleUserId ??
|
||||||
snapshot.routine.responsibleUserId ??
|
snapshot.routine.responsibleUserId ??
|
||||||
null,
|
null,
|
||||||
|
|
@ -9229,6 +9245,27 @@ export function heartbeatService(
|
||||||
input.requestedByActorType === "user"
|
input.requestedByActorType === "user"
|
||||||
? readNonEmptyString(input.requestedByActorId)
|
? readNonEmptyString(input.requestedByActorId)
|
||||||
: null;
|
: 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 (contextResponsibleUserId) return contextResponsibleUserId;
|
||||||
if (input.existingRunResponsibleUserId)
|
if (input.existingRunResponsibleUserId)
|
||||||
return input.existingRunResponsibleUserId;
|
return input.existingRunResponsibleUserId;
|
||||||
|
|
@ -9242,9 +9279,8 @@ export function heartbeatService(
|
||||||
input.issueContext?.parentId,
|
input.issueContext?.parentId,
|
||||||
);
|
);
|
||||||
if (parentResponsibleUserId) return parentResponsibleUserId;
|
if (parentResponsibleUserId) return parentResponsibleUserId;
|
||||||
if (input.issueContext)
|
if (!input.issueContext && requestedUserId) return requestedUserId;
|
||||||
return resolveCompanyDefaultResponsibleUserId(input.companyId);
|
input.contextSnapshot.executionIdentityCause = "company_default";
|
||||||
if (requestedUserId) return requestedUserId;
|
|
||||||
return resolveCompanyDefaultResponsibleUserId(input.companyId);
|
return resolveCompanyDefaultResponsibleUserId(input.companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10483,6 +10519,7 @@ export function heartbeatService(
|
||||||
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
||||||
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
|
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
|
||||||
issueId,
|
issueId,
|
||||||
|
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
|
||||||
responsibleUserId:
|
responsibleUserId:
|
||||||
readNonEmptyString(context.responsibleUserId) ??
|
readNonEmptyString(context.responsibleUserId) ??
|
||||||
readNonEmptyString(context.responsible_user_id),
|
readNonEmptyString(context.responsible_user_id),
|
||||||
|
|
@ -10744,6 +10781,7 @@ export function heartbeatService(
|
||||||
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
|
||||||
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
|
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
|
||||||
issueId,
|
issueId,
|
||||||
|
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
|
||||||
responsibleUserId:
|
responsibleUserId:
|
||||||
readNonEmptyString(context.responsibleUserId) ??
|
readNonEmptyString(context.responsibleUserId) ??
|
||||||
readNonEmptyString(context.responsible_user_id),
|
readNonEmptyString(context.responsible_user_id),
|
||||||
|
|
@ -11266,7 +11304,7 @@ export function heartbeatService(
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: RUN_LIVENESS_CONTINUATION_REASON,
|
reason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||||
payload: decision.payload,
|
payload: decision.payload,
|
||||||
contextSnapshot: decision.contextSnapshot,
|
contextSnapshot: { ...decision.contextSnapshot, originIdentityContextId: null, parentRunId: run.id },
|
||||||
idempotencyKey: decision.idempotencyKey,
|
idempotencyKey: decision.idempotencyKey,
|
||||||
requestedByActorType: "system",
|
requestedByActorType: "system",
|
||||||
requestedByActorId: "heartbeat",
|
requestedByActorId: "heartbeat",
|
||||||
|
|
@ -11638,7 +11676,7 @@ export function heartbeatService(
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
reason: FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
|
||||||
payload: decision.payload,
|
payload: decision.payload,
|
||||||
contextSnapshot: decision.contextSnapshot,
|
contextSnapshot: { ...decision.contextSnapshot, originIdentityContextId: null, parentRunId: run.id },
|
||||||
idempotencyKey: decision.idempotencyKey,
|
idempotencyKey: decision.idempotencyKey,
|
||||||
requestedByActorType: "system",
|
requestedByActorType: "system",
|
||||||
requestedByActorId: "heartbeat",
|
requestedByActorId: "heartbeat",
|
||||||
|
|
@ -18034,6 +18072,7 @@ export function heartbeatService(
|
||||||
|
|
||||||
activeRunExecutions.add(run.id);
|
activeRunExecutions.add(run.id);
|
||||||
let runScratch: HeartbeatRunScratch | null = null;
|
let runScratch: HeartbeatRunScratch | null = null;
|
||||||
|
let githubLauncherLocation: Parameters<typeof cleanupGitHubOperationLaunchers>[0] | null = null;
|
||||||
let nativeSessionResumeScheduled = false;
|
let nativeSessionResumeScheduled = false;
|
||||||
let nativeWorkspaceFinalizeScheduled = false;
|
let nativeWorkspaceFinalizeScheduled = false;
|
||||||
let nativeWorkspaceSync: Awaited<
|
let nativeWorkspaceSync: Awaited<
|
||||||
|
|
@ -18306,19 +18345,30 @@ export function heartbeatService(
|
||||||
agent.companyId,
|
agent.companyId,
|
||||||
issueContext,
|
issueContext,
|
||||||
);
|
);
|
||||||
const responsibleUserId = await resolveResponsibleUserIdForRun({
|
let responsibleUserId: string | null = await resolveResponsibleUserIdForRun({
|
||||||
run,
|
run,
|
||||||
contextSnapshot: context,
|
contextSnapshot: context,
|
||||||
issueContext,
|
issueContext,
|
||||||
routineEnvContext,
|
routineEnvContext,
|
||||||
});
|
});
|
||||||
if (responsibleUserId && run.responsibleUserId !== responsibleUserId) {
|
const identityContext = await initializeRunIdentity(db, {
|
||||||
await db
|
companyId: agent.companyId, runId: run.id, responsibleUserId,
|
||||||
.update(heartbeatRuns)
|
interactionId: readNonEmptyString(context.interactionId),
|
||||||
.set({ responsibleUserId, updatedAt: new Date() })
|
issueId, messageIds: run.retryOfRunId || context.retryOfRunId ? [] : queuedCommentIdsFromRunContext(context).length
|
||||||
.where(eq(heartbeatRuns.id, run.id));
|
? queuedCommentIdsFromRunContext(context)
|
||||||
run = { ...run, responsibleUserId };
|
: 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 (
|
if (
|
||||||
responsibleUserId &&
|
responsibleUserId &&
|
||||||
issueContext &&
|
issueContext &&
|
||||||
|
|
@ -18832,23 +18882,9 @@ export function heartbeatService(
|
||||||
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
|
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
|
||||||
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
|
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
|
||||||
: runScopedMentionedSkillKeys;
|
: 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 } =
|
const { resolvedConfig, secretKeys, secretManifest } =
|
||||||
await resolveExecutionRunAdapterConfig({
|
await resolveExecutionRunAdapterConfig({
|
||||||
|
managedGitHubCredentials: true,
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
adapterType: agent.adapterType,
|
adapterType: agent.adapterType,
|
||||||
|
|
@ -18865,25 +18901,6 @@ export function heartbeatService(
|
||||||
routineEnv: routineEnvContext.env,
|
routineEnv: routineEnvContext.env,
|
||||||
secretsSvc,
|
secretsSvc,
|
||||||
trustPreset,
|
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) {
|
if (secretManifest.length > 0) {
|
||||||
context.paperclipSecrets = {
|
context.paperclipSecrets = {
|
||||||
|
|
@ -19187,9 +19204,8 @@ export function heartbeatService(
|
||||||
: null,
|
: null,
|
||||||
issueId,
|
issueId,
|
||||||
});
|
});
|
||||||
// One credential provider per run: base-ref refreshes during workspace realization and
|
// The run-scoped provider resolves the active identity at each Git operation,
|
||||||
// restore authenticate against private GitHub remotes with the same company-secret token
|
// including base-ref refreshes, workspace realization, and restore.
|
||||||
// the managed clone uses.
|
|
||||||
const workspaceGitAuthProvider = createGitRemoteAuthProvider(
|
const workspaceGitAuthProvider = createGitRemoteAuthProvider(
|
||||||
db,
|
db,
|
||||||
agent.companyId,
|
agent.companyId,
|
||||||
|
|
@ -19855,6 +19871,19 @@ export function heartbeatService(
|
||||||
} else {
|
} else {
|
||||||
delete context.paperclipScratch;
|
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 = {
|
context.paperclipEnvironment = {
|
||||||
id: selectedEnvironment.id,
|
id: selectedEnvironment.id,
|
||||||
name: selectedEnvironment.name,
|
name: selectedEnvironment.name,
|
||||||
|
|
@ -20336,20 +20365,8 @@ export function heartbeatService(
|
||||||
environmentDriver: selectedEnvironment.driver,
|
environmentDriver: selectedEnvironment.driver,
|
||||||
leaseMetadata: activeEnvironmentLease.lease.metadata,
|
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(
|
const adapterEnv = Object.fromEntries(
|
||||||
Object.entries(parseObject(resolvedConfig.env)).filter(
|
Object.entries(parseObject(runtimeConfig.env)).filter(
|
||||||
(entry): entry is [string, string] =>
|
(entry): entry is [string, string] =>
|
||||||
typeof entry[0] === "string" && typeof entry[1] === "string",
|
typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||||
),
|
),
|
||||||
|
|
@ -21355,77 +21372,104 @@ export function heartbeatService(
|
||||||
endedAtMs: nativeDispatchAtMs,
|
endedAtMs: nativeDispatchAtMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const guardedDispatch =
|
// Native Git/gh uses the same authenticated remote callback
|
||||||
await dispatchResolvedInteractionContinuationWithAtomicGate(
|
// transport as managed adapters. A bridge failure must not make
|
||||||
(markDispatchStarted) =>
|
// GitHub a prerequisite for otherwise unrelated native work.
|
||||||
executePaperclipNativeSession({
|
let nativeGitHubBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
||||||
db,
|
if (executionTarget?.kind === "remote" && adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN) {
|
||||||
execution: nativeExecution,
|
try {
|
||||||
runnerInstanceId: nativeRunnerInstanceId,
|
nativeGitHubBridge = await startAdapterExecutionTargetPaperclipBridge({
|
||||||
leaseOwner: runOptions.nativeLeaseOwner,
|
runId: run.id,
|
||||||
restartRecovery: runOptions.nativeRestartRecovery,
|
target: executionTarget,
|
||||||
backend:
|
runtimeRootDir: path.posix.join(executionTarget.remoteCwd, ".paperclip-runtime", "github", run.id),
|
||||||
options.nativeSessionBackendFactory?.(nativeExecution),
|
adapterKey: "native-github",
|
||||||
useRunnerd: agent.adapterType === "paperclip_runner",
|
hostApiToken: adapterEnv.PAPERCLIP_GITHUB_BROKER_TOKEN,
|
||||||
onLog,
|
hostApiUrl: adapterEnv.PAPERCLIP_GITHUB_BROKER_URL,
|
||||||
onEvent: onAdapterEvent,
|
onLog,
|
||||||
preparationSpans: nativeRunnerPreparationSpans,
|
});
|
||||||
// Bootstrap with executable/home discovery while keeping
|
} catch {
|
||||||
// configured provider values and the server-selected
|
await onLog("stderr", "[paperclip] GitHub runtime transport unavailable; continuing without managed GitHub access.\n");
|
||||||
// workspace boundary authoritative.
|
}
|
||||||
runnerEnvironment: {
|
}
|
||||||
...buildNativeProviderEnvironment(
|
try {
|
||||||
adapterEnv,
|
const guardedDispatch =
|
||||||
process.env,
|
await dispatchResolvedInteractionContinuationWithAtomicGate(
|
||||||
executionWorkspace.cwd,
|
(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
|
runnerPublicUrl:
|
||||||
? {
|
runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
|
||||||
PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
|
runnerCaBundlePath:
|
||||||
PAPERCLIP_NATIVE_MCP_URL: nativeMcpServer.url,
|
runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
|
||||||
PAPERCLIP_NATIVE_MCP_TOKEN: nativeMcpServer.token,
|
null,
|
||||||
}
|
runnerRemoteBinaryPath:
|
||||||
: {}),
|
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
|
||||||
...(providerTraceCapture
|
null,
|
||||||
? {
|
runnerRemoteCodexPath:
|
||||||
PAPERCLIP_PROVIDER_TRACE_PATH:
|
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim() ||
|
||||||
providerTraceCapture.path,
|
null,
|
||||||
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(
|
runnerRemoteCodexNpmSpec:
|
||||||
PROVIDER_TRACE_MAX_BYTES,
|
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() ||
|
||||||
),
|
null,
|
||||||
}
|
runnerRemoteProviderPackPath:
|
||||||
: {}),
|
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() ||
|
||||||
},
|
null,
|
||||||
runnerExecutionTarget: executionTarget,
|
enqueueWakeup,
|
||||||
runnerIngressAuthorized: isRunnerIngressAuthorized(
|
onSpawn: async (meta) => {
|
||||||
nativeRuntimeResolution,
|
markDispatchStarted();
|
||||||
),
|
await persistRunProcessMetadata(run.id, meta);
|
||||||
runnerPublicUrl:
|
},
|
||||||
runtimeEnv.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
|
}),
|
||||||
runnerCaBundlePath:
|
);
|
||||||
runtimeEnv.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() ||
|
if (!guardedDispatch.dispatched) return;
|
||||||
null,
|
adapterResult = await guardedDispatch.resultPromise;
|
||||||
runnerRemoteBinaryPath:
|
} finally {
|
||||||
runtimeEnv.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() ||
|
await nativeGitHubBridge?.stop();
|
||||||
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;
|
|
||||||
} else {
|
} else {
|
||||||
const interactionId = readNonEmptyString(context.interactionId);
|
const interactionId = readNonEmptyString(context.interactionId);
|
||||||
const legacyQuestionResponse =
|
const legacyQuestionResponse =
|
||||||
|
|
@ -22820,6 +22864,13 @@ export function heartbeatService(
|
||||||
latestRun?.status,
|
latestRun?.status,
|
||||||
);
|
);
|
||||||
if (!nativeSessionResumeScheduled && !nativeWorkspaceFinalizeScheduled) {
|
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({
|
await releaseEnvironmentLeasesForRun({
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
companyId: run.companyId,
|
companyId: run.companyId,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
companies,
|
companies,
|
||||||
documents,
|
documents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
runIdentityContexts,
|
||||||
issueComments,
|
issueComments,
|
||||||
issueDocuments,
|
issueDocuments,
|
||||||
issueQuestionResponseDeliveries,
|
issueQuestionResponseDeliveries,
|
||||||
|
|
@ -101,6 +102,7 @@ export { extractGitHubPullRequestReferences } from "./github-pull-request-merge.
|
||||||
export type { GitHubPullRequestReference } from "./github-pull-request-merge.js";
|
export type { GitHubPullRequestReference } from "./github-pull-request-merge.js";
|
||||||
|
|
||||||
type InteractionActor = {
|
type InteractionActor = {
|
||||||
|
identityContextId?: string | null;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
|
@ -2707,10 +2709,12 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sourceIdentityContextId: string | null = null;
|
||||||
if (data.sourceRunId) {
|
if (data.sourceRunId) {
|
||||||
const sourceRun = await db
|
const sourceRun = await db
|
||||||
.select({
|
.select({
|
||||||
companyId: heartbeatRuns.companyId,
|
companyId: heartbeatRuns.companyId,
|
||||||
|
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
|
||||||
})
|
})
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(eq(heartbeatRuns.id, data.sourceRunId))
|
.where(eq(heartbeatRuns.id, data.sourceRunId))
|
||||||
|
|
@ -2718,6 +2722,14 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
||||||
if (!sourceRun || sourceRun.companyId !== issue.companyId) {
|
if (!sourceRun || sourceRun.companyId !== issue.companyId) {
|
||||||
throw unprocessable("sourceRunId must belong to the same company");
|
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 =
|
const requiresCurrentTarget =
|
||||||
|
|
@ -2770,6 +2782,7 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
||||||
idempotencyKey: data.idempotencyKey ?? null,
|
idempotencyKey: data.idempotencyKey ?? null,
|
||||||
sourceCommentId: data.sourceCommentId ?? null,
|
sourceCommentId: data.sourceCommentId ?? null,
|
||||||
sourceRunId: data.sourceRunId ?? null,
|
sourceRunId: data.sourceRunId ?? null,
|
||||||
|
sourceIdentityContextId,
|
||||||
title: data.title ?? null,
|
title: data.title ?? null,
|
||||||
summary: data.summary ?? null,
|
summary: data.summary ?? null,
|
||||||
createdByAgentId: actor.agentId ?? null,
|
createdByAgentId: actor.agentId ?? null,
|
||||||
|
|
@ -3018,6 +3031,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
|
||||||
billingCode: task.billingCode ?? null,
|
billingCode: task.billingCode ?? null,
|
||||||
createdByAgentId: actor.agentId ?? null,
|
createdByAgentId: actor.agentId ?? null,
|
||||||
createdByUserId: actor.userId ?? null,
|
createdByUserId: actor.userId ?? null,
|
||||||
|
originIdentityContextId: interaction.sourceIdentityContextId ?? null,
|
||||||
|
originRunId: interaction.sourceRunId ?? null,
|
||||||
actorAgentId: actor.agentId ?? null,
|
actorAgentId: actor.agentId ?? null,
|
||||||
actorUserId: actor.userId ?? null,
|
actorUserId: actor.userId ?? null,
|
||||||
} as Parameters<ReturnType<typeof issueService>["createChild"]>[1]);
|
} as Parameters<ReturnType<typeof issueService>["createChild"]>[1]);
|
||||||
|
|
|
||||||
|
|
@ -3266,6 +3266,8 @@ const issueListSelect = {
|
||||||
originKind: issues.originKind,
|
originKind: issues.originKind,
|
||||||
originId: issues.originId,
|
originId: issues.originId,
|
||||||
originRunId: issues.originRunId,
|
originRunId: issues.originRunId,
|
||||||
|
originIdentityContextId: issues.originIdentityContextId,
|
||||||
|
continuationIdentityContextId: issues.continuationIdentityContextId,
|
||||||
originFingerprint: issues.originFingerprint,
|
originFingerprint: issues.originFingerprint,
|
||||||
requestDepth: issues.requestDepth,
|
requestDepth: issues.requestDepth,
|
||||||
billingCode: issues.billingCode,
|
billingCode: issues.billingCode,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
mkdir,
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
readdir,
|
readdir,
|
||||||
|
readFile,
|
||||||
rm,
|
rm,
|
||||||
symlink,
|
symlink,
|
||||||
writeFile,
|
writeFile,
|
||||||
|
|
@ -180,6 +181,7 @@ import {
|
||||||
nativeSessionRecoveryProjection,
|
nativeSessionRecoveryProjection,
|
||||||
nativeGovernedWaitResult,
|
nativeGovernedWaitResult,
|
||||||
parseRemoteExecutableCandidate,
|
parseRemoteExecutableCandidate,
|
||||||
|
buildRemoteCodexLauncherCommand,
|
||||||
mayUsePreinstalledRunnerArtifact,
|
mayUsePreinstalledRunnerArtifact,
|
||||||
nativeUsageCostUsd,
|
nativeUsageCostUsd,
|
||||||
normalizeNativeUsage,
|
normalizeNativeUsage,
|
||||||
|
|
@ -1601,6 +1603,32 @@ describe("remote provider checkpoint restores", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("remote preinstalled executable discovery", () => {
|
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", () => {
|
it("accepts one normalized absolute executable path", () => {
|
||||||
expect(
|
expect(
|
||||||
parseRemoteExecutableCandidate(
|
parseRemoteExecutableCandidate(
|
||||||
|
|
@ -3319,7 +3347,7 @@ describe("native warm session supervision", () => {
|
||||||
expect(close).not.toHaveBeenCalled();
|
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(
|
const stateBase = await mkdtemp(
|
||||||
join(tmpdir(), "paperclip-runnerd-warm-authority-"),
|
join(tmpdir(), "paperclip-runnerd-warm-authority-"),
|
||||||
);
|
);
|
||||||
|
|
@ -3391,8 +3419,13 @@ describe("native warm session supervision", () => {
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
.mockImplementationOnce(async (options) => {
|
.mockImplementationOnce(async (options) => {
|
||||||
expect(options.existingSession).toBe(firstSession);
|
if (useBroker) {
|
||||||
expect(options.persistedSession).toBeUndefined();
|
expect(options.existingSession).toBeUndefined();
|
||||||
|
expect(options.persistedSession?.providerSessionId).toBe("provider-runnerd-warm");
|
||||||
|
} else {
|
||||||
|
expect(options.existingSession).toBe(firstSession);
|
||||||
|
expect(options.persistedSession).toBeUndefined();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3400,6 +3433,7 @@ describe("native warm session supervision", () => {
|
||||||
await executePaperclipNativeSession({
|
await executePaperclipNativeSession({
|
||||||
db: leaseDb(first),
|
db: leaseDb(first),
|
||||||
execution: first,
|
execution: first,
|
||||||
|
runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "first-run-capability" } : undefined,
|
||||||
runnerInstanceId: "runner-runnerd-warm",
|
runnerInstanceId: "runner-runnerd-warm",
|
||||||
useRunnerd: true,
|
useRunnerd: true,
|
||||||
runnerExecutionTarget: remoteTarget,
|
runnerExecutionTarget: remoteTarget,
|
||||||
|
|
@ -3441,20 +3475,26 @@ describe("native warm session supervision", () => {
|
||||||
await executePaperclipNativeSession({
|
await executePaperclipNativeSession({
|
||||||
db: continuationDb,
|
db: continuationDb,
|
||||||
execution: second,
|
execution: second,
|
||||||
|
runnerEnvironment: useBroker ? { PAPERCLIP_GITHUB_BROKER_TOKEN: "second-run-capability" } : undefined,
|
||||||
runnerInstanceId: "runner-runnerd-warm",
|
runnerInstanceId: "runner-runnerd-warm",
|
||||||
useRunnerd: true,
|
useRunnerd: true,
|
||||||
runnerExecutionTarget: remoteTarget,
|
runnerExecutionTarget: remoteTarget,
|
||||||
});
|
});
|
||||||
expect(firstClose).not.toHaveBeenCalled();
|
if (useBroker) {
|
||||||
await vi.waitFor(
|
expect(firstClose).toHaveBeenCalledOnce();
|
||||||
() =>
|
expect(firstClose).toHaveBeenCalledWith({ reason: "warm native session configuration changed" });
|
||||||
expect(firstClose).toHaveBeenCalledWith({
|
} else {
|
||||||
reason: "warm native session idle timeout",
|
expect(firstClose).not.toHaveBeenCalled();
|
||||||
}),
|
await vi.waitFor(
|
||||||
{
|
() =>
|
||||||
timeout: 1_500,
|
expect(firstClose).toHaveBeenCalledWith({
|
||||||
},
|
reason: "warm native session idle timeout",
|
||||||
);
|
}),
|
||||||
|
{
|
||||||
|
timeout: 1_500,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (previousStateDirectory === undefined) {
|
if (previousStateDirectory === undefined) {
|
||||||
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
delete process.env.PAPERCLIP_RUNNER_STATE_DIR;
|
||||||
|
|
@ -4013,6 +4053,11 @@ describe("runnerd provider runtime wiring", () => {
|
||||||
useRunnerd: true,
|
useRunnerd: true,
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
|
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(
|
await expect(
|
||||||
executePaperclipNativeSession({
|
executePaperclipNativeSession({
|
||||||
db: leaseDb(second),
|
db: leaseDb(second),
|
||||||
|
|
@ -6040,7 +6085,7 @@ describe("runnerd provider runtime wiring", () => {
|
||||||
stdout = script.includes("/opt/paperclip-runner/bin/codex")
|
stdout = script.includes("/opt/paperclip-runner/bin/codex")
|
||||||
? "/opt/paperclip-runner/bin/codex\n"
|
? "/opt/paperclip-runner/bin/codex\n"
|
||||||
: "/usr/local/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}`);
|
throw new Error(`unexpected command: ${command.command}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,7 @@ function clearNativeRuntimeRequestResolutions(runId: string): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WarmNativeSession = {
|
type WarmNativeSession = {
|
||||||
|
credentialRunId?: string;
|
||||||
session: NativeSession;
|
session: NativeSession;
|
||||||
ownerToken: symbol;
|
ownerToken: symbol;
|
||||||
configDigest: string;
|
configDigest: string;
|
||||||
|
|
@ -1880,6 +1881,8 @@ function hasIdleWarmNativeSessionOwner(input: {
|
||||||
if (input.execution.session.lifecyclePolicy.mode !== "warm") return false;
|
if (input.execution.session.lifecyclePolicy.mode !== "warm") return false;
|
||||||
const entry = warmNativeSessions.get(nativeSessionScopeKey(input.execution));
|
const entry = warmNativeSessions.get(nativeSessionScopeKey(input.execution));
|
||||||
if (!entry || entry.busy) return false;
|
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 =
|
const environmentId =
|
||||||
input.runnerExecutionTarget?.kind === "remote"
|
input.runnerExecutionTarget?.kind === "remote"
|
||||||
? (input.runnerExecutionTarget.environmentId ?? null)
|
? (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. */
|
/** Dispatches a true same-turn steering message and resolves only after ack. */
|
||||||
export async function steerNativeSession(input: {
|
export async function steerNativeSession(input: {
|
||||||
runId: string;
|
runId: string;
|
||||||
message: string;
|
message: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
onAcknowledged?: () => Promise<void>;
|
||||||
}): Promise<{ turnId: string }> {
|
}): Promise<{ turnId: string }> {
|
||||||
const active = activeNativeSessions.get(input.runId);
|
const active = activeNativeSessions.get(input.runId);
|
||||||
if (!active) {
|
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;
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
const acknowledged = await Promise.race([
|
||||||
active.session.steer({
|
delivery,
|
||||||
turnId,
|
|
||||||
message: { role: "user", text: input.message },
|
|
||||||
correlationId: input.correlationId,
|
|
||||||
}),
|
|
||||||
new Promise<never>((_resolve, reject) => {
|
new Promise<never>((_resolve, reject) => {
|
||||||
timeout = setTimeout(
|
timeout = setTimeout(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -2975,7 +2996,7 @@ export async function steerNativeSession(input: {
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
return { turnId };
|
return acknowledged;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NativeSessionSteeringError) throw error;
|
if (error instanceof NativeSessionSteeringError) throw error;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
@ -4267,7 +4288,11 @@ async function executePaperclipNativeSessionWithinScope(
|
||||||
if (warmSessionId !== null && warmConfigDigest !== null) {
|
if (warmSessionId !== null && warmConfigDigest !== null) {
|
||||||
const entry = warmNativeSessions.get(warmSessionId);
|
const entry = warmNativeSessions.get(warmSessionId);
|
||||||
if (entry) {
|
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.busy) throw new Error("native_session_supervisor_busy");
|
||||||
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
if (entry.idleTimer !== null) clearTimeout(entry.idleTimer);
|
||||||
warmNativeSessions.delete(warmSessionId);
|
warmNativeSessions.delete(warmSessionId);
|
||||||
|
|
@ -4424,9 +4449,9 @@ async function executePaperclipNativeSessionWithinScope(
|
||||||
warmSessionId === null
|
warmSessionId === null
|
||||||
? undefined
|
? undefined
|
||||||
: NATIVE_WARM_SEMANTIC_RESULT_TERMINAL_GRACE_MS,
|
: NATIVE_WARM_SEMANTIC_RESULT_TERMINAL_GRACE_MS,
|
||||||
requireSessionCloseBeforeReturn:
|
// Every durable runner must finish its bounded suspension before
|
||||||
runnerdBackend !== null &&
|
// the next run verifies and rotates the saved authority.
|
||||||
input.runnerExecutionTarget?.kind === "remote",
|
requireSessionCloseBeforeReturn: runnerdBackend !== null,
|
||||||
onCheckpoint:
|
onCheckpoint:
|
||||||
warmSessionId !== null && warmConfigDigest !== null
|
warmSessionId !== null && warmConfigDigest !== null
|
||||||
? async (snapshot) =>
|
? async (snapshot) =>
|
||||||
|
|
@ -4489,6 +4514,8 @@ async function executePaperclipNativeSessionWithinScope(
|
||||||
existing.session = session;
|
existing.session = session;
|
||||||
} else
|
} else
|
||||||
warmNativeSessions.set(warmSessionId, {
|
warmNativeSessions.set(warmSessionId, {
|
||||||
|
credentialRunId: input.runnerEnvironment?.PAPERCLIP_GITHUB_BROKER_TOKEN
|
||||||
|
? input.execution.binding.runId : undefined,
|
||||||
session,
|
session,
|
||||||
ownerToken: warmSessionOwnerToken,
|
ownerToken: warmSessionOwnerToken,
|
||||||
configDigest: warmConfigDigest,
|
configDigest: warmConfigDigest,
|
||||||
|
|
@ -4518,6 +4545,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
activeNativeSessions.delete(input.execution.binding.runId);
|
activeNativeSessions.delete(input.execution.binding.runId);
|
||||||
|
clearSteeringDeliveries(input.execution.binding.runId);
|
||||||
clearNativeRuntimeRequestResolutions(
|
clearNativeRuntimeRequestResolutions(
|
||||||
input.execution.binding.runId,
|
input.execution.binding.runId,
|
||||||
);
|
);
|
||||||
|
|
@ -4541,6 +4569,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||||
endedAtMs: Date.now(),
|
endedAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
activeNativeSessions.delete(input.execution.binding.runId);
|
activeNativeSessions.delete(input.execution.binding.runId);
|
||||||
|
clearSteeringDeliveries(input.execution.binding.runId);
|
||||||
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
|
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await leaseRenewal.stop().catch(() => undefined);
|
await leaseRenewal.stop().catch(() => undefined);
|
||||||
|
|
@ -4572,6 +4601,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||||
}
|
}
|
||||||
trace.activate(taskSettleScope);
|
trace.activate(taskSettleScope);
|
||||||
activeNativeSessions.delete(input.execution.binding.runId);
|
activeNativeSessions.delete(input.execution.binding.runId);
|
||||||
|
clearSteeringDeliveries(input.execution.binding.runId);
|
||||||
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
|
clearNativeRuntimeRequestResolutions(input.execution.binding.runId);
|
||||||
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
|
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
|
||||||
await releaseWarmNativeSession(
|
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 {
|
export function parseRemoteExecutableCandidate(stdout: string): string | null {
|
||||||
const lines = stdout
|
const lines = stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|
@ -6498,8 +6544,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||||
command: "sh",
|
command: "sh",
|
||||||
args: [
|
args: [
|
||||||
"-c",
|
"-c",
|
||||||
`umask 077; mkdir -p '${escapedDirectory}' && ` +
|
targetPath === remoteCodexBinary
|
||||||
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
|
? buildRemoteCodexLauncherCommand(sourcePath, targetPath)
|
||||||
|
: `umask 077; mkdir -p '${escapedDirectory}' && ` +
|
||||||
|
`ln -sfn '${escapedSource}' '${escapedTarget}'`,
|
||||||
],
|
],
|
||||||
cwd: remoteTarget.remoteCwd,
|
cwd: remoteTarget.remoteCwd,
|
||||||
bypassSession: true,
|
bypassSession: true,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { activityLog, agents, approvals, companies, createDb, documents, heartbeatRuns, issueApprovals, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
import { activityLog, agents, approvals, companies, createDb, documents, heartbeatRuns, issueApprovals, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
||||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||||
|
import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js";
|
||||||
import { documentService } from "../documents.js";
|
import { documentService } from "../documents.js";
|
||||||
import { issueService } from "../issues.js";
|
import { issueService } from "../issues.js";
|
||||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||||
|
|
@ -603,6 +604,46 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||||
.toHaveLength(0);
|
.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 () => {
|
it("fails closed once the run is no longer active", async () => {
|
||||||
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
|
||||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { documentService } from "../documents.js";
|
||||||
import { issueService } from "../issues.js";
|
import { issueService } from "../issues.js";
|
||||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||||
import { persistActivity, publishActivity } from "../activity-log.js";
|
import { persistActivity, publishActivity } from "../activity-log.js";
|
||||||
|
import { captureRunIdentity } from "../run-identity.js";
|
||||||
|
|
||||||
const IMPLEMENTED_OPERATIONS = new Set([
|
const IMPLEMENTED_OPERATIONS = new Set([
|
||||||
"search_api", "call_api",
|
"search_api", "call_api",
|
||||||
|
|
@ -189,8 +190,10 @@ export class PaperclipRunnerToolAuthority {
|
||||||
return { approval, tasks: tasks.map((row) => row.issue) };
|
return { approval, tasks: tasks.map((row) => row.issue) };
|
||||||
}
|
}
|
||||||
case "report_progress": return this.#reportProgress(input);
|
case "report_progress": return this.#reportProgress(input);
|
||||||
case "request_human_input": return this.#requestHumanInput(input);
|
case "request_human_input": return this.#requestHumanInput(input,
|
||||||
case "create_task": return this.#createTask(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);
|
case "set_dependencies": return this.#setDependencies(input);
|
||||||
default: throw new Error("paperclip_runner_tool_not_bound");
|
default: throw new Error("paperclip_runner_tool_not_bound");
|
||||||
}
|
}
|
||||||
|
|
@ -427,7 +430,7 @@ export class PaperclipRunnerToolAuthority {
|
||||||
return result;
|
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 idempotencyKey = requiredString(input.idempotencyKey);
|
||||||
const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined
|
const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined
|
||||||
? this.binding.agentId
|
? this.binding.agentId
|
||||||
|
|
@ -489,6 +492,9 @@ export class PaperclipRunnerToolAuthority {
|
||||||
createdByAgentId: this.binding.agentId,
|
createdByAgentId: this.binding.agentId,
|
||||||
originKind: "manual",
|
originKind: "manual",
|
||||||
originId: durableIdempotencyKey,
|
originId: durableIdempotencyKey,
|
||||||
|
originRunId: this.binding.runId,
|
||||||
|
originIdentityContextId: identityContextId,
|
||||||
|
continuationIdentityContextId: identityContextId,
|
||||||
originFingerprint: inputFingerprint,
|
originFingerprint: inputFingerprint,
|
||||||
actorAgentId: this.binding.agentId,
|
actorAgentId: this.binding.agentId,
|
||||||
actorRunId: this.binding.runId,
|
actorRunId: this.binding.runId,
|
||||||
|
|
@ -693,6 +699,10 @@ export class PaperclipRunnerToolAuthority {
|
||||||
issue: typeof issues.$inferSelect;
|
issue: typeof issues.$inferSelect;
|
||||||
actor: typeof agents.$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
|
// Authorization for writes is intentionally re-read only after the
|
||||||
// transaction starts. Locking the run and issue in the same statement
|
// transaction starts. Locking the run and issue in the same statement
|
||||||
// closes the gap between the discovery-time check and the mutation: a
|
// closes the gap between the discovery-time check and the mutation: a
|
||||||
|
|
@ -733,7 +743,7 @@ export class PaperclipRunnerToolAuthority {
|
||||||
return context;
|
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 interactionKind = requiredString(input.interactionKind);
|
||||||
const interactionKinds = {
|
const interactionKinds = {
|
||||||
confirmation: "request_confirmation",
|
confirmation: "request_confirmation",
|
||||||
|
|
@ -802,7 +812,7 @@ export class PaperclipRunnerToolAuthority {
|
||||||
supersedeOnUserComment: normalizedPayload.supersedeOnUserComment ?? true,
|
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, {
|
const activity = await persistActivity(tx, {
|
||||||
companyId: this.binding.companyId,
|
companyId: this.binding.companyId,
|
||||||
actorType: "agent",
|
actorType: "agent",
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
const temporaryRoots: string[] = [];
|
||||||
let previousPaperclipHome: string | undefined;
|
let previousPaperclipHome: string | undefined;
|
||||||
|
|
@ -297,3 +298,26 @@ describe("buildNativeRuntimeContext", () => {
|
||||||
expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]);
|
expect(context.skills.map((skill) => skill.key)).toEqual(["company-1/supported"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("pins both permitted GitHub tool catalogs even when another user’s 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();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||||
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
|
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
|
||||||
});
|
});
|
||||||
const [runIdentity] = hasGitHubConnection
|
const [runIdentity] = hasGitHubConnection
|
||||||
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(heartbeatRuns.id, input.runId),
|
eq(heartbeatRuns.id, input.runId),
|
||||||
|
|
@ -187,7 +187,10 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||||
))
|
))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
: [];
|
: [];
|
||||||
const resolvedInstalledConnections = await filterResolvedGitHubConnectionsForRun({
|
// Broker runs pin the permitted tool catalog, not one person’s credential.
|
||||||
|
// Selection happens at each operation and can change through steering.
|
||||||
|
const resolvedInstalledConnections = runIdentity?.activeIdentityContextId
|
||||||
|
? effective.installedConnections : await filterResolvedGitHubConnectionsForRun({
|
||||||
db: input.db,
|
db: input.db,
|
||||||
companyId: input.agent.companyId,
|
companyId: input.agent.companyId,
|
||||||
agentId: input.agent.id,
|
agentId: input.agent.id,
|
||||||
|
|
@ -200,7 +203,8 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||||
permitted.has(connection.id)
|
permitted.has(connection.id)
|
||||||
&& connection.status === "active"
|
&& connection.status === "active"
|
||||||
&& connection.enabled
|
&& 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)
|
&& ["mcp_remote", "local_stdio"].includes(connection.transport)
|
||||||
).map((connection) => connection.id));
|
).map((connection) => connection.id));
|
||||||
const assignment = {
|
const assignment = {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { captureRunIdentity } from "./run-identity.js";
|
||||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, max, ne, sql } from "drizzle-orm";
|
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 }) {
|
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) {
|
if (!run || run.companyId !== input.companyId || run.agentId !== input.agentId) {
|
||||||
throw forbidden("Agent run context does not match the authenticated actor");
|
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 snapshot = asRecord(run.contextSnapshot);
|
||||||
const paperclipIssue = asRecord(snapshot.paperclipIssue);
|
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")
|
?? runSnapshotString(paperclipIssue, "responsibleUserId", "responsible_user_id")
|
||||||
?? run.responsibleUserId;
|
?? run.responsibleUserId;
|
||||||
if (!responsibleUserId) {
|
if (!responsibleUserId) {
|
||||||
|
|
@ -4195,6 +4197,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||||
eq(companySecretBindings.targetId, connection.id),
|
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 = [
|
const rawBindings = [
|
||||||
...connection.credentialRefs.map((ref) => ({
|
...connection.credentialRefs.map((ref) => ({
|
||||||
secretId: ref.secretId,
|
secretId: ref.secretId,
|
||||||
|
|
@ -4204,7 +4214,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||||
required: true,
|
required: true,
|
||||||
label: null,
|
label: null,
|
||||||
})),
|
})),
|
||||||
...[...connection.credentialSecretRefs, ...grantSecretRefs].map((ref) => ({
|
...[...connection.credentialSecretRefs, ...grantSecretRefs, ...activeGrants.flatMap((grant) => grant.refs)].map((ref) => ({
|
||||||
secretId: ref.secretId,
|
secretId: ref.secretId,
|
||||||
configPath: ref.configPath,
|
configPath: ref.configPath,
|
||||||
projectionClass: ref.projectionClass ?? "unclassified",
|
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;
|
const requestedSubject = input.body.subject;
|
||||||
if (requestedSubject?.type === "user" && requestedSubject.userId !== runContext.responsibleUserId) {
|
if (requestedSubject?.type === "user" && requestedSubject.userId !== runContext.responsibleUserId) {
|
||||||
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {
|
await fail(403, "The agent run cannot act as the requested user", "denied", "subject_not_permitted", {
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export function signToolArguments(args: {
|
||||||
canonicalArguments: string;
|
canonicalArguments: string;
|
||||||
approvalSnapshot?: unknown;
|
approvalSnapshot?: unknown;
|
||||||
executionOnApprove?: boolean;
|
executionOnApprove?: boolean;
|
||||||
|
identityContextId?: string;
|
||||||
signingSecret?: string;
|
signingSecret?: string;
|
||||||
}) {
|
}) {
|
||||||
const payloadValue: Record<string, unknown> = {
|
const payloadValue: Record<string, unknown> = {
|
||||||
|
|
@ -93,6 +94,7 @@ export function signToolArguments(args: {
|
||||||
toolName: args.toolName,
|
toolName: args.toolName,
|
||||||
canonicalArguments: args.canonicalArguments,
|
canonicalArguments: args.canonicalArguments,
|
||||||
};
|
};
|
||||||
|
if (args.identityContextId) payloadValue.identityContextId = args.identityContextId;
|
||||||
if (args.executionOnApprove === true) {
|
if (args.executionOnApprove === true) {
|
||||||
payloadValue.executionOnApprove = true;
|
payloadValue.executionOnApprove = true;
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +113,7 @@ export function verifyToolArgumentsSignature(input: {
|
||||||
canonicalArguments: string;
|
canonicalArguments: string;
|
||||||
approvalSnapshot?: unknown;
|
approvalSnapshot?: unknown;
|
||||||
executionOnApprove?: boolean;
|
executionOnApprove?: boolean;
|
||||||
|
identityContextId?: string;
|
||||||
signingSecret?: string;
|
signingSecret?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!input.signedArguments) return false;
|
if (!input.signedArguments) return false;
|
||||||
|
|
@ -127,6 +130,7 @@ export function verifyToolArgumentsSignature(input: {
|
||||||
toolName: input.toolName,
|
toolName: input.toolName,
|
||||||
canonicalArguments: input.canonicalArguments,
|
canonicalArguments: input.canonicalArguments,
|
||||||
};
|
};
|
||||||
|
if (input.identityContextId) expectedPayloadValue.identityContextId = input.identityContextId;
|
||||||
if (input.executionOnApprove !== undefined) {
|
if (input.executionOnApprove !== undefined) {
|
||||||
expectedPayloadValue.executionOnApprove = input.executionOnApprove;
|
expectedPayloadValue.executionOnApprove = input.executionOnApprove;
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +150,7 @@ export function readSignedToolArgumentsPayload(input: {
|
||||||
invocationId: string;
|
invocationId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
signingSecret?: string;
|
signingSecret?: string;
|
||||||
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean } | null {
|
}): { arguments: unknown; approvalSnapshot?: unknown; executionOnApprove?: boolean; identityContextId?: string } | null {
|
||||||
if (!input.signedArguments) return null;
|
if (!input.signedArguments) return null;
|
||||||
let parsed: { payload?: unknown };
|
let parsed: { payload?: unknown };
|
||||||
try {
|
try {
|
||||||
|
|
@ -161,6 +165,7 @@ export function readSignedToolArgumentsPayload(input: {
|
||||||
canonicalArguments?: unknown;
|
canonicalArguments?: unknown;
|
||||||
approvalSnapshot?: unknown;
|
approvalSnapshot?: unknown;
|
||||||
executionOnApprove?: unknown;
|
executionOnApprove?: unknown;
|
||||||
|
identityContextId?: unknown;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(parsed.payload);
|
payload = JSON.parse(parsed.payload);
|
||||||
|
|
@ -176,6 +181,7 @@ export function readSignedToolArgumentsPayload(input: {
|
||||||
canonicalArguments: payload.canonicalArguments,
|
canonicalArguments: payload.canonicalArguments,
|
||||||
approvalSnapshot: payload.approvalSnapshot,
|
approvalSnapshot: payload.approvalSnapshot,
|
||||||
executionOnApprove: payload.executionOnApprove === true ? true : undefined,
|
executionOnApprove: payload.executionOnApprove === true ? true : undefined,
|
||||||
|
identityContextId: typeof payload.identityContextId === "string" ? payload.identityContextId : undefined,
|
||||||
signingSecret: input.signingSecret,
|
signingSecret: input.signingSecret,
|
||||||
})) {
|
})) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -185,6 +191,7 @@ export function readSignedToolArgumentsPayload(input: {
|
||||||
arguments: JSON.parse(payload.canonicalArguments) as unknown,
|
arguments: JSON.parse(payload.canonicalArguments) as unknown,
|
||||||
...(payload.approvalSnapshot !== undefined ? { approvalSnapshot: payload.approvalSnapshot } : {}),
|
...(payload.approvalSnapshot !== undefined ? { approvalSnapshot: payload.approvalSnapshot } : {}),
|
||||||
...(payload.executionOnApprove === true ? { executionOnApprove: true } : {}),
|
...(payload.executionOnApprove === true ? { executionOnApprove: true } : {}),
|
||||||
|
...(typeof payload.identityContextId === "string" ? { identityContextId: payload.identityContextId } : {}),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -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 { spawn } from "node:child_process";
|
||||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||||
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||||
|
|
@ -257,6 +260,8 @@ export interface ToolGatewaySession {
|
||||||
actorId?: string | null;
|
actorId?: string | null;
|
||||||
/** Human whose personal connection grant applies to this execution. */
|
/** Human whose personal connection grant applies to this execution. */
|
||||||
responsibleUserId?: string | null;
|
responsibleUserId?: string | null;
|
||||||
|
/** Captured by the controller for this request, never accepted from tool arguments. */
|
||||||
|
identityContextId?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
expiresAt: Date;
|
expiresAt: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -1306,6 +1311,7 @@ export function createToolGatewayService(
|
||||||
projectId: input.session?.projectId ?? null,
|
projectId: input.session?.projectId ?? null,
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
gatewaySessionId: input.session?.id ?? null,
|
gatewaySessionId: input.session?.id ?? null,
|
||||||
|
identityContextId: input.session?.identityContextId ?? null,
|
||||||
gatewayId: input.session?.gatewayId ?? null,
|
gatewayId: input.session?.gatewayId ?? null,
|
||||||
gatewayPublicId: input.session?.gatewayPublicId ?? null,
|
gatewayPublicId: input.session?.gatewayPublicId ?? null,
|
||||||
gatewayName: input.session?.gatewayName ?? null,
|
gatewayName: input.session?.gatewayName ?? null,
|
||||||
|
|
@ -1450,7 +1456,21 @@ export function createToolGatewayService(
|
||||||
.set({ lastUsedAt: now, updatedAt: now })
|
.set({ lastUsedAt: now, updatedAt: now })
|
||||||
.where(eq(toolGatewaySessions.id, row.id));
|
.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[] {
|
function normalizeGatewayTokenActions(value: unknown): ToolMcpGatewayTokenAction[] {
|
||||||
|
|
@ -1523,9 +1543,10 @@ export function createToolGatewayService(
|
||||||
resultHash: input.resultSummary?.sha256 ?? null,
|
resultHash: input.resultSummary?.sha256 ?? null,
|
||||||
resultSummary: input.resultSummary ?? null,
|
resultSummary: input.resultSummary ?? null,
|
||||||
resultSizeBytes: input.resultSummary?.sizeBytes ?? 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,
|
...metadata,
|
||||||
|
identityContextId: input.session.identityContextId ?? null,
|
||||||
gatewayId: input.session.gatewayId ?? null,
|
gatewayId: input.session.gatewayId ?? null,
|
||||||
gatewayName: input.session.gatewayName ?? null,
|
gatewayName: input.session.gatewayName ?? null,
|
||||||
projectId: input.session.projectId ?? null,
|
projectId: input.session.projectId ?? null,
|
||||||
|
|
@ -1709,6 +1730,7 @@ export function createToolGatewayService(
|
||||||
canonicalArguments,
|
canonicalArguments,
|
||||||
approvalSnapshot: approvalSnapshot ?? undefined,
|
approvalSnapshot: approvalSnapshot ?? undefined,
|
||||||
executionOnApprove: true,
|
executionOnApprove: true,
|
||||||
|
identityContextId: input.session.identityContextId ?? undefined,
|
||||||
signingSecret: options.toolActionSigningSecret,
|
signingSecret: options.toolActionSigningSecret,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2017,6 +2039,21 @@ export function createToolGatewayService(
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new ToolGatewayHttpError(404, `Tool "${toolName}" not found`, "tool_not_found", { tool: toolName });
|
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;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2773,6 +2810,28 @@ export function createToolGatewayService(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveCredentialHeaders(
|
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,
|
session: ToolGatewaySession,
|
||||||
connection: typeof toolConnections.$inferSelect,
|
connection: typeof toolConnections.$inferSelect,
|
||||||
grant: typeof connectionGrants.$inferSelect,
|
grant: typeof connectionGrants.$inferSelect,
|
||||||
|
|
@ -2913,7 +2972,10 @@ export function createToolGatewayService(
|
||||||
connection,
|
connection,
|
||||||
grant,
|
grant,
|
||||||
grantRef,
|
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}`;
|
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -3114,7 +3176,7 @@ export function createToolGatewayService(
|
||||||
session: ToolGatewaySession,
|
session: ToolGatewaySession,
|
||||||
connection: typeof toolConnections.$inferSelect,
|
connection: typeof toolConnections.$inferSelect,
|
||||||
): Promise<typeof connectionGrants.$inferSelect> {
|
): Promise<typeof connectionGrants.$inferSelect> {
|
||||||
const [run] = session.runId
|
const [run] = session.runId && !session.identityContextId
|
||||||
? await db.select({
|
? await db.select({
|
||||||
responsibleUserId: heartbeatRuns.responsibleUserId,
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
||||||
invocationSource: heartbeatRuns.invocationSource,
|
invocationSource: heartbeatRuns.invocationSource,
|
||||||
|
|
@ -3123,8 +3185,30 @@ export function createToolGatewayService(
|
||||||
eq(heartbeatRuns.companyId, session.companyId),
|
eq(heartbeatRuns.companyId, session.companyId),
|
||||||
)).limit(1)
|
)).limit(1)
|
||||||
: [];
|
: [];
|
||||||
const actingUserId = run?.responsibleUserId ?? session.responsibleUserId ?? null;
|
const actingUserId = session.identityContextId ? session.responsibleUserId ?? null : run?.responsibleUserId ?? session.responsibleUserId ?? null;
|
||||||
const autonomous = run?.invocationSource === "automation" || run?.invocationSource === "timer";
|
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 () => {
|
const findUserGrant = async () => {
|
||||||
if (!actingUserId) return undefined;
|
if (!actingUserId) return undefined;
|
||||||
const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and(
|
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),
|
expiresAt: row.token.expiresAt ?? new Date(Date.now() + 3650 * 24 * 60 * 60 * 1000),
|
||||||
};
|
};
|
||||||
await assertNamedGatewayProtocolLimit(session, input.protocolMethod, clientMetadata);
|
await assertNamedGatewayProtocolLimit(session, input.protocolMethod, clientMetadata);
|
||||||
return session;
|
return captureSessionIdentity(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -5510,6 +5594,17 @@ export function createToolGatewayService(
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
expiresAt: new Date(Date.now() + DEFAULT_SESSION_TTL_MS),
|
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 tool: ToolGatewayDescriptor;
|
||||||
let liveApprovalSnapshot: Awaited<ReturnType<typeof connectedRemoteApprovalSnapshot>>;
|
let liveApprovalSnapshot: Awaited<ReturnType<typeof connectedRemoteApprovalSnapshot>>;
|
||||||
try {
|
try {
|
||||||
|
|
@ -6771,6 +6866,30 @@ export function createToolGatewayService(
|
||||||
let invocationId = String(randomUUID());
|
let invocationId = String(randomUUID());
|
||||||
const startedAt = Date.now();
|
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 tool = await findToolForSession(session, input.tool);
|
||||||
let virtualToolName: string | null = null;
|
let virtualToolName: string | null = null;
|
||||||
let requestedParameters: unknown = input.parameters ?? {};
|
let requestedParameters: unknown = input.parameters ?? {};
|
||||||
|
|
@ -7109,6 +7228,7 @@ export function createToolGatewayService(
|
||||||
canonicalArguments: storedCanonical,
|
canonicalArguments: storedCanonical,
|
||||||
approvalSnapshot: signedPayload.approvalSnapshot,
|
approvalSnapshot: signedPayload.approvalSnapshot,
|
||||||
executionOnApprove: signedPayload.executionOnApprove,
|
executionOnApprove: signedPayload.executionOnApprove,
|
||||||
|
identityContextId: signedPayload.identityContextId,
|
||||||
signingSecret: options.toolActionSigningSecret,
|
signingSecret: options.toolActionSigningSecret,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ afterEach(async () => {
|
||||||
function serviceCommand() {
|
function serviceCommand() {
|
||||||
// Answers `/api/health` the way a real Paperclip dev runtime does: managed
|
// Answers `/api/health` the way a real Paperclip dev runtime does: managed
|
||||||
// publication requires semantic health, not just a 200 (PAP-17572).
|
// 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"]);
|
expect(calls).toEqual(["reserve", "expose", "remove"]);
|
||||||
}, 20_000);
|
}, 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();
|
const { broker, calls } = createBroker();
|
||||||
installDeps({ broker });
|
installDeps({ broker });
|
||||||
|
|
||||||
|
|
@ -630,7 +631,8 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
|
||||||
expect(calls).toEqual(["reserve", "remove"]);
|
expect(calls).toEqual(["reserve", "remove"]);
|
||||||
}, 20_000);
|
}, 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();
|
const { broker } = createBroker();
|
||||||
installDeps({ broker });
|
installDeps({ broker });
|
||||||
|
|
||||||
|
|
@ -676,7 +678,9 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
|
||||||
serviceName: "paperclip-dev",
|
serviceName: "paperclip-dev",
|
||||||
command: declared,
|
command: declared,
|
||||||
expose: { ...LEGACY_HTTP_EXPOSE, tailscaleHttps: false },
|
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([]);
|
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)", () => {
|
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
|
// 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
|
// `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.
|
// is. The argv rewrite fixes the app port; only the preflight catches this.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ declare global {
|
||||||
keyScope?: AgentApiKeyScope;
|
keyScope?: AgentApiKeyScope;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
onBehalfOfUserId?: string | null;
|
onBehalfOfUserId?: string | null;
|
||||||
|
identityContextId?: string | null;
|
||||||
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
|
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3688,6 +3688,9 @@ export function AccessStep({
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
<section className="p-6">
|
<section className="p-6">
|
||||||
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
|
<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 everyone’s work, instead of the person giving instructions.</p>
|
||||||
|
) : null}
|
||||||
{identityLoading ? (
|
{identityLoading ? (
|
||||||
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
|
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
|
||||||
<Skeleton className="h-20 w-full rounded-md" />
|
<Skeleton className="h-20 w-full rounded-md" />
|
||||||
|
|
|
||||||
|
|
@ -3579,6 +3579,29 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 && (
|
{resumeRun.isError && (
|
||||||
<div className="text-xs text-destructive">
|
<div className="text-xs text-destructive">
|
||||||
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
|
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ export function IdentitiesSection({
|
||||||
return (
|
return (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<h2 className="text-sm font-semibold text-foreground">GitHub identity</h2>
|
<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 everyone’s work, instead of the person giving instructions.</p>
|
||||||
<IdentityRow
|
<IdentityRow
|
||||||
title={github ? `@${github.login}` : "Dedicated GitHub account"}
|
title={github ? `@${github.login}` : "Dedicated GitHub account"}
|
||||||
status={agentGrant?.status ?? null}
|
status={agentGrant?.status ?? null}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue