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
14 changes: 7 additions & 7 deletions .github/skills/coc-knowledge/references/remote-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,24 @@ The dashboard and client package use these server routes:
| `PATCH /api/servers/:id` | Edit a remote server; old unused tunnel connections are disconnected |
| `DELETE /api/servers/:id` | Remove a remote server; unused tunnel connections are disconnected |
| `POST /api/servers/test` | Test a direct URL or DevTunnel input before saving |
| `POST /api/servers/:id/connect` | Connect a DevTunnel server |
| `POST /api/servers/:id/disconnect` | Disconnect a DevTunnel server |
| `POST /api/servers/:id/reconnect` | Kill and recreate the managed `devtunnel connect` process |
| `POST /api/servers/:id/connect` | Connect a DevTunnel or SSH server |
| `POST /api/servers/:id/disconnect` | Disconnect a DevTunnel or SSH server |
| `POST /api/servers/:id/reconnect` | Kill and recreate the managed `devtunnel connect` or `ssh -N` process |
| `GET /api/servers/:id/health` | Connect if needed, then probe health |
| `GET /api/servers/:id/connection` | Return current runtime connection state |

Direct URL servers do not support connect, disconnect, or reconnect because they do not have a managed tunnel process.

## Reconnect behavior

Reconnect is available from the Servers UI for DevTunnel entries. It:
Reconnect is available from the Servers UI for DevTunnel and SSH entries. It:

1. Marks the existing managed child process as intentionally stopped.
2. Kills the old `devtunnel connect` process if one exists.
2. Kills the old `devtunnel connect` / `ssh -N` process if one exists.
3. Clears any in-flight connection attempt.
4. Re-runs the full connection flow: port list, process start, and health polling.
4. Re-runs the full connection flow: process start and health polling (plus port list for DevTunnel).

Use reconnect when the DevTunnel CLI process is stale, the local listener stopped responding, or the public tunnel endpoint changed.
Use reconnect when the managed CLI/`ssh` process is stale, the local listener stopped responding, or the public tunnel endpoint changed. SSH connections also auto-reconnect with exponential backoff when the `ssh` process exits unexpectedly.

## Common failure modes

Expand Down
2 changes: 1 addition & 1 deletion .github/skills/coc-knowledge/references/sdk-wrapper.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Codex quota and model catalog lookups spawn the `@openai/codex` CLI that ships a

Codex SDK thread options do not expose Copilot's native `skillDirectories` or `disabledSkills` fields. CoC maps resolved skill directories (and any caller-supplied `additionalDirectories`) to Codex `additionalDirectories` so external/global skill folders are available to the Codex process, and always appends `~/.coc` (CoC data/skills dir) so out-of-repo data and skill files remain reachable. The `~/.coc` entry is only added when not already present (compared case-insensitively on Windows); caller-supplied paths are preserved verbatim. For explicitly selected skills, CoC keeps prompts path-based by adding the resolved `SKILL.md` file paths to the `<selected_skills>` directive rather than inlining skill bodies.

Codex permission mode is mapped at the provider boundary with `approvalPolicy: 'never'` for every CoC mode. Interactive/ask mode and omitted mode use `sandboxMode: 'read-only'` with network access disabled. Plan mode uses the same full-access Codex sandbox as autopilot (`sandboxMode: 'danger-full-access'`, network access enabled) and relies on CoC's read-only/plan system prompt rather than Codex sandbox enforcement.
Codex permission mode is mapped at the provider boundary with `approvalPolicy: 'never'` for every CoC mode. Interactive/ask mode and omitted mode use `sandboxMode: 'workspace-write'` with network access disabled, so writes permitted by CoC's read-only system prompt (plan file, attached note, `.goal.md` specs) succeed within the workspace and `additionalDirectories` (such as `~/.coc`). Plan mode uses the same full-access Codex sandbox as autopilot (`sandboxMode: 'danger-full-access'`, network access enabled) and relies on CoC's read-only/plan system prompt rather than Codex sandbox enforcement.

Codex image attachments are passed at the provider boundary as `@openai/codex-sdk` structured `local_image` inputs. When `SendMessageOptions.attachments` includes file attachments with supported raster image extensions (`png`, `jpg`/`jpeg`, `gif`, `webp`), `CodexSDKService` sends an input array containing the prompt text plus `{ type: 'local_image', path }` entries in attachment order. Directories, non-images, and SVGs are ignored so text-only behavior is preserved.

