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
1 change: 1 addition & 0 deletions .changelog/next/added-issue-4143.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- AI Providers editor now warns when a custom CLI/TUI command isn't on the CoS Agent Runner's allowlist — the provider still saves and runs in direct-spawn mode, it just can't be launched by /spawn or /spawn-tui
46 changes: 44 additions & 2 deletions client/src/pages/AIProviders.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { AlertTriangle } from 'lucide-react';
import toast from '../components/ui/Toast';
import * as api from '../services/api';
import socket from '../services/socket';
import { filterSelectableModels, filterGenerationModels, isEmbeddingModel, mergeModelLists, configuredDefaultIn, localBackendForProvider, modelOptionLabel, providerTypeClass, isTuiProvider, isApiProvider, isProcessProvider, supportsModelRefresh, isGrokBuildCli, isLocalEndpoint, effectiveModelContextWindow } from '../utils/providers';
import { filterSelectableModels, filterGenerationModels, isEmbeddingModel, mergeModelLists, configuredDefaultIn, localBackendForProvider, modelOptionLabel, providerTypeClass, isTuiProvider, isApiProvider, isProcessProvider, supportsModelRefresh, isGrokBuildCli, isLocalEndpoint, effectiveModelContextWindow, isRunnerAllowedCommand } from '../utils/providers';
import useLocalModels from '../hooks/useLocalModels';
import BrailleSpinner from '../components/BrailleSpinner';
import EmptyState from '../components/EmptyState';
Expand All @@ -21,6 +22,10 @@ import CodeReviewDefaultsPanel from '../components/providers/CodeReviewDefaultsP
import Modal from '../components/ui/Modal';
import { FormField } from '../components/ui/FormField';

// One phrasing for "this command isn't on the CoS Agent Runner's allowlist",
// shared by the provider-card badge tooltip and the editor's inline banner.
const RUNNER_NOT_ALLOWED_HINT = 'This command is not on the CoS Agent Runner’s allowlist, so /spawn and /spawn-tui will refuse it. The provider still works everywhere else (direct spawn, chat, pipeline). The allowlist is curated in the PortOS source, not in this form.';

// Privacy disclosure for the Grok Build CLI/TUI: its harness uploads the entire
// working repo to xAI (GCP) as it works unless the user opts out. Shown both on
// the provider card and in the create/edit form (before enabling) — see
Expand All @@ -39,6 +44,9 @@ disable_codebase_upload = true`}</pre>

export default function AIProviders() {
const [providers, setProviders] = useState([]);
// The CoS Agent Runner's exec allowlist, published by GET /api/providers.
// `null` = not fetched yet (or the fetch failed) — never warn from that state.
const [runnerAllowedCommands, setRunnerAllowedCommands] = useState(null);
const [statuses, setStatuses] = useState({}); // runtime availability by providerId (separate from the `enabled` toggle)
const [recovering, setRecovering] = useState({});
const [activeProviderId, setActiveProviderId] = useState(null);
Expand Down Expand Up @@ -110,6 +118,12 @@ export default function AIProviders() {
setLoadError(false);
setProviders(providersData.providers || []);
setActiveProviderId(providersData.activeProvider);
// Keep `null` (not an empty array) when an older server omits the field,
// so the "off the allowlist" warning stays silent rather than firing on
// every command.
setRunnerAllowedCommands(Array.isArray(providersData.runnerAllowedCommands)
? providersData.runnerAllowedCommands
: null);
}
setApps(appsData);
setRuns(runsData.runs || []);
Expand Down Expand Up @@ -534,6 +548,17 @@ export default function AIProviders() {
UNAVAILABLE{statuses[provider.id]?.reason ? ` · ${statuses[provider.id].reason}` : ''}
</span>
)}
{/* Off the CoS Agent Runner's exec allowlist: the provider still
works for direct spawn, it just can't be launched by /spawn
or /spawn-tui. Informational — never a save-time rejection. */}
{isProcessProvider(provider) && isRunnerAllowedCommand(provider.command, runnerAllowedCommands) === false && (
<span
className="text-xs px-2 py-0.5 rounded bg-port-warning/20 text-port-warning"
title={RUNNER_NOT_ALLOWED_HINT}
>
NO AGENT RUNNER
</span>
)}
</div>

{provider.enabled && statuses[provider.id]?.available === false && (
Expand Down Expand Up @@ -748,6 +773,7 @@ export default function AIProviders() {
<ProviderForm
provider={editingProvider}
allProviders={providers}
runnerAllowedCommands={runnerAllowedCommands}
onClose={() => { setShowForm(false); setEditingProvider(null); }}
onSave={() => { setShowForm(false); setEditingProvider(null); loadData(); }}
/>
Expand All @@ -757,7 +783,7 @@ export default function AIProviders() {
);
}

function ProviderForm({ provider, onClose, onSave, allProviders = [] }) {
function ProviderForm({ provider, onClose, onSave, allProviders = [], runnerAllowedCommands = null }) {
const [formData, setFormData] = useState({
name: provider?.name || '',
type: provider?.type || 'cli',
Expand Down Expand Up @@ -952,6 +978,22 @@ function ProviderForm({ provider, onClose, onSave, allProviders = [] }) {
required={formData.type === 'cli' || formData.type === 'tui'}
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden"
/>
{/* Informational only — an off-allowlist command saves fine and runs
fine in direct-spawn mode; it just can't be launched by the CoS
Agent Runner. Rejecting the save would break that valid config. */}
{isRunnerAllowedCommand(formData.command, runnerAllowedCommands) === false && (
<Banner tone="warning" icon={AlertTriangle} className="mt-2">
<p>
<code className="font-mono break-all">{formData.command}</code> is not on the CoS Agent Runner’s
command allowlist, so <code className="font-mono">/spawn</code> and{' '}
<code className="font-mono">/spawn-tui</code> will refuse it. Saving is fine — the provider still
runs in direct-spawn mode and everywhere else.
</p>
<p className="mt-1 text-port-warning/80 break-words">
Allowlisted: {runnerAllowedCommands.join(', ')}
</p>
</Banner>
)}
</FormField>

<FormField label="Arguments (space-separated)">
Expand Down
72 changes: 72 additions & 0 deletions client/src/pages/AIProviders.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,75 @@ describe('handleAddAllSamples partial failure handling', () => {
});
});

describe('CoS Agent Runner allowlist warning', () => {
const cliProvider = (command) => ({
id: 'p1', name: 'Custom Agent', type: 'cli', enabled: true, command, args: [],
});

beforeEach(() => {
vi.clearAllMocks();
api.getApps.mockResolvedValue([]);
api.getRuns.mockResolvedValue({ runs: [] });
api.getProviderStatuses.mockResolvedValue({ providers: {} });
});

it('badges a provider whose command is off the published allowlist', async () => {
api.getProviders.mockResolvedValue({
providers: [cliProvider('my-custom-agent')],
activeProvider: 'p1',
runnerAllowedCommands: ['claude', 'codex'],
});

renderPage();

expect(await screen.findByText('NO AGENT RUNNER')).toBeInTheDocument();
});

it('does not badge a provider whose command IS on the allowlist', async () => {
api.getProviders.mockResolvedValue({
providers: [cliProvider('/usr/local/bin/claude')],
activeProvider: 'p1',
runnerAllowedCommands: ['claude', 'codex'],
});

renderPage();

expect(await screen.findByText('Custom Agent')).toBeInTheDocument();
expect(screen.queryByText('NO AGENT RUNNER')).not.toBeInTheDocument();
});

// A server that predates #4143 omits `runnerAllowedCommands`; an unfetchable
// list must read as "can't tell", never as "nothing is allowed".
it('stays silent when the server omits runnerAllowedCommands', async () => {
api.getProviders.mockResolvedValue({
providers: [cliProvider('my-custom-agent')],
activeProvider: 'p1',
});

renderPage();

expect(await screen.findByText('Custom Agent')).toBeInTheDocument();
expect(screen.queryByText('NO AGENT RUNNER')).not.toBeInTheDocument();
});

it('warns inline in the editor as the command is typed, without blocking Save', async () => {
api.getProviders.mockResolvedValue({
providers: [cliProvider('claude')],
activeProvider: 'p1',
runnerAllowedCommands: ['claude', 'codex'],
});

renderPage();

fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));

const commandInput = await screen.findByDisplayValue('claude');
expect(screen.queryByText(/command allowlist/)).not.toBeInTheDocument();

fireEvent.change(commandInput, { target: { value: 'my-custom-agent' } });

expect(await screen.findByText(/command allowlist/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled();
});
});

2 changes: 1 addition & 1 deletion client/src/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ grep -i "what you want to do" client/src/utils/README.md
| `urlNormalize` | `isUrl` detection, `normalizeUrl` (optional git/`requireDot` modes), `isHttpUrl` (explicit http(s) only — safe-href check), and `tiktokVideoId` / `tiktokEmbedSrc` (host-anchored TikTok video-id extraction + its Embed Player URL, so a reference embeds without loading TikTok's embed.js). |
| `platform` | `isMac` detection and `modKey` (⌘/Ctrl) for keyboard-shortcut display. |
| `navWorkingSet` | Recent/pinned nav persistence (`recordVisit`, `togglePin`, `isPinned`) plus `resolveRecentNavEntries` for mapping stored deep links back to their longest matching nav-manifest entry. |
| `providers` | AI-provider type predicates and helpers (`isCliProvider`, `isApiProvider`, `isCodexProvider`, `isAntigravityProvider`, `filterSelectableModels`, `resolveCliEffort` (mirror — what a stored effort actually runs as, so the picker can name a clamped level), `configuredDefaultIn` — the sentinel a provider's catalog carries, so a picker can render an option matching a sentinel-valued tier instead of a blank select — `getProviderTimeout`, `resolveEffectiveProvider` — the provider a record actually runs on (its pin, else the active provider) plus whether it fell back, so a "Default" option can name what it resolves to — `resolveSeriesRunLlm` (mirror of server `seriesLlmOverride.js`: which provider/model a Pipeline **series** run resolves to — per-run override → `series.llm` → active provider) and `providerModelLabel` (the one "Provider / model" phrasing), configured-default sentinels, and the claude/codex/agy thinking-effort levels — `effortLevelsForProvider`, mirror of server `providerModels.js`). |
| `providers` | AI-provider type predicates and helpers (`isCliProvider`, `isApiProvider`, `isCodexProvider`, `isAntigravityProvider`, `filterSelectableModels`, `resolveCliEffort` (mirror — what a stored effort actually runs as, so the picker can name a clamped level), `configuredDefaultIn` — the sentinel a provider's catalog carries, so a picker can render an option matching a sentinel-valued tier instead of a blank select — `getProviderTimeout`, `resolveEffectiveProvider` — the provider a record actually runs on (its pin, else the active provider) plus whether it fell back, so a "Default" option can name what it resolves to — `resolveSeriesRunLlm` (mirror of server `seriesLlmOverride.js`: which provider/model a Pipeline **series** run resolves to — per-run override → `series.llm` → active provider) and `providerModelLabel` (the one "Provider / model" phrasing), configured-default sentinels, and the claude/codex/agy thinking-effort levels — `effortLevelsForProvider`, mirror of server `providerModels.js`). Also `isRunnerAllowedCommand(command, allowedCommands)` — would the CoS Agent Runner (`/spawn`, `/spawn-tui`) accept this command? Mirrors only the *normalization* in `server/cos-runner/allowedCommands.js` (the list itself arrives as `runnerAllowedCommands` on `GET /api/providers`, because the allowlist is an exec boundary and stays hand-curated server-side); returns `null` for "list not fetched / field blank" so a failed fetch never renders a warning. Pinned by `server/cos-runner/allowedCommands.parity.test.js`. |
| `layeredIntelligenceReasons` | Canonical gloss for the Layered Intelligence loop's run-outcome reason tokens, shared by the on-demand toast and the durable "Last run" line (`formatLiReason`, `liReasonTone`, `LI_NEUTRAL_REASONS`). |

## Module loading / resilience
Expand Down
45 changes: 45 additions & 0 deletions client/src/utils/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,51 @@ export const enabledApiProviderFilter = (provider) => Boolean(provider?.enabled)
*/
export const isProcessProvider = (provider) => isCliProvider(provider) || isTuiProvider(provider);

/**
* Base name of a spawn command, normalized the way the CoS Agent Runner's
* allowlist check does before its membership test: strip any directory
* prefix, then a trailing Windows `.exe`. Mirror of `isAllowedCommand`'s
* normalization in `server/cos-runner/allowedCommands.js`, pinned by
* `server/cos-runner/allowedCommands.parity.test.js`.
*
* The server uses `path.basename`, which is platform-specific — on a POSIX
* host a backslash is NOT a separator. This mirror always treats both `/` and
* `\` as separators, so a Windows-style path typed into the editor on a POSIX
* install reads as "allowed" when the server would spawn-time reject it. That
* direction is deliberate: this drives an informational warning, and a false
* *warning* about a path the user's own platform handles fine is worse than a
* missing one for a path shape that platform can't run anyway.
*/
const runnerCommandBaseName = (command) => {
const base = String(command).replace(/[/\\]+$/, '').split(/[/\\]/).pop();
// Only `.exe` — a `.cmd`/`.bat` npm shim is deliberately NOT stripped,
// matching the server: the spawn path runs with `shell: false` and cannot
// execute a batch shim, so accepting it would only move the failure later.
return base.toLowerCase().endsWith('.exe') ? base.slice(0, -4) : base;
};

/**
* Would the CoS Agent Runner (`/spawn`, `/spawn-tui`) accept this command?
*
* `allowedCommands` is the server-published list (`runnerAllowedCommands` on
* `GET /api/providers`) — the client never carries its own copy, because the
* allowlist is the runner's exec boundary and must stay hand-curated
* server-side rather than derived from user-writable provider config.
*
* Returns `null` for "can't tell" — the list hasn't been fetched, or the field
* is still blank — which is distinct from `false` ("fetched, and this command
* is definitely off the list"). Only an explicit `false` should render a
* warning; a failed fetch must not accuse a perfectly good command.
*
* The command is matched UNTRIMMED (past the blank guard), because the editor
* persists it untrimmed too — `'claude '` really would fail the runner's check.
*/
export const isRunnerAllowedCommand = (command, allowedCommands) => {
if (!Array.isArray(allowedCommands) || allowedCommands.length === 0) return null;
if (typeof command !== 'string' || command.trim() === '') return null;
return allowedCommands.includes(runnerCommandBaseName(command));
};

/**
* Whether `provider` is served by an Ollama daemon rather than its nominal
* cloud/CLI backend: the built-in `ollama` API provider itself (id match), an
Expand Down
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ PortOS is designed for personal/developer use on trusted networks. It implements

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/providers` | List all AI providers |
| GET | `/providers` | List all AI providers. Also returns `runnerAllowedCommands` — the CoS Agent Runner's exec allowlist, read-only, so the editor can warn that a custom `command` won't spawn via `/spawn` / `/spawn-tui`. |
| POST | `/providers` | Add new provider |
| PUT | `/providers/:id` | Update provider |
| DELETE | `/providers/:id` | Delete provider |
Expand Down
71 changes: 71 additions & 0 deletions server/cos-runner/allowedCommands.parity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Parity pin for the AI Providers editor's runner-allowlist warning (#4143).
*
* The client never carries its own copy of the allowlist — it receives it as
* `runnerAllowedCommands` on `GET /api/providers`, so the list itself cannot
* drift. What it DOES mirror is the normalization `isAllowedCommand` applies
* before the membership test (strip directory prefix, strip a trailing
* `.exe`), and that mirror is what this file pins.
*/

