fix(codex): deliver configured service secrets to shell tools (#17216)

## Thinking Path

> - Paperclip lets operators assign tools and credentials to agents.
> - Legacy Codex starts provider processes and builds a separate shell
environment policy.
> - Both ACP and CLI removed every variable whose name contained KEY,
SECRET, or TOKEN.
> - This also removed service credentials that the operator explicitly
assigned, such as SLACK_BOT_TOKEN.
> - This pull request uses the resolved configuration to admit assigned
service secrets by name.
> - Every Codex launch keeps a bounded shell allowlist, including
launches without an agent identity.
> - Assigned scripts can use their credentials without adding unassigned
host secrets to shell tools.

## Linked Issues or Issue Description

Related: #16255. That PR fixes project-level exclude patterns. This PR
fixes the removal of explicitly assigned service secrets and the
fallback to an inherited policy when no agent identity is present.

**What happened?**

A Codex task could not run a Slack posting script. SLACK_BOT_TOKEN was
present in its resolved launch environment, but Paperclip removed its
name from shell_environment_policy.include_only. A live removal check
also showed that an identity-free launch without an explicit policy
could inherit an unassigned host token.

**Expected behavior**

An explicitly assigned service credential reaches shell commands.
Removing that binding removes its shell access on the next launch.
Unassigned host credentials stay outside the allowlist. Codex login
credentials and observability headers keep their separate provider
paths.

**Steps to reproduce**

1. Configure a Legacy Codex agent with a synthetic SLACK_BOT_TOKEN
value.
2. Run a shell command that checks whether the variable exists without
printing its value.
3. Before this fix, the variable is absent. Repeat through ACP and CLI.
4. Remove the binding and run a second turn. Check that neither the
removed token nor an unassigned host token reaches the command.

**Paperclip version or commit**

Reproduced in the executor regressions before the fix on master at
4dfcb4c9b8. The final change is rebased on 25f2d05282.

**Deployment mode**

Source checkout. Local macOS arm64 provider acceptance with Codex CLI
0.160.0 and Codex ACP 1.6.2, gpt-5.6-sol, and an existing ChatGPT login.

## What Changed

- Added one shared Codex shell-name policy for Legacy ACP and CLI
execution.
- Admit explicitly resolved service-secret bindings. Keep Codex login
keys, observability credentials, and private runtime transport tokens
outside that opt-in.
- Reject glob-shaped environment names so one configured name cannot
match other credentials.
- Always emit the bounded policy. Do not fall back to host inheritance
after a binding is removed.
- Updated executor regressions, the identity contract, and the paired
verification maps.

## Verification

- Red before the fix: executor regressions failed because
SLACK_BOT_TOKEN was absent in ACP and CLI, with and without agent
identities.
- Green: 267 tests passed across the two executor suites and the shared
shell-policy suite. Coverage includes host/provider isolation,
unresolved and removed bindings, glob names, identity delivery, and
names-only metadata.
- The complete Codex adapter source suite passed after the CI follow-up:
571 tests passed, 1 skipped across 31 files. Its typecheck also passed.
- The first CI run caught four stale expectations in ACP config and SSH
launch tests. Those now assert the bounded policy and the existing
command/resume behavior. No production code changed in that follow-up.
- Live CLI and ACP acceptance: run a Python presence check through each
real adapter, remove the synthetic Slack binding, and run again. The
assigned token was present only on the first turn. The unassigned host
token and OpenAI API key were absent on both turns. CLI retained its
session; ACP correctly invalidated its session after the configuration
changed. Workspace proof files persisted.
- An earlier live CLI check failed after binding removal because the
no-policy fallback exposed the synthetic host token. The final change
removes that fallback. Both live checks then passed.
- Full pnpm -r typecheck and pnpm build passed. The changed packages
were compiled again after the safety correction.
- node scripts/check-maps.mjs and git diff --check passed.
- The local pnpm test:run attempt reported failures in native
warm-session tests and was interrupted. That full-suite result is not a
pass; PR CI must establish the final repository-wide gate. One named
native failure passed alone, and the complete native executor file then
passed separately (774 tests). The original broad-run failure cause
remains unconfirmed.
- After rebase, one Gemini skill-root case timed out at five seconds
while the broad run was active. The case passed alone, and the full
focused set then passed again at the PR head (267 tests). The earlier
timeout remains part of the verification record.
- An initial broad adapter run failed because sandbox restrictions
denied loopback listeners. Running the same suites with loopback access
passed.

## Risks

- Explicitly assigned service secrets now reach shell commands. This is
the intended behavior change: assigning a credential authorizes the run
to use it, and a model-directed command can read it. This is not a
broker that hides assigned credentials from the model. Operators must
assign only credentials and service permissions authorized for the run.
Configuration and secret-access authorization remain with their existing
owners.
- Superagent initially flagged this intended assigned-credential
exposure as a P2. The inline reply records the trust-model rationale and
the documentation states it explicitly. The security scan on the final
documented revision passed without any check override.
- The launch policy contains names, not values. Log redaction remains in
place. Provider login credentials and observability headers remain
excluded from the service-secret opt-in.
- Identity-free Codex runs now also receive a bounded policy. This
removes reliance on the operator's inherited shell policy.
- Existing explicit project exclude patterns retain their behavior;
#16255 addresses that separate case.
- The change has not been deployed to a shared instance.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. The exact served model ID and context-window size are not
exposed in this session.

## 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
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run the focused tests locally and they pass; the full-suite
limitation is recorded above
- [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:
DottaandPaperclip authored and GitHub committed 2026-10-22 18:17:34 -05:00
1 parent 12bb7e1976
commit 042051904d
11 files changed
+169 -53

No files matched your search

+17 -1
View File
@@ -97,7 +97,23 @@ have distinct identities.
Native failure records and reports redact the assigned key before truncating diagnostics. Streaming output buffers settle at item or turn completion: short structural prefixes (such as a trailing dash) are preserved, longer interrupted key fragments become redaction markers, and pending buffers are cleared. Terminal events carry these settled `outputTails`; transcript projection displays them as deltas without changing the source event receipt.
Codex shell delivery preserves its default `KEY`/`SECRET`/`TOKEN` name exclusions for other configured credentials. The exceptions are the three identity variables and `PAPERCLIP_API_KEY`: the latter is the short-lived, scoped run/bridge credential required by the Paperclip agent skill’s Bash/curl API calls. Disabling Codex’s automatic exclusions is paired with this explicit filtered allowlist; it does not admit arbitrary host environment variables. Provider authentication secrets can still reach the provider process without being newly exposed to shell commands by this feature.
Legacy Codex shell delivery includes explicitly configured task variables, including
resolved secret references such as `SLACK_BOT_TOKEN`. Both ACP and CLI execution
use the same name-only allowlist, including runs without an agent identity.
Codex login keys (`OPENAI_API_KEY`, `CODEX_API_KEY`), managed provider keys,
observability credentials, and private runtime transport credentials remain
outside that service-secret opt-in. Unassigned host secrets are not added.
The three identity variables and the short-lived, scoped `PAPERCLIP_API_KEY`
retain their runtime delivery. Secret values remain in the child environment;
the shell policy contains only variable names. Removing a configured variable
removes its shell access at the next launch.
Assigning a service credential through the adapter's environment grants the
run's shell commands access to that credential. It is not a brokered credential
that is hidden from the agent: a model-directed command can read it. Assign only
credentials the run is authorized to use, with the service permissions it needs.
The shell allowlist protects unassigned host and provider credentials; it does
not isolate an assigned service credential from the run's own commands.
Cloud customer-success inspection can consume this existing identity together
with strict active-run authority. See [inspection support](CUSTOMER-SUCCESS-INSPECTION.md).
+10
View File
@@ -114,6 +114,16 @@ remaining operations before qualifying the whole capability.
Gaps: Environment test path checks creation/configuration policy; missing provider auth
must use its owner setup flow.
Scoped Legacy Codex shell delivery: resolved task configuration can explicitly
assign service-secret variables to shell tools through ACP or CLI execution.
The existing configuration and secret-access checks still authorize that binding;
the shell allowlist grants no extra company or vault access. Codex login keys,
observability credentials, private runtime transport credentials, and unassigned
host secrets retain their separate paths. Source/control evidence:
[shared shell policy](../../packages/adapter-utils/src/codex-shell-environment.ts)
and the adapter executor regressions. Live delivery remains a separate check
under `runs-adapters/validation`.
Scoped source review: [test authorization and resolution](../../server/src/routes/agents.ts)
requires same-company `agents:create`, saved-agent update access when supplied,
allowed environment selection, and normal secret/responsible-user checks. An
+7
View File
@@ -56,6 +56,13 @@ models/authentication and unsupported Legacy-only options must fail visibly.
Manual: Select the intended adapter and account, refresh model choices, and run its environment test. Save, reload, then execute a small task. Repeat with a deliberately unavailable account and verify the failure remains visible.
For Legacy Codex, assign a service secret such as `SLACK_BOT_TOKEN` through agent
configuration and run a task that checks its presence without printing its value.
Check both ACP and CLI execution. Remove the binding and repeat on the next run.
The assigned secret must reach shell tools; an unassigned host token and Codex's
own login keys must remain unavailable. Verify the same delivery without an
agent identity. Shell-policy metadata must contain names rather than values.
Choose Codex as the harness; the UI displays its name/icon for both runners.
Advanced identifies the automatic default and offers Paperclip Runner or Legacy.
New Codex agents default to the native runner on supported macOS or GNU/Linux
@@ -669,7 +669,7 @@ describe("shared ACPX engine runtime behavior", () => {
fastMode: true,
});
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toEqual({
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toMatchObject({
model: "gpt-5.6-sol",
model_reasoning_effort: "high",
service_tier: "fast",
@@ -683,18 +683,33 @@ describe("shared ACPX engine runtime behavior", () => {
);
});
it("keeps identity and scoped API access without exposing configured service tokens to Codex shells", async () => {
const { meta } = await runExecutor({ agent: "codex", env: { MY_SERVICE_TOKEN: "assigned-tool-token" } }, {
authToken: "assigned-run-token",
agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" },
});
const config = JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG));
expect(config.shell_environment_policy.include_only).toEqual(expect.arrayContaining([
"PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
]));
expect(config.shell_environment_policy.include_only).not.toContain("MY_SERVICE_TOKEN");
expect(JSON.stringify(config)).not.toContain("assigned-run-token");
expect(JSON.stringify(config)).not.toContain("assigned-tool-token");
it.each([true, false])("delivers configured service tokens to Codex shells while isolating host and provider credentials (identity: %s)", async (withIdentity) => {
const root = await makeTempRoot();
vi.stubEnv("PAPERCLIP_HOME", root);
vi.stubEnv("HOST_SERVICE_TOKEN", "host-token-canary");
vi.stubEnv("DATABASE_URL", "postgres://host/private");
try {
const { meta } = await runExecutor({ agent: "codex", stateDir: path.join(root, "state"), env: {
CODEX_HOME: path.join(root, "codex-home"),
SLACK_BOT_TOKEN: "assigned-tool-token", OPENAI_API_KEY: "provider-key-canary",
OTEL_EXPORTER_OTLP_HEADERS: "authorization=export-secret-canary",
} }, {
...(withIdentity ? {
authToken: "assigned-run-token",
agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" },
} : {}),
});
const config = JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG ?? "{}"));
expect(config.shell_environment_policy?.include_only).toContain("SLACK_BOT_TOKEN");
if (withIdentity) expect(config.shell_environment_policy.include_only).toEqual(expect.arrayContaining([
"PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
]));
for (const key of ["HOST_SERVICE_TOKEN", "DATABASE_URL", "OPENAI_API_KEY", "OTEL_EXPORTER_OTLP_HEADERS"]) {
expect(config.shell_environment_policy.include_only).not.toContain(key);
}
expect(JSON.stringify(config)).not.toContain("assigned-run-token");
expect(JSON.stringify(config)).not.toContain("assigned-tool-token");
} finally { vi.unstubAllEnvs(); }
});
it("forwards arbitrary Codex model IDs verbatim without picker-dependent session config", async () => {
@@ -728,7 +743,7 @@ describe("shared ACPX engine runtime behavior", () => {
},
});
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toEqual({
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toMatchObject({
model: "gpt-runtime",
approval_policy: "never",
service_tier: "fast",
@@ -743,7 +758,7 @@ describe("shared ACPX engine runtime behavior", () => {
env: { CODEX_CONFIG: "not-json" },
});
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toEqual({
expect(JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG))).toMatchObject({
model: "gpt-runtime",
});
expect(logs).toContainEqual({
@@ -771,10 +786,15 @@ describe("shared ACPX engine runtime behavior", () => {
]);
});
it("does not inject CODEX_CONFIG or session config when Codex overrides are absent", async () => {
it("bounds Codex shell inheritance even without model overrides or an agent identity", async () => {
const { configOptions, meta } = await runExecutor({ agent: "codex" });
expect((meta[0]?.env as Record<string, string>).CODEX_CONFIG).toBeUndefined();
const config = JSON.parse(String((meta[0]?.env as Record<string, string>).CODEX_CONFIG));
expect(config.model).toBeUndefined();
expect(config.features.shell_snapshot).toBe(false);
expect(config.shell_environment_policy).toMatchObject({ inherit: "all", ignore_default_excludes: true });
expect(config.shell_environment_policy.include_only).toContain("PATH");
expect(config.shell_environment_policy.include_only).not.toContain("HOST_SERVICE_TOKEN");
expect(configOptions).toEqual([]);
});
@@ -1,6 +1,7 @@
import { isOpenRouterUrl, openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution";
import { prepareAcpxCodexObservabilityHome } from "../codex-observability-home.js";
import { AGENT_OBSERVABILITY_ENV_KEYS, applyObservabilityEnvironment } from "../agent-observability.js";
import { codexShellEnvironmentKeys } from "../codex-shell-environment.js";
import { applyObservabilityEnvironment } from "../agent-observability.js";
import { cancellableSandboxStartup } from "./startup-cancellation.js";
import { emitStartupReuse, environmentStartupReuse, summarizeStartupReuse, type StartupReuseReason } from "../startup-reuse.js";
import { withAdapterExecutionPhase, type AdapterExecutionPhase } from "../execution-phase.js";
@@ -1545,10 +1546,10 @@ function buildCodexStartupConfig(input: {
requestedModel: string;
requestedThinkingEffort: string;
fastMode: boolean;
identityEnvironmentKeys?: string[];
shellEnvironmentKeys?: string[];
}): { value: string | null; invalidExistingConfig: boolean } {
const hasRuntimeConfig = Boolean(
input.requestedModel || input.requestedThinkingEffort || input.fastMode || input.identityEnvironmentKeys,
input.requestedModel || input.requestedThinkingEffort || input.fastMode || input.shellEnvironmentKeys,
);
if (!hasRuntimeConfig) return { value: null, invalidExistingConfig: false };
@@ -1579,10 +1580,10 @@ function buildCodexStartupConfig(input: {
},
}
: {}),
...(input.identityEnvironmentKeys ? {
...(input.shellEnvironmentKeys ? {
features: { ...parseObject(existing.features), ...(input.fastMode ? { fast_mode: true } : {}), shell_snapshot: false },
shell_environment_policy: { ...parseObject(existing.shell_environment_policy),
inherit: "all", ignore_default_excludes: true, include_only: input.identityEnvironmentKeys },
inherit: "all", ignore_default_excludes: true, include_only: input.shellEnvironmentKeys },
} : {}),
}),
invalidExistingConfig,
@@ -2119,15 +2120,10 @@ async function buildRuntime(input: {
env.GEMINI_MODEL = requestedModel;
}
if (acpxAgent === "codex") {
const shellEnvironmentKeys = codexShellEnvironmentKeys(env, resolvedAdapterEnv, Boolean(input.ctx.agentIdentity));
const codexStartupConfig = buildCodexStartupConfig({
existingConfig: env.CODEX_CONFIG,
...(input.ctx.agentIdentity ? { identityEnvironmentKeys: [...new Set([
"PATH", "HOME", "LANG", "TMPDIR", "CODEX_HOME",
"PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
// Preserve Codex's default secret-name exclusions. The short-lived
// Paperclip API token is required by the agent skill's Bash/curl calls.
...Object.keys(env).filter(key => !(AGENT_OBSERVABILITY_ENV_KEYS as readonly string[]).includes(key) && (key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key))),
])] } : {}),
shellEnvironmentKeys,
requestedModel,
requestedThinkingEffort,
fastMode,
@@ -0,0 +1,36 @@
import { expect, it } from "vitest";
import { codexShellEnvironmentKeys } from "./codex-shell-environment.js";
it("admits assigned service secrets by name while retaining unassigned secret exclusions", () => {
const assigned = { SLACK_BOT_TOKEN: "slack-canary", SERVICE_API_KEY: "key-canary", APP_SECRET: "secret-canary" };
const names = codexShellEnvironmentKeys({ ...assigned, HOST_API_TOKEN: "host-canary" }, assigned, false);
expect(names).toEqual(expect.arrayContaining(Object.keys(assigned)));
expect(names).not.toContain("HOST_API_TOKEN");
for (const value of Object.values(assigned)) expect(JSON.stringify(names)).not.toContain(value);
});
it("keeps Codex authentication, observability and runtime transports out of the service-secret opt-in", () => {
const configured = {
SLACK_BOT_TOKEN: "assigned", OPENAI_API_KEY: "openai", CODEX_API_KEY: "codex",
PAPERCLIP_AI_PROVIDER_KEY: "managed", OTEL_EXPORTER_OTLP_HEADERS: "authorization=export",
PAPERCLIP_GITHUB_BROKER_TOKEN: "broker", PAPERCLIP_NATIVE_MCP_TOKEN: "mcp",
};
const names = codexShellEnvironmentKeys(configured, configured, true);
expect(names).toContain("SLACK_BOT_TOKEN");
for (const key of Object.keys(configured).filter(key => key !== "SLACK_BOT_TOKEN")) expect(names).not.toContain(key);
});
it("does not turn a configured glob into permission to inherit other secrets", () => {
const configured = { "*TOKEN*": "wildcard", "SERVICE[AB]_KEY": "pattern" };
const names = codexShellEnvironmentKeys({ ...configured, HOST_TOKEN: "host" }, configured, true);
expect(names).not.toContain("*TOKEN*");
expect(names).not.toContain("SERVICE[AB]_KEY");
expect(names).not.toContain("HOST_TOKEN");
});
it("removes the secret opt-in when a binding is removed or has not been resolved", () => {
const environment = { SLACK_BOT_TOKEN: "old-provider-environment" };
expect(codexShellEnvironmentKeys(environment, {}, false)).not.toContain("SLACK_BOT_TOKEN");
expect(codexShellEnvironmentKeys(environment, { SLACK_BOT_TOKEN: { type: "secret_ref" } }, false)).not.toContain("SLACK_BOT_TOKEN");
expect(codexShellEnvironmentKeys(environment, {}, true)).not.toContain("SLACK_BOT_TOKEN");
});
@@ -0,0 +1,31 @@
import { AGENT_OBSERVABILITY_ENV_KEYS } from "./agent-observability.js";
const SECRET_NAME = /key|secret|token/i;
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const PROVIDER_ONLY_KEYS: ReadonlySet<string> = new Set([
"OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_AI_PROVIDER_KEY",
...AGENT_OBSERVABILITY_ENV_KEYS,
]);
const IDENTITY_KEYS = ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"];
/** Only resolved adapter configuration can opt a service secret into shell access.
* The launch environment here is Paperclip-built, never ambient process.env.
* Return names only: values must stay out of provider config and argv.
*/
export function codexShellEnvironmentKeys(
environment: Record<string, string>,
configuredEnvironment: Record<string, unknown>,
hasAgentIdentity: boolean,
): string[] {
const configuredSecrets = new Set(Object.entries(configuredEnvironment)
.filter(([key, value]) => ENVIRONMENT_NAME.test(key) && typeof value === "string" && typeof environment[key] === "string"
&& !/^PAPERCLIP_(?:GITHUB_|RUNNER_|NATIVE_|ACPX_)/i.test(key)
&& SECRET_NAME.test(key) && !PROVIDER_ONLY_KEYS.has(key.toUpperCase()))
.map(([key]) => key));
return [...new Set([
"PATH", "HOME", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME",
...Object.keys(environment).filter(key => ENVIRONMENT_NAME.test(key) && !PROVIDER_ONLY_KEYS.has(key.toUpperCase())
&& (key === "PAPERCLIP_API_KEY" || configuredSecrets.has(key) || !SECRET_NAME.test(key))),
...(hasAgentIdentity ? IDENTITY_KEYS : []),
])].sort();
}
@@ -780,11 +780,16 @@ describe("codex_local ACP lane", () => {
expect(runtimes[0]?.setConfigInputs).toEqual([]);
expect(meta[0]?.commandNotes?.join("\n")).toContain("Prepared ACPX Codex skill home");
expect(meta[0]?.env?.CODEX_HOME).toBe(path.join(root, "codex-home"));
expect(JSON.parse(String(meta[0]?.env?.CODEX_CONFIG))).toEqual({
expect(JSON.parse(String(meta[0]?.env?.CODEX_CONFIG))).toMatchObject({
model: "gpt-5.5",
model_reasoning_effort: "high",
service_tier: "fast",
features: { fast_mode: true },
shell_environment_policy: {
inherit: "all",
ignore_default_excludes: true,
include_only: expect.arrayContaining(["PATH", "CODEX_HOME"]),
},
});
});
@@ -392,7 +392,7 @@ describe("codex remote execution", () => {
expect(runChildProcess).toHaveBeenCalledTimes(1);
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
expect(call?.[2]).toEqual([
expect(call?.[2].slice(call[2].indexOf("exec"))).toEqual([
"exec",
"--json",
"--dangerously-bypass-approvals-and-sandbox",
@@ -465,7 +465,7 @@ describe("codex remote execution", () => {
expect(runChildProcess).toHaveBeenCalledTimes(1);
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
expect(call?.[2]).toEqual([
expect(call?.[2].slice(call[2].indexOf("exec"))).toEqual([
"exec",
"--json",
"--dangerously-bypass-approvals-and-sandbox",
@@ -546,7 +546,7 @@ describe("codex remote execution", () => {
const call = runChildProcess.mock.calls[0] as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined;
expect(call?.[2]).toEqual([
expect(call?.[2].slice(call[2].indexOf("exec"))).toEqual([
"exec",
"--json",
"--dangerously-bypass-approvals-and-sandbox",
@@ -118,7 +118,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
);
}
async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise<void>; withIdentity?: boolean }) {
async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise<void>; withIdentity?: boolean; env?: Record<string, string> }) {
const rootDir = await mkdtemp(
path.join(os.tmpdir(), "paperclip-codex-copyback-e2e-"),
);
@@ -159,7 +159,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
engine: "cli",
// External CODEX_HOME (outside the managed company tree) so no managed
// seeding rewrites auth.json before teardown; equals the shared host home.
env: { CODEX_HOME: sharedHostHome, ...(input.withIdentity ? { MY_SERVICE_TOKEN: "assigned-tool-token" } : {}) },
env: { CODEX_HOME: sharedHostHome, ...input.env },
},
context: {
paperclipWorkspace: {
@@ -190,16 +190,19 @@ describe("codex execute — outbound auth copy-back restore contribution", () =>
};
}
it("keeps identity and scoped API access without exposing configured service tokens to CLI shells", async () => {
it.each([true, false])("delivers configured service tokens to CLI shells while isolating host and provider credentials (identity: %s)", async (withIdentity) => {
vi.stubEnv("DATABASE_URL", "postgres://operator:host-password@host/private");
vi.stubEnv("HOST_DATABASE_PASSWORD", "host-password");
const { commandArgs } = await runTeardown({ sandboxAuth: "{}", hostAuth: "{}", withIdentity: true });
const { commandArgs } = await runTeardown({ sandboxAuth: "{}", hostAuth: "{}", withIdentity,
env: { SLACK_BOT_TOKEN: "assigned-tool-token", OPENAI_API_KEY: "provider-key-canary" },
});
const policy = commandArgs.find(arg => arg.startsWith("shell_environment_policy.include_only="));
const keys = JSON.parse(policy!.slice(policy!.indexOf("=") + 1));
expect(keys).toEqual(expect.arrayContaining([
const keys = policy ? JSON.parse(policy.slice(policy.indexOf("=") + 1)) : [];
expect(keys).toContain("SLACK_BOT_TOKEN");
if (withIdentity) expect(keys).toEqual(expect.arrayContaining([
"PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY",
]));
expect(keys).not.toContain("MY_SERVICE_TOKEN");
expect(keys).not.toContain("OPENAI_API_KEY");
expect(keys).not.toContain("DATABASE_URL");
expect(keys).not.toContain("HOST_DATABASE_PASSWORD");
expect(commandArgs.join(" ")).not.toContain("assigned-run-token");
@@ -114,6 +114,7 @@ import {
createCodexAcpExecutor,
resolveCodexExecutionEngineForRun,
} from "./acp.js";
import { codexShellEnvironmentKeys } from "@paperclipai/adapter-utils/codex-shell-environment";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const executeCodexAcp = createCodexAcpExecutor();
@@ -1291,18 +1292,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (observabilityProfile) replaceCodexProfileArgs(args, observabilityProfile.name);
else if (observabilityPreparationFailed) disableCodexObservabilityArgs(args);
const pricingContext = execArgs.fastModeApplied ? { serviceTier: "fast" } : undefined;
if (ctx.agentIdentity) {
const identityNames = ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"];
const shellKeys = [...new Set([
"PATH", "HOME", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME",
// Keep Codex's default secret-name exclusions except for the scoped
// Paperclip API token used by the agent skill. Never include host env.
...Object.keys(env).filter(key => key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)),
...identityNames,
])];
args.unshift("-c", "features.shell_snapshot=false", "-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true",
"-c", `shell_environment_policy.include_only=${JSON.stringify(shellKeys)}`);
}
const shellKeys = codexShellEnvironmentKeys(env, envConfigStrings, Boolean(ctx.agentIdentity));
args.unshift("-c", "features.shell_snapshot=false", "-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true",
"-c", `shell_environment_policy.include_only=${JSON.stringify(shellKeys)}`);
const commandNotesWithFastMode =
execArgs.fastModeIgnoredReason == null
? commandNotes