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:
parent
b1b7a9dff6
commit
f5e9ca3e89
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
Loading…
Reference in New Issue