Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<sub>Cursor sessions use the documented <code>agent</code> command; the legacy <code>cursor-agent</code> alias remains supported for older installations.</sub>

## Remote access
Expand Down
2 changes: 2 additions & 0 deletions docs/readme/README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ SSH tmux とローカルシェルも、ターミナル専用接続として利

**フルアクセス**は、新しく起動するローカルセッションごとに明示的に選択するオプションです。設定は保存されず、既存またはリモートのセッションには影響しません。CLI に応じて、そのプロバイダーで検証済みの自動承認またはフルアクセスモードを選択するため、ツール、シェルコマンド、ファイル変更が追加確認なしで実行される場合があります。GJC は独自のデフォルト許可ポリシーを使用し、個別の起動スイッチはありません。Cursor には検証済みの対話型フルアクセスフラグがありません。

管理者は `CHATMUX_DISABLE_FULL_ACCESS=1` を設定すると、このオプションを非表示にし、フルアクセスでの起動要求をサーバー側でも拒否できます。値は前後の空白と大文字・小文字を区別せず、空の値、`0`、`false` 以外のすべての値で機能が無効になります。フルアクセスで起動したセッションには、サイドバーで警告バッジが継続して表示されます。

<sub>Cursor セッションはドキュメントに記載された <code>agent</code> コマンドを使用します。古いインストール向けにレガシーの <code>cursor-agent</code> エイリアスも引き続きサポートされます。</sub>

## リモートアクセス
Expand Down
2 changes: 2 additions & 0 deletions docs/readme/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ SSH tmux와 로컬 셸도 터미널 전용 연결로 지원합니다.

**모두 허용**은 새로 시작하는 로컬 세션마다 명시적으로 선택하는 옵션입니다. 선택은 저장되지 않으며 기존 세션이나 원격 세션에는 영향을 주지 않습니다. CLI에 따라 해당 프로바이더에서 검증된 자동 승인 또는 전체 권한 모드를 사용하므로, 도구 실행과 셸 명령, 파일 변경이 추가 확인 없이 진행될 수 있습니다. GJC는 자체 기본 허용 정책을 사용하고 별도 시작 옵션이 없으며, Cursor는 검증된 대화형 전체 권한 옵션이 없습니다.

관리자는 `CHATMUX_DISABLE_FULL_ACCESS=1`을 설정해 이 옵션을 숨기고 서버에서도 모두 허용 시작 요청을 거부할 수 있습니다. 값은 앞뒤 공백과 대소문자를 무시하며, 비어 있거나 `0` 또는 `false`인 경우를 제외한 모든 설정값이 기능을 비활성화합니다. 모두 허용으로 시작한 세션은 사이드바에 경고 배지가 계속 표시됩니다.

<sub>Cursor 세션은 문서화된 <code>agent</code> 명령을 사용합니다. 구형 설치를 위한 레거시 <code>cursor-agent</code> 별칭도 계속 지원됩니다.</sub>

## 원격 접속
Expand Down
2 changes: 2 additions & 0 deletions docs/readme/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ SSH tmux 和本地 shell 也可作为纯终端连接使用。

**完全访问**是为每个新启动的本地会话明确选择的选项。该选项不会保存,也不会影响现有会话或远程会话。根据 CLI,它会选择该提供商已验证的自动批准或完全访问模式,因此工具、Shell 命令和文件更改可能无需再次确认即可执行。GJC 使用其原生的默认允许策略,没有单独的启动开关;Cursor 没有经过验证的交互式完全访问参数。

管理员可以设置 `CHATMUX_DISABLE_FULL_ACCESS=1` 来隐藏此选项,并在服务器端拒绝完全访问启动请求。该值会忽略首尾空格和大小写;除空值、`0` 或 `false` 外,任何值都会禁用此功能。以完全访问模式启动的会话会在侧边栏持续显示警告徽章。

<sub>Cursor 会话使用文档中的 <code>agent</code> 命令;为旧安装保留了传统的 <code>cursor-agent</code> 别名。</sub>

