From f5e9ca3e89f806b404fa075cd00785967a3f29be Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 5 Aug 2026 17:18:41 -0700 Subject: [PATCH] fix(adapters): keep user-scoped env bindings on the agent Test action (#10926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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** c09d2509e3266cc24129ba752ea240b3c0378609 **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 --- .../adapter-utils/src/env-bindings.test.ts | 82 +++++++++++++++ packages/adapter-utils/src/env-bindings.ts | 99 +++++++++++++++++++ packages/adapter-utils/src/index.ts | 5 + .../claude-local/src/ui/build-config.test.ts | 30 ++++++ .../claude-local/src/ui/build-config.ts | 53 +--------- .../codex-local/src/ui/build-config.ts | 53 +--------- .../cursor-cloud/src/ui/build-config.ts | 53 +--------- .../cursor-local/src/ui/build-config.ts | 53 +--------- .../gemini-local/src/ui/build-config.ts | 53 +--------- .../grok-local/src/ui/build-config.ts | 53 +--------- .../opencode-local/src/ui/build-config.ts | 53 +--------- .../adapters/pi-local/src/ui/build-config.ts | 53 +--------- ui/src/pages/audit/AuditFeed.test.tsx | 42 +++++++- 13 files changed, 272 insertions(+), 410 deletions(-) create mode 100644 packages/adapter-utils/src/env-bindings.test.ts create mode 100644 packages/adapter-utils/src/env-bindings.ts diff --git a/packages/adapter-utils/src/env-bindings.test.ts b/packages/adapter-utils/src/env-bindings.test.ts new file mode 100644 index 0000000000..a3d1dd78d3 --- /dev/null +++ b/packages/adapter-utils/src/env-bindings.test.ts @@ -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" }, + }); + }); +}); diff --git a/packages/adapter-utils/src/env-bindings.ts b/packages/adapter-utils/src/env-bindings.ts new file mode 100644 index 0000000000..efdd57b858 --- /dev/null +++ b/packages/adapter-utils/src/env-bindings.ts @@ -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 { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + 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; + 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 { + const env: Record = {}; + 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 { + 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; +} diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index dc5db8865f..5df19260aa 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -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, diff --git a/packages/adapters/claude-local/src/ui/build-config.test.ts b/packages/adapters/claude-local/src/ui/build-config.test.ts index 3f8d3626bf..30eb04c9d7 100644 --- a/packages/adapters/claude-local/src/ui/build-config.test.ts +++ b/packages/adapters/claude-local/src/ui/build-config.test.ts @@ -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" }, + }); + }); }); diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index c17fcd7178..9262984806 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 | null { const trimmed = text.trim(); if (!trimmed) return null; @@ -84,13 +41,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.maxTurnsPerRun = v.maxTurnsPerRun; ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index e82293fefe..1a1da822b8 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 | null { const trimmed = text.trim(); if (!trimmed) return null; @@ -79,13 +36,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.search = v.search; ac.fastMode = v.fastMode; diff --git a/packages/adapters/cursor-cloud/src/ui/build-config.ts b/packages/adapters/cursor-cloud/src/ui/build-config.ts index 3e37804028..7ffecbaa24 100644 --- a/packages/adapters/cursor-cloud/src/ui/build-config.ts +++ b/packages/adapters/cursor-cloud/src/ui/build-config.ts @@ -1,47 +1,4 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; - -function parseEnvVars(text: string): Record { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const config: Record = { @@ -52,13 +9,7 @@ export function buildCursorCloudConfig(values: CreateConfigValues): Record 0) { config.env = env; } diff --git a/packages/adapters/cursor-local/src/ui/build-config.ts b/packages/adapters/cursor-local/src/ui/build-config.ts index 0bc09e8312..a6da4ab930 100644 --- a/packages/adapters/cursor-local/src/ui/build-config.ts +++ b/packages/adapters/cursor-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 0) ac.env = env; if (v.command) ac.command = v.command; if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts index baa5418308..d500b8ef65 100644 --- a/packages/adapters/gemini-local/src/ui/build-config.ts +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -66,13 +23,7 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.sandbox = !v.dangerouslyBypassSandbox; diff --git a/packages/adapters/grok-local/src/ui/build-config.ts b/packages/adapters/grok-local/src/ui/build-config.ts index 6c9e9a66d5..37c9a48863 100644 --- a/packages/adapters/grok-local/src/ui/build-config.ts +++ b/packages/adapters/grok-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -59,13 +16,7 @@ export function buildGrokLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; if (v.command) ac.command = v.command; diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index a6ab272888..17d9da9509 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -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 { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const ac: Record = {}; if (v.cwd) ac.cwd = v.cwd; @@ -61,13 +18,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; if (v.command) ac.command = v.command; if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); diff --git a/packages/adapters/pi-local/src/ui/build-config.ts b/packages/adapters/pi-local/src/ui/build-config.ts index 470ecedaf0..06927f6f8d 100644 --- a/packages/adapters/pi-local/src/ui/build-config.ts +++ b/packages/adapters/pi-local/src/ui/build-config.ts @@ -1,47 +1,4 @@ -import type { CreateConfigValues } from "@paperclipai/adapter-utils"; - -function parseEnvVars(text: string): Record { - const env: Record = {}; - 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 { - if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; - const env: Record = {}; - 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; - 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 { const ac: Record = {}; @@ -54,13 +11,7 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; if (v.command) ac.command = v.command; if (v.extraArgs) ac.extraArgs = v.extraArgs; diff --git a/ui/src/pages/audit/AuditFeed.test.tsx b/ui/src/pages/audit/AuditFeed.test.tsx index 5b4f4bb661..bfec859faa 100644 --- a/ui/src/pages/audit/AuditFeed.test.tsx +++ b/ui/src/pages/audit/AuditFeed.test.tsx @@ -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 }),