mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #458 from opticon454/followups
feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2)
This commit is contained in:
@@ -98,6 +98,8 @@ DeepSeek is worth reading before assuming an entry looks like its siblings — t
|
||||
|
||||
`test/cli-registry-no-id-branching.test.ts` fails the build if a CLI id comparison appears outside the stock catalog. It builds its id list from the live catalog, blanks comment lines before scanning (comments legitimately quote the pattern to explain why a branch was removed, and blanking rather than dropping is what keeps reported line numbers pointing at the real file), and keeps an allowlist in which **every entry carries its reason**.
|
||||
|
||||
`test/frontend-cli-no-id-branching.test.ts` is the same guard for the two frontend files the CLI registry's Run-menu consolidation touches, `session-ui.js` and `mobile-overview.js` — deliberately not the rest of `src/web/public/`, whose per-CLI rules stay out of scope for now (see "Fields declared for later" below). Its allowlist keys on `<file>::<expression>` with no line number, since a single unrelated edit to a contended file would otherwise shift every subsequent line and make every entry go stale at once, and each entry additionally carries the exact number of approved call sites — a bare key would let a brand-new branch reusing an already-approved expression land unreviewed.
|
||||
|
||||
It matches four shapes, not one: `mode === '<id>'`, `mode !== '<id>'`, `case '<id>':`, and `['<id>', …].includes(mode)`. The first version matched `===` only, and that gap was not academic — the refactor it guards converted the `===` sites and left the negated ones, so 36 `!==` branches survived it, including a seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in step with a predicate by hand while the sibling code path already read the capability. A guard that sees half the shapes reports a count measured over the half it happens to catch.
|
||||
|
||||
The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `<Mode>Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong).
|
||||
|
||||
+153
-447
@@ -11,6 +11,114 @@
|
||||
* @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
||||
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||
* own no-id-branching rule exists to prevent server-side.
|
||||
*
|
||||
* Deliberately a LOCAL table rather than a server-injected catalogue: several
|
||||
* unit tests exercise these run*() methods inside a bare `vm.createContext()`
|
||||
* sandbox with no `window` global at all (see test/run-mode-ui.test.ts) —
|
||||
* referencing `window` there unguarded would throw, not degrade. `buildConfig`
|
||||
* returns the CLI's top-level legacy config field for a LOCAL launch, or
|
||||
* `null` for a CLI that sends none (pi: no bypass flag exists, so there is
|
||||
* nothing to send — see runPi's own history below for why that must stay
|
||||
* true).
|
||||
*/
|
||||
const RUN_MODE_LAUNCH = {
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }),
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: (globalSettings) => ({
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
}),
|
||||
},
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }),
|
||||
},
|
||||
antigravity: {
|
||||
label: 'Antigravity',
|
||||
installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash',
|
||||
// antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md
|
||||
// calls it `unsupported`) — never fold a pending pick into its launch body.
|
||||
supportsCustomModel: false,
|
||||
buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }),
|
||||
},
|
||||
pi: {
|
||||
label: 'Pi',
|
||||
installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent',
|
||||
supportsCustomModel: true,
|
||||
// Deliberately NO piConfig: pi has no permission prompts, so there is no
|
||||
// bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||
// decision (an interactive prompt the user answers in the terminal).
|
||||
// Sending `approveProjectTrust: true` here would silently opt every
|
||||
// browser-launched pi session into executing repo-supplied TypeScript.
|
||||
buildConfig: () => null,
|
||||
},
|
||||
omp: {
|
||||
label: 'OMP',
|
||||
installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh',
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => null,
|
||||
},
|
||||
grok: {
|
||||
label: 'Grok',
|
||||
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||
supportsCustomModel: true,
|
||||
// Sends `grokConfig: { alwaysApprove: true }` the way antigravity sends
|
||||
// `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
// work, so the Run button opts into grok's bypassPermissions mode
|
||||
// (`--always-approve`; config-level deny rules still apply on top). The
|
||||
// multi-user clamp forces it back off for non-granted owners server-side.
|
||||
buildConfig: () => ({ grokConfig: { alwaysApprove: true } }),
|
||||
},
|
||||
deepseek: {
|
||||
label: 'DeepSeek',
|
||||
installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh',
|
||||
// The two-part availability check is deliberate. `dsh` being installed is
|
||||
// not enough — DeepSeek ships no terminal front door, so a box can have a
|
||||
// perfect binary and nothing a pane can run.
|
||||
unrunnableHint:
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui',
|
||||
supportsCustomModel: true,
|
||||
// Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
// sibling Run button sends its bypass switch. The harness has no bypass
|
||||
// FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the
|
||||
// multi-user clamp forces it back down to `workspace-write` server-side.
|
||||
buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH
|
||||
* above, kept as its own Set (`EXTERNAL_CLI_MODES.has(mode)`) rather than an
|
||||
* array recomputed per call. Single source for what used to be two hand-copied
|
||||
* 8-way `session.mode === '<id>' || ...` chains inside one function
|
||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||
* ninth CLI landed in one and not the other.
|
||||
*/
|
||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
/**
|
||||
* Build envOverrides payload from case + global settings.
|
||||
@@ -395,34 +503,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
try {
|
||||
const mode = this._runMode || 'claude';
|
||||
if (mode === 'opencode') {
|
||||
return await this.runOpenCode();
|
||||
}
|
||||
if (mode === 'codex') {
|
||||
return await this.runCodex();
|
||||
}
|
||||
if (mode === 'gemini') {
|
||||
return await this.runGemini();
|
||||
}
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
return await this.runOmp();
|
||||
}
|
||||
if (mode === 'pi') {
|
||||
return await this.runPi();
|
||||
}
|
||||
if (mode === 'grok') {
|
||||
return await this.runGrok();
|
||||
}
|
||||
if (mode === 'deepseek') {
|
||||
return await this.runDeepSeek();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
return await this.runClaude();
|
||||
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
|
||||
return await this.runClaude();
|
||||
}
|
||||
return await this._runCliMode(mode);
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining));
|
||||
@@ -1917,88 +2004,41 @@ Object.assign(CodemanApp.prototype, {
|
||||
return firstSessionId;
|
||||
},
|
||||
|
||||
async runOpenCode() {
|
||||
/**
|
||||
* Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except
|
||||
* claude/shell, which have their own flows — claude for its remote/docker
|
||||
* branching and parallel-create path, shell for needing no CLI probe at
|
||||
* all). The eight run<Mode>() methods below are thin named wrappers: their
|
||||
* names stay because index.html's welcome-screen buttons and the run-mode
|
||||
* menu call them directly by name (`app.runOpenCode()` etc.), and several
|
||||
* tests assert on that name directly too.
|
||||
*/
|
||||
async _runCliMode(mode) {
|
||||
const entry = RUN_MODE_LAUNCH[mode];
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
// Remote/docker cases run the CLI on the OTHER side — the local status
|
||||
// probe and the local-only config/env below don't apply (quick-start
|
||||
// rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} OpenCode session(s) in ${caseName}...`
|
||||
`Starting ${tabCount} ${entry.label} session(s) in ${caseName}...`
|
||||
);
|
||||
// Focus in sync gesture context (see runClaude comment)
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available (local sessions only)
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const statusRes = await fetch(`/api/${mode}/status`);
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default).
|
||||
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'OpenCode',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
// Switch to the new session (don't pre-set activeSessionId — selectSession
|
||||
// early-returns when IDs match, skipping buffer load and sendResize)
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Codex session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
if (entry.unrunnableHint && !status.runnable) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2008,19 +2048,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Codex',
|
||||
entry.label,
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
mode,
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
animations: globalSettings.codexAnimationsEnabled ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(entry.buildConfig(globalSettings) || {}),
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
...(entry.supportsCustomModel && this._pendingCustomModelForLaunch
|
||||
? { customModel: this._pendingCustomModelForLaunch }
|
||||
: {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
@@ -2038,368 +2076,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runOpenCode() {
|
||||
return this._runCliMode('opencode');
|
||||
},
|
||||
|
||||
async runCodex() {
|
||||
return this._runCliMode('codex');
|
||||
},
|
||||
|
||||
async runGemini() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Gemini session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Gemini',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('gemini');
|
||||
},
|
||||
|
||||
async runAntigravity() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run agy on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Antigravity session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/antigravity/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Antigravity',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'antigravity',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
antigravityConfig: { dangerouslySkipPermissions: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('antigravity');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Pi (pi.dev) session.
|
||||
*
|
||||
* Deliberately sends NO piConfig: pi has no permission prompts, so there is no
|
||||
* bypass to opt into, and project trust is pi's own `defaultProjectTrust`
|
||||
* decision (an interactive prompt the user answers in the terminal). Sending
|
||||
* `approveProjectTrust: true` here would silently opt every browser-launched pi
|
||||
* session into executing repo-supplied TypeScript.
|
||||
*/
|
||||
async runPi() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run pi on the OTHER side — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Pi session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/pi/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Pi',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'pi',
|
||||
sessionName,
|
||||
...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }),
|
||||
...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('pi');
|
||||
},
|
||||
|
||||
async runOmp() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
||||
// and the local-only config below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} OMP session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/omp/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'OMP',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'omp',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('omp');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Grok Build (xAI `grok`) session.
|
||||
*
|
||||
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
|
||||
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
|
||||
* work, so the Run button opts into grok's bypassPermissions mode
|
||||
* (`--always-approve`; config-level deny rules still apply on top). The
|
||||
* multi-user clamp forces it back off for non-granted owners server-side.
|
||||
*/
|
||||
async runGrok() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} Grok session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/grok/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'Grok',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'grok',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
grokConfig: { alwaysApprove: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('grok');
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a DeepSeek Harness (`dsh`) session.
|
||||
*
|
||||
* Sends `permissionMode: 'danger-full-access'` for the same reason every
|
||||
* sibling Run button sends its bypass switch: Codeman sessions exist for
|
||||
* autonomous work. The harness has no bypass FLAG, so this rides the
|
||||
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
|
||||
* back down to `workspace-write` for non-granted owners server-side.
|
||||
*
|
||||
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
|
||||
* output-stabilization guessing to definitive idle/blocked hook events.
|
||||
*
|
||||
* The two-part availability check is deliberate. `dsh` being installed is not
|
||||
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
|
||||
* binary and nothing a pane can run. Reporting that precisely, with the exact
|
||||
* command that fixes it, is the difference between "the Run button is broken"
|
||||
* and a 30-second fix.
|
||||
*/
|
||||
async runDeepSeek() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const tabCount = this._readTabCount();
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(
|
||||
`Starting ${tabCount} DeepSeek session(s) in ${caseName}...`
|
||||
);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/deepseek/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!status.runnable) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
|
||||
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
|
||||
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const firstSessionId = await this._launchQuickStartInstances(
|
||||
caseName,
|
||||
tabCount,
|
||||
'DeepSeek',
|
||||
(sessionName) => ({
|
||||
caseName,
|
||||
mode: 'deepseek',
|
||||
sessionName,
|
||||
...(isRemote ? {} : {
|
||||
deepSeekConfig: { permissionMode: 'danger-full-access' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}),
|
||||
}),
|
||||
}),
|
||||
ownsLaunchTerminal
|
||||
);
|
||||
|
||||
if (firstSessionId) {
|
||||
await this.selectSession(firstSessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
return this._runCliMode('deepseek');
|
||||
},
|
||||
|
||||
|
||||
@@ -2467,7 +2173,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -2497,7 +2203,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const isExternalCli = isAltMode;
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched
|
||||
* (`session-ui.js`, `mobile-overview.js`), mirroring
|
||||
* `test/cli-registry-no-id-branching.test.ts` for the backend registry.
|
||||
*
|
||||
* Deliberately scoped to ONLY these two files, not all of `src/web/public/`.
|
||||
* `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the
|
||||
* frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …)
|
||||
* keeps its own hand-authored per-CLI rules deliberately — "moving them is
|
||||
* its own piece of work verified by a browser/mobile suite the CI gate cannot
|
||||
* see." Widening this guard to the whole directory would force either fixing
|
||||
* or allowlisting dozens of branches in files nobody has touched or reviewed
|
||||
* for this change, which is scope B2 never took on.
|
||||
*
|
||||
* Port: none (pure static analysis).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url));
|
||||
const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
|
||||
|
||||
/**
|
||||
* Every currently-surviving branch, each with the COUNT of physical call
|
||||
* sites carrying it and the reason none of them is a `CliCapabilities`
|
||||
* field, keyed `<file>::<the matched expression>` — deliberately NO line
|
||||
* number. An earlier version keyed on `<file>::<line>::<expression>`, and
|
||||
* inserting one comment line at the top of `session-ui.js` shifted every
|
||||
* subsequent line number, so all 21 entries went stale and the same 21
|
||||
* branches were then reported as "new". `session-ui.js` is one of the most
|
||||
* contended files in the repo, so a guard that goes red on any unrelated
|
||||
* edit to it sends the next person after the wrong problem.
|
||||
*
|
||||
* The `count` is what closes the gap dropping the line number opened: a key
|
||||
* alone says "this expression is approved somewhere in this file", so a
|
||||
* BRAND NEW `mode === 'codex'` site anywhere in `session-ui.js` would reuse
|
||||
* the same key as the two approved ones and pass silently. The count makes
|
||||
* that a mismatch — one more occurrence than declared — and the "counts
|
||||
* match" test below catches it, while a genuinely new expression (a CLI id
|
||||
* with no ALLOWED_BRANCHES entry at all) is still caught by the separate
|
||||
* "no unapproved id branches" test either way.
|
||||
*/
|
||||
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
|
||||
"session-ui.js::mode === 'shell'": {
|
||||
count: 2,
|
||||
reason:
|
||||
'run() dispatch (shell needs no CLI probe at all) and the button-label ternary (pinned exact ' +
|
||||
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
|
||||
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
|
||||
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
|
||||
'hardcoded, see the PR #458 review thread)',
|
||||
},
|
||||
|
||||
"session-ui.js::mode === 'claude'": {
|
||||
count: 4,
|
||||
reason:
|
||||
'four claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
|
||||
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
|
||||
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
|
||||
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
|
||||
"by design, mirroring the backend capabilities.ralph gate), and the runMode setter's " +
|
||||
'validity check',
|
||||
},
|
||||
|
||||
// The 8 external CLIs share the same two call sites and the same reason at
|
||||
// each: the button-label ternary (see the shell entry above for why it
|
||||
// stays hardcoded) and the runMode property setter's validity allowlist
|
||||
// (not a behaviour branch; left hardcoded in Phase 2 since its chain has
|
||||
// no shell arm at all and no evidence of what callers rely on it).
|
||||
"session-ui.js::mode === 'opencode'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'codex'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'gemini'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'antigravity'": {
|
||||
count: 2,
|
||||
reason: 'button-label ternary + runMode setter validity check',
|
||||
},
|
||||
"session-ui.js::mode === 'pi'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'grok'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'deepseek'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
"session-ui.js::mode === 'omp'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
|
||||
|
||||
// mobile-overview.js: shell is exempt from the isCliAvailable() gate the
|
||||
// same way the toolbar's #runModeMenu exempts it (shell needs no CLI).
|
||||
"mobile-overview.js::mode !== 'shell'": {
|
||||
count: 1,
|
||||
reason: 'shell needs no CLI, so it is exempt from the availability gate',
|
||||
},
|
||||
};
|
||||
|
||||
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
||||
const IDS = STOCK_CLIS.map((e) => e.id as string);
|
||||
const ID_ALT = IDS.join('|');
|
||||
|
||||
/** Same four shapes as the backend guard — see its own comment for why all four matter. */
|
||||
const BRANCH_PATTERN = new RegExp(
|
||||
[
|
||||
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
||||
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
||||
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
|
||||
].join('|'),
|
||||
'g'
|
||||
);
|
||||
|
||||
/** Blanks comment lines before scanning — see the backend guard's own comment on why. */
|
||||
function uncommented(source: string): string {
|
||||
return source
|
||||
.split('\n')
|
||||
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
file: string;
|
||||
expression: string;
|
||||
line: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function scan(): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const file of SCANNED_FILES) {
|
||||
const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
||||
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
||||
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
|
||||
findings.push({ file, expression, line: i + 1, key: `${file}::${expression}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
const findings = scan();
|
||||
|
||||
function actualCounts(): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const f of findings) counts.set(f.key, (counts.get(f.key) ?? 0) + 1);
|
||||
return counts;
|
||||
}
|
||||
|
||||
describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => {
|
||||
it('scans both files (sanity)', () => {
|
||||
// If this drops to zero the scanner or the file list drifted and every
|
||||
// assertion below would pass vacuously.
|
||||
const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0);
|
||||
expect(scannedBytes).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
||||
it('builds its id list from the live catalog (sanity)', () => {
|
||||
expect(IDS).toContain('claude');
|
||||
expect(IDS).toContain('deepseek');
|
||||
expect(IDS.length).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
|
||||
it('still detects a branch when one exists (anti-vacuity)', () => {
|
||||
const samples = [
|
||||
"if (session.mode === 'codex') { doSomething(); }",
|
||||
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
||||
"switch (mode) { case 'gemini': return 1; }",
|
||||
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
|
||||
}
|
||||
BRANCH_PATTERN.lastIndex = 0;
|
||||
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
|
||||
});
|
||||
|
||||
it('has no unapproved id branches', () => {
|
||||
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
|
||||
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length === 0
|
||||
? ''
|
||||
: `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` +
|
||||
'Two ways out, in order of preference:\n' +
|
||||
' 1. Derive the difference from a shared module-level constant, the way\n' +
|
||||
' _runCliMode()/RUN_MODE_LAUNCH/EXTERNAL_CLI_MODES do.\n' +
|
||||
' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' +
|
||||
' ALLOWED_BRANCHES in this file WITH the reason.'
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('every allowlisted branch occurs exactly its declared number of times', () => {
|
||||
// This is what closes the gap the line-number removal opened (see the
|
||||
// ALLOWED_BRANCHES header comment): a key alone cannot tell "the two
|
||||
// approved sites" from "the two approved sites plus a brand new third
|
||||
// one reusing the same expression" — the count can. A mismatch in
|
||||
// either direction is real: higher means an unreviewed NEW branch
|
||||
// landed reusing an approved expression, lower means one of the
|
||||
// reviewed call sites was removed and the entry is now a stale lie
|
||||
// about the codebase (the count going to 0 is the old "stale entry"
|
||||
// case, now folded into this same check rather than a separate one).
|
||||
const actual = actualCounts();
|
||||
const mismatches: string[] = [];
|
||||
for (const [key, { count: expected }] of Object.entries(ALLOWED_BRANCHES)) {
|
||||
const got = actual.get(key) ?? 0;
|
||||
if (got !== expected) {
|
||||
mismatches.push(` ${key} expected ${expected}, found ${got}`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
mismatches,
|
||||
mismatches.length === 0
|
||||
? ''
|
||||
: `ALLOWED_BRANCHES count mismatch(es):\n${mismatches.join('\n')}\n\n` +
|
||||
'A count LOWER than declared means a reviewed call site was removed — update or delete ' +
|
||||
'the entry. A count HIGHER than declared means a NEW branch landed reusing an already-' +
|
||||
'approved expression — review it and bump the count (or fix the branch) explicitly, ' +
|
||||
'rather than let it ride in on an existing approval.'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -55,18 +55,22 @@ describe('OpenCode session initial resize', () => {
|
||||
await context?.close();
|
||||
});
|
||||
|
||||
it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => {
|
||||
// This test verifies at the code level that runOpenCode does NOT
|
||||
// pre-set activeSessionId before calling selectSession.
|
||||
// If it did, selectSession would early-return and skip sendResize.
|
||||
it('selectSession is not bypassed when the shared launcher sets activeSessionId', async () => {
|
||||
// This test verifies at the code level that the OpenCode launch path does
|
||||
// NOT pre-set activeSessionId before calling selectSession. If it did,
|
||||
// selectSession would early-return and skip sendResize.
|
||||
//
|
||||
// PR B2 consolidated runOpenCode() (and 7 siblings) into one shared
|
||||
// _runCliMode(mode) — runOpenCode is now a one-line wrapper
|
||||
// (`return this._runCliMode('opencode')`), so inspecting ITS source would
|
||||
// never see the real launch logic and this check would pass vacuously
|
||||
// regardless of what _runCliMode actually does. Inspect _runCliMode itself.
|
||||
({ context, page } = await freshPage());
|
||||
await navigateAndWait(page);
|
||||
|
||||
// Read the runOpenCode source from the live app and verify
|
||||
// it doesn't assign activeSessionId before selectSession
|
||||
const hasPreAssignment = await page.evaluate(() => {
|
||||
const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app;
|
||||
const source = app.runOpenCode.toString();
|
||||
const app = (window as unknown as { app: { _runCliMode: { toString: () => string } } }).app;
|
||||
const source = app._runCliMode.toString();
|
||||
|
||||
// Check: the source should NOT have activeSessionId = ... before selectSession
|
||||
// Find positions of both patterns
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview `RUN_MODE_LAUNCH` (session-ui.js, PR B2) restates four things
|
||||
* `stock.ts` already owns: label, an install command, whether the CLI
|
||||
* supports a custom-model launch, and the external-mode key set itself.
|
||||
* They agree today, but nothing enforced it — the dangerous drift is
|
||||
* `supportsCustomModel`: the Run menu's "CLI (endpoint)" rows come from the
|
||||
* server-injected `window.__codemanCustomModelClis` (built from
|
||||
* `capabilities.customModelInjection.kind !== 'unsupported'`), so a CLI that
|
||||
* gains a real injection recipe later would be OFFERED in that menu while
|
||||
* `_runCliMode` still drops the `customModel` field for it — the session
|
||||
* launches on the vendor's cloud while the UI claims the local endpoint.
|
||||
*
|
||||
* Drives the REAL session-ui.js via JSDOM (`runScripts: 'dangerously'`, same
|
||||
* approach as test/custom-model-one-shot-launch.test.ts), extracting the
|
||||
* module-level `RUN_MODE_LAUNCH` const by appending one assignment line to
|
||||
* the SAME source string before the one `eval()` call — it is not attached
|
||||
* to `window` on its own (top-level `const` lives in the script's own
|
||||
* lexical scope, not the global object), and a SEPARATE later `eval()` call
|
||||
* cannot see an earlier call's top-level bindings either (measured: each
|
||||
* `window.eval()` invocation gets its own top-level lexical environment in
|
||||
* jsdom), so the assignment has to ride in the same evaluated string.
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||
|
||||
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
|
||||
|
||||
interface RunModeLaunchEntry {
|
||||
label: string;
|
||||
installHint: string;
|
||||
supportsCustomModel: boolean;
|
||||
buildConfig: (globalSettings: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function loadRunModeLaunch(): Record<string, RunModeLaunchEntry> {
|
||||
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/', runScripts: 'dangerously' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => unknown };
|
||||
(win as unknown as { eval: (s: string) => void }).eval('window.CodemanApp = function CodemanApp() {};');
|
||||
// The assignment MUST be part of the same evaluated string as
|
||||
// SESSION_UI_JS — RUN_MODE_LAUNCH is a bare top-level `const`, so it only
|
||||
// exists in the lexical scope of THIS eval call.
|
||||
(win as unknown as { eval: (s: string) => void }).eval(
|
||||
`${SESSION_UI_JS}\nwindow.__TEST_RUN_MODE_LAUNCH = RUN_MODE_LAUNCH;`
|
||||
);
|
||||
return (win as unknown as { __TEST_RUN_MODE_LAUNCH: Record<string, RunModeLaunchEntry> }).__TEST_RUN_MODE_LAUNCH;
|
||||
}
|
||||
|
||||
describe('RUN_MODE_LAUNCH (session-ui.js) stays in step with stock.ts', () => {
|
||||
const runModeLaunch = loadRunModeLaunch();
|
||||
const byId = new Map(STOCK_CLIS.map((e) => [e.id as string, e]));
|
||||
|
||||
it('covers exactly the non-claude, non-shell stock CLIs — no more, no fewer', () => {
|
||||
const expectedIds = STOCK_CLIS.map((e) => e.id as string)
|
||||
.filter((id) => id !== 'claude' && id !== 'shell')
|
||||
.sort();
|
||||
expect(Object.keys(runModeLaunch).sort()).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it('label matches CliEntry.label for every entry', () => {
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const stockEntry = byId.get(id);
|
||||
expect(stockEntry, `no stock entry for ${id}`).toBeTruthy();
|
||||
expect(entry.label, `${id} label drifted from stock.ts`).toBe(stockEntry!.label);
|
||||
}
|
||||
});
|
||||
|
||||
it('installHint embeds the real linux install command', () => {
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const command = byId.get(id)!.discovery.install.command?.linux;
|
||||
if (!command) continue; // shell-less entries (none today) carry no command to check
|
||||
expect(entry.installHint, `${id} installHint no longer matches stock.ts's linux install command`).toContain(
|
||||
command
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsCustomModel matches capabilities.customModelInjection.kind !== "unsupported"', () => {
|
||||
// This is the one that fails SILENTLY if it drifts (see file header):
|
||||
// window.__codemanCustomModelClis (server.ts) is built from this same
|
||||
// stock.ts field, so a mismatch here means the Run-menu picker and the
|
||||
// actual launch body disagree about which CLIs are custom-model-capable.
|
||||
for (const [id, entry] of Object.entries(runModeLaunch)) {
|
||||
const supported = byId.get(id)!.capabilities.customModelInjection.kind !== 'unsupported';
|
||||
expect(entry.supportsCustomModel, `${id}.supportsCustomModel drifted from stock.ts's capability`).toBe(supported);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -166,8 +166,18 @@ describe('Run launch synchronization', () => {
|
||||
|
||||
// Methods live in one Object.assign(prototype, {...}) block at a fixed
|
||||
// 2-space indent, so `\n },` reliably closes the one we are inside.
|
||||
//
|
||||
// `(\w*)` in the param list, not `()`, and the leading `_?`: PR B2
|
||||
// consolidated the eight run<Mode>() bodies into one shared
|
||||
// `_runCliMode(mode)`, and the ORIGINAL `\(\)`-only pattern matched every
|
||||
// one-line wrapper (`async runOpenCode() { return this._runCliMode(...) }`)
|
||||
// but not `_runCliMode` itself, where the real terminal-ownership logic
|
||||
// now lives — so this guard could see 8 clean one-liners and stay green
|
||||
// while the actual bug shipped unseen for all eight external CLIs at
|
||||
// once. Confirmed live: adding `this.terminal.clear()` to `_runCliMode`
|
||||
// left this test 32/32 green under the old pattern.
|
||||
const bodies = new Map<string, string>();
|
||||
const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm;
|
||||
const header = /^ {2}async (_?run[A-Za-z]*)\(\w*\) \{$/gm;
|
||||
for (let m = header.exec(src); m; m = header.exec(src)) {
|
||||
const start = m.index + m[0].length;
|
||||
const end = src.indexOf('\n },', start);
|
||||
@@ -181,6 +191,7 @@ describe('Run launch synchronization', () => {
|
||||
expect.arrayContaining([
|
||||
'runClaude',
|
||||
'runShell',
|
||||
'_runCliMode',
|
||||
'runOpenCode',
|
||||
'runCodex',
|
||||
'runGemini',
|
||||
|
||||
Reference in New Issue
Block a user