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:
parent
1dceee9a4e
commit
3796c6f259
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue