From 188d4577cf198717fd8649c2a4d96dca8fbdccf5 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 21 Sep 2026 13:19:39 +0900 Subject: [PATCH 1/3] fix: harden full-access session visibility --- README.md | 2 ++ docs/readme/README.ja.md | 2 ++ docs/readme/README.ko.md | 2 ++ docs/readme/README.zh-CN.md | 2 ++ server/modules/providers/README.md | 17 ++++++--- server/modules/providers/provider.routes.ts | 9 +++++ .../services/discovery-collector.service.ts | 4 +++ .../contracts-and-resume.ts | 6 +++- .../external-cli-sessions/discovery.ts | 3 +- .../inference-and-spawn.ts | 16 +++++++-- .../process-classification.ts | 1 + .../session-correlation.ts | 1 + .../services/full-access-spawn-policy.ts | 8 +++++ .../host-discovery-snapshot.service.ts | 5 +++ .../services/provider-capabilities.service.ts | 25 ++++++++++--- .../services/tmux-pane-actions.service.ts | 1 + .../external-cli-sessions.service.test.ts | 22 +++++++++++- .../host-discovery-snapshot.service.test.ts | 7 ++-- .../provider-contract-documentation.test.ts | 7 ++++ .../tests/provider-routes-contract-cases-3.ts | 10 ++++++ .../tests/tmux-pane-actions.service.test.ts | 6 +++- .../chat/utils/chatFormatting.test.ts | 5 +-- src/components/chat/utils/chatFormatting.ts | 13 ++++++- .../chat/view/subcomponents/Markdown.tsx | 5 ++- .../hooks/useExternalCliSessions.test.ts | 17 +++++++++ .../sidebar/hooks/useExternalCliSessions.ts | 10 +++++- .../SidebarExternalSection.rows.test.tsx | 15 ++++++++ .../subcomponents/SidebarExternalSection.tsx | 9 +++++ .../SidebarNewSession.hosts.mounted.test.tsx | 36 ++++++++++++++++++- .../SidebarNewSession.testSupport.tsx | 16 +++++++-- .../view/subcomponents/SidebarNewSession.tsx | 33 +++++++++++++++-- src/hooks/useDiscoveryStream.ts | 1 + src/utils/api.js | 1 + 33 files changed, 287 insertions(+), 30 deletions(-) create mode 100644 server/modules/providers/services/full-access-spawn-policy.ts diff --git a/README.md b/README.md index e0cc9887..caefce8e 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. 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 91d3ad96..b81b5ba8 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` を設定すると、このオプションを非表示にし、フルアクセスでの起動要求をサーバー側でも拒否できます。フルアクセスで起動したセッションには、サイドバーで警告バッジが継続して表示されます。 + Cursor セッションはドキュメントに記載された agent コマンドを使用します。古いインストール向けにレガシーの cursor-agent エイリアスも引き続きサポートされます。 ## リモートアクセス diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index bc087248..ddd3a1ec 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`을 설정해 이 옵션을 숨기고 서버에서도 모두 허용 시작 요청을 거부할 수 있습니다. 모두 허용으로 시작한 세션은 사이드바에 경고 배지가 계속 표시됩니다. + Cursor 세션은 문서화된 agent 명령을 사용합니다. 구형 설치를 위한 레거시 cursor-agent 별칭도 계속 지원됩니다. ## 원격 접속 diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 92dc1413..f24f5aa4 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` 来隐藏此选项,并在服务器端拒绝完全访问启动请求。以完全访问模式启动的会话会在侧边栏持续显示警告徽章。 + Cursor 会话使用文档中的 agent 命令;为旧安装保留了传统的 cursor-agent 别名。 ## 远程访问 diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md index 65726648..53afe86b 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -52,11 +52,18 @@ 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. The backend then advertises no full-access capability and rejects +full-access spawn requests even if a stale or custom client sends one. A session +that starts in this mode 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 f6a6cb93..7093d0a3 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 b3ff1c1f..336e43bc 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 dd033246..8eaee922 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 5ca323fb..85bb917a 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 ad08595e..ba40e6b4 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 7a56b9c1..e5df8569 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 d73d420e..272001a4 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 00000000..d902f9be --- /dev/null +++ b/server/modules/providers/services/full-access-spawn-policy.ts @@ -0,0 +1,8 @@ +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 { + return environment[FULL_ACCESS_DISABLE_ENV] === '1'; +} diff --git a/server/modules/providers/services/host-discovery-snapshot.service.ts b/server/modules/providers/services/host-discovery-snapshot.service.ts index 2daf1562..2ffe89aa 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 0ceee008..8eb2b3a8 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 9d11b517..a1e00350 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 8db3f72c..e4967241 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: '1' }, + 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 db68f027..51d92999 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 9e019df4..a75061df 100644 --- a/server/modules/providers/tests/provider-contract-documentation.test.ts +++ b/server/modules/providers/tests/provider-contract-documentation.test.ts @@ -68,3 +68,10 @@ 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: '1', + }); + assert.equal(capabilities.every((entry) => entry.supportsFullAccessSpawn === false), true); +}); 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 db1299aa..4f844126 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 7f1cb996..d3844b44 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 b8976874..f2d1cc51 100644 --- a/src/components/chat/utils/chatFormatting.test.ts +++ b/src/components/chat/utils/chatFormatting.test.ts @@ -79,8 +79,9 @@ 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); + const grep = String.raw`grep '\(a\|b\)' file`; + assert.equal(normalizeLatexMathDelimiters(grep), grep); + 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'); diff --git a/src/components/chat/utils/chatFormatting.ts b/src/components/chat/utils/chatFormatting.ts index d3cd3873..2c74b6b5 100644 --- a/src/components/chat/utils/chatFormatting.ts +++ b/src/components/chat/utils/chatFormatting.ts @@ -46,6 +46,15 @@ function displayMathShouldNormalize(args: { return /^[ \t\r]*$/.test(suffix); } +function inlineMathShouldNormalize(text: string, openIndex: number, closeIndex: number): boolean { + const body = text.slice(openIndex + 2, closeIndex); + // GNU/basic-regex extensions are stronger evidence than the ambiguous + // \( ... \) pair itself. Preserve those shell patterns without making a + // following slash reject ordinary units such as \(v\)/s. + return !/\\(?:[|+?]|[1-9])/.test(body) + && !/\\\{\d+(?:,\d*)?\\\}/.test(body); +} + function findClosingBacktickRun(text: string, start: number, length: number): number { let cursor = start; while (cursor < text.length) { @@ -77,6 +86,7 @@ export function normalizeLatexMathDelimiters(text: string) { close: '\\)' | '\\]'; replacement: '$' | '$$'; outputIndex: number; + openIndex: number; prefixOnlyWhitespace: boolean; strongMathSyntax: boolean; } | null = null; @@ -100,7 +110,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 +217,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 f46bcb87..c8bfa6be 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 55714376..0487bb05 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 976a7590..31317bfe 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 bfb83a7f..8053348d 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 5901eeef..6957e438 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 4a823594..8fab10ec 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 7a0bc750..3d6f1bbe 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 98888e5c..72a36f24 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 a4363af8..2feb6c73 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 68a22295..e29a68e0 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( From 9d0aaae7dd9773f62fc0a15130bb25c2d9e522a7 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 21 Sep 2026 15:04:23 +0900 Subject: [PATCH 2/3] fix: address full-access follow-up review --- README.md | 2 +- docs/readme/README.ja.md | 2 +- docs/readme/README.ko.md | 2 +- docs/readme/README.zh-CN.md | 2 +- server/modules/providers/README.md | 8 ++-- .../services/full-access-spawn-policy.ts | 3 +- .../external-cli-sessions.service.test.ts | 2 +- .../provider-contract-documentation.test.ts | 13 ++++++- .../chat/utils/chatFormatting.test.ts | 39 ++++++++++++++++++- src/components/chat/utils/chatFormatting.ts | 29 +++++++++++--- 10 files changed, 84 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index caefce8e..76853e38 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ 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. Sessions launched with full access retain a warning badge in the sidebar. +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. diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b81b5ba8..5dba9dc8 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -92,7 +92,7 @@ SSH tmux とローカルシェルも、ターミナル専用接続として利 **フルアクセス**は、新しく起動するローカルセッションごとに明示的に選択するオプションです。設定は保存されず、既存またはリモートのセッションには影響しません。CLI に応じて、そのプロバイダーで検証済みの自動承認またはフルアクセスモードを選択するため、ツール、シェルコマンド、ファイル変更が追加確認なしで実行される場合があります。GJC は独自のデフォルト許可ポリシーを使用し、個別の起動スイッチはありません。Cursor には検証済みの対話型フルアクセスフラグがありません。 -管理者は `CHATMUX_DISABLE_FULL_ACCESS=1` を設定すると、このオプションを非表示にし、フルアクセスでの起動要求をサーバー側でも拒否できます。フルアクセスで起動したセッションには、サイドバーで警告バッジが継続して表示されます。 +管理者は `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 ddd3a1ec..1c6230bf 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -94,7 +94,7 @@ SSH tmux와 로컬 셸도 터미널 전용 연결로 지원합니다. **모두 허용**은 새로 시작하는 로컬 세션마다 명시적으로 선택하는 옵션입니다. 선택은 저장되지 않으며 기존 세션이나 원격 세션에는 영향을 주지 않습니다. CLI에 따라 해당 프로바이더에서 검증된 자동 승인 또는 전체 권한 모드를 사용하므로, 도구 실행과 셸 명령, 파일 변경이 추가 확인 없이 진행될 수 있습니다. GJC는 자체 기본 허용 정책을 사용하고 별도 시작 옵션이 없으며, Cursor는 검증된 대화형 전체 권한 옵션이 없습니다. -관리자는 `CHATMUX_DISABLE_FULL_ACCESS=1`을 설정해 이 옵션을 숨기고 서버에서도 모두 허용 시작 요청을 거부할 수 있습니다. 모두 허용으로 시작한 세션은 사이드바에 경고 배지가 계속 표시됩니다. +관리자는 `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 f24f5aa4..3a7d4998 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -92,7 +92,7 @@ SSH tmux 和本地 shell 也可作为纯终端连接使用。 **完全访问**是为每个新启动的本地会话明确选择的选项。该选项不会保存,也不会影响现有会话或远程会话。根据 CLI,它会选择该提供商已验证的自动批准或完全访问模式,因此工具、Shell 命令和文件更改可能无需再次确认即可执行。GJC 使用其原生的默认允许策略,没有单独的启动开关;Cursor 没有经过验证的交互式完全访问参数。 -管理员可以设置 `CHATMUX_DISABLE_FULL_ACCESS=1` 来隐藏此选项,并在服务器端拒绝完全访问启动请求。以完全访问模式启动的会话会在侧边栏持续显示警告徽章。 +管理员可以设置 `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 53afe86b..59167169 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -59,9 +59,11 @@ 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. The backend then advertises no full-access capability and rejects -full-access spawn requests even if a stale or custom client sends one. A session -that starts in this mode is tagged with tmux option `@chatmux_full_access=1`; +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. diff --git a/server/modules/providers/services/full-access-spawn-policy.ts b/server/modules/providers/services/full-access-spawn-policy.ts index d902f9be..a3c36a30 100644 --- a/server/modules/providers/services/full-access-spawn-policy.ts +++ b/server/modules/providers/services/full-access-spawn-policy.ts @@ -4,5 +4,6 @@ export const FULL_ACCESS_DISABLE_ENV = 'CHATMUX_DISABLE_FULL_ACCESS'; export function isFullAccessSpawnDisabled( environment: Readonly> = process.env, ): boolean { - return environment[FULL_ACCESS_DISABLE_ENV] === '1'; + const value = environment[FULL_ACCESS_DISABLE_ENV]?.trim().toLowerCase(); + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; } 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 e4967241..688753e7 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -1396,7 +1396,7 @@ test('external CLI spawn fails closed when the deployment disables full access', await assert.rejects( spawnExternalCliSession('codex', 'disabled-full-access', '/workspace', { fullAccess: true, - environment: { CHATMUX_DISABLE_FULL_ACCESS: '1' }, + environment: { CHATMUX_DISABLE_FULL_ACCESS: 'true' }, launch: async () => { launched = true; return { command: 'tmux', prefixArgs: [] }; }, run: async () => '', }), diff --git a/server/modules/providers/tests/provider-contract-documentation.test.ts b/server/modules/providers/tests/provider-contract-documentation.test.ts index a75061df..c16fb8c8 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 () => { @@ -71,7 +72,17 @@ test('only verified native CLIs advertise full-access startup', () => { test('the deployment kill switch removes full-access startup from every capability', () => { const capabilities = providerCapabilitiesService.listAllProviderCapabilities({ - CHATMUX_DISABLE_FULL_ACCESS: '1', + 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/src/components/chat/utils/chatFormatting.test.ts b/src/components/chat/utils/chatFormatting.test.ts index f2d1cc51..5f54c29b 100644 --- a/src/components/chat/utils/chatFormatting.test.ts +++ b/src/components/chat/utils/chatFormatting.test.ts @@ -79,8 +79,6 @@ 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 grep = String.raw`grep '\(a\|b\)' file`; - assert.equal(normalizeLatexMathDelimiters(grep), grep); 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'); @@ -90,6 +88,43 @@ 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\)\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 2c74b6b5..f3e8ef25 100644 --- a/src/components/chat/utils/chatFormatting.ts +++ b/src/components/chat/utils/chatFormatting.ts @@ -47,12 +47,29 @@ function displayMathShouldNormalize(args: { } function inlineMathShouldNormalize(text: string, openIndex: number, closeIndex: number): boolean { - const body = text.slice(openIndex + 2, closeIndex); - // GNU/basic-regex extensions are stronger evidence than the ambiguous - // \( ... \) pair itself. Preserve those shell patterns without making a - // following slash reject ordinary units such as \(v\)/s. - return !/\\(?:[|+?]|[1-9])/.test(body) - && !/\\\{\d+(?:,\d*)?\\\}/.test(body); + const before = text[openIndex - 1] ?? ''; + if (before === "'" || before === '"') return false; + + // BRE postfix 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 === '?' || /^[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 { From b856e575492039b845e6231f40a0ae275f4f3901 Mon Sep 17 00:00:00 2001 From: YoonwooHa Date: Mon, 21 Sep 2026 15:51:03 +0900 Subject: [PATCH 3/3] fix: preserve trailing BRE alternation --- src/components/chat/utils/chatFormatting.test.ts | 1 + src/components/chat/utils/chatFormatting.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/chat/utils/chatFormatting.test.ts b/src/components/chat/utils/chatFormatting.test.ts index 5f54c29b..c1a40cbc 100644 --- a/src/components/chat/utils/chatFormatting.test.ts +++ b/src/components/chat/utils/chatFormatting.test.ts @@ -102,6 +102,7 @@ test('preserves shell-quoted BRE groups and postfix operators', () => { } const unquotedPostfixes = [ + String.raw`pattern \(a\)\|b`, String.raw`pattern \(a\)\1`, String.raw`pattern \(ab\)\+`, String.raw`pattern \(ab\)\?`, diff --git a/src/components/chat/utils/chatFormatting.ts b/src/components/chat/utils/chatFormatting.ts index f3e8ef25..b3c9884c 100644 --- a/src/components/chat/utils/chatFormatting.ts +++ b/src/components/chat/utils/chatFormatting.ts @@ -50,15 +50,17 @@ function inlineMathShouldNormalize(text: string, openIndex: number, closeIndex: const before = text[openIndex - 1] ?? ''; if (before === "'" || before === '"') return false; - // BRE postfix 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. + // 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 === '?' || /^[1-9]$/.test(operator ?? '')) return false; + if (operator === '|' || operator === '+' || operator === '?' || /^[1-9]$/.test(operator ?? '')) { + return false; + } if (operator !== '{') return true; cursor += 1;