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>
This commit is contained in:
Nicky Leach 2026-08-05 17:18:41 -07:00 committed by GitHub
parent b1b7a9dff6
commit f5e9ca3e89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 272 additions and 410 deletions

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { buildAdapterEnvConfig, parseEnvBindings, parseEnvVars } from "./env-bindings.js";
describe("parseEnvBindings", () => {
it("keeps plain, company secret, and user-scoped bindings", () => {
const env = parseEnvBindings({
PLAIN: { type: "plain", value: "on" },
LEGACY_STRING: "raw",
COMPANY: { type: "secret_ref", secretId: "11111111-1111-1111-1111-111111111111", version: "latest" },
USER: { type: "user_secret_ref", key: "github_token", version: "latest", required: true },
});
expect(env).toEqual({
PLAIN: { type: "plain", value: "on" },
LEGACY_STRING: { type: "plain", value: "raw" },
COMPANY: { type: "secret_ref", secretId: "11111111-1111-1111-1111-111111111111", version: "latest" },
USER: { type: "user_secret_ref", key: "github_token", version: "latest", required: true },
});
});
it("keeps a user-scoped binding without an optional version or required flag", () => {
const env = parseEnvBindings({
USER: { type: "user_secret_ref", key: "github_token" },
});
expect(env).toEqual({ USER: { type: "user_secret_ref", key: "github_token" } });
});
it("preserves allowMissingOverride when present", () => {
const env = parseEnvBindings({
USER: { type: "user_secret_ref", key: "k", allowMissingOverride: true },
});
expect(env.USER).toEqual({ type: "user_secret_ref", key: "k", allowMissingOverride: true });
});
it("drops invalid keys, unknown shapes, and incomplete refs", () => {
const env = parseEnvBindings({
"1BAD": { type: "plain", value: "x" },
MISSING_KEY: { type: "user_secret_ref" },
MISSING_ID: { type: "secret_ref" },
UNKNOWN: { type: "mystery", value: "y" },
});
expect(env).toEqual({});
});
it("returns an empty map for non-object input", () => {
expect(parseEnvBindings(null)).toEqual({});
expect(parseEnvBindings([])).toEqual({});
expect(parseEnvBindings("nope")).toEqual({});
});
});
describe("parseEnvVars", () => {
it("reads KEY=value lines and skips blanks, comments, and invalid keys", () => {
// The key is trimmed; the value keeps every character after the first "=".
const env = parseEnvVars("A=1\n# comment\n\nB = two = three\n1BAD=x\nNOEQ");
expect(env).toEqual({ A: "1", B: " two = three" });
});
});
describe("buildAdapterEnvConfig", () => {
it("lets a structured binding win over a legacy plain-text entry with the same key", () => {
const env = buildAdapterEnvConfig(
{ SHARED: { type: "user_secret_ref", key: "token" } },
"SHARED=legacy\nONLY_LEGACY=x",
);
expect(env).toEqual({
SHARED: { type: "user_secret_ref", key: "token" },
ONLY_LEGACY: { type: "plain", value: "x" },
});
});
it("tolerates missing legacy text", () => {
expect(buildAdapterEnvConfig({ A: { type: "plain", value: "1" } }, undefined)).toEqual({
A: { type: "plain", value: "1" },
});
});
});

View File

@ -0,0 +1,99 @@
// 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;
}

View File

@ -65,6 +65,11 @@ export {
redactCommandText,
} from "./command-redaction.js";
export { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
export {
buildAdapterEnvConfig,
parseEnvBindings,
parseEnvVars,
} from "./env-bindings.js";
export { createRuntimeProgressReporter } from "./runtime-progress.js";
export type {
RuntimeProgressSink,

View File

@ -47,4 +47,34 @@ describe("buildClaudeLocalConfig", () => {
expect(buildClaudeLocalConfig(makeValues({ claudeEngine: "cli" }))).toMatchObject({ engine: "cli" });
expect(buildClaudeLocalConfig(makeValues({ claudeEngine: "acp" }))).toMatchObject({ engine: "acp" });
});
it("keeps user-scoped env bindings so the server resolves them at test time", () => {
const config = buildClaudeLocalConfig(
makeValues({
envBindings: {
GH_TOKEN: { type: "user_secret_ref", key: "github_token", version: "latest", required: true },
},
}),
);
expect(config.env).toEqual({
GH_TOKEN: { type: "user_secret_ref", key: "github_token", version: "latest", required: true },
});
});
it("keeps company secret and plain env bindings", () => {
const config = buildClaudeLocalConfig(
makeValues({
envBindings: {
API_KEY: { type: "secret_ref", secretId: "11111111-1111-1111-1111-111111111111", version: "latest" },
FLAG: { type: "plain", value: "on" },
},
}),
);
expect(config.env).toEqual({
API_KEY: { type: "secret_ref", secretId: "11111111-1111-1111-1111-111111111111", version: "latest" },
FLAG: { type: "plain", value: "on" },
});
});
});

