Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/src/components/cos/AppProviderPin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export default function AppProviderPin({
disabled = false,
loading = false,
compact = false,
layout = 'row'
layout = 'row',
selectionPolicy
}) {
const selectedProviderId = providerId || '';
const selectedModel = model || '';
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function AppProviderPin({
loading={loading}
compact={compact}
layout={layout}
selectionPolicy={selectionPolicy}
/>
);
}
10 changes: 3 additions & 7 deletions client/src/components/cos/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -351,14 +351,10 @@ export {
sanitizeReviewerModelInput
} from '../../lib/reviewerPins';

// pr-watcher author gate (taskMetadata.prAuthorFilter). Mirrors
// PR_AUTHOR_FILTERS in server/lib/validation.js. 'self' = PRs opened by the
// gh-authenticated operator (or their automation); 'others' = external
// contributors; 'any' = react to every opened PR.
// pr-watcher owns trusted remediation. Legacy filter values remain accepted
// server-side for compatibility; every dispatch enforces collaborator trust.
export const PR_AUTHOR_FILTER_OPTIONS = [
{ value: 'any', label: 'Any author', description: 'React to every PR opened on the default branch' },
{ value: 'self', label: 'Opened by me', description: 'Only PRs opened by the gh-authenticated user (or their automation)' },
{ value: 'others', label: 'Opened by others', description: 'Only PRs opened by someone other than the gh-authenticated user' }
{ value: 'trusted', label: 'Owner and write collaborators', description: 'Verified repository collaborators and the signed-in operator; external PRs use PR Reviewer' }
];

// claim-issue author gate (taskMetadata.issueAuthorFilter). Mirrors
Expand Down
1 change: 1 addition & 0 deletions client/src/components/cos/tabs/schedule/AppOverrideRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
<div className="w-full sm:w-auto sm:min-w-[240px] sm:max-w-[360px] sm:flex-1">
<AppProviderPin
providers={providers}
selectionPolicy={taskType === 'issue-watcher' ? { provider: (provider) => provider.type === 'api' } : undefined}
loading={!providersLoaded}
providerId={override?.providerId}
model={override?.model}
Expand Down
30 changes: 11 additions & 19 deletions client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import useReviewerModelOptions from '../../../../hooks/useReviewerModelOptions';
import { reviewerModelsFromDefaults, reviewerEffortsFromDefaults } from '../../../../lib/reviewerModels';
import ToggleSwitch from '../../../ToggleSwitch';
import useTaskModelPins from '../../../../hooks/useTaskModelPins';
import { effectiveModelFor } from '../../../../utils/providers';
import { effectiveModelFor, selectableProviders } from '../../../../utils/providers';
import EffortSelect from '../../EffortSelect';
import PromptEditor from './PromptEditor';
import RunTaskButton from './RunTaskButton';
Expand Down Expand Up @@ -82,6 +82,7 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
provider: selectedProvider,
defaultProviderLabel,
availableModels,
toolFree,
changeProvider: handleProviderChange,
changeModel: handleModelChange,
changeEffort: handleEffortChange,
Expand Down Expand Up @@ -150,16 +151,6 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
setUpdating(false);
};

const handlePrAuthorFilterChange = async (value) => {
setUpdating(true);
// Send the full merged taskMetadata — updateTaskInterval replaces the
// object wholesale, and loadSchedule re-merges defaults on read.
await onUpdate(taskType, {
taskMetadata: { ...(config.taskMetadata || {}), prAuthorFilter: value }
});
setUpdating(false);
};

