Skip to content

fix: pass adapter command from UI form for model discovery#1920

Open
mjaverto wants to merge 3 commits intopaperclipai:masterfrom
mjaverto:fix/opencode-models-command-passthrough
Open

fix: pass adapter command from UI form for model discovery#1920
mjaverto wants to merge 3 commits intopaperclipai:masterfrom
mjaverto:fix/opencode-models-command-passthrough

Conversation

@mjaverto
Copy link
Copy Markdown

Summary

  • The Model dropdown on agent configuration pages was always empty for adapters with custom command binaries (e.g. opencode_local). The server needed to run the configured binary to discover models, but the previous approach required an agentId DB lookup — meaning users had to save first before models would populate.
  • The UI now sends the command value directly from the live form state as a query parameter, so model discovery works immediately as the user types — no save required.
  • Added 800ms debounce, server-side input validation, and 60s stale time on model queries.

Security considerations

We evaluated two approaches:

Approach Pros Cons
agentId (DB lookup) Command is server-sourced Must save before dropdown works; broken UX
command (client param) Works before save; live preview Client controls binary path

We chose the direct command parameter because:

  1. local_trusted mode (primary deployment) is single-user on localhost — user already has shell access
  2. Existing precedenttest-environment endpoint and agent config save already accept user-supplied command paths at the same privilege level
  3. spawn() with shell: false — attacker controls only the executable path, not arguments (hardcoded to ["models"])
  4. Filesystem write required — exploiting this requires placing a malicious binary on the server, which implies pre-existing code execution
  5. Input validation — shell metacharacters are rejected as defense-in-depth

Mitigations added

  • Shell metacharacter regex validation on command query param
  • spawn() uses shell: false with fixed args ["models"]
  • 800ms UI debounce prevents subprocess storm on keystrokes
  • staleTime: 60_000 matches server-side discovery cache TTL

Test plan

  • pnpm -r typecheck — 19 packages pass
  • pnpm test:run — 683 tests pass, 0 failures
  • pnpm -r build — all packages build
  • Manual: edit agent command field, verify Model dropdown populates without saving
  • Manual: create new agent, type command path, verify models load
  • Manual: verify debounce (no request burst while typing)

Replaces #1905

🤖 Generated with Claude Code

mjaverto and others added 3 commits March 27, 2026 07:37
…el discovery

The models listing endpoint called listOpenCodeModels() with no arguments,
so it always fell back to the default "opencode" command. When the default
binary was unavailable (e.g. stale nix store path), the endpoint returned []
even though agents had a working custom command in their adapterConfig.

Thread an optional { command } through the call chain. The route accepts an
optional agentId query param and resolves the command from the agent's
persisted adapterConfig — never from raw user input — to avoid command
injection.

Fixes paperclipai#1904

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
getById takes a single id param — was incorrectly called with (companyId, agentId)
which silently passed companyId as the id, making the feature a no-op.
Added explicit companyId ownership check to prevent cross-company data leakage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The models endpoint previously required an agentId to look up the
configured command binary from the database. This meant users had to
save their agent config before the Model dropdown would work — broken
UX when configuring a new command path.

Now the UI sends the command value directly from the live form state,
so model discovery works immediately as the user types — no save needed.

Security note: we considered the agentId/DB-lookup approach (server-
sourced command) vs direct client parameter. The direct approach was
chosen because:
- local_trusted mode (primary deployment) is single-user on localhost
- The codebase already trusts user-supplied commands in agent config
  save and test-environment endpoints at the same privilege level
- spawn() uses shell:false with hardcoded args ["models"], so the
  attacker controls only the binary path, not arguments
- An attacker would need filesystem write access to place a malicious
  binary, at which point they already have code execution
- Input validation rejects shell metacharacters as defense-in-depth

Additional improvements from code review:
- 800ms debounce on command input to prevent subprocess storm
- staleTime: 60s on all adapter model queries (matches server cache)
- Shell metacharacter validation on server route

Replaces paperclipai#1905

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR fixes a broken UX where the Model dropdown was always empty for adapters with custom command binaries (e.g. opencode_local) because model discovery required a prior save. The fix passes the live command form value as a query parameter so models populate immediately as the user types, with server-side shell-metacharacter validation and a 60 s cache aligned with the existing server-side TTL.\n\nKey changes:\n- listModels interface widened to accept an optional { command } option, threaded through the registry and route handler.\n- Server validates the command query param against a shell-metacharacter regex and rejects empty/whitespace values.\n- AgentConfigForm.tsx correctly introduces an 800 ms debounced debouncedCommand so keystrokes don't storm the server with subprocesses.\n- NewAgent.tsx and OnboardingWizard.tsx use raw command state directly in the query key and queryFn with no debounce, meaning every keystroke still fires an immediate HTTP request and a new server-side subprocess — directly contradicting the stated mitigation.\n- AgentDetail.tsx and NewIssueDialog.tsx correctly read the command from the already-persisted agent config, so no debounce is needed there.\n\nProcess note: Per CONTRIBUTING.md, PR descriptions should include a thinking path that walks from the top of the project down to the specific fix, as well as before/after screenshots for UI/behavioural changes. Both are missing from this PR's description.

