mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix: COD-24 stabilize remote host sessions
This commit is contained in:
committed by
Aamer Akhter
parent
568d93efb0
commit
26e78daf58
+17
-1
@@ -49,6 +49,7 @@ import {
|
|||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
type GeminiConfig,
|
type GeminiConfig,
|
||||||
|
type SessionRemote,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
|
||||||
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||||
@@ -209,6 +210,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
|
|||||||
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string {
|
||||||
|
return remote ? '/tmp' : workingDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||||
* Messages are newline-delimited JSON objects parsed from PTY output.
|
* Messages are newline-delimited JSON objects parsed from PTY output.
|
||||||
@@ -385,6 +390,9 @@ export class Session extends EventEmitter {
|
|||||||
// tmux history-limit (scrollback lines) applied to this session's pane.
|
// tmux history-limit (scrollback lines) applied to this session's pane.
|
||||||
private readonly _tmuxHistoryLimit: number;
|
private readonly _tmuxHistoryLimit: number;
|
||||||
|
|
||||||
|
// Remote execution metadata, present when this session runs over SSH through local tmux.
|
||||||
|
private readonly _remote?: SessionRemote;
|
||||||
|
|
||||||
// Session color for visual differentiation
|
// Session color for visual differentiation
|
||||||
private _color: import('./types.js').SessionColor = 'default';
|
private _color: import('./types.js').SessionColor = 'default';
|
||||||
|
|
||||||
@@ -456,6 +464,8 @@ export class Session extends EventEmitter {
|
|||||||
tmuxHistoryLimit?: number;
|
tmuxHistoryLimit?: number;
|
||||||
/** Restored per-session attachment history. May include server-private external paths. */
|
/** Restored per-session attachment history. May include server-private external paths. */
|
||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
|
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
|
||||||
|
remote?: SessionRemote;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@@ -528,6 +538,7 @@ export class Session extends EventEmitter {
|
|||||||
this._effort = config.effort;
|
this._effort = config.effort;
|
||||||
}
|
}
|
||||||
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
|
||||||
|
this._remote = config.remote;
|
||||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||||
}
|
}
|
||||||
@@ -987,6 +998,7 @@ export class Session extends EventEmitter {
|
|||||||
pid: this.pid,
|
pid: this.pid,
|
||||||
status: this._status,
|
status: this._status,
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
|
remote: this._remote,
|
||||||
currentTaskId: this._currentTaskId,
|
currentTaskId: this._currentTaskId,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
lastActivityAt: this._lastActivityAt,
|
lastActivityAt: this._lastActivityAt,
|
||||||
@@ -1171,7 +1183,7 @@ export class Session extends EventEmitter {
|
|||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: ptyCols,
|
cols: ptyCols,
|
||||||
rows: ptyRows,
|
rows: ptyRows,
|
||||||
cwd: this.workingDir,
|
cwd: resolveMuxAttachCwd(this.workingDir, this._remote),
|
||||||
env: buildMuxAttachEnv(),
|
env: buildMuxAttachEnv(),
|
||||||
});
|
});
|
||||||
} catch (spawnErr) {
|
} catch (spawnErr) {
|
||||||
@@ -1282,6 +1294,7 @@ export class Session extends EventEmitter {
|
|||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
|
remote: this._remote,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1299,6 +1312,7 @@ export class Session extends EventEmitter {
|
|||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
effort: this._effort,
|
effort: this._effort,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
|
remote: this._remote,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'mux attachment',
|
spawnErrLabel: 'mux attachment',
|
||||||
});
|
});
|
||||||
@@ -1637,6 +1651,7 @@ export class Session extends EventEmitter {
|
|||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
|
remote: this._remote,
|
||||||
},
|
},
|
||||||
createSessionOptions: {
|
createSessionOptions: {
|
||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
@@ -1646,6 +1661,7 @@ export class Session extends EventEmitter {
|
|||||||
niceConfig: this._niceConfig,
|
niceConfig: this._niceConfig,
|
||||||
envOverrides: this._envOverrides,
|
envOverrides: this._envOverrides,
|
||||||
historyLimit: this._tmuxHistoryLimit,
|
historyLimit: this._tmuxHistoryLimit,
|
||||||
|
remote: this._remote,
|
||||||
},
|
},
|
||||||
spawnErrLabel: 'shell mux attachment',
|
spawnErrLabel: 'shell mux attachment',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ import {
|
|||||||
type CodexConfig,
|
type CodexConfig,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
type GeminiConfig,
|
type GeminiConfig,
|
||||||
|
type SessionRemote,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { buildEffortCliArgs } from './session-cli-builder.js';
|
import { buildEffortCliArgs } from './session-cli-builder.js';
|
||||||
|
import { defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
|
||||||
import {
|
import {
|
||||||
wrapWithNice,
|
wrapWithNice,
|
||||||
SAFE_PATH_PATTERN,
|
SAFE_PATH_PATTERN,
|
||||||
@@ -669,6 +671,16 @@ function buildSpawnCommand(options: {
|
|||||||
return '$SHELL';
|
return '$SHELL';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildRemoteLaunchCommand(options: { mode: SessionMode; remote: SessionRemote }): string {
|
||||||
|
const { mode, remote } = options;
|
||||||
|
const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
|
||||||
|
const args = ['ssh', '-o', 'BatchMode=yes', '-t'];
|
||||||
|
if (remote.port) args.push('-p', String(remote.port));
|
||||||
|
const remoteCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
||||||
|
args.push(remoteSshTarget(remote), `bash -lc ${shellescape(remoteCommand)}`);
|
||||||
|
return args.map((arg) => shellescape(arg)).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set sensitive environment variables on a tmux session via setenv.
|
* Set sensitive environment variables on a tmux session via setenv.
|
||||||
* These are inherited by panes but not visible in ps output or tmux history.
|
* These are inherited by panes but not visible in ps output or tmux history.
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ export interface CaseInfo {
|
|||||||
/** Remote case metadata for display and session creation */
|
/** Remote case metadata for display and session creation */
|
||||||
remote?: {
|
remote?: {
|
||||||
hostId: string;
|
hostId: string;
|
||||||
hostLabel: string;
|
|
||||||
host: string;
|
host: string;
|
||||||
username: string;
|
username: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -1649,6 +1649,36 @@
|
|||||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Remote Tab -->
|
||||||
|
<div class="modal-tab-content hidden" id="case-remote">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Case Name</label>
|
||||||
|
<input type="text" id="remoteCaseName" placeholder="gpu-work" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
<span class="form-hint">Name to identify this remote case in Codeman</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Remote Path</label>
|
||||||
|
<input type="text" id="remoteCasePath" placeholder="/home/user/projects/work" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||||
|
<span class="form-hint">Absolute path on the remote host. Codeman will not create or delete it.</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Host ID</label>
|
||||||
|
<input type="text" id="remoteHostId" placeholder="gpu-box" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>SSH Host/IP</label>
|
||||||
|
<input type="text" id="remoteHostAddress" placeholder="10.0.0.42" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>SSH Username</label>
|
||||||
|
<input type="text" id="remoteHostUsername" placeholder="ubuntu" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Codex Command Override</label>
|
||||||
|
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
|
<span class="form-hint">Optional. Leave blank to use exec codex on the remote host.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Manage Tab -->
|
<!-- Manage Tab -->
|
||||||
<div class="modal-tab-content hidden" id="case-manage">
|
<div class="modal-tab-content hidden" id="case-manage">
|
||||||
<div class="case-manage-list" id="caseManageList">
|
<div class="case-manage-list" id="caseManageList">
|
||||||
|
|||||||
@@ -71,9 +71,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
|
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
|
||||||
|
|
||||||
cases.forEach(c => {
|
cases.forEach(c => {
|
||||||
const displayName = c.name.length > maxNameLength
|
const baseLabel = c.location === 'remote' && c.remote
|
||||||
? c.name.substring(0, maxNameLength) + '…'
|
? `${c.name} @ ${c.remote.hostId}`
|
||||||
: c.name;
|
: c.name;
|
||||||
|
const displayName = baseLabel.length > maxNameLength
|
||||||
|
? baseLabel.substring(0, maxNameLength) + '…'
|
||||||
|
: baseLabel;
|
||||||
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
|
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1254,6 +1257,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('newCaseDescription').value = '';
|
document.getElementById('newCaseDescription').value = '';
|
||||||
document.getElementById('linkCaseName').value = '';
|
document.getElementById('linkCaseName').value = '';
|
||||||
document.getElementById('linkCasePath').value = '';
|
document.getElementById('linkCasePath').value = '';
|
||||||
|
const remoteFields = [
|
||||||
|
'remoteCaseName',
|
||||||
|
'remoteCasePath',
|
||||||
|
'remoteHostId',
|
||||||
|
'remoteHostAddress',
|
||||||
|
'remoteHostUsername',
|
||||||
|
'remoteHostCodexCommand',
|
||||||
|
];
|
||||||
|
remoteFields.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
// Reset to first tab
|
// Reset to first tab
|
||||||
this.caseModalTab = 'case-create';
|
this.caseModalTab = 'case-create';
|
||||||
this.switchCaseModalTab('case-create');
|
this.switchCaseModalTab('case-create');
|
||||||
@@ -1407,6 +1422,64 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async linkRemoteCase() {
|
||||||
|
const name = document.getElementById('remoteCaseName').value.trim();
|
||||||
|
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||||
|
const hostId = document.getElementById('remoteHostId').value.trim();
|
||||||
|
const host = document.getElementById('remoteHostAddress').value.trim();
|
||||||
|
const username = document.getElementById('remoteHostUsername').value.trim();
|
||||||
|
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
||||||
|
|
||||||
|
if (!name || !remotePath || !hostId || !host || !username) {
|
||||||
|
this.showToast('Please complete all required remote fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
|
||||||
|
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!remotePath.startsWith('/')) {
|
||||||
|
this.showToast('Remote path must be absolute', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hostPayload = {
|
||||||
|
id: hostId,
|
||||||
|
label: hostId,
|
||||||
|
host,
|
||||||
|
username,
|
||||||
|
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
||||||
|
};
|
||||||
|
const hostRes = await fetch('/api/remote-hosts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(hostPayload)
|
||||||
|
});
|
||||||
|
const hostData = await hostRes.json();
|
||||||
|
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
||||||
|
throw new Error(hostData.error || 'Failed to save remote host');
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseRes = await fetch('/api/cases/remote-link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, hostId, remotePath })
|
||||||
|
});
|
||||||
|
const caseData = await caseRes.json();
|
||||||
|
if (caseData.success) {
|
||||||
|
this.closeCreateCaseModal();
|
||||||
|
this.showToast(`Remote case "${name}" linked`, 'success');
|
||||||
|
await this.loadQuickStartCases(name);
|
||||||
|
await this.saveLastUsedCase(name);
|
||||||
|
} else {
|
||||||
|
this.showToast(caseData.error || 'Failed to link remote case', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to link remote case:', err);
|
||||||
|
this.showToast('Failed to link remote case: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Case Management (reorder + delete)
|
// Case Management (reorder + delete)
|
||||||
|
|||||||
@@ -95,21 +95,26 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||||
const host = remoteHostMap.get(remoteCase.hostId);
|
const host = remoteHostMap.get(remoteCase.hostId);
|
||||||
if (!host || existingNames.has(remoteCase.name) || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||||
existingNames.add(remoteCase.name);
|
existingNames.add(remoteCase.name);
|
||||||
cases.push({
|
const remoteCaseInfo: CaseInfo = {
|
||||||
name: remoteCase.name,
|
name: remoteCase.name,
|
||||||
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
||||||
hasClaudeMd: false,
|
hasClaudeMd: false,
|
||||||
location: 'remote',
|
location: 'remote',
|
||||||
remote: {
|
remote: {
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
hostLabel: host.label,
|
|
||||||
host: host.host,
|
host: host.host,
|
||||||
username: host.username,
|
username: host.username,
|
||||||
path: remoteCase.remotePath,
|
path: remoteCase.remotePath,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
const existingIndex = cases.findIndex((item) => item.name === remoteCase.name);
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
cases.push(remoteCaseInfo);
|
||||||
|
} else {
|
||||||
|
cases[existingIndex] = remoteCaseInfo;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by persisted caseOrder from settings.json
|
// Sort by persisted caseOrder from settings.json
|
||||||
@@ -341,6 +346,25 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||||
|
const remoteCase = remoteCases.find((item) => item.name === name);
|
||||||
|
if (remoteCase) {
|
||||||
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||||
|
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
||||||
|
hasClaudeMd: false,
|
||||||
|
location: 'remote',
|
||||||
|
remote: {
|
||||||
|
hostId: host.id,
|
||||||
|
host: host.host,
|
||||||
|
username: host.username,
|
||||||
|
path: remoteCase.remotePath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const casePath = await resolveCasePath(name);
|
const casePath = await resolveCasePath(name);
|
||||||
|
|
||||||
if (!existsSync(casePath)) {
|
if (!existsSync(casePath)) {
|
||||||
|
|||||||
@@ -1293,24 +1293,36 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
let remote = undefined;
|
||||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
let linkedCasePath: string | undefined;
|
||||||
// external project directories are honoured by quick-start just like regular case routes.
|
let casePath: string | null = null;
|
||||||
let linkedCases: Record<string, string> = {};
|
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||||
try {
|
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
if (remoteCase) {
|
||||||
linkedCases = JSON.parse(raw);
|
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||||
} catch {
|
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||||
// File missing or unparseable — treat as empty registry
|
casePath = remoteCase.remotePath;
|
||||||
}
|
remote = toSessionRemote(host, remoteCase);
|
||||||
const linkedCasePath = linkedCases[caseName];
|
} else {
|
||||||
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||||
if (!casePath) {
|
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
// external project directories are honoured by quick-start just like regular case routes.
|
||||||
|
let linkedCases: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||||
|
linkedCases = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// File missing or unparseable — treat as empty registry
|
||||||
|
}
|
||||||
|
linkedCasePath = linkedCases[caseName];
|
||||||
|
casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
||||||
|
if (!casePath) {
|
||||||
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
|
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
|
||||||
if (!existsSync(casePath)) {
|
if (!remote && !existsSync(casePath)) {
|
||||||
try {
|
try {
|
||||||
mkdirSync(casePath, { recursive: true });
|
mkdirSync(casePath, { recursive: true });
|
||||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||||
@@ -1343,6 +1355,7 @@ export function registerSessionRoutes(
|
|||||||
mode !== 'opencode' &&
|
mode !== 'opencode' &&
|
||||||
mode !== 'codex' &&
|
mode !== 'codex' &&
|
||||||
mode !== 'gemini' &&
|
mode !== 'gemini' &&
|
||||||
|
!remote &&
|
||||||
envOverrides &&
|
envOverrides &&
|
||||||
Object.keys(envOverrides).length > 0
|
Object.keys(envOverrides).length > 0
|
||||||
) {
|
) {
|
||||||
@@ -1379,12 +1392,13 @@ export function registerSessionRoutes(
|
|||||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
effort,
|
effort,
|
||||||
|
remote,
|
||||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||||
// so the initial state already has the phrase configured (only if globally enabled)
|
// so the initial state already has the phrase configured (only if globally enabled)
|
||||||
if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
|
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
|
||||||
autoConfigureRalph(session, casePath, ctx);
|
autoConfigureRalph(session, casePath, ctx);
|
||||||
if (!session.ralphTracker.enabled) {
|
if (!session.ralphTracker.enabled) {
|
||||||
session.ralphTracker.enable();
|
session.ralphTracker.enable();
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ describe('case-routes', () => {
|
|||||||
|
|
||||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||||
expect(list.statusCode).toBe(200);
|
expect(list.statusCode).toBe(200);
|
||||||
expect(JSON.parse(list.body)).toEqual([
|
expect(JSON.parse(list.body).data).toEqual([
|
||||||
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
|
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -255,12 +255,38 @@ describe('case-routes', () => {
|
|||||||
expect(link.statusCode).toBe(200);
|
expect(link.statusCode).toBe(200);
|
||||||
|
|
||||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||||
expect(JSON.parse(cases.body)).toContainEqual(
|
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'gpu-work',
|
||||||
|
location: 'remote',
|
||||||
|
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||||
|
remote: expect.objectContaining({ hostId: 'gpu-box', path: '/home/ubuntu/work' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers remote case metadata over a same-name local managed case', async () => {
|
||||||
|
setupRemoteConfigStore();
|
||||||
|
mockedReaddir.mockResolvedValue([{ name: 'gpu-work', isDirectory: () => true }] as never);
|
||||||
|
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/remote-hosts',
|
||||||
|
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||||
|
});
|
||||||
|
await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/cases/remote-link',
|
||||||
|
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||||
|
});
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
|
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||||
|
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'gpu-work',
|
name: 'gpu-work',
|
||||||
location: 'remote',
|
location: 'remote',
|
||||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||||
remote: expect.objectContaining({ hostId: 'gpu-box', hostLabel: 'GPU Box', path: '/home/ubuntu/work' }),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import fastifyCookie from '@fastify/cookie';
|
|||||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||||
|
import { Session } from '../../src/session.js';
|
||||||
|
|
||||||
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
||||||
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
||||||
@@ -481,6 +482,166 @@ describe('session-routes', () => {
|
|||||||
expect(body.data.terminalBuffer).toBeDefined();
|
expect(body.data.terminalBuffer).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
|
||||||
|
const shellHistory = Array.from(
|
||||||
|
{ length: 3000 },
|
||||||
|
(_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d`
|
||||||
|
).join('\n');
|
||||||
|
harness.ctx._session.terminalBuffer = shellHistory;
|
||||||
|
harness.ctx._session.mode = 'shell';
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(() => null);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001');
|
||||||
|
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||||
|
harness.ctx._session.mode = 'codex';
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
|
||||||
|
() => 'visible tmux pane only\n› current prompt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain('hello world');
|
||||||
|
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||||
|
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||||
|
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||||
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
|
);
|
||||||
|
expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats stale Codex scrollback config as TUI replay', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||||
|
harness.ctx._session.mode = 'codex';
|
||||||
|
harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any;
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
|
||||||
|
() => 'visible tmux pane only\n› current prompt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain('hello world');
|
||||||
|
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||||
|
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||||
|
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||||
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
|
);
|
||||||
|
expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
|
||||||
|
const authUrl =
|
||||||
|
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
|
||||||
|
harness.ctx._session.terminalBuffer =
|
||||||
|
'Authorize `atlassian` by opening this URL in your browser:\n' +
|
||||||
|
authUrl +
|
||||||
|
'\n(Browser launch failed; please copy the URL above manually.)\n';
|
||||||
|
harness.ctx._session.mode = 'codex';
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
|
||||||
|
() => 'visible tmux pane only\n› current prompt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain(authUrl);
|
||||||
|
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||||
|
expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan(
|
||||||
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => {
|
||||||
|
const authUrl =
|
||||||
|
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
|
||||||
|
harness.ctx._session.terminalBuffer =
|
||||||
|
'Root cause: URLs like ' +
|
||||||
|
authUrl +
|
||||||
|
' could be present in history but missing from browser-rendered terminal replay.\n' +
|
||||||
|
"+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n";
|
||||||
|
harness.ctx._session.mode = 'codex';
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
|
||||||
|
() => 'visible tmux pane only\n› current prompt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain(authUrl);
|
||||||
|
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
|
||||||
|
harness.ctx._session.mode = 'claude';
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
|
||||||
|
() => 'visible tmux pane only\n› current prompt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain('hello world');
|
||||||
|
expect(body.data.terminalBuffer).toContain('later accumulated history');
|
||||||
|
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
|
||||||
|
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
|
||||||
|
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
|
||||||
|
body.data.terminalBuffer.indexOf('visible tmux pane only')
|
||||||
|
);
|
||||||
|
expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
|
||||||
|
harness.ctx._session.terminalBuffer = '';
|
||||||
|
harness.ctx._session.mode = 'codex';
|
||||||
|
(harness.ctx.mux as { capturePaneBuffer?: unknown }).capturePaneBuffer = vi.fn(
|
||||||
|
() => 'visible restored tmux pane\n› current prompt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
|
||||||
|
expect(body.data.terminalBuffer).toContain('› current prompt');
|
||||||
|
expect(harness.ctx.mux.capturePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns error for unknown session', async () => {
|
it('returns error for unknown session', async () => {
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -765,6 +926,76 @@ describe('session-routes', () => {
|
|||||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||||
|
|
||||||
describe('POST /api/sessions with resumeSessionId', () => {
|
describe('POST /api/sessions with resumeSessionId', () => {
|
||||||
|
it('creates session from a remote case without local stat validation', async () => {
|
||||||
|
remoteStore.hosts = [
|
||||||
|
{
|
||||||
|
id: 'gpu-box',
|
||||||
|
label: 'GPU Box',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
commands: { codex: 'exec codx personal' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sessions',
|
||||||
|
payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.session.workingDir).toBe('/home/ubuntu/work');
|
||||||
|
expect(body.data.session.remote).toMatchObject({
|
||||||
|
hostId: 'gpu-box',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
remotePath: '/home/ubuntu/work',
|
||||||
|
commands: { codex: 'exec codx personal' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-start creates remote case sessions through ssh metadata', async () => {
|
||||||
|
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||||
|
try {
|
||||||
|
remoteStore.hosts = [
|
||||||
|
{
|
||||||
|
id: 'gpu-box',
|
||||||
|
label: 'GPU Box',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
commands: { codex: 'exec codx personal' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||||
|
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/quick-start',
|
||||||
|
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||||
|
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||||
|
expect(session?.toState()).toMatchObject({
|
||||||
|
workingDir: '/home/ubuntu/work',
|
||||||
|
remote: expect.objectContaining({
|
||||||
|
hostId: 'gpu-box',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
remotePath: '/home/ubuntu/work',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
startShell.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('creates session with valid resumeSessionId', async () => {
|
it('creates session with valid resumeSessionId', async () => {
|
||||||
const res = await harness.app.inject({
|
const res = await harness.app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Session } from '../src/session.js';
|
import { resolveMuxAttachCwd, Session } from '../src/session.js';
|
||||||
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
||||||
import {
|
import {
|
||||||
ATTACHMENT_HISTORY_LIMIT,
|
ATTACHMENT_HISTORY_LIMIT,
|
||||||
@@ -161,4 +161,17 @@ describe('session attachment history', () => {
|
|||||||
expect(persisted).toHaveLength(1);
|
expect(persisted).toHaveLength(1);
|
||||||
expect(persisted?.[0].fileName).toBe('ok.png');
|
expect(persisted?.[0].fileName).toBe('ok.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('attaches remote mux sessions from a local cwd', () => {
|
||||||
|
expect(
|
||||||
|
resolveMuxAttachCwd('/Users/remote/project', {
|
||||||
|
hostId: 'mac-mini',
|
||||||
|
label: 'Mac Mini',
|
||||||
|
host: '192.168.21.109',
|
||||||
|
username: 'saqebakhter',
|
||||||
|
remotePath: '/Users/remote/project',
|
||||||
|
})
|
||||||
|
).toBe('/tmp');
|
||||||
|
expect(resolveMuxAttachCwd('/opt/projects/Codeman')).toBe('/opt/projects/Codeman');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
|
import {
|
||||||
|
TmuxManager,
|
||||||
|
buildRemoteLaunchCommand,
|
||||||
|
formatPaneSnapshot,
|
||||||
|
parsePaneList,
|
||||||
|
resolveActivePaneTarget,
|
||||||
|
} from '../src/tmux-manager.js';
|
||||||
import { execSync, exec } from 'node:child_process';
|
import { execSync, exec } from 'node:child_process';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -91,6 +97,44 @@ describe('TmuxManager (unit)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('remote launch command builder', () => {
|
||||||
|
it('wraps codex command overrides in ssh with remote cd', () => {
|
||||||
|
const command = buildRemoteLaunchCommand({
|
||||||
|
mode: 'codex',
|
||||||
|
remote: {
|
||||||
|
hostId: 'gpu-box',
|
||||||
|
label: 'GPU Box',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
remotePath: '/home/ubuntu/work',
|
||||||
|
commands: { codex: 'exec codx personal' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command).toContain('ssh');
|
||||||
|
expect(command).toContain('BatchMode=yes');
|
||||||
|
expect(command).toContain('ubuntu@10.0.0.42');
|
||||||
|
expect(command).toContain('/home/ubuntu/work');
|
||||||
|
expect(command).toContain('bash -lc');
|
||||||
|
expect(command).toContain('exec codx personal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default shell command when no override is configured', () => {
|
||||||
|
const command = buildRemoteLaunchCommand({
|
||||||
|
mode: 'shell',
|
||||||
|
remote: {
|
||||||
|
hostId: 'gpu-box',
|
||||||
|
label: 'GPU Box',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
remotePath: '/home/ubuntu/work',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(command).toContain('exec bash -l');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getAttachCommand', () => {
|
describe('getAttachCommand', () => {
|
||||||
it('should return tmux', () => {
|
it('should return tmux', () => {
|
||||||
expect(manager.getAttachCommand()).toBe('tmux');
|
expect(manager.getAttachCommand()).toBe('tmux');
|
||||||
|
|||||||
Reference in New Issue
Block a user