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
15 changes: 9 additions & 6 deletions client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ import {
// Which providers on THIS install the stage's picker actually offers. The
// posture half is server-published (`publicReviewPostures`, derived from the
// vendor rows — no vendor names on the client); the rest is the picker's own
// visibility rule, reused so the "eligible" note can never list a provider
// the dropdown hides (switched off, hardware-incompatible), which is what left
// Stage 3 looking unconfigurable.
// visibility rule, reused so the notes below can never name a provider the
// dropdown hides (switched off, hardware-incompatible). The eligible set is
// not re-listed in the copy — the dropdown already IS that list.
const eligibleProvidersFor = (providers, policy) =>
selectableProviders(providers, { allowed: policy.provider });

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.";

// 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
// choice that relies on the disposable worktree alone is a visible one.
Expand All @@ -39,13 +42,13 @@ const actionsStageNote = (eligibleProviders) => {
const sandboxed = eligibleProviders.filter(isSandboxed);
const worktreeOnly = eligibleProviders.filter((p) => !isSandboxed(p));
const isolation = worktreeOnly.length === 0
? ['Each runs headless inside its vendor\'s maintained OS sandbox.']
? ['Every selectable provider runs headless inside its vendor\'s maintained OS sandbox.']
: [
sandboxed.length > 0 && `OS-sandboxed by the vendor's own recipe: ${providerNames(sandboxed)}.`,
`Headless with standard permissions, isolated by the disposable worktree only: ${providerNames(worktreeOnly)}.`,
].filter(Boolean);
return [
`Sandboxed stage. Eligible on this install: ${providerNames(eligibleProviders)}.`,
'Sandboxed stage.',
...isolation,
'PortOS passes the selected provider, model, and thinking effort through, with no forge credential or configuration overlays; the deterministic coordinator owns comments, issue filing, CI triggers, and merges.',
].join(' ');
Expand Down Expand Up @@ -233,7 +236,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…'
: `Tool-free stage. Eligible on this install: ${eligibleProviders.map((p) => p.name || p.id).join(', ')}. 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.`}
: NO_TOOL_STAGE_NOTE}
</p>
)}
{isActionsStage && eligibleProviders?.length > 0 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,24 +174,28 @@ describe('PipelineStageConfig — posture-driven eligibility', () => {
expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'grok-cli']);
// A non-local provider's own catalog is selectable — the installed-local
// model list only applies where PortOS can probe capabilities.
expect(screen.getAllByText(/Eligible on this install: Grok/).length).toBe(2);
expect(screen.getByText(/^Tool-free stage\./)).toBeInTheDocument();
expect(screen.getByText(/^Sandboxed stage\./)).toBeInTheDocument();
});

// The bug behind #5906's blocked run: the CLI records were disabled and the
// TUI records enabled, so the note listed three "eligible" providers while
// the dropdown offered only the placeholder. Eligibility now follows what the
// server publishes for the ENABLED records — TUI siblings included.
it('lists only enabled providers as eligible, matching what the dropdown offers', () => {
// server publishes for the ENABLED records — TUI siblings included — and the
// dropdown is the only place that list is rendered.
it('offers only enabled providers in the dropdown, and names none of them in the note', () => {
renderWith([
{ id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', enabled: false, models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] },
{ id: 'codex-tui', name: 'Codex TUI', type: 'tui', command: 'codex', enabled: true, models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] },
{ id: 'grok-cli', name: 'Grok Build CLI', type: 'cli', command: 'grok', enabled: false, models: ['grok-4'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] },
]);
const providerSelects = screen.getAllByLabelText('Provider');
expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui']);
const note = screen.getByText(/Sandboxed stage\. Eligible on this install:/);
expect(note.textContent).toContain('Eligible on this install: Codex TUI.');
// The dropdown IS the eligible list; the note must not re-name providers,
// least of all the disabled ones.
const note = screen.getByText(/^Sandboxed stage\./);
expect(note.textContent).not.toContain('Grok Build CLI');
expect(note.textContent).not.toContain('Codex CLI');
});

it('warns instead of silently offering nothing when a stage has no eligible provider', () => {
Expand All @@ -212,8 +216,10 @@ describe('PipelineStageConfig — posture-driven eligibility', () => {
const providerSelects = screen.getAllByLabelText('Provider');
expect([...providerSelects[0].options].map((o) => o.value)).toEqual(['', 'codex-tui']);
expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui', 'opencode-tui']);
const note = screen.getByText(/Sandboxed stage\. Eligible on this install:/);
expect(note.textContent).toContain('Eligible on this install: Codex TUI, OpenCode TUI.');
const note = screen.getByText(/^Sandboxed stage\./);
// The eligible set is the dropdown's job; the note only separates the
// vendor-sandboxed providers from the worktree-only ones.
expect(note.textContent).not.toContain('Eligible on this install');
expect(note.textContent).toContain("OS-sandboxed by the vendor's own recipe: Codex TUI.");
expect(note.textContent).toContain('isolated by the disposable worktree only: OpenCode TUI.');
});
Expand Down