const handleIssueAuthorFilterChange = async (value) => {
setUpdating(true);
await onUpdate(taskType, {
Expand Down Expand Up @@ -398,11 +389,13 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri
{/* Mid-fetch the list is empty, so "Default (active provider)" would
be this select's only option — a slow control that reads broken. */}
<option value="">{providersLoaded ? defaultProviderLabel : 'Loading providers…'}</option>
{providers?.map(provider => (
<option key={provider.id} value={provider.id}>{provider.name}</option>
{selectableProviders(providers || [], { selectedId: selectedProviderId, allowed: toolFree ? (provider) => provider.type === 'api' : undefined }).map(provider => (
<option key={provider.id} value={provider.id} disabled={toolFree && provider.type !== 'api'}>{provider.name}{toolFree && provider.type !== 'api' ? ' (API provider required)' : ''}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Leave as default to use the currently active provider</p>
<p className="text-xs text-gray-500 mt-1">{toolFree
? <>Uses a text API with no tools. Default follows <a href="/models/llms/abuse" className="text-port-accent underline">Abuse Guard source settings</a>; a saved CLI provider must be cleared or replaced.</>
: 'Leave as default to use the currently active provider'}</p>
</FormField>

<FormField label="Model (optional)" labelClassName="text-sm text-gray-400 block mb-2">
Expand Down Expand Up @@ -438,20 +431,19 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri

{taskType === 'pr-watcher' && (
<div>
<label htmlFor={`pr-author-filter-${taskType}`} className="text-sm text-gray-400 block mb-2">PR Author Filter</label>
<label htmlFor={`pr-author-filter-${taskType}`} className="text-sm text-gray-400 block mb-2">PR Remediation Scope</label>
<select
id={`pr-author-filter-${taskType}`}
value={config.taskMetadata?.prAuthorFilter || 'any'}
onChange={(e) => handlePrAuthorFilterChange(e.target.value)}
disabled={updating}
value="trusted"
disabled
className="w-full bg-port-card border border-port-border rounded px-3 py-2 text-white text-sm"
>
{PR_AUTHOR_FILTER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
{PR_AUTHOR_FILTER_OPTIONS.find(o => o.value === (config.taskMetadata?.prAuthorFilter || 'any'))?.description}
{PR_AUTHOR_FILTER_OPTIONS.find(o => o.value === 'trusted')?.description}
{' '}Edit the prompt below to control what the agent does for each opened PR (it can use <code>{'{prData}'}</code>, <code>{'{repoFullName}'}</code>, <code>{'{defaultBranch}'}</code>).
</p>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ const BASE_CONFIG = {
// The real `onUpdate` (ScheduleTab's handleUpdateTask) is async, and several
// handlers here attach a rejection handler to what it returns — so the default
// mock must resolve a promise, not `undefined`.
function renderControls({ taskMetadata, onUpdate = vi.fn(async () => {}), taskType = 'feature-ideas', config: extraConfig = {}, setUpdating = () => {} } = {}) {
function renderControls({ taskMetadata, onUpdate = vi.fn(async () => {}), taskType = 'feature-ideas', config: extraConfig = {}, setUpdating = () => {}, providers = [] } = {}) {
render(
<GlobalConfigControls
taskType={taskType}
config={{ ...BASE_CONFIG, taskMetadata, ...extraConfig }}
onUpdate={onUpdate}
onTrigger={() => {}}
providers={[]}
providers={providers}
apps={[]}
updating={false}
setUpdating={setUpdating}
Expand Down Expand Up @@ -306,3 +306,24 @@ describe('GlobalConfigControls — cadence + perpetual', () => {
expect(screen.queryByText('Recheck Cadence')).not.toBeInTheDocument();
});
});


describe('GlobalConfigControls — external issue isolation', () => {
it('offers text API providers and explains the source policy while retaining an invalid saved pin', async () => {
const onUpdate = renderControls({
taskType: 'issue-watcher',
config: { providerId: 'coding-cli', promptMode: 'runtime-generated' },
providers: [
{ id: 'coding-cli', name: 'Coding CLI', type: 'cli', enabled: true },
{ id: 'local-api', name: 'Local API', type: 'api', enabled: true },
{ id: 'another-cli', name: 'Another CLI', type: 'cli', enabled: true },
],
});
expect(screen.getByRole('option', { name: 'Coding CLI (API provider required)' })).toBeDisabled();
expect(screen.getByRole('option', { name: 'Local API' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Another CLI' })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Abuse Guard source settings' })).toHaveAttribute('href', '/models/llms/abuse');
await act(async () => { fireEvent.change(screen.getByLabelText('Provider (optional)'), { target: { value: '' } }); });
expect(onUpdate).toHaveBeenCalledWith('issue-watcher', { providerId: null, model: null, effort: null });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default function PerAppOverrideList({ taskType, config, apps, providers,
// that pins nothing of its own actually runs on.
const inheritedProviderText = config.providerId
? providerModelLabel(providers || [], config.providerId, config.model)
: 'the active provider';
: taskType === 'issue-watcher' ? 'Abuse Guard source policy' : 'the active provider';

if (activeApps.length === 0) return null;

Expand Down
39 changes: 12 additions & 27 deletions client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const eligibleProvidersFor = (providers, policy) =>
const providerNames = (providers) => providers.map((p) => p.name || p.id).join(', ');

// Constant now that the eligible set is left to the dropdown.
const NO_TOOL_STAGE_NOTE = "Tool-free stage. A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one. It returns only a binary allowlist; the final stage never receives rejected content.";
const NO_TOOL_STAGE_NOTE = "A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one.";

// Every enabled CLI/TUI provider can run the actions stage; the note says which
// of them the server additionally wraps in the vendor's own OS sandbox, so a
Expand Down Expand Up @@ -129,7 +129,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi
<div>
<p className="text-sm font-medium text-white">Run final code review and actions</p>
<p className="text-xs text-gray-400 mt-1">
When enabled, a sandbox-capable reviewer applies only the screened patch, runs local tests, and returns a structured review for the deterministic GitHub coordinator. It is nested here, not a separate scheduled task.
When enabled, a tool-free reviewer analyzes screened PR content and returns a structured static review. The deterministic GitHub coordinator validates any resulting actions. Contributor code is never executed. This stage is nested here, not a separate scheduled task.
</p>
</div>
<ToggleSwitch
Expand All @@ -154,32 +154,17 @@ export default function PipelineStageConfig({ taskType, config, providers, provi
? (prReviewerStageRole(stage) || (i === 0 ? 'security' : i === 1 ? 'eligibility' : 'actions'))
: null;
const isSecurityStage = role === 'security';
// The posture is read off the stage's own execution profile, so a
// custom pipeline that reuses one of these profiles gets the same
// gating without being a pr-reviewer stage.
const posture = isSecurityStage ? null : stagePublicReviewPosture(stage);
// PR roles reassert the server's no-tool contract even for legacy
// profiles or position-only stages. Other pipelines keep their profile.
const posture = isSecurityStage ? null : stagePublicReviewPosture(role ? { ...stage, role } : stage);
const isNoToolStage = posture === PUBLIC_REVIEW_NO_TOOL_POSTURE;
const isActionsStage = Boolean(posture) && !isNoToolStage;
const eligibleProviders = posture ? eligibleProvidersFor(providers, selectionPolicies[posture]) : null;
const localBackend = localBackendForProvider(stageProvider);
const localModelIds = localBackend === 'ollama' ? ollama : localBackend === 'lmstudio' ? lmstudio : [];
// A LOCAL provider's installed-model list is the source of truth (its
// stored catalog is a cached snapshot, and only an installed model has
// a probeable capability report). That holds for the sandboxed actions
// stage as much as the tool-free gate — offering the stale catalog
// there let a stage be pinned to a model the daemon no longer serves,
// and hid one that had just been pulled. Every other provider uses its
// own catalog, so a cloud CLI stage can pick any model it offers.
//
// `useLocalModels` reports BOTH "not fetched yet" and "daemon said
// nothing" as `[]`, so an empty list is not evidence the daemon serves
// no models. The two stages part ways on what to do about that. The
// tool-free gate must stay strict: its policy needs a probeable
// capability report, and a model with none is not selectable at all,
// so an empty list correctly offers nothing. The actions stage has no
// such gate, so it falls back to the record's catalog — otherwise a
// stopped daemon (or the in-flight window) renders an empty picker and
// drops the stage's own saved pin out of the dropdown.
// Both PR analysis stages require the local daemon's capability report.
// Cached models cannot establish no-tool eligibility when it is offline.
// Generic sandboxed-action stages retain their catalog fallback.
const localStageModels = localModelIds.map(id => ({
id,
name: id,
Expand All @@ -202,7 +187,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi
<span className="text-[10px] px-1 py-0.5 bg-gray-600/30 text-gray-400 rounded">read-only</span>
)}
{isNoToolStage && (
<span className="text-[10px] px-1 py-0.5 bg-port-accent/15 text-port-accent rounded">tool-free gate</span>
<span className="text-[10px] px-1 py-0.5 bg-port-accent/15 text-port-accent rounded">{role === 'actions' ? 'tool-free review' : 'tool-free gate'}</span>
)}
{isActionsStage && (
<span className="text-[10px] px-1 py-0.5 bg-port-accent-2/15 text-port-accent-2 rounded">sandboxed actions</span>
Expand All @@ -217,7 +202,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi
<p className="font-medium text-port-accent">Deterministic hidden-content screen</p>
<p className="mt-1">Server-side checks on each external PR&apos;s complete title, description, and diff for content a human reviewer would miss — invisible or direction-control Unicode, comments GitHub never renders that address a model — and for obvious model-directed harm: instruction overrides, decode-and-follow or download-and-run instructions, credential exfiltration, and attempts to steer the review verdict. No model, tools, repository checkout, or GitHub credentials are involved.</p>
<p className="mt-1 text-gray-500">
The pinned Llama Prompt Guard 2 classifier runs as an optional second layer only when it is installed on{' '}
The pinned Llama Prompt Guard 2 classifier is required by default. Install and configure it on{' '}
<Link to="/models/llms/abuse" className="underline hover:text-port-accent">Models → LLMs → Abuse Guard</Link>.
</p>
</div>
Expand Down Expand Up @@ -254,7 +239,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi
<p className="text-xs text-gray-500 mt-2">
{localModelsLoading
? 'Loading installed local model capability reports…'
: NO_TOOL_STAGE_NOTE}
: `${role === 'actions' ? 'Tool-free review. Returns a structured static review for server-validated actions; it cannot run contributor code or tests.' : role === 'eligibility' ? 'Tool-free stage. Returns only a binary allowlist; rejected content never reaches the final review.' : 'Tool-free stage.'} ${NO_TOOL_STAGE_NOTE}`}
</p>
)}
{isActionsStage && eligibleProviders?.length > 0 && (
Expand All @@ -273,7 +258,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi
</div>
<p className="text-xs text-gray-500 mt-2">
{needsSecurityModelPolicy
? 'Stage 1 screens complete public content with a managed classifier; only cleared content reaches the tool-free Eligibility Gate, and only eligible PRs reach the optional sandboxed final review. Stages are nested, not independently scheduled.'
? 'Stage 1 screens complete public content with a managed classifier; only cleared content reaches the tool-free Eligibility Gate, and only eligible PRs reach the optional tool-free final review. The server validates resulting GitHub actions. Stages are nested, not independently scheduled.'
: 'Each stage runs as a separate agent inside this pipeline; stages are not scheduled independently.'}
{' Configure a different provider, model, and thinking effort per stage.'}
</p>
Expand Down
Loading