Confidence Score: 4/5

Do not merge until the missing debounce in NewAgent.tsx and OnboardingWizard.tsx is addressed — the stated subprocess-storm mitigation is currently ineffective on those two pages.

Two P1 findings: both NewAgent.tsx and OnboardingWizard.tsx pass raw command state directly into React Query without debouncing, defeating the 800 ms debounce carefully added in AgentConfigForm.tsx. Every keystroke still spawns a new server-side child process from those pages. The rest of the change is correct and well-structured.

ui/src/pages/NewAgent.tsx and ui/src/components/OnboardingWizard.tsx need a debounced command value before the query key/fn construction.

Important Files Changed

Filename Overview
ui/src/pages/NewAgent.tsx Adds command-aware model query but omits debounce, meaning every keystroke fires a new HTTP request and server-side subprocess — defeating the mitigation added in AgentConfigForm.tsx
ui/src/components/OnboardingWizard.tsx Passes command directly into the query key/fn without debounce, same per-keystroke subprocess-storm issue as NewAgent.tsx
ui/src/components/AgentConfigForm.tsx Correctly debounces command with an 800 ms timer before updating the query key/fn; staleTime aligned with server cache TTL
server/src/routes/agents.ts Adds shell-metacharacter validation and forwards command opt to listAdapterModels; backslash and relative path traversal not rejected but low-risk given shell:false and local_trusted deployment
packages/adapters/opencode-local/src/server/models.ts Threads optional command through to discoverOpenCodeModelsCached; cache key incorporates command so different binaries get separate cache entries
server/src/adapters/registry.ts Simple pass-through of opts to adapter.listModels — correct and minimal change
packages/adapter-utils/src/types.ts Widens listModels signature to accept optional command option — backward-compatible
ui/src/api/agents.ts Correctly appends optional command as a query parameter using URLSearchParams
ui/src/lib/queryKeys.ts Adds command to adapter-models cache key so different commands get distinct cache entries
ui/src/pages/AgentDetail.tsx Reads savedCommand from persisted agent config — correct, no debounce needed for edit page
ui/src/components/NewIssueDialog.tsx Reads command from already-saved assignee agent config, not a live form field — no debounce needed
packages/adapters/opencode-local/src/server/models.test.ts Adds test verifying a missing/invalid command gracefully resolves to an empty array
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/pages/NewAgent.tsx
Line: 89-96

Comment:
**Debounce bypassed — subprocess storm still possible**

`NewAgent.tsx` uses `configValues.command` directly in both the `queryKey` and `queryFn`, with no debouncing. `AgentConfigForm.tsx` (also rendered on this page) has its own debounced query using `debouncedCommand`, but that only gates `AgentConfigForm.tsx`'s own fetch. Because `NewAgent.tsx`'s query fires immediately on every `configValues.command` change, each keystroke in the command field produces a new query key → new HTTP request → new server-side subprocess.

Since the two queries share a key only once `debouncedCommand` has caught up (after 800 ms of no typing), the debounce protection added in `AgentConfigForm.tsx` provides no benefit for the `NewAgent.tsx` create flow. `OnboardingWizard.tsx` has the same problem since its `command` state is also used directly without a debounce.

**Fix**: introduce a locally debounced command value in `NewAgent.tsx` (matching the 800 ms `useEffect` timer pattern already used in `AgentConfigForm.tsx`), and use that debounced value in the `queryKey` and `queryFn` instead of `configValues.command` directly.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/components/OnboardingWizard.tsx
Line: 200-208

Comment:
**No debounce on `command` — per-keystroke subprocess spawning**

Like `NewAgent.tsx`, this query uses the raw `command` state value directly. Each character the user types changes the query key, triggering a new HTTP request and a new server-side `opencode models` subprocess immediately. The 800 ms debounce added in `AgentConfigForm.tsx` has no effect here because this component manages its own `command` state and query independently.

A `debouncedCommand` local state (same 800 ms pattern) should be introduced for the query key and queryFn to prevent the subprocess storm on each keystroke.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: server/src/routes/agents.ts
Line: 673