View File

@ -1,4 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
function parseCommaArgs(value: string): string[] {
return value
@ -7,49 +7,6 @@ function parseCommaArgs(value: string): string[] {
.filter(Boolean);
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
@ -84,13 +41,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
if (v.chrome) ac.chrome = true;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
ac.maxTurnsPerRun = v.maxTurnsPerRun;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;

View File

@ -1,4 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "../index.js";
function parseCommaArgs(value: string): string[] {
@ -8,49 +8,6 @@ function parseCommaArgs(value: string): string[] {
.filter(Boolean);
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
@ -79,13 +36,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
}
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
ac.search = v.search;
ac.fastMode = v.fastMode;

View File

@ -1,47 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildCursorCloudConfig(values: CreateConfigValues): Record<string, unknown> {
const config: Record<string, unknown> = {
@ -52,13 +9,7 @@ export function buildCursorCloudConfig(values: CreateConfigValues): Record<strin
if (values.bootstrapPrompt) config.bootstrapPromptTemplate = values.bootstrapPrompt;
if (values.model?.trim()) config.model = values.model.trim();
const env = parseEnvBindings(values.envBindings);
const legacy = parseEnvVars(values.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(values.envBindings, values.envVars);
if (Object.keys(env).length > 0) {
config.env = env;
}

View File

@ -1,4 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
@ -8,49 +8,6 @@ function parseCommaArgs(value: string): string[] {
.filter(Boolean);
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
function normalizeMode(value: string): "plan" | "ask" | null {
const mode = value.trim().toLowerCase();
if (mode === "plan" || mode === "ask") return mode;
@ -66,13 +23,7 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
if (mode) ac.mode = mode;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);

View File

@ -1,4 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
@ -8,49 +8,6 @@ function parseCommaArgs(value: string): string[] {
.filter(Boolean);
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@ -66,13 +23,7 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
ac.sandbox = !v.dangerouslyBypassSandbox;

View File

@ -1,4 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
@ -8,49 +8,6 @@ function parseCommaArgs(value: string): string[] {
.filter(Boolean);
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildGrokLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@ -59,13 +16,7 @@ export function buildGrokLocalConfig(v: CreateConfigValues): Record<string, unkn
ac.timeoutSec = 0;
ac.graceSec = 20;
if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;

View File

@ -1,4 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
function parseCommaArgs(value: string): string[] {
return value
@ -7,49 +7,6 @@ function parseCommaArgs(value: string): string[] {
.filter(Boolean);
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
@ -61,13 +18,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
ac.timeoutSec = 0;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);

View File

@ -1,47 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
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 (!/^[A-Za-z_][A-Za-z0-9_]*$/.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,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
import { buildAdapterEnvConfig, type CreateConfigValues } from "@paperclipai/adapter-utils";
export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
@ -54,13 +11,7 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
ac.timeoutSec = 0;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = v.extraArgs;

View File

@ -143,6 +143,24 @@ describe("AuditFeed", () => {
expect(container.textContent, `waiting for "${text}"`).toContain(text);
}
/**
* Poll until `predicate` holds. The access-downgrade recovery settles across
* several dependent async steps (the 403 error, the filter reset, and the
* basic-tier refetch). A fixed flush count can end mid-chain on a slow runner,
* so wait for the settled state instead. `label` names the awaited condition
* in the timeout error.
*/
async function waitForCondition(predicate: () => boolean, label: string, timeoutMs: number) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 25));
});
}
expect(predicate(), `waiting for ${label}`).toBe(true);
}
function clickButton(text: string) {
const btn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
expect(btn, `button "${text}"`).toBeTruthy();
@ -230,7 +248,18 @@ describe("AuditFeed", () => {
setInputValue!.call(fromDate, "2026-08-01");
fromDate!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
// The recovery settles across the 403, the filter reset, and the basic-tier
// refetch. The privileged filter fires first (from set), then the recovery
// refetch clears it (from undefined). Wait for that recovery refetch and the
// dropped filter chrome instead of a fixed flush count.
await waitForCondition(
() =>
listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)
&& listAgentActionsMock.mock.calls.at(-1)?.[1]?.from === undefined
&& !container.textContent?.includes("All agents"),
"the basic feed after the access downgrade",
20_000,
);
expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)).toBe(true);
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
@ -273,7 +302,16 @@ describe("AuditFeed", () => {
expect(container.textContent).toContain("Export CSV");
permissionRevoked = true;
await clickButton("Load more");
await flushReact();
// The basic second page and the recovery refetch settle across several async
// steps. Wait for the recovery refetch and the dropped attribution instead of
// a fixed flush count.
await waitForCondition(
() =>
listAgentActionsMock.mock.calls.at(-1)?.[1]?.cursor === undefined
&& !container.textContent?.includes("on behalf of Dotta"),
"the basic feed after the pagination downgrade",
20_000,
);
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ actorScope: "all", cursor: undefined }),