fix(connections): project GitHub identity into sandbox runners (#12907)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Managed GitHub connections resolve a responsible user's or dedicated
agent's identity into an audited, run-scoped credential projection.
> - An enrolled instance could retain a hidden managed setup method
after Cloud stopped advertising it, producing a blank, disabled setup
step.
> - Native runner processes also dropped the resolved GitHub projection
before the provider shell, so `gh` and Git could not use the selected
identity in Daytona.
> - Daytona already provides the outer isolation boundary. Applying
Codex's inner Linux sandbox there both duplicated containment and failed
because nested user namespaces are unavailable.
> - This change repairs setup fallback, carries only the bounded GitHub
projection across each runner boundary, and allows only a
controller-selected managed sandbox transport to act as the outer
sandbox.

## Linked Issues or Issue Description

**What happened?**

An enrolled self-hosted instance could show a blank GitHub setup step
when its managed profile was unavailable. Separately, a native Codex run
in Daytona could resolve a managed GitHub connection on the Paperclip
host but lose it before the provider shell. Once projected, Codex's
nested sandbox failed before commands could run because Daytona does not
expose the user-namespace operation used by the inner sandbox.

**Expected behavior**

Setup must select an advertised customer method when the managed method
is unavailable. A Daytona run must receive the exact managed GitHub
identity selected for that run, support `gh` and HTTPS Git, and rely on
Daytona as its outer sandbox without weakening local or SSH execution.

**Steps to reproduce**

1. Enroll a self-hosted instance while Cloud does not advertise the
managed GitHub profile and open GitHub setup.
2. Observe the blank second step and disabled action.
3. Configure a native Codex agent with a Daytona environment and a
responsible-user GitHub grant.
4. Run `gh api user` or HTTPS Git from the agent shell.
5. Observe missing GitHub environment projection or nested-sandbox
startup failure.

**Paperclip version or commit**

The setup bug reproduces on `1dceee9a4`; the runner proof was developed
from the same branch and verified at the latest head below.

**Deployment mode**

Self-hosted Paperclip enrolled with Paperclip Cloud, using the Daytona
sandbox-provider plugin and native Paperclip runner.

## What Changed

- Wait for connector enrollment hydration, retain a hidden managed
method only while enrollment is needed, and otherwise select an
advertised customer fallback.
- Add a single bounded GitHub credential-environment projection for
`GH_TOKEN`, `GITHUB_TOKEN`, the process-only Git helper token, GitHub
commit identity, and at most 32 controller-generated Git config entries.
- Forward that projection through the durable controller, Codex
app-server transport, and Rust provider child without placing token
values in arguments or config.
- Allow Codex shell inheritance only for the exact projected GitHub keys
and enable provider network access only when the managed credential
exists.
- Derive outer-sandbox authority exclusively from a managed `sandbox`
transport; strip the same flag from configured, host, local, and SSH
environments.
- Define a named external-sandbox permission profile that Codex resolves
to `dangerFullAccess` for default-mode Daytona turns while plan mode
remains read-only.
- Add regression tests for setup fallback, credential projection,
local/SSH/sandbox authority separation, provider forwarding, and
permission-profile selection.

## Verification

- `pnpm exec vitest run ui/src/pages/apps/AppsConnect.test.tsx` — 96
passed.
- `pnpm exec vitest run src/drivers/codex/codex-security-config.test.ts
src/drivers/codex/app-server-transport.test.ts
src/control-plane/durable-prp-control-plane.test.ts` from
`packages/paperclip-runner` — 31 passed.
- Focused native-session executor tests — 3 passed.
- `pnpm --filter @paperclipai/paperclip-runner typecheck` — passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `cargo test --manifest-path
packages/paperclip-runner/runner/Cargo.toml --locked -p
paperclip-runner-core --lib` — 194 passed.
- Real Codex app-server configuration probe accepted
`paperclip-runner-external-sandbox` and reported
`sandbox.type=\"dangerFullAccess\"` while using the named profile.
- [Signed Daytona image
workflow](https://github.com/paperclipai/paperclip/actions/runs/33995270328)
built commit `86571f7997e7100e47bd131aac1f1e773112a0ce`; the isolated
environment was pinned to
`sha256:ecef21105f8de382d75787e59439d936be239b77ae74a31c8ed3a17cde39b023`.
- Live isolated Daytona proof passed: the three projected token
variables were non-empty and equal; the host-scoped Git credential
helper returned the same token without printing it; `gh api user`
resolved `cryppadotta`; authenticated `git ls-remote
https://github.com/paperclipai/paperclip.git HEAD` returned
`1dceee9a4e75b13456760bb54c752deb2dba1d79`; no repository mutation
occurred.
- The persisted 28,476-byte run log contains no GitHub token shape,
bearer header, credential-bearing URL, or private-key marker.
- Latest-head pull-request CI and reviews provide the remaining
full-suite gate.

## Risks

- This deliberately gives shell Git and `gh` access to the run's
resolved GitHub identity. It is the audited class-3 behavior required by
the GitHub connection design and is outside per-tool Ask-first controls.
- The credential source is the trusted broker projection, which
overwrites configured environment values. The helper is scoped to HTTPS
`github.com`, revalidates protocol and host, and never places its token
in command arguments, URLs, or files.
- Managed Daytona sandboxes become the containment boundary for
default-mode provider commands. Local and SSH targets retain the inner
Codex workspace sandbox, and plan mode remains read-only everywhere.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5, with reasoning, browser control, shell access,
and code execution. The product did not expose the context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-05 17:35:21 -05:00 committed by GitHub
parent 1dceee9a4e
commit 3796c6f259
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 568 additions and 23 deletions

View File

@ -531,6 +531,103 @@ pub struct CodexProvider {
last_trace_frame_id: Option<u64>,
opencode_launch_profile: Option<OpenCodeLaunchProfile>,
completion_contract: Option<ProviderCompletionContract>,
permission_profile: &'static str,
}
// The controller accepts at most 32 process-scoped Git config entries and
// projects only these exact GitHub credential names into runnerd. Keep the
// provider child boundary equally explicit: runnerd may inherit a configured
// entry from this static ceiling, but cannot introduce another environment
// variable by changing GIT_CONFIG_COUNT.
const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[
"GH_TOKEN",
"GITHUB_TOKEN",
"PAPERCLIP_GIT_TOKEN",
"GIT_TERMINAL_PROMPT",
"GIT_CONFIG_COUNT",
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_VALUE_0",
"GIT_CONFIG_KEY_1",
"GIT_CONFIG_VALUE_1",
"GIT_CONFIG_KEY_2",
"GIT_CONFIG_VALUE_2",
"GIT_CONFIG_KEY_3",
"GIT_CONFIG_VALUE_3",
"GIT_CONFIG_KEY_4",
"GIT_CONFIG_VALUE_4",
"GIT_CONFIG_KEY_5",
"GIT_CONFIG_VALUE_5",
"GIT_CONFIG_KEY_6",
"GIT_CONFIG_VALUE_6",
"GIT_CONFIG_KEY_7",
"GIT_CONFIG_VALUE_7",
"GIT_CONFIG_KEY_8",
"GIT_CONFIG_VALUE_8",
"GIT_CONFIG_KEY_9",
"GIT_CONFIG_VALUE_9",
"GIT_CONFIG_KEY_10",
"GIT_CONFIG_VALUE_10",
"GIT_CONFIG_KEY_11",
"GIT_CONFIG_VALUE_11",
"GIT_CONFIG_KEY_12",
"GIT_CONFIG_VALUE_12",
"GIT_CONFIG_KEY_13",
"GIT_CONFIG_VALUE_13",
"GIT_CONFIG_KEY_14",
"GIT_CONFIG_VALUE_14",
"GIT_CONFIG_KEY_15",
"GIT_CONFIG_VALUE_15",
"GIT_CONFIG_KEY_16",
"GIT_CONFIG_VALUE_16",
"GIT_CONFIG_KEY_17",
"GIT_CONFIG_VALUE_17",
"GIT_CONFIG_KEY_18",
"GIT_CONFIG_VALUE_18",
"GIT_CONFIG_KEY_19",
"GIT_CONFIG_VALUE_19",
"GIT_CONFIG_KEY_20",
"GIT_CONFIG_VALUE_20",
"GIT_CONFIG_KEY_21",
"GIT_CONFIG_VALUE_21",
"GIT_CONFIG_KEY_22",
"GIT_CONFIG_VALUE_22",
"GIT_CONFIG_KEY_23",
"GIT_CONFIG_VALUE_23",
"GIT_CONFIG_KEY_24",
"GIT_CONFIG_VALUE_24",
"GIT_CONFIG_KEY_25",
"GIT_CONFIG_VALUE_25",
"GIT_CONFIG_KEY_26",
"GIT_CONFIG_VALUE_26",
"GIT_CONFIG_KEY_27",
"GIT_CONFIG_VALUE_27",
"GIT_CONFIG_KEY_28",
"GIT_CONFIG_VALUE_28",
"GIT_CONFIG_KEY_29",
"GIT_CONFIG_VALUE_29",
"GIT_CONFIG_KEY_30",
"GIT_CONFIG_VALUE_30",
"GIT_CONFIG_KEY_31",
"GIT_CONFIG_VALUE_31",
];
const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[
"CODEX_HOME",
"OPENAI_API_KEY",
"CODEX_API_KEY",
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
];
fn codex_permission_profile(provider: &str, external_sandbox: bool) -> &'static str {
if provider == "codex" && external_sandbox {
"paperclip-runner-external-sandbox"
} else {
"paperclip-runner-workspace-only"
}
}
impl CodexProvider {
@ -578,6 +675,10 @@ impl CodexProvider {
));
}
let authorized_tools = authorized_tools.into_iter().collect::<Vec<_>>();
let permission_profile = codex_permission_profile(
&config.provider,
std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"),
);
let (dynamic_tools, authorized_tool_ids) =
codex_dynamic_tools(authorized_tools.iter().cloned())?;
let common_environment_keys = [
@ -598,12 +699,13 @@ impl CodexProvider {
let provider_environment_keys = if config.provider == "opencode" {
OPENCODE_PROVIDER_ENVIRONMENT_KEYS
} else {
&["CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY"][..]
CODEX_PROVIDER_ENVIRONMENT_KEYS
};
let environment_keys = common_environment_keys
.iter()
.copied()
.chain(provider_environment_keys.iter().copied())
.chain(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.iter().copied())
.collect::<Vec<_>>();
let process = if config.provider == "opencode" {
let profile = opencode_launch_profile.ok_or_else(|| {
@ -676,6 +778,7 @@ impl CodexProvider {
criterion_ids: criterion_ids.to_vec(),
}
}),
permission_profile,
};
let initialized = provider.request(
"initialize",
@ -697,7 +800,7 @@ impl CodexProvider {
"cwd": config.cwd,
"model": config.model,
"approvalPolicy": config.approval_policy,
"permissions": "paperclip-runner-workspace-only",
"permissions": provider.permission_profile,
"runtimeWorkspaceRoots": [config.cwd],
"baseInstructions": config.instructions,
"dynamicTools": dynamic_tools,
@ -1013,7 +1116,7 @@ impl CodexProvider {
json!({
"threadId": self.thread_id,
"cwd": cwd,
"permissions": "paperclip-runner-workspace-only",
"permissions": self.permission_profile,
"runtimeWorkspaceRoots": [cwd],
"input": [{"type": "text", "text": message, "text_elements": []}],
}),
@ -2842,6 +2945,48 @@ mod tests {
assert!(!OPENCODE_PROVIDER_ENVIRONMENT_KEYS.contains(&"PAPERCLIP_OPENCODE_COMMAND"));
}
#[test]
fn github_credentials_cross_only_the_bounded_provider_environment() {
assert_eq!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.len(), 73);
for key in [
"GH_TOKEN",
"GITHUB_TOKEN",
"PAPERCLIP_GIT_TOKEN",
"GIT_TERMINAL_PROMPT",
"GIT_CONFIG_COUNT",
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"GIT_CONFIG_KEY_0",
"GIT_CONFIG_VALUE_0",
"GIT_CONFIG_KEY_31",
"GIT_CONFIG_VALUE_31",
] {
assert!(GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.contains(&key));
}
assert!(!GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.contains(&"GIT_CONFIG_KEY_32"));
assert!(!GITHUB_CREDENTIAL_ENVIRONMENT_KEYS.contains(&"GIT_CONFIG_VALUE_32"));
}
#[test]
fn codex_provider_accepts_only_the_controller_derived_external_sandbox_bit() {
assert!(CODEX_PROVIDER_ENVIRONMENT_KEYS.contains(&"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX"));
assert!(!CODEX_PROVIDER_ENVIRONMENT_KEYS.contains(&"PAPERCLIP_SANDBOX_MODE"));
assert_eq!(
codex_permission_profile("codex", true),
"paperclip-runner-external-sandbox"
);
assert_eq!(
codex_permission_profile("codex", false),
"paperclip-runner-workspace-only"
);
assert_eq!(
codex_permission_profile("opencode", true),
"paperclip-runner-workspace-only"
);
}
#[test]
fn converts_codex_questions_and_responses_without_provider_leakage() {
let (request_id, question_set, labels) = codex_question_set(

View File

@ -243,6 +243,66 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary"
expect(launches[0]!.environment.PAPERCLIP_OPENCODE_COMMAND).toBeUndefined();
});
it("preserves only bounded GitHub credential projection at the runner spawn boundary", () => {
const launches: RunnerProcessLaunchSpec[] = [];
spawnRunner({
connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" },
stateDirectory: "/tmp/paperclip-runner-test",
identity,
ticket: "bootstrap-ticket",
maxOutboxBytes: 256 * 1024,
p0ReserveBytes: 64 * 1024,
runnerVersion: expectedRunnerVersion,
runnerDigest: expectedRunnerDigest,
environment: {
PATH: "/bin",
GH_TOKEN: "github-token",
GITHUB_TOKEN: "github-token",
PAPERCLIP_GIT_TOKEN: "github-token",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.https://github.com.helper",
GIT_CONFIG_VALUE_0: "!trusted-helper",
GIT_CONFIG_KEY_1: "must.not.cross",
GIT_CONFIG_VALUE_1: "must-not-cross",
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
DATABASE_URL: "must-not-cross",
},
processLauncher: (spec) => {
launches.push(spec);
return {
child: {
pid: 42,
exitCode: null,
signalCode: null,
kill: () => true,
},
completion: Promise.resolve({
code: 0,
signal: null,
stdout: "",
stderr: "",
}),
};
},
});
expect(launches).toHaveLength(1);
expect(launches[0]!.environment).toMatchObject({
GH_TOKEN: "github-token",
GITHUB_TOKEN: "github-token",
PAPERCLIP_GIT_TOKEN: "github-token",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.https://github.com.helper",
GIT_CONFIG_VALUE_0: "!trusted-helper",
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
});
expect(launches[0]!.environment.GIT_CONFIG_KEY_1).toBeUndefined();
expect(launches[0]!.environment.GIT_CONFIG_VALUE_1).toBeUndefined();
expect(launches[0]!.environment.DATABASE_URL).toBeUndefined();
});
it("preserves the controller-selected ACPX provider package root", () => {
const launches: RunnerProcessLaunchSpec[] = [];
spawnRunner({

View File

@ -28,6 +28,7 @@ import { dirname, resolve } from "node:path";
import type { Duplex } from "node:stream";
import { fileURLToPath } from "node:url";
import { githubCredentialEnvironment } from "../github-credential-environment.js";
import {
validatePrpEvent,
type PrpEvent,
@ -1980,6 +1981,7 @@ const runnerExplicitProviderEnvironmentKeys = [
"PAPERCLIP_NATIVE_MCP_URL",
"PAPERCLIP_NATIVE_MCP_TOKEN",
"PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH",
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT",
"PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST",
"PAPERCLIP_ACPX_PROVIDER_RECOVERY_POLICY",
@ -2007,6 +2009,7 @@ function runnerEnvironment(
const value = explicitSource[key];
if (value !== undefined) environment[key] = value;
}
Object.assign(environment, githubCredentialEnvironment(explicitSource));
}
return environment;
}

View File

@ -1,7 +1,7 @@
import { ChildProcess } from "node:child_process";
import { describe, expect, it, vi } from "vitest";
import { ProcessCodexAppServerTransport, redactCodexDiagnostic } from "./app-server-transport.js";
import { createSanitizedCodexEnvironment, ProcessCodexAppServerTransport, redactCodexDiagnostic } from "./app-server-transport.js";
function nodeTransport(
source: string,
@ -16,6 +16,42 @@ function nodeTransport(
}
describe("Codex app-server transport limits", () => {
it("passes only bounded controller-projected GitHub credentials", () => {
expect(
createSanitizedCodexEnvironment({
PATH: "/safe/bin",
GH_TOKEN: "github-token",
PAPERCLIP_GIT_TOKEN: "github-token",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.https://github.com.helper",
GIT_CONFIG_VALUE_0: "!trusted-helper",
GIT_CONFIG_KEY_1: "must.not.cross",
GIT_CONFIG_VALUE_1: "must-not-cross",
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
DATABASE_URL: "must-not-cross",
}),
).toEqual({
PATH: "/safe/bin",
GH_TOKEN: "github-token",
PAPERCLIP_GIT_TOKEN: "github-token",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.https://github.com.helper",
GIT_CONFIG_VALUE_0: "!trusted-helper",
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
});
const invalid = createSanitizedCodexEnvironment({
GH_TOKEN: "github-token",
GIT_CONFIG_COUNT: "33",
GIT_CONFIG_KEY_0: "must.not.cross",
});
expect(invalid.GH_TOKEN).toBe("github-token");
expect(invalid.GIT_CONFIG_COUNT).toBeUndefined();
expect(invalid.GIT_CONFIG_KEY_0).toBeUndefined();
});
it("redacts real Basic credentials without corrupting ordinary question copy", () => {
expect(redactCodexDiagnostic("Authorization: Basic dXNlcjpwYXNz"))
.toBe("Authorization: Basic [REDACTED]");

View File

@ -1,5 +1,6 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
import { githubCredentialEnvironment } from "../../github-credential-environment.js";
export interface CodexRpcNotification {
method: string;
@ -198,6 +199,7 @@ const SAFE_ENVIRONMENT_KEYS = [
"LC_ALL",
"NO_PROXY",
"NODE_EXTRA_CA_CERTS",
"PAPERCLIP_RUNNER_EXTERNAL_SANDBOX",
"PATH",
"PATHEXT",
"SSL_CERT_FILE",
@ -223,6 +225,7 @@ export function createSanitizedCodexEnvironment(
if (key.includes("PROXY") && proxyContainsCredentials(value)) continue;
environment[key] = value;
}
Object.assign(environment, githubCredentialEnvironment(source));
return environment;
}

View File

@ -224,6 +224,7 @@ export class CodexAppServerDriver implements HarnessDriver {
requestedMode,
this.#options.includeCollaborationModeInstructions ?? true,
this.#options.includeSkillInstructions ?? false,
this.#options.environment,
),
approvalPolicy: this.#options.approvalPolicy ?? "untrusted",
...(this.#options.model ? { model: this.#options.model } : {}),
@ -356,6 +357,7 @@ export class CodexAppServerDriver implements HarnessDriver {
this.#options.requestedCollaborationMode ?? "default",
this.#options.includeCollaborationModeInstructions ?? true,
this.#options.includeSkillInstructions ?? false,
this.#options.environment,
),
baseInstructions: this.#direct() ? "" : this.#baseInstructions(),
approvalPolicy: this.#options.approvalPolicy ?? "untrusted",

View File

@ -51,6 +51,34 @@ describe("Codex security configuration", () => {
expect(serialized).not.toContain("must-not-cross");
});
it("inherits only projected GitHub credentials without serializing their values", () => {
const args = createIsolatedCodexAppServerArgs({
PATH: "/safe/bin",
GH_TOKEN: "must-remain-in-process-environment",
GITHUB_TOKEN: "must-remain-in-process-environment",
PAPERCLIP_GIT_TOKEN: "must-remain-in-process-environment",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "credential.https://github.com.helper",
GIT_CONFIG_VALUE_0: "!trusted-helper",
OPENAI_API_KEY: "must-not-cross",
});
const serialized = args.join("\n");
expect(serialized).toContain("network.enabled=true");
expect(serialized).toContain('shell_environment_policy.inherit="all"');
expect(serialized).toContain(
"shell_environment_policy.ignore_default_excludes=true",
);
expect(serialized).toContain("shell_environment_policy.include_only=");
expect(serialized).toContain('"GH_TOKEN"');
expect(serialized).toContain('"GIT_CONFIG_KEY_0"');
expect(serialized).toContain('"GIT_CONFIG_VALUE_0"');
expect(serialized).not.toContain("must-remain-in-process-environment");
expect(serialized).not.toContain("must-not-cross");
expect(serialized).not.toContain("!trusted-helper");
});
it("uses a read-only permission profile for plan mode", () => {
expect(createSecuredCodexThreadParams("/workspace", "plan")).toMatchObject({
cwd: "/workspace",
@ -62,4 +90,47 @@ describe("Codex security configuration", () => {
},
});
});
it("uses the outer sandbox for default-mode commands only when the controller authorizes it", () => {
const source = { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" };
const externalArgs = createIsolatedCodexAppServerArgs(source);
const serializedExternalArgs = externalArgs.join("\n");
expect(externalArgs).toContain(
"--dangerously-bypass-approvals-and-sandbox",
);
expect(serializedExternalArgs).toContain(
'default_permissions="paperclip-runner-external-sandbox"',
);
expect(serializedExternalArgs).toContain(
'permissions.paperclip-runner-external-sandbox.filesystem={":root"="write"}',
);
expect(serializedExternalArgs).toContain(
"permissions.paperclip-runner-external-sandbox.network.enabled=true",
);
expect(
createSecuredCodexThreadParams(
"/workspace",
"default",
true,
false,
source,
),
).toMatchObject({
permissions: "paperclip-runner-external-sandbox",
});
expect(
createSecuredCodexThreadParams(
"/workspace",
"plan",
true,
false,
source,
),
).toMatchObject({
permissions: "paperclip-runner-workspace-read-only",
});
expect(createIsolatedCodexAppServerArgs({})).not.toContain(
"--dangerously-bypass-approvals-and-sandbox",
);
});
});

View File

@ -1,7 +1,20 @@
import { resolve } from "node:path";
export const CODEX_SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only";
export const CODEX_PLANNING_PERMISSION_PROFILE = "paperclip-runner-workspace-read-only";
import {
githubCredentialEnvironmentKeys,
hasGitHubCredentialEnvironment,
} from "../../github-credential-environment.js";
export const CODEX_SKILLLESS_PERMISSION_PROFILE =
"paperclip-runner-workspace-only";
export const CODEX_PLANNING_PERMISSION_PROFILE =
"paperclip-runner-workspace-read-only";
export const CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE =
"paperclip-runner-external-sandbox";
function usesExternalRunnerSandbox(source: NodeJS.ProcessEnv): boolean {
return source.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX === "1";
}
const SKILLLESS_BASE_CONFIG = {
"skills.include_instructions": false,
@ -64,6 +77,9 @@ export function createIsolatedCodexAppServerArgs(
source: NodeJS.ProcessEnv = process.env,
readOnlyRoots: string[] = [],
): string[] {
const hasGitHubCredential = hasGitHubCredentialEnvironment(source);
const externalRunnerSandbox = usesExternalRunnerSandbox(source);
const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source);
const deniedHostRoots = [
...new Set(
[source.HOME, source.CODEX_HOME]
@ -93,26 +109,46 @@ export function createIsolatedCodexAppServerArgs(
const commandEnv = Object.entries(codexCommandEnvironment(source))
.map(([key, value]) => `${key}=${tomlString(value)}`)
.join(",");
const defaultPermissionProfile = externalRunnerSandbox
? CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE
: CODEX_SKILLLESS_PERMISSION_PROFILE;
return [
"-c",
`default_permissions=${tomlString(CODEX_SKILLLESS_PERMISSION_PROFILE)}`,
`default_permissions=${tomlString(defaultPermissionProfile)}`,
"-c",
`permissions.${CODEX_SKILLLESS_PERMISSION_PROFILE}.filesystem={${filesystemRules}}`,
"-c",
`permissions.${CODEX_SKILLLESS_PERMISSION_PROFILE}.network.enabled=false`,
`permissions.${CODEX_SKILLLESS_PERMISSION_PROFILE}.network.enabled=${hasGitHubCredential}`,
...(externalRunnerSandbox
? [
"-c",
`permissions.${CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE}.filesystem={":root"="write"}`,
"-c",
`permissions.${CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE}.network.enabled=true`,
]
: []),
"-c",
`permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.filesystem={${planningFilesystemRules}}`,
"-c",
`permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.network.enabled=false`,
`permissions.${CODEX_PLANNING_PERMISSION_PROFILE}.network.enabled=${hasGitHubCredential}`,
"-c",
`shell_environment_policy.inherit="none"`,
`shell_environment_policy.inherit=${tomlString(hasGitHubCredential ? "all" : "none")}`,
"-c",
"shell_environment_policy.ignore_default_excludes=false",
`shell_environment_policy.ignore_default_excludes=${hasGitHubCredential}`,
...(hasGitHubCredential
? [
"-c",
`shell_environment_policy.include_only=${JSON.stringify(inheritedGitHubKeys)}`,
]
: []),
...(commandEnv.length > 0
? ["-c", `shell_environment_policy.set={${commandEnv}}`]
: []),
"--disable",
"image_generation",
...(externalRunnerSandbox
? ["--dangerously-bypass-approvals-and-sandbox"]
: []),
"app-server",
];
}
@ -122,9 +158,12 @@ export function createSecuredCodexThreadParams(
mode: "default" | "plan" = "default",
includeCollaborationModeInstructions = true,
includeSkillInstructions = false,
source: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
const permissionProfile =
mode === "plan"
mode === "default" && usesExternalRunnerSandbox(source)
? CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE
: mode === "plan"
? CODEX_PLANNING_PERMISSION_PROFILE
: CODEX_SKILLLESS_PERMISSION_PROFILE;
return {

View File

@ -0,0 +1,68 @@
const STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS = [
"GH_TOKEN",
"GITHUB_TOKEN",
"PAPERCLIP_GIT_TOKEN",
"GIT_TERMINAL_PROMPT",
"GIT_CONFIG_COUNT",
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
] as const;
const MAX_GIT_CONFIG_ENTRIES = 32;
function gitConfigCount(source: NodeJS.ProcessEnv): number | null {
const raw = source.GIT_CONFIG_COUNT;
if (raw === undefined || !/^\d+$/u.test(raw)) return null;
const count = Number(raw);
if (!Number.isSafeInteger(count) || count > MAX_GIT_CONFIG_ENTRIES) {
return null;
}
return count;
}
/**
* Returns the exact controller-projected GitHub environment accepted by the
* native runner. Dynamic Git config entries are bounded by GIT_CONFIG_COUNT so
* callers cannot smuggle arbitrary environment variables across the boundary.
*/
export function githubCredentialEnvironment(
source: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {};
for (const key of STATIC_GITHUB_CREDENTIAL_ENVIRONMENT_KEYS) {
const value = source[key];
if (value !== undefined) environment[key] = value;
}
const count = gitConfigCount(source);
if (count === null) {
delete environment.GIT_CONFIG_COUNT;
return environment;
}
for (let index = 0; index < count; index += 1) {
for (const prefix of ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"] as const) {
const key = `${prefix}${index}`;
const value = source[key];
if (value !== undefined) environment[key] = value;
}
}
return environment;
}
export function githubCredentialEnvironmentKeys(
source: NodeJS.ProcessEnv,
): string[] {
return Object.keys(githubCredentialEnvironment(source)).sort();
}
export function hasGitHubCredentialEnvironment(
source: NodeJS.ProcessEnv,
): boolean {
return [
source.GH_TOKEN,
source.GITHUB_TOKEN,
source.PAPERCLIP_GIT_TOKEN,
].some((value) => typeof value === "string" && value.trim().length > 0);
}

View File

@ -3758,6 +3758,13 @@ describe("runnerd provider runtime wiring", () => {
runnerExecutionTarget: remoteTarget,
});
state.createBackend.mock.calls[0]![1].codexTransportFactory!();
expect(state.createTransport).toHaveBeenCalledWith(
expect.objectContaining({
environment: expect.objectContaining({
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
}),
}),
);
const archiveExternalRunnerState =
state.createTransport.mock.calls[0]![0].archiveExternalRunnerState;
expect(archiveExternalRunnerState).toBeTypeOf("function");
@ -3816,6 +3823,7 @@ describe("runnerd provider runtime wiring", () => {
runnerEnvironment: {
HOME: "/home/runner",
PAPERCLIP_WORKSPACE_CWD: "/untrusted/configured-workspace",
PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1",
},
});
@ -3829,6 +3837,12 @@ describe("runnerd provider runtime wiring", () => {
}),
}),
);
const localTransportOptions = state.createTransport.mock.calls[0]![0] as {
environment: NodeJS.ProcessEnv;
};
expect(
localTransportOptions.environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX,
).toBeUndefined();
});
it("atomically migrates legacy unscoped state only for its exact durable run identity", async () => {
@ -5538,6 +5552,12 @@ describe("runnerd provider runtime wiring", () => {
}),
}),
);
const sshTransportOptions = state.createTransport.mock.calls[0]![0] as {
environment: NodeJS.ProcessEnv;
};
expect(
sshTransportOptions.environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX,
).toBeUndefined();
expect(state.createTransport.mock.calls[0]![0].runnerBinary).not.toBe(
`${remoteCwd}/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd`,
);

View File

@ -7176,15 +7176,25 @@ async function createRunnerdBackendWithinSessionClaim(
},
}
: input.execution;
const effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = {
...(input.runnerEnvironment ?? process.env),
};
// This authority bit is derived only from the selected execution target.
// Never let an agent, environment binding, or host variable disable the
// Codex sandbox for a local runner by supplying the same key.
delete effectiveRunnerEnvironmentBase.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX;
const effectiveRunnerEnvironment: NodeJS.ProcessEnv = remoteRuntimeRoot
? {
...(input.runnerEnvironment ?? process.env),
...effectiveRunnerEnvironmentBase,
HOME: remoteTarget!.remoteCwd,
CODEX_HOME: posix.join(remoteTarget!.remoteCwd, ".codex"),
PAPERCLIP_WORKSPACE_CWD: remoteTarget!.remoteCwd,
...(remoteTarget!.transport === "sandbox"
? { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "1" }
: {}),
}
: {
...(input.runnerEnvironment ?? process.env),
...effectiveRunnerEnvironmentBase,
PAPERCLIP_WORKSPACE_CWD: input.execution.workspace.cwd,
};
const archiveContinuityState = async () => {

View File

@ -1214,7 +1214,30 @@ export function ConnectionSetupFlow({
reconnectConnection,
applications: applicationsQuery.data?.applications ?? [],
});
const requestedEntryAdvertisesManagedConnector = Boolean(
requestedEntry?.methods.some((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
|| candidate.oauthStrategy === "paperclip_id_connector"
),
);
// The enrollment lookup decides whether a hidden managed method means
// "enroll this instance" or "that Cloud profile is unavailable here".
// Do not choose a method until that distinction is known: retaining the
// hidden method key after an active enrollment produces an empty setup
// screen with a permanently disabled generic Connect button.
if (
requestedDefinitionUsesManagedConnector
&& !requestedEntryAdvertisesManagedConnector
&& connectorEnrollmentQuery.isLoading
) return;
const methods = connectionMethodsForCredentialSource(requestedEntry, credentialSource);
const initialMethod = (
requestedDefinitionUsesManagedConnector
&& !requestedEntryAdvertisesManagedConnector
&& connectorEnrollmentQuery.data?.configured !== true
? recommendedManagedConnectorMethod(fullRequestedDefinition)
: null
) ?? recommendedSetupConnectionMethod(methods);
const method = methods.length === 1 ? methods[0]! : null;
const automaticOAuth = credentialSource === "paperclip_vault" && Boolean(automaticOAuthMethod(requestedEntry));
const vercelUnavailable = isVercelConnectUnavailable({
@ -1266,15 +1289,6 @@ export function ConnectionSetupFlow({
setCuratedOAuthClientId("");
setCuratedOAuthClientSecret("");
setVercelConnector("");
const requestedEntryAdvertisesManagedConnector = requestedEntry.methods.some((candidate) =>
candidate.oauthStrategy === "paperclip_cloud_connector"
|| candidate.oauthStrategy === "paperclip_id_connector"
);
const initialMethod = (
requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector
? recommendedManagedConnectorMethod(fullRequestedDefinition)
: null
) ?? recommendedSetupConnectionMethod(methods);
setConnectionMethodKey(initialMethod?.key ?? "");
setConfigValues(defaultMethodConfig(initialMethod));
setGoogleSheetsLinks("");
@ -1298,6 +1312,16 @@ export function ConnectionSetupFlow({
hasPrefilledLink: Boolean(prefill.link),
zapierSource,
}));
} else if (
connectorEnrollmentQuery.data?.configured === true
&& connectionMethodKey
&& !methods.some((candidate) => candidate.key === connectionMethodKey)
) {
// A failed enrollment lookup can select the hidden pre-enrollment
// method. Replace it after a successful refetch proves that the instance
// is enrolled and the current Cloud gallery does not advertise it.
setConnectionMethodKey(initialMethod?.key ?? "");
setConfigValues(defaultMethodConfig(initialMethod));
}
if (automaticOAuth && (
@ -1317,6 +1341,9 @@ export function ConnectionSetupFlow({
applicationsQuery.data,
connectionsQuery.isError,
connectionsQuery.isFetchedAfterMount,
connectorEnrollmentQuery.data?.configured,
connectorEnrollmentQuery.isLoading,
connectionMethodKey,
credentialSource,
entry?.slug,
galleryQuery.data,

View File

@ -776,6 +776,67 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).not.toContain("Connect with Paperclip");
});
it("uses GitHub's advertised PAT fallback when an enrolled Cloud omits the managed profile", async () => {
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
listGalleryMock.mockResolvedValueOnce({
apps: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
await render();
expect(container.textContent).toContain("Your GitHub key");
expect(container.textContent).not.toContain("Connect with Paperclip");
expect(container.textContent).not.toContain("Continue to GitHub");
const connect = buttonByText("Connect");
expect(connect?.disabled).toBe(true);
const tokenInput = container.querySelector<HTMLInputElement>('input[type="password"]');
expect(tokenInput).toBeTruthy();
await act(async () => setInputValue(tokenInput!, "github_pat_test"));
await flushReact();
expect(connect?.disabled).toBe(false);
});
it("replaces a hidden managed method after enrollment recovery reveals an advertised PAT fallback", async () => {
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
listGalleryMock.mockResolvedValueOnce({
apps: [{
...GITHUB,
methods: GITHUB.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
getCloudConnectorEnrollmentMock
.mockRejectedValueOnce(new Error("Cloud unavailable"))
.mockResolvedValueOnce({
configured: true,
status: "active",
brokerBaseUrl: "https://my-staging.paperclip.app",
instanceId: "inst-test",
environment: "staging",
origins: ["https://paperclip.example.test"],
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(queryClient);
expect(container.textContent).toContain("Paperclip couldnt check Cloud registration. Try again.");
expect(container.textContent).not.toContain("Your GitHub key");
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ["cloud-connector", "enrollment"] });
});
await flushReact();
expect(container.textContent).toContain("Your GitHub key");
expect(container.textContent).not.toContain("Connect with Paperclip");
expect(container.textContent).not.toContain("Continue to GitHub");
expect(buttonByText("Connect")?.disabled).toBe(true);
});
it("preserves a dedicated agent identity across the full-page enrollment callback", async () => {
mockParams.appKey = "github";
listGalleryMock.mockResolvedValue({