Comment:
**`\` (backslash) and `..` (path traversal) not rejected**

The current regex blocks common shell metacharacters but does not block `\` (Windows path separator) or relative path traversal sequences like `../../tmp/malicious`. With `shell: false` none of these characters enable command injection, and the PR correctly acknowledges that reaching an arbitrary binary still requires pre-existing filesystem write access. However, if the goal is defense-in-depth, consider also rejecting `\` and possibly enforcing that the value is an absolute path.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: pass adapter command from UI form f..." | Re-trigger Greptile

Comment on lines 89 to 96
queryKey: selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
: ["agents", "none", "adapter-models", configValues.adapterType],
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType, configValues.command || undefined)
: ["agents", "none", "adapter-models", configValues.adapterType, configValues.command || ""],
queryFn: () =>
agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType, configValues.command ? { command: configValues.command } : undefined),
enabled: Boolean(selectedCompanyId),
staleTime: 60_000,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Debounce bypassed — subprocess storm still possible

NewAgent.tsx uses configValues.command directly in both the queryKey and queryFn, with no debouncing. AgentConfigForm.tsx (also rendered on this page) has its own debounced query using debouncedCommand, but that only gates AgentConfigForm.tsx's own fetch. Because NewAgent.tsx's query fires immediately on every configValues.command change, each keystroke in the command field produces a new query key → new HTTP request → new server-side subprocess.

Since the two queries share a key only once debouncedCommand has caught up (after 800 ms of no typing), the debounce protection added in AgentConfigForm.tsx provides no benefit for the NewAgent.tsx create flow. OnboardingWizard.tsx has the same problem since its command state is also used directly without a debounce.

Fix: introduce a locally debounced command value in NewAgent.tsx (matching the 800 ms useEffect timer pattern already used in AgentConfigForm.tsx), and use that debounced value in the queryKey and queryFn instead of configValues.command directly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/pages/NewAgent.tsx
Line: 89-96

Comment:
**Debounce bypassed — subprocess storm still possible**

`NewAgent.tsx` uses `configValues.command` directly in both the `queryKey` and `queryFn`, with no debouncing. `AgentConfigForm.tsx` (also rendered on this page) has its own debounced query using `debouncedCommand`, but that only gates `AgentConfigForm.tsx`'s own fetch. Because `NewAgent.tsx`'s query fires immediately on every `configValues.command` change, each keystroke in the command field produces a new query key → new HTTP request → new server-side subprocess.

Since the two queries share a key only once `debouncedCommand` has caught up (after 800 ms of no typing), the debounce protection added in `AgentConfigForm.tsx` provides no benefit for the `NewAgent.tsx` create flow. `OnboardingWizard.tsx` has the same problem since its `command` state is also used directly without a debounce.

**Fix**: introduce a locally debounced command value in `NewAgent.tsx` (matching the 800 ms `useEffect` timer pattern already used in `AgentConfigForm.tsx`), and use that debounced value in the `queryKey` and `queryFn` instead of `configValues.command` directly.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 200 to 208
} = useQuery({
queryKey: createdCompanyId
? queryKeys.agents.adapterModels(createdCompanyId, adapterType)
: ["agents", "none", "adapter-models", adapterType],
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
? queryKeys.agents.adapterModels(createdCompanyId, adapterType, command || undefined)
: ["agents", "none", "adapter-models", adapterType, command || ""],
queryFn: () =>
agentsApi.adapterModels(createdCompanyId!, adapterType, command ? { command } : undefined),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2,
staleTime: 60_000,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No debounce on command — per-keystroke subprocess spawning

Like NewAgent.tsx, this query uses the raw command state value directly. Each character the user types changes the query key, triggering a new HTTP request and a new server-side opencode models subprocess immediately. The 800 ms debounce added in AgentConfigForm.tsx has no effect here because this component manages its own command state and query independently.

A debouncedCommand local state (same 800 ms pattern) should be introduced for the query key and queryFn to prevent the subprocess storm on each keystroke.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/components/OnboardingWizard.tsx
Line: 200-208

Comment:
**No debounce on `command` — per-keystroke subprocess spawning**

Like `NewAgent.tsx`, this query uses the raw `command` state value directly. Each character the user types changes the query key, triggering a new HTTP request and a new server-side `opencode models` subprocess immediately. The 800 ms debounce added in `AgentConfigForm.tsx` has no effect here because this component manages its own `command` state and query independently.

A `debouncedCommand` local state (same 800 ms pattern) should be introduced for the query key and queryFn to prevent the subprocess storm on each keystroke.

How can I resolve this? If you propose a fix, please make it concise.

const command = typeof req.query.command === "string" ? req.query.command : undefined;
if (command !== undefined) {
// Reject shell metacharacters and empty/whitespace-only values
if (!command.trim() || /[;&|`$(){}[\]!#~]/.test(command)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 \ (backslash) and .. (path traversal) not rejected

The current regex blocks common shell metacharacters but does not block \ (Windows path separator) or relative path traversal sequences like ../../tmp/malicious. With shell: false none of these characters enable command injection, and the PR correctly acknowledges that reaching an arbitrary binary still requires pre-existing filesystem write access. However, if the goal is defense-in-depth, consider also rejecting \ and possibly enforcing that the value is an absolute path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/routes/agents.ts
Line: 673

Comment:
**`\` (backslash) and `..` (path traversal) not rejected**

The current regex blocks common shell metacharacters but does not block `\` (Windows path separator) or relative path traversal sequences like `../../tmp/malicious`. With `shell: false` none of these characters enable command injection, and the PR correctly acknowledges that reaching an arbitrary binary still requires pre-existing filesystem write access. However, if the goal is defense-in-depth, consider also rejecting `\` and possibly enforcing that the value is an absolute path.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant