diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 06b9d0d0fd..d6fd0e4411 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -531,6 +531,103 @@ pub struct CodexProvider { last_trace_frame_id: Option, opencode_launch_profile: Option, completion_contract: Option, + 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::>(); + 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::>(); 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( diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 47c655bbb8..f5e0c49447 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -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({ diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 85ee0ab0e1..b183da10a4 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -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; } diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts index 5ca2301d33..739f021293 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.test.ts @@ -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]"); diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index e7786f62ce..e6a44fe932 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -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; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts index 70fc2fbead..44cd92905b 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts @@ -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", diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index e2b8854a20..8822ea665c 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -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", + ); + }); }); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index 7da56d171b..59f42d3909 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -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 { const permissionProfile = - mode === "plan" + mode === "default" && usesExternalRunnerSandbox(source) + ? CODEX_EXTERNAL_SANDBOX_PERMISSION_PROFILE + : mode === "plan" ? CODEX_PLANNING_PERMISSION_PROFILE : CODEX_SKILLLESS_PERMISSION_PROFILE; return { diff --git a/packages/paperclip-runner/src/github-credential-environment.ts b/packages/paperclip-runner/src/github-credential-environment.ts new file mode 100644 index 0000000000..7c04bf4931 --- /dev/null +++ b/packages/paperclip-runner/src/github-credential-environment.ts @@ -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); +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index e6e70a53d5..10a981df18 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -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`, ); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 5af22bd551..a9094a4606 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -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 () => { diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index dab0571889..295de8a693 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -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, diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index a81915e19a..14ce1ae01a 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -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('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 couldn’t 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({