import { describe, it, expect } from 'vitest';
import { ALLOWED_COMMANDS, isAllowedCommand } from './allowedCommands.js';
import { isRunnerAllowedCommand } from '../../client/src/utils/providers.js';

const allowlist = [...ALLOWED_COMMANDS].sort();
const sampleAllowed = allowlist[0];

describe('runner allowlist client mirror', () => {
// Forward-slash / bare-name / .exe forms only: `path.basename` is
// platform-specific, so a backslash is NOT a separator on a POSIX host while
// the client mirror always treats it as one. That deliberate divergence is
// documented on `runnerCommandBaseName` and asserted separately below.
const cases = [
sampleAllowed,
`/usr/local/bin/${sampleAllowed}`,
`./${sampleAllowed}`,
`${sampleAllowed}.exe`,
`${sampleAllowed}.EXE`,
`/opt/bin/${sampleAllowed}.exe`,
`${sampleAllowed}/`,
`${sampleAllowed}.cmd`,
`${sampleAllowed}.bat`,
`${sampleAllowed} `,
`my-${sampleAllowed}`,
'definitely-not-a-real-agent-cli',
'/usr/bin/rm',
'rm -rf /',
'/',
];

it.each(cases)('agrees with isAllowedCommand for %j', (command) => {
// The client's third state (`null` = list not fetched / field blank) never
// occurs here: a real list is passed and every case is non-blank.
expect(isRunnerAllowedCommand(command, allowlist)).toBe(isAllowedCommand(command));
});

it('reports every shipped allowlist entry as allowed', () => {
for (const command of allowlist) {
expect(isRunnerAllowedCommand(command, allowlist)).toBe(true);
}
});

it('returns null (not false) when the allowlist has not been fetched', () => {
expect(isRunnerAllowedCommand(sampleAllowed, null)).toBeNull();
expect(isRunnerAllowedCommand(sampleAllowed, undefined)).toBeNull();
expect(isRunnerAllowedCommand(sampleAllowed, [])).toBeNull();
});

it('returns null (not false) for a blank command field', () => {
expect(isRunnerAllowedCommand('', allowlist)).toBeNull();
expect(isRunnerAllowedCommand(' ', allowlist)).toBeNull();
expect(isRunnerAllowedCommand(null, allowlist)).toBeNull();
});

it('treats a backslash as a separator even where POSIX path.basename would not', () => {
// Client-only behavior, by design: this drives an informational warning,
// and a false warning about a Windows path on a Windows install would be
// worse than a missing one for a path shape POSIX cannot spawn anyway.
expect(isRunnerAllowedCommand(`C:\\bin\\${sampleAllowed}.exe`, allowlist)).toBe(true);
});
});
Loading