Expand Down
7 changes: 6 additions & 1 deletion packages/coc-agent-sdk/src/codex-sdk-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,9 +869,14 @@ export class CodexSDKService implements ISDKService {
networkAccessEnabled: true,
};
}
// Interactive (ask) mode is constrained at the prompt level by
// READ_ONLY_SYSTEM_MESSAGE, which permits writing only the plan file,
// the attached note file, and .goal.md specs. Use `workspace-write` so
// those permitted writes (within the workspace and additionalDirectories
// such as ~/.coc) succeed, while network access stays disabled.
return {
approvalPolicy: 'never',
sandboxMode: 'read-only',
sandboxMode: 'workspace-write',
networkAccessEnabled: false,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('CodexSDKService skills', () => {
);
});

it('uses read-only Codex sandbox options for interactive mode', async () => {
it('uses workspace-write Codex sandbox options for interactive mode', async () => {
svc = new CodexSDKService();
const codexMock = makeCodexSdkMock();
(svc as unknown as { sdk: unknown }).sdk = codexMock;
Expand All @@ -96,13 +96,13 @@ describe('CodexSDKService skills', () => {
expect(codexMock.startThread).toHaveBeenCalledWith(
expect.objectContaining({
approvalPolicy: 'never',
sandboxMode: 'read-only',
sandboxMode: 'workspace-write',
networkAccessEnabled: false,
}),
);
});

it('uses read-only Codex sandbox options when mode is omitted', async () => {
it('uses workspace-write Codex sandbox options when mode is omitted', async () => {
svc = new CodexSDKService();
const codexMock = makeCodexSdkMock();
(svc as unknown as { sdk: unknown }).sdk = codexMock;
Expand All @@ -113,7 +113,7 @@ describe('CodexSDKService skills', () => {
expect(codexMock.startThread).toHaveBeenCalledWith(
expect.objectContaining({
approvalPolicy: 'never',
sandboxMode: 'read-only',
sandboxMode: 'workspace-write',
networkAccessEnabled: false,
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export function ServerCard({ health, isLocal, onRemove, onEdit, onReconnect, rec
Copy public URL
</button>
)}
{isRemoteServer(health.server) && health.server.kind === 'devtunnel' && onReconnect && (
{isRemoteServer(health.server) && (health.server.kind === 'devtunnel' || health.server.kind === 'ssh') && onReconnect && (
<button
type="button"
data-testid="server-card-menu-reconnect"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { AddServerDialog, EditServerDialog } from './AddServerDialog';

type ViewMode = 'grid' | 'split' | 'list';
type FilterMode = 'all' | 'online' | 'offline' | 'local' | 'url' | 'devtunnel';
type ServerKind = 'local' | 'url' | 'devtunnel';
type ServerKind = 'local' | 'url' | 'devtunnel' | 'ssh';

interface UnifiedHealth extends ServerCardHealth {
isLocal: boolean;
Expand All @@ -37,6 +37,10 @@ function inputToPatch(input: RemoteServerInput): RemoteServerPatch {
: { kind: 'devtunnel', label: input.label, tunnelId: input.tunnelId };
}

function supportsReconnect(kind: ServerKind): boolean {
return kind === 'devtunnel' || kind === 'ssh';
}

function getEndpoint(h: UnifiedHealth): string | undefined {
if (h.isLocal) { return undefined; }
if (h.effectiveUrl) { return h.effectiveUrl; }
Expand Down Expand Up @@ -89,6 +93,7 @@ function KindBadge({ kind }: { kind: ServerKind }) {
local: { label: 'Local', cls: 'bg-[#0078d4]/10 text-[#0078d4] dark:bg-[#3794ff]/10 dark:text-[#3794ff]' },
url: { label: 'URL', cls: 'bg-[#16c060]/10 text-[#16a060] dark:text-[#16c060]' },
devtunnel: { label: 'Tunnel', cls: 'bg-[#c586c0]/10 text-[#9a4e9a] dark:text-[#c586c0]' },
ssh: { label: 'SSH', cls: 'bg-[#16a3b8]/10 text-[#0e7c8c] dark:text-[#3bc9db]' },
};
const { label, cls } = cfg[kind];
return (
Expand Down Expand Up @@ -237,16 +242,17 @@ function HeaderBar({

// ─── Summary Strip ────────────────────────────────────────────────────────────

function SummaryStrip({ online, offline, total, procs, tunnels }: {
online: number; offline: number; total: number; procs: number; tunnels: number;
function SummaryStrip({ online, offline, total, procs, tunnels, sshTunnels }: {
online: number; offline: number; total: number; procs: number; tunnels: number; sshTunnels: number;
}) {
return (
<div data-testid="summary-strip" className="grid grid-cols-4 border-b border-[#e0e0e0] dark:border-[#3c3c3c] flex-shrink-0 gap-px bg-[#e0e0e0] dark:bg-[#3c3c3c]">
<div data-testid="summary-strip" className="grid grid-cols-5 border-b border-[#e0e0e0] dark:border-[#3c3c3c] flex-shrink-0 gap-px bg-[#e0e0e0] dark:bg-[#3c3c3c]">
{[
{ label: 'Online', value: online, sub: `/${total}`, color: '#16c060' },
{ label: 'Offline', value: offline, sub: `/${total}`, color: offline > 0 ? '#f14c4c' : undefined },
{ label: 'Active tasks', value: procs, sub: null, color: undefined },
{ label: 'DevTunnels', value: tunnels, sub: `/${total}`, color: '#c586c0' },
{ label: 'Online', value: online, sub: `/${total}`, color: '#16c060' },
{ label: 'Offline', value: offline, sub: `/${total}`, color: offline > 0 ? '#f14c4c' : undefined },
{ label: 'Active tasks', value: procs, sub: null, color: undefined },
{ label: 'DevTunnels', value: tunnels, sub: `/${total}`, color: '#c586c0' },
{ label: 'SSH tunnels', value: sshTunnels, sub: `/${total}`, color: '#16a3b8' },
].map(t => (
<div key={t.label} className="bg-white dark:bg-[#1e1e1e] px-4 py-3">
<div className="text-[10px] font-semibold uppercase tracking-widest text-[#999] dark:text-[#6e6e6e] mb-1.5">
Expand Down Expand Up @@ -366,9 +372,9 @@ function ServerRow({
<QuickAction title="Open dashboard" onClick={e => { e.stopPropagation(); onOpen(); }}>
<Svg d={ICON.external} size={13} />
</QuickAction>
{kind === 'devtunnel' && onReconnect && (
{supportsReconnect(kind) && onReconnect && (
<QuickAction
title={reconnecting ? 'Reconnecting…' : 'Reconnect tunnel'}
title={reconnecting ? 'Reconnecting…' : 'Reconnect'}
onClick={e => { e.stopPropagation(); onReconnect(); }}
>
<Svg d={ICON.reconnect} size={13} />
Expand Down Expand Up @@ -418,8 +424,13 @@ function ConnectionRows({ health }: { health: UnifiedHealth }) {
const url = 'url' in server ? (server as { url: string }).url : effectiveUrl ?? '—';
rows.push(['URL', url]);
rows.push(['Hostname', serverName ?? '—']);
} else if (kind === 'ssh') {
const host = 'host' in server ? (server as { host?: string }).host ?? '—' : '—';
rows.push(['SSH host', host]);
const port = localPort ?? ('localPort' in server ? (server as { localPort?: number }).localPort : undefined);
rows.push(['Local port', port ? `localhost:${port}` : '—']);
if (effectiveUrl) { rows.push(['Endpoint', effectiveUrl]); }
} else {
rows.push(['Tunnel ID', tunnelId ?? '—']);
if (publicUrl) { rows.push(['Public URL', publicUrl]); }
rows.push(['Local port', localPort ? `localhost:${localPort}` : '—']);
if (effectiveUrl) { rows.push(['Endpoint', effectiveUrl]); }
Expand Down Expand Up @@ -502,7 +513,7 @@ function DetailPanel({
Open dashboard
</a>
)}
{kind === 'devtunnel' && onReconnect && (
{supportsReconnect(kind) && onReconnect && (
<ActionBtn onClick={onReconnect} disabled={reconnecting}>
<Svg d={ICON.reconnect} size={12} />
{reconnecting ? 'Reconnecting…' : 'Reconnect'}
Expand Down Expand Up @@ -603,7 +614,7 @@ export function ServersView() {
const [editServerId, setEditServerId] = useState<string | undefined>();
const [reconnectingId, setReconnectingId] = useState<string | undefined>();
const [loadError, setLoadError] = useState<string | undefined>();
const [view, setView] = useState<ViewMode>('grid');
const [view, setView] = useState<ViewMode>('split');
const [filter, setFilter] = useState<FilterMode>('all');
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string>('local');
Expand Down Expand Up @@ -673,18 +684,19 @@ export function ServersView() {
remoteHealthStates.map(h => ({
...h,
isLocal: false,
kind: h.server.kind as 'url' | 'devtunnel',
kind: h.server.kind,
})),
[remoteHealthStates]);

const allHealthStates = useMemo(() => [localUnified, ...remoteUnified], [localUnified, remoteUnified]);

const counts = useMemo(() => ({
total: allHealthStates.length,
online: allHealthStates.filter(h => h.status === 'online').length,
offline: allHealthStates.filter(h => h.status === 'offline').length,
procs: allHealthStates.reduce((a, h) => a + (h.processCount ?? 0), 0),
tunnels: allHealthStates.filter(h => h.kind === 'devtunnel').length,
total: allHealthStates.length,
online: allHealthStates.filter(h => h.status === 'online').length,
offline: allHealthStates.filter(h => h.status === 'offline').length,
procs: allHealthStates.reduce((a, h) => a + (h.processCount ?? 0), 0),
tunnels: allHealthStates.filter(h => h.kind === 'devtunnel').length,
sshTunnels: allHealthStates.filter(h => h.kind === 'ssh').length,
}), [allHealthStates]);

const filtered = useMemo(() => allHealthStates.filter(h => {
Expand All @@ -698,7 +710,8 @@ export function ServersView() {
const srv = h.server;
const url = ('url' in srv ? (srv as { url: string }).url : '') ?? '';
const tunnelId = ('tunnelId' in srv ? (srv as { tunnelId: string }).tunnelId : '') ?? '';
return [srv.label, h.serverName ?? '', url, tunnelId, h.effectiveUrl ?? '']
const host = ('host' in srv ? (srv as { host: string }).host : '') ?? '';
return [srv.label, h.serverName ?? '', url, tunnelId, host, h.effectiveUrl ?? '']
.some(v => v.toLowerCase().includes(q));
}
return true;
Expand Down Expand Up @@ -747,7 +760,7 @@ export function ServersView() {
selected: selectedHealth?.server.id === h.server.id,
onClick: () => setSelectedId(h.server.id),
onOpen: () => handleOpen(h),
onReconnect: (!h.isLocal && h.kind === 'devtunnel')
onReconnect: (!h.isLocal && supportsReconnect(h.kind))
? () => { void handleReconnect(h.server.id); }
: undefined,
onCopy: () => handleCopy(h),
Expand Down Expand Up @@ -777,6 +790,7 @@ export function ServersView() {
total={counts.total}
procs={counts.procs}
tunnels={counts.tunnels}
sshTunnels={counts.sshTunnels}
/>

{loadError && (
Expand Down Expand Up @@ -825,7 +839,7 @@ export function ServersView() {
<DetailPanel
health={selectedHealth}
onOpen={() => handleOpen(selectedHealth)}
onReconnect={!selectedHealth.isLocal && selectedHealth.kind === 'devtunnel'
onReconnect={!selectedHealth.isLocal && supportsReconnect(selectedHealth.kind)
? () => { void handleReconnect(selectedHealth.server.id); }
: undefined}
onCopy={() => handleCopy(selectedHealth)}
Expand Down
49 changes: 47 additions & 2 deletions packages/coc/test/spa/react/features/servers/ServerCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ const DEVTUNNEL_WITH_PUBLIC_URL: ServerCardHealth = {
publicUrl: 'https://my-remote-coc-4000.usw2.devtunnels.ms',
};

const SSH_BASE: ServerCardHealth = {
server: {
id: 's1',
kind: 'ssh',
label: 'ubuntu-arm',
host: 'ubuntu-arm',
localPort: 4000,
addedAt: 1,
updatedAt: 1,
},
status: 'online',
effectiveUrl: 'http://127.0.0.1:4000',
localPort: 4000,
};

afterEach(() => {
cleanup();
});
Expand Down Expand Up @@ -241,8 +256,38 @@ describe('ServerCard — menu actions', () => {
}
});

it('closes menu on outside mousedown', () => {
render(
it('shows Reconnect menu item for SSH servers and calls onReconnect', () => {
const onReconnect = vi.fn();
render(<ServerCard health={SSH_BASE} isLocal={false} onReconnect={onReconnect} />);
fireEvent.click(screen.getByTestId('server-card-menu-btn'));
fireEvent.click(screen.getByTestId('server-card-menu-reconnect'));
expect(onReconnect).toHaveBeenCalledWith('s1');
expect(screen.queryByTestId('server-card-menu')).toBeNull();
});

it('shows Reconnect menu item for DevTunnel servers', () => {
const onReconnect = vi.fn();
render(<ServerCard health={DEVTUNNEL_BASE} isLocal={false} onReconnect={onReconnect} />);
fireEvent.click(screen.getByTestId('server-card-menu-btn'));
expect(screen.getByTestId('server-card-menu-reconnect')).toBeTruthy();
});

it('does not show Reconnect menu item for URL servers', () => {
const onReconnect = vi.fn();
render(<ServerCard health={REMOTE_BASE} isLocal={false} onReconnect={onReconnect} />);
fireEvent.click(screen.getByTestId('server-card-menu-btn'));
expect(screen.queryByTestId('server-card-menu-reconnect')).toBeNull();
});

it('disables the SSH Reconnect menu item while reconnecting', () => {
render(<ServerCard health={SSH_BASE} isLocal={false} onReconnect={vi.fn()} reconnecting />);
fireEvent.click(screen.getByTestId('server-card-menu-btn'));
const btn = screen.getByTestId('server-card-menu-reconnect') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.textContent).toContain('Reconnecting');
});

it('closes menu on outside mousedown', () => { render(
<div>
<button data-testid="outside">outside</button>
<ServerCard health={REMOTE_BASE} isLocal={false} />
Expand Down
Loading
Loading