mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(remote): bootstrap PATH consistently for remote tmux (johoja12/Codeman#15)
This commit is contained in:
1 parent
002de32a08
commit
5b9445de9e
8 files changed
+223
-19
No files matched your search
@@ -26,7 +26,12 @@ import {
|
||||
decideReconnect,
|
||||
} from '../src/remote-reconnect.js';
|
||||
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
|
||||
import { buildRemoteSessionAliveCommand, classifyRemoteAliveExit } from '../src/remote-hosts.js';
|
||||
import {
|
||||
buildRemoteSessionAliveCommand,
|
||||
classifyRemoteAliveExit,
|
||||
shellescape,
|
||||
withRemoteTmuxPath,
|
||||
} from '../src/remote-hosts.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
@@ -227,7 +232,7 @@ describe('remote has-session probe (pure)', () => {
|
||||
// Literal pin: the session name is shellescaped inside the remote command,
|
||||
// which is itself one shellescaped ssh argument.
|
||||
expect(cmd).toBe(
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box 'tmux -L codeman-remote has-session -t '\\''codeman-ssh-abc'\\'' 2>/dev/null'"
|
||||
`ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box ${shellescape(withRemoteTmuxPath("tmux -L codeman-remote has-session -t 'codeman-ssh-abc' 2>/dev/null"))}`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseRemoteSessionList } from '../src/remote-hosts.js';
|
||||
import { parseRemoteSessionList, withRemoteTmuxPath } from '../src/remote-hosts.js';
|
||||
import { buildRemoteAttachCommand } from '../src/tmux-manager.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
@@ -103,7 +103,7 @@ describe('COD-105 buildRemoteAttachCommand', () => {
|
||||
.map((l) => l.slice(2));
|
||||
expect(sshArgs).toContain('ubuntu@10.0.0.42');
|
||||
const tmuxArg = sshArgs.find((a) => a.includes('attach'));
|
||||
expect(tmuxArg).toBe("tmux -L codeman attach -t 'codeman-disco1'");
|
||||
expect(tmuxArg).toBe(withRemoteTmuxPath("tmux -L codeman attach -t 'codeman-disco1'"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* `-o ProxyCommand=…` token; the local shell must not expand/mangle them).
|
||||
* - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at
|
||||
* build time (ssh does NOT expand `~` in `-i`), then shellescaped.
|
||||
* - empty options ⇒ byte-identical ssh to today (full back-compat).
|
||||
* - empty options retain the SSH option order; tmux gets the shared PATH wrapper.
|
||||
*
|
||||
* Pure command-string construction; no real tmux, no ssh. Port: N/A.
|
||||
*/
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
buildRemoteTmuxCheckCommand,
|
||||
buildRemoteCliVersionProbeCommand,
|
||||
remoteSshTarget,
|
||||
shellescape,
|
||||
withRemoteTmuxPath,
|
||||
} from '../src/remote-hosts.js';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
@@ -146,10 +148,10 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
expect(idxProxy).toBeLessThan(idxTarget);
|
||||
});
|
||||
|
||||
it('a remote with no advanced options is byte-identical to the expected form', () => {
|
||||
it('preserves the no-options launch command inside the shared remote PATH wrapper', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID });
|
||||
// Reconstruct the command using the SAME nested POSIX single-quote escaping the
|
||||
// production code uses, to prove byte-identity. Session runs on the DEDICATED
|
||||
// production code uses, preserving the payload inside the PATH wrapper. The DEDICATED
|
||||
// `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's
|
||||
// discovery ignores; set-options are scoped per-session (never `-g`).
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
@@ -170,7 +172,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
`set -t ${remoteName} remain-on-exit failed`,
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(withRemoteTmuxPath(tmuxInvocation))}`;
|
||||
expect(command).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -193,12 +195,12 @@ describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the
|
||||
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(cmd).toContain('aakht@192.168.55.170');
|
||||
expect(cmd).toContain("'command -v tmux'");
|
||||
expect(cmd).toContain(shellescape(withRemoteTmuxPath('command -v tmux')));
|
||||
});
|
||||
|
||||
it('back-compat: no advanced options ⇒ unchanged probe string', () => {
|
||||
it('no advanced options preserve the ssh prefix and wrap the tmux probe', () => {
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe(
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'"
|
||||
`ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 ${shellescape(withRemoteTmuxPath('command -v tmux'))}`
|
||||
);
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Real shell/argv regression for every remote tmux builder. No network or real tmux runs. */
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildRemoteTmuxCheckCommand,
|
||||
buildRemoteSessionAliveCommand,
|
||||
buildRemoteListSessionsCommand,
|
||||
} from '../src/remote-hosts.js';
|
||||
import {
|
||||
buildRemoteLaunchCommand,
|
||||
buildRemoteAttachCommand,
|
||||
buildRemoteKillCommand,
|
||||
remoteTmuxSessionName,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { buildRemoteScrollbackCommand, parseRemoteScrollbackOutput } from '../src/remote-pane-history.js';
|
||||
import type { SessionRemote } from '../src/types/session.js';
|
||||
|
||||
const id = '663e3eae-8da6-4c7f-9231-611eb2840641';
|
||||
const marker = 'path-history-marker';
|
||||
const remote: SessionRemote = {
|
||||
hostId: 'offline',
|
||||
label: 'Offline fixture',
|
||||
host: 'never-connect.invalid',
|
||||
username: 'tester',
|
||||
port: 2222,
|
||||
identityFile: '/tmp/key with spaces',
|
||||
jumpHost: 'jump.invalid',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
remotePath: '/tmp/project with spaces',
|
||||
};
|
||||
const discovered = "team's session $literal";
|
||||
const cases = [
|
||||
{ name: 'link preflight', build: () => buildRemoteTmuxCheckCommand(remote), command: null, socket: null },
|
||||
{
|
||||
name: 'launch',
|
||||
build: () => buildRemoteLaunchCommand({ remote, sessionId: id, mode: 'shell' }),
|
||||
command: 'new-session',
|
||||
socket: 'codeman-remote',
|
||||
},
|
||||
{ name: 'attach', build: () => buildRemoteAttachCommand(remote, discovered), command: 'attach', socket: 'codeman' },
|
||||
{
|
||||
name: 'alive probe',
|
||||
build: () => buildRemoteSessionAliveCommand(remote, remoteTmuxSessionName(id)),
|
||||
command: 'has-session',
|
||||
socket: 'codeman-remote',
|
||||
},
|
||||
{
|
||||
name: 'discovery',
|
||||
build: () => buildRemoteListSessionsCommand(remote),
|
||||
command: 'list-sessions',
|
||||
socket: 'codeman',
|
||||
},
|
||||
{
|
||||
name: 'history and screen capture',
|
||||
build: () => buildRemoteScrollbackCommand(remote, id, 100, 0, marker),
|
||||
command: 'capture-pane',
|
||||
socket: 'codeman-remote',
|
||||
},
|
||||
{
|
||||
name: 'cleanup',
|
||||
build: () => buildRemoteKillCommand({ remote, sessionId: id }),
|
||||
command: 'kill-session',
|
||||
socket: 'codeman-remote',
|
||||
},
|
||||
];
|
||||
|
||||
describe('remote tmux PATH under a sparse non-interactive shell', () => {
|
||||
let root: string;
|
||||
let home: string;
|
||||
let sshBin: string;
|
||||
let tmuxPath: string;
|
||||
let tmuxLog: string;
|
||||
let sshLog: string;
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'remote-tmux-path-'));
|
||||
home = join(root, 'remote home with spaces');
|
||||
sshBin = join(root, 'ssh tools');
|
||||
tmuxPath = join(home, '.local', 'bin', 'tmux');
|
||||
tmuxLog = join(root, 'tmux-args');
|
||||
sshLog = join(root, 'ssh-args');
|
||||
mkdirSync(join(home, '.local', 'bin'), { recursive: true });
|
||||
mkdirSync(sshBin);
|
||||
writeFileSync(
|
||||
join(sshBin, 'ssh'),
|
||||
`#!/bin/sh
|
||||
for arg do printf '%s\\n' "$arg" >> "$TEST_SSH_LOG"; command="$arg"; done
|
||||
exec "$TEST_REMOTE_SHELL" -c "$command"
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
writeFileSync(
|
||||
tmuxPath,
|
||||
`#!/bin/sh
|
||||
for arg do printf '%s\\n' "$arg" >> "$TEST_TMUX_LOG"; done
|
||||
case "$*" in
|
||||
*capture-pane*) printf 'older row\\n${marker}\\n1\\ncurrent screen\\n' ;;
|
||||
*list-sessions*) printf 'discovered\\t0\\t123\\t1\\n' ;;
|
||||
esac
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
});
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
for (const shell of ['/bin/sh', ...(existsSync('/bin/dash') ? ['/bin/dash'] : [])]) {
|
||||
for (const item of cases) {
|
||||
it(`${item.name} finds the remote-user tmux with ${shell} and preserves targeting/options`, () => {
|
||||
writeFileSync(tmuxLog, '');
|
||||
writeFileSync(sshLog, '');
|
||||
const stdout = execFileSync('/bin/sh', ['-c', item.build()], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
// Only the SSH stub is initially on PATH; an unwrapped command cannot
|
||||
// accidentally run this machine's real tmux during a negative test.
|
||||
PATH: sshBin,
|
||||
TEST_REMOTE_SHELL: shell,
|
||||
TEST_TMUX_LOG: tmuxLog,
|
||||
TEST_SSH_LOG: sshLog,
|
||||
},
|
||||
});
|
||||
const sshArgs = readFileSync(sshLog, 'utf8').trimEnd().split('\n');
|
||||
expect(sshArgs).toContain('tester@never-connect.invalid');
|
||||
expect(sshArgs).toContain('ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p');
|
||||
expect(sshArgs).toContain('StrictHostKeyChecking=accept-new');
|
||||
expect(sshArgs.slice(sshArgs.indexOf('-p'), sshArgs.indexOf('-p') + 2)).toEqual(['-p', '2222']);
|
||||
expect(sshArgs.slice(sshArgs.indexOf('-i'), sshArgs.indexOf('-i') + 2)).toEqual(['-i', '/tmp/key with spaces']);
|
||||
expect(sshArgs.slice(sshArgs.indexOf('-J'), sshArgs.indexOf('-J') + 2)).toEqual(['-J', 'jump.invalid']);
|
||||
if (item.command === null) {
|
||||
expect(stdout.trim()).toBe(tmuxPath);
|
||||
return;
|
||||
}
|
||||
const args = readFileSync(tmuxLog, 'utf8').trimEnd().split('\n');
|
||||
expect(args.slice(0, 2)).toEqual(['-L', item.socket]);
|
||||
expect(args).toContain(item.command);
|
||||
if (item.name === 'attach') expect(args).toContain(discovered);
|
||||
if (item.name === 'launch') {
|
||||
expect(args).toContain(remote.remotePath);
|
||||
expect(sshArgs).toContain('-t');
|
||||
}
|
||||
if (['alive probe', 'cleanup'].includes(item.name)) expect(args).toContain(remoteTmuxSessionName(id));
|
||||
if (item.name === 'history and screen capture') {
|
||||
expect(args).toContain(`=${remoteTmuxSessionName(id)}:`);
|
||||
expect(parseRemoteScrollbackOutput(stdout, marker, 0)).toEqual({
|
||||
scrollback: 'older row\n',
|
||||
screen: 'current screen\n',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user