## 远程访问
Expand Down
19 changes: 14 additions & 5 deletions server/modules/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- |
Expand Down
9 changes: 9 additions & 0 deletions server/modules/providers/provider.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -166,6 +168,7 @@ function hostPaneFingerprint(panes: readonly HostDiscoveryPane[]): string {
pane.codexThreadId ?? '',
pane.taggedKind ?? '',
pane.taggedSessionId ?? '',
pane.fullAccess ? '1' : '',
].join('\0'))
.sort()
.join('\n');
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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';

Expand All @@ -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;
};

Expand Down Expand Up @@ -205,6 +208,7 @@ export type ExternalPane = {
cwd?: string;
taggedKind?: ExternalLocalCliKind;
taggedSessionId?: string;
fullAccess?: boolean;
};

export type ProcessTreeEntry = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -269,7 +271,7 @@ export async function ensureExternalCliCwd(input: string): Promise<string | null
return expanded ? ensureHomeCwd(expanded, home) : null;
}

export type ExternalSpawnCli = ExternalLocalCliKind;
export type { ExternalSpawnCli } from '../../../../../shared/external-cli-spawn.js';

export const EXTERNAL_CLI_COMMANDS: Record<ExternalSpawnCli, readonly string[]> = {
claude: ['claude'],
Expand Down Expand Up @@ -398,20 +400,28 @@ export async function spawnExternalCliSession(
cwd: string,
options: {
fullAccess?: boolean;
environment?: Readonly<Record<string, string | undefined>>;
launch?: typeof resolveTmuxSpawnLaunch;
run?: typeof runCommand;
} = {},
): Promise<void> {
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ export function parseExternalPanes(output: string): ExternalPane[] {
? { taggedKind: pane.taggedKind as ExternalLocalCliKind }
: {}),
...(pane.taggedSessionId ? { taggedSessionId: pane.taggedSessionId } : {}),
...(pane.fullAccess ? { fullAccess: true } : {}),
}));
}

Expand Down
9 changes: 9 additions & 0 deletions server/modules/providers/services/full-access-spawn-policy.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>> = process.env,
): boolean {
const value = environment[FULL_ACCESS_DISABLE_ENV]?.trim().toLowerCase();
return value !== undefined && value !== '' && value !== '0' && value !== 'false';
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -46,6 +47,7 @@ export type HostDiscoveryPane = {
cwd?: string;
taggedKind?: string;
taggedSessionId?: string;
fullAccess?: boolean;
};

export type HostDiscoveryProcess = {
Expand Down Expand Up @@ -161,6 +163,7 @@ export function parseHostDiscoveryPanes(output: string): HostDiscoveryPane[] {
fieldEight,
fieldNine,
fieldTen,
fieldEleven,
] = fields;
const rawCodexThreadId = legacyLiveFormat ? '' : fieldSeven;
const rawCwd = legacyLiveFormat ? fieldSeven : fieldEight;
Expand All @@ -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)
Expand All @@ -198,6 +202,7 @@ export function parseHostDiscoveryPanes(output: string): HostDiscoveryPane[] {
...(cwd ? { cwd } : {}),
...(taggedKind ? { taggedKind } : {}),
...(taggedSessionId ? { taggedSessionId } : {}),
...(fullAccess ? { fullAccess: true } : {}),
});
}
return panes;
Expand Down
25 changes: 21 additions & 4 deletions server/modules/providers/services/provider-capabilities.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -144,15 +146,30 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
},
};

function applyDeploymentPolicy(
capabilities: ProviderCapabilities,
environment: Readonly<Record<string, string | undefined>>,
): 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<Record<string, string | undefined>> = process.env,
): ProviderCapabilities {
return applyDeploymentPolicy(PROVIDER_CAPABILITIES[provider], environment);
},

listAllProviderCapabilities(): ProviderCapabilities[] {
return Object.values(PROVIDER_CAPABILITIES);
listAllProviderCapabilities(
environment: Readonly<Record<string, string | undefined>> = process.env,
): ProviderCapabilities[] {
return Object.values(PROVIDER_CAPABILITIES)
.map((capabilities) => applyDeploymentPolicy(capabilities, environment));
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]) {
Expand Down
Loading
Loading