diff --git a/README.md b/README.md
index e0cc988..76853e3 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,8 @@ SSH tmux and local shells are also supported as terminal-only connections.
**Full access** is an explicit, per-session option for newly launched local sessions. It is not saved and does not affect existing or remote sessions. Depending on the CLI, it selects that provider's verified auto-approval or full-access mode, so tools, shell commands, and file changes may proceed without another confirmation. GJC keeps its native default-allow policy and has no separate launch switch; Cursor has no verified interactive full-access flag.
+Administrators can set `CHATMUX_DISABLE_FULL_ACCESS=1` to hide the option and reject full-access launch requests server-side. Values are trimmed and case-insensitive; every non-empty value except `0` or `false` disables the feature. Sessions launched with full access retain a warning badge in the sidebar.
+
Cursor sessions use the documented agent command; the legacy cursor-agent alias remains supported for older installations.
## Remote access
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 91d3ad9..5dba9dc 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -92,6 +92,8 @@ SSH tmux とローカルシェルも、ターミナル専用接続として利
**フルアクセス**は、新しく起動するローカルセッションごとに明示的に選択するオプションです。設定は保存されず、既存またはリモートのセッションには影響しません。CLI に応じて、そのプロバイダーで検証済みの自動承認またはフルアクセスモードを選択するため、ツール、シェルコマンド、ファイル変更が追加確認なしで実行される場合があります。GJC は独自のデフォルト許可ポリシーを使用し、個別の起動スイッチはありません。Cursor には検証済みの対話型フルアクセスフラグがありません。
+管理者は `CHATMUX_DISABLE_FULL_ACCESS=1` を設定すると、このオプションを非表示にし、フルアクセスでの起動要求をサーバー側でも拒否できます。値は前後の空白と大文字・小文字を区別せず、空の値、`0`、`false` 以外のすべての値で機能が無効になります。フルアクセスで起動したセッションには、サイドバーで警告バッジが継続して表示されます。
+
Cursor セッションはドキュメントに記載された agent コマンドを使用します。古いインストール向けにレガシーの cursor-agent エイリアスも引き続きサポートされます。
## リモートアクセス
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index bc08724..1c6230b 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -94,6 +94,8 @@ SSH tmux와 로컬 셸도 터미널 전용 연결로 지원합니다.
**모두 허용**은 새로 시작하는 로컬 세션마다 명시적으로 선택하는 옵션입니다. 선택은 저장되지 않으며 기존 세션이나 원격 세션에는 영향을 주지 않습니다. CLI에 따라 해당 프로바이더에서 검증된 자동 승인 또는 전체 권한 모드를 사용하므로, 도구 실행과 셸 명령, 파일 변경이 추가 확인 없이 진행될 수 있습니다. GJC는 자체 기본 허용 정책을 사용하고 별도 시작 옵션이 없으며, Cursor는 검증된 대화형 전체 권한 옵션이 없습니다.
+관리자는 `CHATMUX_DISABLE_FULL_ACCESS=1`을 설정해 이 옵션을 숨기고 서버에서도 모두 허용 시작 요청을 거부할 수 있습니다. 값은 앞뒤 공백과 대소문자를 무시하며, 비어 있거나 `0` 또는 `false`인 경우를 제외한 모든 설정값이 기능을 비활성화합니다. 모두 허용으로 시작한 세션은 사이드바에 경고 배지가 계속 표시됩니다.
+
Cursor 세션은 문서화된 agent 명령을 사용합니다. 구형 설치를 위한 레거시 cursor-agent 별칭도 계속 지원됩니다.
## 원격 접속
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 92dc141..3a7d499 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -92,6 +92,8 @@ SSH tmux 和本地 shell 也可作为纯终端连接使用。
**完全访问**是为每个新启动的本地会话明确选择的选项。该选项不会保存,也不会影响现有会话或远程会话。根据 CLI,它会选择该提供商已验证的自动批准或完全访问模式,因此工具、Shell 命令和文件更改可能无需再次确认即可执行。GJC 使用其原生的默认允许策略,没有单独的启动开关;Cursor 没有经过验证的交互式完全访问参数。
+管理员可以设置 `CHATMUX_DISABLE_FULL_ACCESS=1` 来隐藏此选项,并在服务器端拒绝完全访问启动请求。该值会忽略首尾空格和大小写;除空值、`0` 或 `false` 外,任何值都会禁用此功能。以完全访问模式启动的会话会在侧边栏持续显示警告徽章。
+
Cursor 会话使用文档中的 agent 命令;为旧安装保留了传统的 cursor-agent 别名。
## 远程访问
diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md
index 6572664..5916716 100644
--- a/server/modules/providers/README.md
+++ b/server/modules/providers/README.md
@@ -52,11 +52,20 @@ provider's verified interactive auto-approval or full-access mode. This is a
per-request boolean: it is off by default, is not persisted, and is not sent to
remote fleet spawns or applied to existing sessions.
-`shared/external-cli-spawn.ts` is the shared capability list. The client uses it
-to enable the checkbox, but `provider.routes.ts` remains authoritative: it
-validates the boolean, rejects unsupported providers, and passes only fixed
-server-owned arguments to the tmux launcher. Never accept arbitrary launch
-arguments from the browser.
+`shared/external-cli-spawn.ts` is the shared capability list. The client reads
+the backend-owned `/api/providers/capabilities` response to enable the checkbox,
+while `provider.routes.ts` remains authoritative: it validates the boolean,
+rejects unsupported providers, and passes only fixed server-owned arguments to
+the tmux launcher. Never accept arbitrary launch arguments from the browser.
+
+Set `CHATMUX_DISABLE_FULL_ACCESS=1` to disable this feature for the entire
+deployment. Values are trimmed and case-insensitive; every non-empty value except
+`0` or `false` disables the feature. The backend then advertises no full-access
+capability and rejects full-access spawn requests even if a stale or custom
+client sends one. A full-access session is tagged with tmux option
+`@chatmux_full_access=1`;
+discovery propagates that tag so the sidebar keeps a visible warning badge for
+the lifetime of the pane.
| Provider | Full-access startup | Native interactive arguments |
| --- | --- | --- |
diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts
index f6a6cb9..7093d0a 100644
--- a/server/modules/providers/provider.routes.ts
+++ b/server/modules/providers/provider.routes.ts
@@ -7,6 +7,7 @@ import { projectsDb, sessionsDb } from '@/modules/database/index.js';
import { emitRelayKeyDiagnostic } from '@/modules/notifications/index.js';
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
import { providerCapabilitiesService } from '@/modules/providers/services/provider-capabilities.service.js';
+import { isFullAccessSpawnDisabled } from '@/modules/providers/services/full-access-spawn-policy.js';
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
import { providerModelsService } from '@/modules/providers/services/provider-models.service.js';
import { providerSkillsService } from '@/modules/providers/services/skills.service.js';
@@ -268,6 +269,7 @@ function snapshotExternalSessions(rows: readonly DiscoveryRow[]): ExternalCliSes
tmux: row.tmux,
kind: row.kind as ExternalCliSession['kind'],
providerSessionId: row.providerSessionId ?? undefined,
+ ...(row.fullAccess ? { fullAccess: true } : {}),
cwd: row.cwd ?? undefined,
agentPid: row.process?.pid,
startedAtMs: row.process?.startedAtMs,
@@ -923,6 +925,7 @@ router.get(
tmux: session.tmux,
process: externalProcessGeneration(session),
kind: session.kind,
+ ...(session.fullAccess ? { fullAccess: true } : {}),
presence: rowPresence(presence, session.tmux),
connectionIssue: session.connectionIssue,
};
@@ -1005,6 +1008,12 @@ router.post(
});
}
const fullAccess = body.fullAccess === true;
+ if (fullAccess && isFullAccessSpawnDisabled()) {
+ throw new AppError('Full-access startup is disabled by this ChatMux deployment.', {
+ code: 'FULL_ACCESS_DISABLED',
+ statusCode: 403,
+ });
+ }
if (fullAccess && !supportsExternalCliFullAccess(cli)) {
throw new AppError(`${cli} does not support full-access startup.`, {
code: 'UNSUPPORTED_FULL_ACCESS',
diff --git a/server/modules/providers/services/discovery-collector.service.ts b/server/modules/providers/services/discovery-collector.service.ts
index b3ff1c1..336e43b 100644
--- a/server/modules/providers/services/discovery-collector.service.ts
+++ b/server/modules/providers/services/discovery-collector.service.ts
@@ -53,6 +53,7 @@ export type DiscoveryRow = Readonly<{
process: TmuxProcessGeneration | null;
kind: string;
providerSessionId: string | null;
+ fullAccess?: boolean;
/** How providerSessionId is tied to the process (see ExternalSessionBinding). Server-local, not on the fleet wire. */
binding?: ExternalSessionBinding;
connectionIssue?: ProviderConnectionIssue;
@@ -146,6 +147,7 @@ function sameRow(a: DiscoveryRow, b: DiscoveryRow): boolean {
&& a.process?.startedAtMs === b.process?.startedAtMs
&& a.kind === b.kind
&& a.providerSessionId === b.providerSessionId
+ && a.fullAccess === b.fullAccess
&& a.binding === b.binding
&& a.connectionIssue === b.connectionIssue
&& a.activity === b.activity
@@ -166,6 +168,7 @@ function hostPaneFingerprint(panes: readonly HostDiscoveryPane[]): string {
pane.codexThreadId ?? '',
pane.taggedKind ?? '',
pane.taggedSessionId ?? '',
+ pane.fullAccess ? '1' : '',
].join('\0'))
.sort()
.join('\n');
@@ -256,6 +259,7 @@ export function createDiscoveryCollector(options: DiscoveryCollectorOptions = {}
process,
kind: session.kind,
providerSessionId: session.providerSessionId ?? null,
+ ...(session.fullAccess ? { fullAccess: true } : {}),
...(session.binding ? { binding: session.binding } : {}),
...(session.connectionIssue ? { connectionIssue: session.connectionIssue } : {}),
activity: process
diff --git a/server/modules/providers/services/external-cli-sessions/contracts-and-resume.ts b/server/modules/providers/services/external-cli-sessions/contracts-and-resume.ts
index dd03324..8eaee92 100644
--- a/server/modules/providers/services/external-cli-sessions/contracts-and-resume.ts
+++ b/server/modules/providers/services/external-cli-sessions/contracts-and-resume.ts
@@ -1,6 +1,7 @@
import { relative, sep, isAbsolute } from 'node:path';
import type { ProviderConnectionIssue } from '../../../../../shared/provider-connection.js';
+import type { ExternalSpawnCli } from '../../../../../shared/external-cli-spawn.js';
import type { TmuxPaneIdentity } from '../../../../../shared/tmux.js';
import { tmuxPaneIdentityKey } from '../../../../../shared/tmux.js';
@@ -74,7 +75,7 @@ export type ClaudeRuntimeReceipt = {
procStart: string | null;
};
-export type ExternalLocalCliKind = 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp' | 'omo';
+export type ExternalLocalCliKind = ExternalSpawnCli;
export type ExternalCliKind = ExternalLocalCliKind | 'ssh' | 'shell';
@@ -101,6 +102,8 @@ export type ExternalCliSession = {
cwd?: string;
agentPid?: number;
startedAtMs?: number;
+ /** ChatMux launched this pane with the provider's dangerous startup mode. */
+ fullAccess?: boolean;
connectionIssue?: ProviderConnectionIssue;
};
@@ -205,6 +208,7 @@ export type ExternalPane = {
cwd?: string;
taggedKind?: ExternalLocalCliKind;
taggedSessionId?: string;
+ fullAccess?: boolean;
};
export type ProcessTreeEntry = {
diff --git a/server/modules/providers/services/external-cli-sessions/discovery.ts b/server/modules/providers/services/external-cli-sessions/discovery.ts
index 5ca323f..85bb917 100644
--- a/server/modules/providers/services/external-cli-sessions/discovery.ts
+++ b/server/modules/providers/services/external-cli-sessions/discovery.ts
@@ -127,6 +127,7 @@ export async function discoverExternalCliSessions(
? { taggedKind: pane.taggedKind as ExternalLocalCliKind }
: {}),
...(pane.taggedSessionId ? { taggedSessionId: pane.taggedSessionId } : {}),
+ ...(pane.fullAccess ? { fullAccess: true } : {}),
}));
procs = [...hostSnapshot.processes];
} else {
@@ -135,7 +136,7 @@ export async function discoverExternalCliSessions(
try {
tmuxOutput = await runDiscoveryCommand(commandRunner, 'tmux', [
'list-panes', '-a', '-F',
- `#{socket_path}${TMUX_FIELD_SEP}#{session_id}${TMUX_FIELD_SEP}#{window_id}${TMUX_FIELD_SEP}#{pane_id}${TMUX_FIELD_SEP}#{session_name}${TMUX_FIELD_SEP}#{pane_pid}${TMUX_FIELD_SEP}#{pane_current_command}${TMUX_FIELD_SEP}#{@chatmux_codex_thread_id}${TMUX_FIELD_SEP}#{pane_current_path}${TMUX_FIELD_SEP}#{@chatmux_cli_kind}${TMUX_FIELD_SEP}#{@chatmux_provider_session_id}`,
+ `#{socket_path}${TMUX_FIELD_SEP}#{session_id}${TMUX_FIELD_SEP}#{window_id}${TMUX_FIELD_SEP}#{pane_id}${TMUX_FIELD_SEP}#{session_name}${TMUX_FIELD_SEP}#{pane_pid}${TMUX_FIELD_SEP}#{pane_current_command}${TMUX_FIELD_SEP}#{@chatmux_codex_thread_id}${TMUX_FIELD_SEP}#{pane_current_path}${TMUX_FIELD_SEP}#{@chatmux_cli_kind}${TMUX_FIELD_SEP}#{@chatmux_provider_session_id}${TMUX_FIELD_SEP}#{@chatmux_full_access}`,
]);
psOutput = await runDiscoveryCommand(commandRunner, 'ps', ['-eo', 'pid,ppid,comm,args']);
} catch {
diff --git a/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts b/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts
index ad08595..ba40e6b 100644
--- a/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts
+++ b/server/modules/providers/services/external-cli-sessions/inference-and-spawn.ts
@@ -9,13 +9,15 @@ import { CURSOR_CLI_COMMAND_CANDIDATES } from '@/modules/providers/list/cursor/c
import {
supportsExternalCliFullAccess,
+ type ExternalSpawnCli,
type FullAccessExternalSpawnCli,
} from '../../../../../shared/external-cli-spawn.js';
import type { TmuxPaneIdentity } from '../../../../../shared/tmux.js';
import { tmuxPaneIdentityKey } from '../../../../../shared/tmux.js';
import { resolveTmuxSpawnLaunch } from '../tmux-spawn-scope.service.js';
+import { isFullAccessSpawnDisabled } from '../full-access-spawn-policy.js';
-import type { ExternalCliSession, ExternalLocalCliKind, ExternalPane, ProcessTreeEntry } from './contracts-and-resume.js';
+import type { ExternalCliSession, ExternalPane, ProcessTreeEntry } from './contracts-and-resume.js';
import type { ExternalProviderSessionInference } from './provider-runtime-inference.js';
import { applyInferredProviderSessionIds, inferClaudeSessionIds, inferIndexedProviderSessionIds, inferOpenPiSessionIds } from './provider-runtime-inference.js';
import { inferFreshCodexThreadIds, inferOpenCodexThreadIds } from './codex-runtime-inference.js';
@@ -269,7 +271,7 @@ export async function ensureExternalCliCwd(input: string): Promise = {
claude: ['claude'],
@@ -398,20 +400,28 @@ export async function spawnExternalCliSession(
cwd: string,
options: {
fullAccess?: boolean;
+ environment?: Readonly>;
launch?: typeof resolveTmuxSpawnLaunch;
run?: typeof runCommand;
} = {},
): Promise {
+ const fullAccess = options.fullAccess === true;
+ if (fullAccess && isFullAccessSpawnDisabled(options.environment)) {
+ throw new Error('Full-access startup is disabled by the server deployment.');
+ }
const run = options.run ?? runCommand;
const executable = await resolveExternalCliExecutable(cli);
const launch = await (options.launch ?? resolveTmuxSpawnLaunch)();
- const cliArgs = externalCliFullAccessArgs(cli, options.fullAccess ?? false);
+ const cliArgs = externalCliFullAccessArgs(cli, fullAccess);
await run(launch.command, [
...launch.prefixArgs,
...buildExternalCliTmuxSpawnArgs(executable, tmuxName, cwd, undefined, cliArgs),
]);
try {
await run('tmux', ['set-option', '-t', tmuxName, '@chatmux_cli_kind', cli]);
+ if (fullAccess) {
+ await run('tmux', ['set-option', '-t', tmuxName, '@chatmux_full_access', '1']);
+ }
} catch (error) {
await run('tmux', ['kill-session', '-t', `=${tmuxName}`]).catch(() => undefined);
throw error;
diff --git a/server/modules/providers/services/external-cli-sessions/process-classification.ts b/server/modules/providers/services/external-cli-sessions/process-classification.ts
index 7a56b9c..e5df856 100644
--- a/server/modules/providers/services/external-cli-sessions/process-classification.ts
+++ b/server/modules/providers/services/external-cli-sessions/process-classification.ts
@@ -208,6 +208,7 @@ export function classifyExternalSessions(args: {
...(ids.length === 1 ? { providerSessionId: ids[0], binding } : {}),
...(pane.cwd ? { cwd: pane.cwd } : {}),
...(agentPid !== undefined ? { agentPid } : {}),
+ ...(pane.fullAccess && pane.taggedKind === kind ? { fullAccess: true } : {}),
});
}
return result.sort((a, b) => (
diff --git a/server/modules/providers/services/external-cli-sessions/session-correlation.ts b/server/modules/providers/services/external-cli-sessions/session-correlation.ts
index d73d420..272001a 100644
--- a/server/modules/providers/services/external-cli-sessions/session-correlation.ts
+++ b/server/modules/providers/services/external-cli-sessions/session-correlation.ts
@@ -221,6 +221,7 @@ export function parseExternalPanes(output: string): ExternalPane[] {
? { taggedKind: pane.taggedKind as ExternalLocalCliKind }
: {}),
...(pane.taggedSessionId ? { taggedSessionId: pane.taggedSessionId } : {}),
+ ...(pane.fullAccess ? { fullAccess: true } : {}),
}));
}
diff --git a/server/modules/providers/services/full-access-spawn-policy.ts b/server/modules/providers/services/full-access-spawn-policy.ts
new file mode 100644
index 0000000..a3c36a3
--- /dev/null
+++ b/server/modules/providers/services/full-access-spawn-policy.ts
@@ -0,0 +1,9 @@
+export const FULL_ACCESS_DISABLE_ENV = 'CHATMUX_DISABLE_FULL_ACCESS';
+
+/** A deployment-wide, server-authoritative kill switch for dangerous startup. */
+export function isFullAccessSpawnDisabled(
+ environment: Readonly> = process.env,
+): boolean {
+ const value = environment[FULL_ACCESS_DISABLE_ENV]?.trim().toLowerCase();
+ return value !== undefined && value !== '' && value !== '0' && value !== 'false';
+}
diff --git a/server/modules/providers/services/host-discovery-snapshot.service.ts b/server/modules/providers/services/host-discovery-snapshot.service.ts
index 2daf156..2ffe89a 100644
--- a/server/modules/providers/services/host-discovery-snapshot.service.ts
+++ b/server/modules/providers/services/host-discovery-snapshot.service.ts
@@ -35,6 +35,7 @@ export const HOST_DISCOVERY_TMUX_FORMAT = [
'#{pane_current_path}',
'#{@chatmux_cli_kind}',
'#{@chatmux_provider_session_id}',
+ '#{@chatmux_full_access}',
].join(TMUX_FIELD_SEP);
export type HostDiscoveryPane = {
@@ -46,6 +47,7 @@ export type HostDiscoveryPane = {
cwd?: string;
taggedKind?: string;
taggedSessionId?: string;
+ fullAccess?: boolean;
};
export type HostDiscoveryProcess = {
@@ -161,6 +163,7 @@ export function parseHostDiscoveryPanes(output: string): HostDiscoveryPane[] {
fieldEight,
fieldNine,
fieldTen,
+ fieldEleven,
] = fields;
const rawCodexThreadId = legacyLiveFormat ? '' : fieldSeven;
const rawCwd = legacyLiveFormat ? fieldSeven : fieldEight;
@@ -179,6 +182,7 @@ export function parseHostDiscoveryPanes(output: string): HostDiscoveryPane[] {
const cwd = rawCwd?.trim() ?? '';
const taggedKind = rawKind?.trim() ?? '';
const taggedSessionId = rawProviderSessionId?.trim() ?? '';
+ const fullAccess = !legacyLiveFormat && fieldEleven?.trim() === '1';
if (
!tmux.socketPath
|| !/^\$\d+$/.test(tmux.sessionId)
@@ -198,6 +202,7 @@ export function parseHostDiscoveryPanes(output: string): HostDiscoveryPane[] {
...(cwd ? { cwd } : {}),
...(taggedKind ? { taggedKind } : {}),
...(taggedSessionId ? { taggedSessionId } : {}),
+ ...(fullAccess ? { fullAccess: true } : {}),
});
}
return panes;
diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts
index 0ceee00..8eb2b3a 100644
--- a/server/modules/providers/services/provider-capabilities.service.ts
+++ b/server/modules/providers/services/provider-capabilities.service.ts
@@ -2,6 +2,8 @@ import type { LLMProvider } from '@/shared/types.js';
import { supportsExternalCliFullAccess } from '../../../../shared/external-cli-spawn.js';
+import { isFullAccessSpawnDisabled } from './full-access-spawn-policy.js';
+
/**
* Static, backend-owned description of what one provider integration supports.
*
@@ -144,15 +146,30 @@ const PROVIDER_CAPABILITIES: Record = {
},
};
+function applyDeploymentPolicy(
+ capabilities: ProviderCapabilities,
+ environment: Readonly>,
+): ProviderCapabilities {
+ return capabilities.supportsFullAccessSpawn && isFullAccessSpawnDisabled(environment)
+ ? { ...capabilities, supportsFullAccessSpawn: false }
+ : capabilities;
+}
+
/**
* Application service exposing the provider capability matrix.
*/
export const providerCapabilitiesService = {
- getProviderCapabilities(provider: LLMProvider): ProviderCapabilities {
- return PROVIDER_CAPABILITIES[provider];
+ getProviderCapabilities(
+ provider: LLMProvider,
+ environment: Readonly> = process.env,
+ ): ProviderCapabilities {
+ return applyDeploymentPolicy(PROVIDER_CAPABILITIES[provider], environment);
},
- listAllProviderCapabilities(): ProviderCapabilities[] {
- return Object.values(PROVIDER_CAPABILITIES);
+ listAllProviderCapabilities(
+ environment: Readonly> = process.env,
+ ): ProviderCapabilities[] {
+ return Object.values(PROVIDER_CAPABILITIES)
+ .map((capabilities) => applyDeploymentPolicy(capabilities, environment));
},
};
diff --git a/server/modules/providers/services/tmux-pane-actions.service.ts b/server/modules/providers/services/tmux-pane-actions.service.ts
index 9d11b51..a1e0035 100644
--- a/server/modules/providers/services/tmux-pane-actions.service.ts
+++ b/server/modules/providers/services/tmux-pane-actions.service.ts
@@ -402,6 +402,7 @@ export async function stopAgentProcessInPane(
for (const option of [
'@chatmux_cli_kind',
+ '@chatmux_full_access',
'@chatmux_provider_session_id',
'@chatmux_codex_thread_id',
]) {
diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts
index 8db3f72..688753e 100644
--- a/server/modules/providers/tests/external-cli-sessions.service.test.ts
+++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts
@@ -358,7 +358,7 @@ test('parseExternalPanes splits exact identitynamepidpane_current
test('parseExternalPanes reads ChatMux provider/session tags for freshly spawned panes', () => {
const out = parseExternalPanes(
- '/tmp/tmux-1000/default\t$710\t@710\t%710\tomp-work\t710\tnode\t\t/workspace\tomp\t019f848f_ff71_77f0\n',
+ '/tmp/tmux-1000/default\t$710\t@710\t%710\tomp-work\t710\tnode\t\t/workspace\tomp\t019f848f_ff71_77f0\t1\n',
);
assert.deepEqual(out, [{
name: 'omp-work',
@@ -368,6 +368,7 @@ test('parseExternalPanes reads ChatMux provider/session tags for freshly spawned
cwd: '/workspace',
taggedKind: 'omp',
taggedSessionId: '019f848f_ff71_77f0',
+ fullAccess: true,
}]);
});
@@ -1154,6 +1155,7 @@ test('classifyExternalSessions trusts a valid ChatMux spawn tag through a node l
command: 'node',
taggedKind: 'omp',
taggedSessionId: 'omp_tagged_123',
+ fullAccess: true,
cwd: '/workspace',
}],
procs: [{ pid: 1100, ppid: 1, comm: 'node', args: 'node wrapper.js' }],
@@ -1164,6 +1166,7 @@ test('classifyExternalSessions trusts a valid ChatMux spawn tag through a node l
kind: 'omp',
providerSessionId: 'omp_tagged_123', binding: 'tagged',
cwd: '/workspace',
+ fullAccess: true,
}]);
});
@@ -1382,5 +1385,22 @@ test('external CLI spawn appends full-access mode to the native command only whe
assert.deepEqual(calls[0]?.args.slice(-suffix.length), suffix);
assert.equal(calls[1]?.args.at(-1), cli);
+ assert.deepEqual(calls[2]?.args, [
+ 'set-option', '-t', 'full-access-' + cli, '@chatmux_full_access', '1',
+ ]);
}
});
+
+test('external CLI spawn fails closed when the deployment disables full access', async () => {
+ let launched = false;
+ await assert.rejects(
+ spawnExternalCliSession('codex', 'disabled-full-access', '/workspace', {
+ fullAccess: true,
+ environment: { CHATMUX_DISABLE_FULL_ACCESS: 'true' },
+ launch: async () => { launched = true; return { command: 'tmux', prefixArgs: [] }; },
+ run: async () => '',
+ }),
+ /disabled by the server deployment/,
+ );
+ assert.equal(launched, false);
+});
diff --git a/server/modules/providers/tests/host-discovery-snapshot.service.test.ts b/server/modules/providers/tests/host-discovery-snapshot.service.test.ts
index db68f02..51d9299 100644
--- a/server/modules/providers/tests/host-discovery-snapshot.service.test.ts
+++ b/server/modules/providers/tests/host-discovery-snapshot.service.test.ts
@@ -24,7 +24,7 @@ import {
} from '@/modules/providers/services/live-sessions.service.js';
const TMUX_OUTPUT = [
- '/tmp/tmux\t$1\t@1\t%1\tcodex\t100\tcodex\t\t/work\tcodex\t',
+ '/tmp/tmux\t$1\t@1\t%1\tcodex\t100\tcodex\t\t/work\tcodex\t\t1',
'/tmp/tmux\t$2\t@2\t%2\tgjc\t200\tgjc\t\t/work\t\t',
].join('\n');
const PS_OUTPUT = [
@@ -254,9 +254,10 @@ test('host snapshot parses the shared tmux and process supersets', () => {
command: pane.command,
cwd: pane.cwd,
taggedKind: pane.taggedKind,
+ fullAccess: pane.fullAccess,
})), [
- { name: 'codex', paneId: '%1', command: 'codex', cwd: '/work', taggedKind: 'codex' },
- { name: 'gjc', paneId: '%2', command: 'gjc', cwd: '/work', taggedKind: undefined },
+ { name: 'codex', paneId: '%1', command: 'codex', cwd: '/work', taggedKind: 'codex', fullAccess: true },
+ { name: 'gjc', paneId: '%2', command: 'gjc', cwd: '/work', taggedKind: undefined, fullAccess: undefined },
]);
assert.deepEqual(parseHostDiscoveryProcesses(PS_OUTPUT), [
{ pid: 100, ppid: 1, comm: 'codex', args: 'codex' },
diff --git a/server/modules/providers/tests/provider-contract-documentation.test.ts b/server/modules/providers/tests/provider-contract-documentation.test.ts
index 9e019df..c16fb8c 100644
--- a/server/modules/providers/tests/provider-contract-documentation.test.ts
+++ b/server/modules/providers/tests/provider-contract-documentation.test.ts
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { providerRegistry } from '../provider.registry.js';
+import { isFullAccessSpawnDisabled } from '../services/full-access-spawn-policy.js';
import { providerCapabilitiesService } from '../services/provider-capabilities.service.js';
test('provider README ids match the registered providers', async () => {
@@ -68,3 +69,20 @@ test('only verified native CLIs advertise full-access startup', () => {
omo: true,
});
});
+
+test('the deployment kill switch removes full-access startup from every capability', () => {
+ const capabilities = providerCapabilitiesService.listAllProviderCapabilities({
+ CHATMUX_DISABLE_FULL_ACCESS: 'true',
+ });
+ assert.equal(capabilities.every((entry) => entry.supportsFullAccessSpawn === false), true);
+});
+
+test('the full-access kill switch fails safe for every non-empty value except explicit false forms', () => {
+ for (const value of [undefined, '', ' ', '0', 'false', ' FALSE ']) {
+ const environment = value === undefined ? {} : { CHATMUX_DISABLE_FULL_ACCESS: value };
+ assert.equal(isFullAccessSpawnDisabled(environment), false, JSON.stringify(value));
+ }
+ for (const value of ['1', 'true', 'yes', 'unexpected']) {
+ assert.equal(isFullAccessSpawnDisabled({ CHATMUX_DISABLE_FULL_ACCESS: value }), true, value);
+ }
+});
diff --git a/server/modules/providers/tests/provider-routes-contract-cases-3.ts b/server/modules/providers/tests/provider-routes-contract-cases-3.ts
index db1299a..4f84412 100644
--- a/server/modules/providers/tests/provider-routes-contract-cases-3.ts
+++ b/server/modules/providers/tests/provider-routes-contract-cases-3.ts
@@ -112,6 +112,16 @@ test('external and live spawn create missing HOME cwd paths while retaining safe
assertError(await request('/sessions/external/spawn', { name: 'contract', cwd: ' ' }), 400, 'EMPTY_CWD');
assertError(await request('/sessions/external/spawn', { name: 'contract', cwd: '~', cli: 'codex', fullAccess: 'yes' }), 400, 'INVALID_FULL_ACCESS');
assertError(await request('/sessions/external/spawn', { name: 'contract', cwd: '~', cli: 'cursor', fullAccess: true }), 400, 'UNSUPPORTED_FULL_ACCESS');
+ process.env.CHATMUX_DISABLE_FULL_ACCESS = '1';
+ try {
+ assertError(
+ await request('/sessions/external/spawn', { name: 'contract', cwd: '~', cli: 'codex', fullAccess: true }),
+ 403,
+ 'FULL_ACCESS_DISABLED',
+ );
+ } finally {
+ delete process.env.CHATMUX_DISABLE_FULL_ACCESS;
+ }
assertError(await request('/sessions/live/spawn', { name: 'contract', cwd: ' ' }), 400, 'EMPTY_CWD');
// Given: an isolated HOME and paths which must remain outside it.
diff --git a/server/modules/providers/tests/tmux-pane-actions.service.test.ts b/server/modules/providers/tests/tmux-pane-actions.service.test.ts
index 7f1cb99..d3844b4 100644
--- a/server/modules/providers/tests/tmux-pane-actions.service.test.ts
+++ b/server/modules/providers/tests/tmux-pane-actions.service.test.ts
@@ -212,6 +212,7 @@ test('process stop respawns a shell when the agent shares the pane root process
]);
assert.deepEqual(calls.slice(2).map(({ args }) => args.slice(2)), [
['set-option', '-p', '-t', identity.paneId, '@chatmux_cli_kind', ''],
+ ['set-option', '-p', '-t', identity.paneId, '@chatmux_full_access', ''],
['set-option', '-p', '-t', identity.paneId, '@chatmux_provider_session_id', ''],
['set-option', '-p', '-t', identity.paneId, '@chatmux_codex_thread_id', ''],
]);
@@ -231,7 +232,10 @@ test('process stop signals the agent job group, confirms its exit against the ve
});
assert.deepEqual(signals, ['SIGTERM']);
assert.equal(calls.some(({ args }) => args.includes('respawn-pane')), false, 'the user shell in the pane survives');
- assert.deepEqual(calls.slice(1).map(({ args }) => args[2]), ['set-option', 'set-option', 'set-option']);
+ assert.deepEqual(
+ calls.slice(1).map(({ args }) => args[2]),
+ ['set-option', 'set-option', 'set-option', 'set-option'],
+ );
});
test('process stop escalates to SIGKILL and reports an agent that will not die', async () => {
diff --git a/src/components/chat/utils/chatFormatting.test.ts b/src/components/chat/utils/chatFormatting.test.ts
index b897687..c1a40cb 100644
--- a/src/components/chat/utils/chatFormatting.test.ts
+++ b/src/components/chat/utils/chatFormatting.test.ts
@@ -79,8 +79,7 @@ test('keeps escaped Markdown brackets while accepting unambiguous display math',
test('normalizes ordinary inline math while preserving slash-delimited BRE groups', () => {
const sed = String.raw`Run sed 's/\(foo\)/bar/' to rename.`;
assert.equal(normalizeLatexMathDelimiters(sed), sed);
- const slashAfter = String.raw`Pattern \(foo\)/bar`;
- assert.equal(normalizeLatexMathDelimiters(slashAfter), slashAfter);
+ assert.equal(normalizeLatexMathDelimiters('speed \\(v\\)/s'), 'speed $v$/s');
assert.equal(normalizeLatexMathDelimiters('원소가 \\(n\\)개 있습니다.'), '원소가 $n$개 있습니다.');
assert.equal(normalizeLatexMathDelimiters('value \\(x+1\\) done'), 'value $x+1$ done');
assert.equal(normalizeLatexMathDelimiters('when \\(x = y\\) holds'), 'when $x = y$ holds');
@@ -89,6 +88,44 @@ test('normalizes ordinary inline math while preserving slash-delimited BRE group
assert.equal(normalizeLatexMathDelimiters('\\(x\\)'), '$x$');
});
+test('preserves shell-quoted BRE groups and postfix operators', () => {
+ const shellPatterns = [
+ String.raw`grep '\(abc\)' file`,
+ String.raw`grep '\(a\|b\)' file`,
+ String.raw`grep '\(a\)\1' file`,
+ String.raw`grep '\(ab\)\{2,3\}' file`,
+ String.raw`grep '\(ab\)\+' file`,
+ String.raw`grep "\(ab\)\?" file`,
+ ];
+ for (const shellPattern of shellPatterns) {
+ assert.equal(normalizeLatexMathDelimiters(shellPattern), shellPattern);
+ }
+
+ const unquotedPostfixes = [
+ String.raw`pattern \(a\)\|b`,
+ String.raw`pattern \(a\)\1`,
+ String.raw`pattern \(ab\)\+`,
+ String.raw`pattern \(ab\)\?`,
+ String.raw`pattern \(ab\)\{12,345\}`,
+ ];
+ for (const pattern of unquotedPostfixes) {
+ assert.equal(normalizeLatexMathDelimiters(pattern), pattern);
+ }
+});
+
+test('normalizes LaTeX constructs that overlap BRE body syntax', () => {
+ const cases = [
+ [String.raw`the norm \(\|x\|\)`, String.raw`the norm $\|x\|$`],
+ [String.raw`\(\|x\|_2 \le 1\)`, String.raw`$\|x\|_2 \le 1$`],
+ [String.raw`lines \(a \| b\)`, String.raw`lines $a \| b$`],
+ [String.raw`the set \(\{1,2\}\)`, String.raw`the set $\{1,2\}$`],
+ [String.raw`the set \(\{1, 2\}\)`, String.raw`the set $\{1, 2\}$`],
+ ];
+ for (const [input, expected] of cases) {
+ assert.equal(normalizeLatexMathDelimiters(input), expected);
+ }
+});
+
test('normalizes long backslash runs without superlinear slowdown', () => {
const markdown = '\\'.repeat(100_000);
const plain = 'a'.repeat(markdown.length);
diff --git a/src/components/chat/utils/chatFormatting.ts b/src/components/chat/utils/chatFormatting.ts
index d3cd387..b3c9884 100644
--- a/src/components/chat/utils/chatFormatting.ts
+++ b/src/components/chat/utils/chatFormatting.ts
@@ -46,6 +46,34 @@ function displayMathShouldNormalize(args: {
return /^[ \t\r]*$/.test(suffix);
}
+function inlineMathShouldNormalize(text: string, openIndex: number, closeIndex: number): boolean {
+ const before = text[openIndex - 1] ?? '';
+ if (before === "'" || before === '"') return false;
+
+ // BRE continuation operators follow the closing group. Read them in place
+ // so a long interval cannot be truncated and repeated math does not copy
+ // the remainder of the message on every match.
+ let cursor = closeIndex + 2;
+ if (text[cursor] !== '\\') return true;
+ cursor += 1;
+
+ const operator = text[cursor];
+ if (operator === '|' || operator === '+' || operator === '?' || /^[1-9]$/.test(operator ?? '')) {
+ return false;
+ }
+ if (operator !== '{') return true;
+
+ cursor += 1;
+ const minimumStart = cursor;
+ while (/^[0-9]$/.test(text[cursor] ?? '')) cursor += 1;
+ if (cursor === minimumStart) return true;
+ if (text[cursor] === ',') {
+ cursor += 1;
+ while (/^[0-9]$/.test(text[cursor] ?? '')) cursor += 1;
+ }
+ return !(text[cursor] === '\\' && text[cursor + 1] === '}');
+}
+
function findClosingBacktickRun(text: string, start: number, length: number): number {
let cursor = start;
while (cursor < text.length) {
@@ -77,6 +105,7 @@ export function normalizeLatexMathDelimiters(text: string) {
close: '\\)' | '\\]';
replacement: '$' | '$$';
outputIndex: number;
+ openIndex: number;
prefixOnlyWhitespace: boolean;
strongMathSyntax: boolean;
} | null = null;
@@ -100,7 +129,7 @@ export function normalizeLatexMathDelimiters(text: string) {
const backslashEscaped = precedingBackslashes % 2 === 1;
if (text[cursor] === '\\' && !backslashEscaped && text.startsWith(math.close, cursor)) {
const shouldNormalize = math.replacement === '$'
- ? text[cursor + math.close.length] !== '/'
+ ? inlineMathShouldNormalize(text, math.openIndex, cursor)
: displayMathShouldNormalize({
text,
closeIndex: cursor,
@@ -207,6 +236,7 @@ export function normalizeLatexMathDelimiters(text: string) {
close: opening.close,
replacement: opening.replacement,
outputIndex,
+ openIndex: cursor - opening.token.length,
prefixOnlyWhitespace: !lineHasContent,
strongMathSyntax: false,
};
diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx
index f46bcb8..c8bfa6b 100644
--- a/src/components/chat/view/subcomponents/Markdown.tsx
+++ b/src/components/chat/view/subcomponents/Markdown.tsx
@@ -185,7 +185,10 @@ const markdownComponents = {
};
export function Markdown({ children, className }: MarkdownProps) {
- const content = normalizeLatexMathDelimiters(normalizeInlineCodeFences(String(children ?? '')));
+ const content = useMemo(
+ () => normalizeLatexMathDelimiters(normalizeInlineCodeFences(String(children ?? ''))),
+ [children],
+ );
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
const { openFileInEditor } = usePaletteOps();
diff --git a/src/components/sidebar/hooks/useExternalCliSessions.test.ts b/src/components/sidebar/hooks/useExternalCliSessions.test.ts
index 5571437..0487bb0 100644
--- a/src/components/sidebar/hooks/useExternalCliSessions.test.ts
+++ b/src/components/sidebar/hooks/useExternalCliSessions.test.ts
@@ -113,6 +113,23 @@ test('stream discovery propagates a deterministic connection exclusion reason',
assert.equal(session.authority, 'stream');
});
+test('stream discovery carries only the current pane full-access tag', () => {
+ const tagged: DiscoveryRow = {
+ key: 'external:tagged', lane: 'external', tmuxName: 'dangerous', tmux, process,
+ kind: 'codex', providerSessionId: null, activity: 'waiting_user', cwd: '/stream',
+ presence: 'present', fullAccess: true,
+ };
+ const [active] = mergeExternalDiscoveryRows([tagged], new Map(), []);
+ assert.equal(active.fullAccess, true);
+
+ const [cleared] = mergeExternalDiscoveryRows(
+ [{ ...tagged, fullAccess: false }],
+ new Map(),
+ [active],
+ );
+ assert.equal(cleared.fullAccess, undefined);
+});
+
test('REST request generations reject a late bootstrap response', () => {
assert.equal(shouldApplyExternalRestResponse(2, 1, false), true);
assert.equal(
diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts
index 976a759..31317bf 100644
--- a/src/components/sidebar/hooks/useExternalCliSessions.ts
+++ b/src/components/sidebar/hooks/useExternalCliSessions.ts
@@ -19,6 +19,8 @@ export type ExternalCliSession = {
sessionName?: string;
model?: string | null;
effort?: string | null;
+ /** The pane was launched with the provider's dangerous startup mode. */
+ fullAccess?: boolean;
activity?: ExternalSessionActivity;
/** True when the transcript stream is closed while the pane may still run. */
transcriptEnded?: boolean;
@@ -65,7 +67,11 @@ export function mergeExternalDiscoveryRows(
: canReuseExternalMetadata(row, previousMetadata)
? previousMetadata
: undefined;
- const { connectionIssue: _staleConnectionIssue, ...stableMetadata } = reusableMetadata ?? {};
+ const {
+ connectionIssue: _staleConnectionIssue,
+ fullAccess: _staleFullAccess,
+ ...stableMetadata
+ } = reusableMetadata ?? {};
if (row.presence !== 'present') {
return externalIdentityOnly({
tmuxName: row.tmuxName,
@@ -85,6 +91,7 @@ export function mergeExternalDiscoveryRows(
...(row.connectionIssue ? { connectionIssue: row.connectionIssue } : {}),
projectPath: row.cwd ?? reusableMetadata?.projectPath,
...(row.providerSessionId ? { transcriptSessionId: row.providerSessionId } : {}),
+ ...(row.fullAccess === true ? { fullAccess: true } : {}),
presence: 'present' as const,
authority: 'stream' as const,
};
@@ -120,6 +127,7 @@ export function externalIdentityOnly(
process: null,
kind: session.kind,
activity: 'unknown',
+ ...(session.fullAccess ? { fullAccess: true } : {}),
presence: session.presence ?? 'stale',
authority,
};
diff --git a/src/components/sidebar/view/subcomponents/SidebarExternalSection.rows.test.tsx b/src/components/sidebar/view/subcomponents/SidebarExternalSection.rows.test.tsx
index bfb83a7..8053348 100644
--- a/src/components/sidebar/view/subcomponents/SidebarExternalSection.rows.test.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarExternalSection.rows.test.tsx
@@ -57,6 +57,21 @@ test('SidebarExternalSection uses the tmux name as primary and transcript metada
assert.ok(!html.includes('%1'));
});
+test('SidebarExternalSection keeps a visible warning badge on full-access sessions', async () => {
+ const html = await renderSection('en', {
+ sessions: [external('codex-yolo', 'codex', '%90', 190, {
+ authority: 'rest',
+ presence: 'present',
+ fullAccess: true,
+ })],
+ projects: [project],
+ onOpen,
+ onChanged: noop,
+ });
+ assert.match(html, /data-external-full-access="true"/);
+ assert.match(html, />Full access<\/span>/);
+});
+
test('SidebarExternalSection explains why an unsafe agent binding was excluded', async () => {
const html = await renderSection('ko', {
diff --git a/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx b/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx
index 5901eee..6957e43 100644
--- a/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx
@@ -328,6 +328,15 @@ export function SidebarExternalSessionRow({
{activityState && !isInputRequired && (
)}
+ {session.fullAccess && (
+
+ {t('newSessionForm.fullAccess')}
+
+ )}
{primary}
diff --git a/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx b/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx
index 4a82359..8fab10e 100644
--- a/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarNewSession.hosts.mounted.test.tsx
@@ -55,6 +55,8 @@ test('Given a verified native CLI, full access is opt-in and reaches spawn as a
t.after(fetches.restore);
const harness = mount({ localHostId: LOCAL, hosts: new Map() });
t.after(harness.dispose);
+ await act(async () => { await fetches.awaitRequest((url) => url === '/api/providers/capabilities'); });
+ await act(async () => { await Promise.resolve(); });
// When
for (const cli of ['claude', 'codex', 'opencode', 'omp', 'omo']) {
@@ -89,10 +91,14 @@ test('Given a verified native CLI, full access is opt-in and reaches spawn as a
});
});
-test('Given an unsupported provider or remote host, full access stays disabled and stale opt-in is cleared', (t) => {
+test('Given an unsupported provider or remote host, full access stays disabled and stale opt-in is cleared', async (t) => {
// Given
+ const fetches = stubFetch();
+ t.after(fetches.restore);
const harness = mount(catalogOf(entry(LOCAL, 'online'), entry(PEER_A, 'online')));
t.after(harness.dispose);
+ await act(async () => { await fetches.awaitRequest((url) => url === '/api/providers/capabilities'); });
+ await act(async () => { await Promise.resolve(); });
press(harness, 'data-spawn-provider', 'claude');
const supported = byAttribute(harness, 'data-spawn-full-access')[0];
act(() => { supported?.props.onChange({ target: { checked: true } }); });
@@ -118,6 +124,34 @@ test('Given an unsupported provider or remote host, full access stays disabled a
assert.equal(remote?.props.checked, false);
});
+test('Given the server kill switch, every full-access checkbox stays disabled', async (t) => {
+ const fetches = stubFetch();
+ fetches.reply((url) => (
+ url === '/api/providers/capabilities'
+ ? {
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ providers: ['claude', 'codex', 'opencode', 'omp', 'omo']
+ .map((provider) => ({ provider, supportsFullAccessSpawn: false })),
+ },
+ },
+ }
+ : { status: 200, body: { data: { ok: true } } }
+ ));
+ t.after(fetches.restore);
+ const harness = mount({ localHostId: LOCAL, hosts: new Map() });
+ t.after(harness.dispose);
+ await act(async () => { await fetches.awaitRequest((url) => url === '/api/providers/capabilities'); });
+ await act(async () => { await Promise.resolve(); });
+
+ for (const cli of ['claude', 'codex', 'opencode', 'omp', 'omo']) {
+ press(harness, 'data-spawn-provider', cli);
+ assert.equal(byAttribute(harness, 'data-spawn-full-access')[0]?.props.disabled, true);
+ }
+});
+
test('Given a local spawn with no working directory, when submit is pressed, then nothing is dispatched', async (t) => {
// Given
const fetches = stubFetch();
diff --git a/src/components/sidebar/view/subcomponents/SidebarNewSession.testSupport.tsx b/src/components/sidebar/view/subcomponents/SidebarNewSession.testSupport.tsx
index 7a0bc75..3d6f1bb 100644
--- a/src/components/sidebar/view/subcomponents/SidebarNewSession.testSupport.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarNewSession.testSupport.tsx
@@ -133,7 +133,20 @@ export function stubFetch(): {
const original = globalThis.fetch;
const calls: FetchCall[] = [];
const waiters = new Set<{ matches: (url: string) => boolean; settle: () => void }>();
- let handler: (url: string) => FetchAnswer = () => ({ status: 200, body: { data: { ok: true, reachable: true, conflict: false } } });
+ let handler: (url: string) => FetchAnswer = (url) => (
+ url === '/api/providers/capabilities'
+ ? {
+ status: 200,
+ body: {
+ success: true,
+ data: {
+ providers: ['claude', 'codex', 'opencode', 'omp', 'omo']
+ .map((provider) => ({ provider, supportsFullAccessSpawn: true })),
+ },
+ },
+ }
+ : { status: 200, body: { data: { ok: true, reachable: true, conflict: false } } }
+ );
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({
@@ -198,4 +211,3 @@ export async function dispatchPeer(harness: Harness, peer: string, name: string)
press(harness, 'data-spawn-submit');
await act(async () => { await Promise.resolve(); });
}
-
diff --git a/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx b/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx
index 98888e5..72a36f2 100644
--- a/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx
+++ b/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx
@@ -1,4 +1,4 @@
-import { useId, useMemo, useState } from 'react';
+import { useEffect, useId, useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -7,7 +7,7 @@ import { EMPTY_HOST_ROW_SET } from '../../../../fleet/discovery/hostRows';
import { spawnableHosts, type SpawnHostChoice } from '../../../../fleet/hostAvailability';
import HomeDirInput from '../../../../shared/view/HomeDirInput';
import { cn } from '../../../../lib/utils';
-import { supportsExternalCliFullAccess } from '../../../../../shared/external-cli-spawn';
+import { api } from '../../../../utils/api';
import PeerDirInput from './newSession/PeerDirInput';
import SpawnHostFields from './newSession/SpawnHostFields';
@@ -74,8 +74,32 @@ export default function SidebarNewSession({
const [hostId, setHostId] = useState(null);
const [projectLocalId, setProjectLocalId] = useState(null);
const [fullAccess, setFullAccess] = useState(false);
+ const [fullAccessProviders, setFullAccessProviders] = useState>(new Set());
const fullAccessWarningId = useId();
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const response = await api.providerCapabilities();
+ const payload = await response.json() as {
+ success?: boolean;
+ data?: { providers?: Array<{ provider?: unknown; supportsFullAccessSpawn?: unknown }> };
+ };
+ if (cancelled || !response.ok || !payload.success || !Array.isArray(payload.data?.providers)) return;
+ setFullAccessProviders(new Set(payload.data.providers.flatMap((entry) => (
+ typeof entry.provider === 'string' && entry.supportsFullAccessSpawn === true
+ ? [entry.provider]
+ : []
+ ))));
+ } catch {
+ // Fail closed: the server capability matrix is authoritative.
+ }
+ };
+ void load();
+ return () => { cancelled = true; };
+ }, []);
+
const hosts = useMemo(
() => spawnableHosts(catalog, t('newSessionForm.thisMachine')),
[catalog, t],
@@ -127,7 +151,10 @@ export default function SidebarNewSession({
? PROVIDERS.filter((item) => (PEER_SPAWN_PROVIDERS as readonly string[]).includes(item.id))
: PROVIDERS;
const activeProvider = isRemote ? 'gjc' : provider;
- const supportsFullAccess = !isRemote && supportsExternalCliFullAccess(activeProvider);
+ const supportsFullAccess = !isRemote && fullAccessProviders.has(activeProvider);
+ useEffect(() => {
+ if (!supportsFullAccess) setFullAccess(false);
+ }, [supportsFullAccess]);
const ready = canDispatchSpawn({ host: selectedHost, name, cwd, projectLocalId });
const submit = () => {
if (!ready || status.kind === 'spawning' || status.kind === 'unknown') return;
diff --git a/src/hooks/useDiscoveryStream.ts b/src/hooks/useDiscoveryStream.ts
index a4363af..2feb6c7 100644
--- a/src/hooks/useDiscoveryStream.ts
+++ b/src/hooks/useDiscoveryStream.ts
@@ -15,6 +15,7 @@ export type DiscoveryRow = {
process: TmuxProcessGeneration | null;
kind: string;
providerSessionId: string | null;
+ fullAccess?: boolean;
connectionIssue?: ProviderConnectionIssue;
activity: 'running' | 'waiting_user' | 'asking_user' | 'error' | 'unknown';
tmuxActionable?: boolean;
diff --git a/src/utils/api.js b/src/utils/api.js
index 68a2229..e29a68e 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -36,6 +36,7 @@ export const authenticatedFetch = (url, options = {}) => {
// API endpoints
export const api = {
+ providerCapabilities: () => authenticatedFetch('/api/providers/capabilities'),
// Auth endpoints (no token required)
auth: {
status: (options = {}) => withBootstrapTimeout(