Files
PaperClipAI/packages/adapter-utils/src/env-bindings.ts
T
Nicky LeachandPaperclip f5e9ca3e89 fix(adapters): keep user-scoped env bindings on the agent Test action (#10926)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The agent Test action builds adapter config from the form.
> - The build-config parser kept plain and secret_ref bindings.
> - It dropped user_secret_ref bindings on the create path.
> - This PR shares one parser that keeps every binding shape.
> - The test path now sends the same env binding set that a real run
sees.
> - The benefit is one fix across every adapter build-config path.

## Linked Issues or Issue Description

**What happened?**
The agent Test action dropped a user-scoped env binding in create mode.
The same agent config worked in a real run. Related public PRs: #10115,
#9321, #9921, #8825.

**Expected behavior**
The Test action should keep user-scoped env bindings and resolve them
like a real run.

**Steps to reproduce**
1. Set a user-scoped env binding on an agent config form.
2. Run Test in create mode.
3. The probe runs without the variable.

**Paperclip version or commit**
c09d2509e3

**Deployment mode**
Local dev (pnpm dev)

**Agent adapter(s) involved**
Not adapter-specific (core bug)

**Database mode**
Embedded PGlite (default — DATABASE_URL unset)

**Additional context**
This change is not Claude-specific.

## What Changed

- Added a shared env binding parser in `@paperclipai/adapter-utils`.
- Replaced the eight adapter build-config copies with the shared helper.
- Kept `plain`, `secret_ref`, and `user_secret_ref` bindings intact in
create mode and edit mode.
- Preserved the runtime merge behavior from the earlier env merge
change.

## Verification

- Author-recorded test run:
`packages/adapter-utils/src/env-bindings.test.ts`
- Author-recorded test run:
`packages/adapters/claude-local/src/ui/build-config.test.ts`
- Author-recorded test run: six adapter build-config test files
- Author-recorded typecheck: `tsc --noEmit` for adapter-utils and the
eight adapter packages
- GitHub checks: all required PR checks pass on PR #10926.
- Greptile review: 5/5 with no open comments.

## Risks

- The change touches adapter config assembly.
- A wrong binding shape would change test-time probe input.
- Tests cover the binding types and the create-mode path.

> 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 GPT-5, code execution and repo inspection.

## 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 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 or
confirmed no documentation update is needed
- [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>
2026-08-05 17:18:41 -07:00

100 lines
3.7 KiB
TypeScript

// Shared parser for the agent configuration form environment fields.
//
// The form supplies two sources for adapter `env`:
// - `envBindings`: structured bindings keyed by variable name. A binding is a
// plain value, a company `secret_ref`, or a user-scoped `user_secret_ref`.
// - `envVars`: a legacy plain-text block of `KEY=value` lines.
//
// The server resolves every binding to a string, both at run time and at test
// time. This parser must keep each binding shape intact so the resolver gets
// it. A dropped binding makes the agent "Test" action miss a variable that a
// real run receives. So this parser keeps all three binding types. It never
// resolves a secret and never reads a secret value; it only preserves the
// binding shape for the server resolver.
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function isVersionSelector(value: unknown): value is number | "latest" {
return typeof value === "number" || value === "latest";
}
/**
* Convert the structured `envBindings` map into an adapter `env` map. Keep
* `plain`, `secret_ref`, and `user_secret_ref` bindings. Drop entries with an
* invalid variable name or an unknown binding shape.
*/
export function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!ENV_KEY_RE.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(isVersionSelector(rec.version) ? { version: rec.version } : {}),
};
continue;
}
if (rec.type === "user_secret_ref" && typeof rec.key === "string") {
env[key] = {
type: "user_secret_ref",
key: rec.key,
...(isVersionSelector(rec.version) ? { version: rec.version } : {}),
...(typeof rec.required === "boolean" ? { required: rec.required } : {}),
...(typeof rec.allowMissingOverride === "boolean"
? { allowMissingOverride: rec.allowMissingOverride }
: {}),
};
}
}
return env;
}
/**
* Parse the legacy plain-text `KEY=value` block. Skip blank lines, comment
* lines, and lines with an invalid variable name.
*/
export function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!ENV_KEY_RE.test(key)) continue;
env[key] = value;
}
return env;
}
/**
* Build the adapter `env` map from both form sources. A structured binding wins
* over a legacy plain-text entry with the same key.
*/
export function buildAdapterEnvConfig(
envBindings: unknown,
envVars: string | undefined | null,
): Record<string, unknown> {
const env = parseEnvBindings(envBindings);
const legacy = parseEnvVars(envVars ?? "");
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
return env;
}