mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
012ca2e971 | ||
|
|
21256202ba | ||
|
|
46b85fa6d0 | ||
|
|
aa463d9d28 | ||
|
|
9eac5ecf01 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. Behaviour change: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host, and a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out. Thanks to @Randalix for the report and the fix.
|
||||||
@@ -133,6 +133,24 @@ Key points:
|
|||||||
**login shell**, not the agent.
|
**login shell**, not the agent.
|
||||||
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
- The **whole tmux invocation is a single shell-quoted ssh argument**, and the
|
||||||
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
pane command is independently quoted, so a `remotePath` with spaces is safe.
|
||||||
|
- **That argument reaches the far end verbatim** (#760): the launch goes to the
|
||||||
|
local `bash -c` in single quotes, so `$HOME`, `$PATH` and `$SHELL` are the
|
||||||
|
remote user's. In double quotes the local `sh` expanded them first, which sent
|
||||||
|
this host's home and PATH to the remote. ⚠️ Every `$` in a `commands.*` override
|
||||||
|
now expands on the far end too. A Docker launch is verbatim as well, so a bare
|
||||||
|
`$VAR` in `extraCreateArgs`/`extraExecArgs` reaches `docker` as those literal
|
||||||
|
characters: write the value out.
|
||||||
|
- **tmux picks the shell by argument count** (measured on 3.4): one argument runs
|
||||||
|
through the remote `default-shell`, two or more are exec'd directly. Codeman's own
|
||||||
|
pane command is POSIX, so it goes to `/bin/sh -c` as separate arguments, and a fish
|
||||||
|
login shell (or a `default-shell` set in the remote `~/.tmux.conf`) never parses it:
|
||||||
|
fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*`
|
||||||
|
override stays one argument on purpose, since it is written for the user's own shell
|
||||||
|
(`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`).
|
||||||
|
- **Login shells**: POSIX shells and fish work. tcsh refuses the `-i -l` pair and needs
|
||||||
|
a `commands.*` override per mode (`exec claude`). nushell and elvish cannot run the
|
||||||
|
PATH bootstrap at all, so no override helps. A `remotePath` holding an apostrophe does
|
||||||
|
not start under fish.
|
||||||
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
- Connection options come from the **same `buildSshConnectionArgs(remote)`** as
|
||||||
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
|
the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`,
|
||||||
preserving historical token order.
|
preserving historical token order.
|
||||||
|
|||||||
Generated
+10
-28
@@ -63,7 +63,7 @@
|
|||||||
"exceljs": "4.4.0",
|
"exceljs": "4.4.0",
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
"pixelmatch": "^6.0.0",
|
"pixelmatch": "^6.0.0",
|
||||||
"playwright": "^1.58.0",
|
"playwright": "^1.64.1",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
@@ -9736,50 +9736,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.60.0",
|
"version": "1.64.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.60.0"
|
"playwright-core": "1.64.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.60.0",
|
"version": "1.64.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright/node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@
|
|||||||
"exceljs": "4.4.0",
|
"exceljs": "4.4.0",
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
"pixelmatch": "^6.0.0",
|
"pixelmatch": "^6.0.0",
|
||||||
"playwright": "^1.58.0",
|
"playwright": "^1.64.1",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
|
|||||||
+1
-1
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
|
|||||||
/**
|
/**
|
||||||
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
|
||||||
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
|
||||||
* interpolated into the outer `bash -c "..."` launch layer is escaped through
|
* interpolated into the `bash -c` launch layer is escaped through
|
||||||
* this so a path with spaces stays a single shell token. Operator-entered fields
|
* this so a path with spaces stays a single shell token. Operator-entered fields
|
||||||
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+14
-10
@@ -66,10 +66,11 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
|
|||||||
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
|
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
|
||||||
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
|
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
|
||||||
* SHELL into a pane from its own `default-shell` option, so the command as USED
|
* SHELL into a pane from its own `default-shell` option, so the command as USED
|
||||||
* here is safe either way (also verified). The default matters because these
|
* here is safe either way (also verified). Quoted for a shell path containing
|
||||||
* strings are the seed values a per-host `commands.*` override is edited from, and
|
* spaces. `/bin/sh` exists on every POSIX host.
|
||||||
* nothing constrains where an edited one ends up running. Quoted for a shell path
|
*
|
||||||
* containing spaces. `/bin/sh` exists on every POSIX host.
|
* ⚠️ This is POSIX text. fish refuses `${…}` (status 127), so a launch hands it to
|
||||||
|
* `/bin/sh -c`, never to the remote default-shell (`buildRemoteLaunchCommand`).
|
||||||
*/
|
*/
|
||||||
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
||||||
|
|
||||||
@@ -80,10 +81,12 @@ const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
|
|||||||
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
|
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
|
||||||
* "command not found" (exit 127).
|
* "command not found" (exit 127).
|
||||||
*
|
*
|
||||||
* Shells that take neither flag (nushell, elvish, …) cannot be detected from here
|
* Shells that take neither flag cannot be detected from here the way
|
||||||
* the way `loginShellArgs()` detects them locally, since the shell is whatever the
|
* `loginShellArgs()` detects them locally, since the shell is whatever the REMOTE
|
||||||
* REMOTE passwd says. A host like that is what the per-host `commands.*` override
|
* passwd says. A host like that is what the per-host `commands.*` override is for
|
||||||
* is for.
|
* (tcsh, which rejects `-l` combined with `-i`). It only goes so far: the PATH
|
||||||
|
* bootstrap below runs in the login shell first, so a shell that cannot parse
|
||||||
|
* `export` at all (nushell, elvish) cannot be rescued by an override.
|
||||||
*/
|
*/
|
||||||
export function remoteLoginShellCommand(command: string): string {
|
export function remoteLoginShellCommand(command: string): string {
|
||||||
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
|
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
|
||||||
@@ -143,8 +146,9 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
|
|||||||
* The per-host `commands.*` override remains the escape hatch for every mode.
|
* The per-host `commands.*` override remains the escape hatch for every mode.
|
||||||
*/
|
*/
|
||||||
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||||
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
|
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, and
|
||||||
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
|
// tmux exports its default-shell as SHELL into the pane, so this launches their actual
|
||||||
|
// login shell, or the default-shell their tmux.conf picks (zsh, fish, …). `-i -l` so it sources rc files,
|
||||||
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
|
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
|
||||||
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
|
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
|
||||||
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
|
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
|
||||||
|
|||||||
+45
-18
@@ -1303,10 +1303,12 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
|
|||||||
* - The `set` options are scoped per-session (`set -t <name>` / server-level
|
* - The `set` options are scoped per-session (`set -t <name>` / server-level
|
||||||
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
|
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
|
||||||
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
|
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
|
||||||
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
|
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
|
||||||
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
|
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
|
||||||
* shell-quoted inside it too. This keeps escaping correct through every layer
|
* arguments, and a per-host `commands.*` override stays one argument, which
|
||||||
* even when the remote path contains spaces.
|
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
|
||||||
|
* Each is shell-quoted, so escaping stays correct through every layer even
|
||||||
|
* when the remote path contains spaces.
|
||||||
*/
|
*/
|
||||||
export function buildRemoteLaunchCommand(options: {
|
export function buildRemoteLaunchCommand(options: {
|
||||||
mode: SessionMode;
|
mode: SessionMode;
|
||||||
@@ -1389,17 +1391,25 @@ export function buildRemoteLaunchCommand(options: {
|
|||||||
}
|
}
|
||||||
const remoteName = remoteTmuxSessionName(sessionId);
|
const remoteName = remoteTmuxSessionName(sessionId);
|
||||||
|
|
||||||
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
|
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
|
||||||
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
|
// with the CLI so the pane PID is the agent itself.
|
||||||
// the CLI so the pane PID is the agent itself.
|
//
|
||||||
|
// tmux runs a ONE-argument command through the remote default-shell, which is
|
||||||
|
// the user's login shell, and bypasses it for two or more arguments (both
|
||||||
|
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
|
||||||
|
// a parse error in fish, status 127, a dead pane on every new session), so it
|
||||||
|
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
|
||||||
|
// An override is the user's own text for THEIR shell: it stays one argument,
|
||||||
|
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
|
||||||
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
|
||||||
|
const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
|
||||||
|
|
||||||
// The tmux command line, with `\;` separating commands so the config `set`s
|
// The tmux command line, with `\;` separating commands so the config `set`s
|
||||||
// apply on the SAME connection (and are idempotent on reattach). Options are
|
// apply on the SAME connection (and are idempotent on reattach). Options are
|
||||||
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
|
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
|
||||||
// shared remote tmux server's other sessions keep their own prefix/mouse.
|
// shared remote tmux server's other sessions keep their own prefix/mouse.
|
||||||
const tmuxInvocation = [
|
const tmuxInvocation = [
|
||||||
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
|
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
|
||||||
`set -t ${remoteName} status off`,
|
`set -t ${remoteName} status off`,
|
||||||
`set -t ${remoteName} mouse off`,
|
`set -t ${remoteName} mouse off`,
|
||||||
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
|
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
|
||||||
@@ -1574,7 +1584,7 @@ export interface DockerLaunchOptions {
|
|||||||
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
* Build the ONE `bash -c` launch string for a docker session: image-check ->
|
||||||
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
|
||||||
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
|
||||||
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
|
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
|
||||||
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
|
||||||
*/
|
*/
|
||||||
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||||
@@ -1602,8 +1612,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
|||||||
} else if (resumeSessionId) {
|
} else if (resumeSessionId) {
|
||||||
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
|
||||||
}
|
}
|
||||||
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
|
// Run by tmux through the container's default-shell, so the path is shell-quoted
|
||||||
// pane PID the agent itself.
|
// here. `exec` makes the pane PID the agent itself.
|
||||||
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
const paneCommand = `cd ${workdir} && ${modeCommand}`;
|
||||||
|
|
||||||
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
|
||||||
@@ -1665,11 +1675,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
|||||||
const ensure = adopted
|
const ensure = adopted
|
||||||
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
|
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
|
||||||
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
||||||
// ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
|
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
|
||||||
// embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
|
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
|
||||||
// the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
|
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
|
||||||
// A `grep -qx` pipeline reads the same answer using only the single-quoted form
|
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
|
||||||
// every other line in this builder already uses.
|
|
||||||
const start = adopted
|
const start = adopted
|
||||||
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
|
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
|
||||||
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||||
@@ -2661,7 +2670,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// pane launch in /tmp, then cd inside bash against the current mount table.
|
// pane launch in /tmp, then cd inside bash against the current mount table.
|
||||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
execSync(
|
execSync(
|
||||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
|
||||||
{
|
{
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
@@ -2914,7 +2923,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
|
||||||
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
|
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
|
||||||
{
|
{
|
||||||
timeout: EXEC_TIMEOUT_MS,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
}
|
}
|
||||||
@@ -4663,6 +4672,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
|
||||||
|
* local sh). A remote or docker command is already quoted for the shell at the far end and
|
||||||
|
* must reach bash verbatim, so it goes in single quotes. Double quotes (JSON.stringify) let
|
||||||
|
* the local sh expand every `$` first: the PATH bootstrap of `withRemoteTmuxPath`
|
||||||
|
* (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this host's home and PATH, so
|
||||||
|
* a remote user with another home found no tmux and every new remote pane exited 127.
|
||||||
|
* `${SHELL:-/bin/sh}` in the remote launch was the local shell for the same reason.
|
||||||
|
* Local commands keep their double-quoted form.
|
||||||
|
*
|
||||||
|
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
|
||||||
|
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
|
||||||
|
* as those literal characters.
|
||||||
|
*/
|
||||||
|
function bashCArg(launchCmd: string, verbatim: boolean): string {
|
||||||
|
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell-escape a string for use as a single argument.
|
* Shell-escape a string for use as a single argument.
|
||||||
* Wraps in single quotes, escaping any embedded single quotes.
|
* Wraps in single quotes, escaping any embedded single quotes.
|
||||||
|
|||||||
+9
-9
@@ -878,11 +878,11 @@ const RemoteCommandOverridesSchema = z
|
|||||||
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
|
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
|
||||||
const noCommandSubstitution = (s: string) => !s.includes('$(');
|
const noCommandSubstitution = (s: string) => !s.includes('$(');
|
||||||
|
|
||||||
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
|
// `remotePath`/`identityFile` are shell-escaped, and a remote launch reaches `bash -c`
|
||||||
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
|
// single-quoted (`bashCArg()` in tmux-manager). They are also pasted into strings the
|
||||||
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
|
// far end's shells parse, and hosts stored before a launch-quoting change are read with
|
||||||
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
|
// no schema, so `$` and backtick (and newline/CR/NUL) stay rejected at the boundary as
|
||||||
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
|
// defense in depth, whatever the current quoting does with them.
|
||||||
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
||||||
|
|
||||||
export const RemoteHostSchema = z.object({
|
export const RemoteHostSchema = z.object({
|
||||||
@@ -982,10 +982,10 @@ export const RemoteCaseLinkSchema = z.object({
|
|||||||
//
|
//
|
||||||
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
|
||||||
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
|
||||||
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
|
// `containerWorkdir`, and `container` all reach the `bash -c` launch layer, so they
|
||||||
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
|
// carry NO_SHELL_META (rejects `$`/backtick) exactly like remotePath/identityFile.
|
||||||
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
|
// `--privileged` and any docker-socket mount are structurally unrepresentable
|
||||||
// any docker-socket mount are structurally unrepresentable (never accepted).
|
// (never accepted).
|
||||||
|
|
||||||
const DockerResourceLimitsSchema = z
|
const DockerResourceLimitsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -428,14 +428,20 @@ describe('EditHistorySync', () => {
|
|||||||
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
host.append('main', editLine('t1', T0, '/w/a.ts'));
|
||||||
const orig = host.read;
|
const orig = host.read;
|
||||||
let first = true;
|
let first = true;
|
||||||
|
let inRead!: () => void;
|
||||||
|
const reachedRead = new Promise<void>((r) => (inRead = r));
|
||||||
host.read = async (...a) => {
|
host.read = async (...a) => {
|
||||||
if (first) {
|
if (first) {
|
||||||
first = false;
|
first = false;
|
||||||
|
inRead();
|
||||||
await gate;
|
await gate;
|
||||||
}
|
}
|
||||||
return orig(...a);
|
return orig(...a);
|
||||||
};
|
};
|
||||||
const slow = sync.sync(local);
|
const slow = sync.sync(local);
|
||||||
|
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
|
||||||
|
// could reach the read first, take the gate and hang the loop below before `release()`.
|
||||||
|
await reachedRead;
|
||||||
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
|
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
|
||||||
release();
|
release();
|
||||||
expect(await slow).toEqual({ state: 'ok' });
|
expect(await slow).toEqual({ state: 'ok' });
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Which shell parses a remote pane command (#760).
|
||||||
|
*
|
||||||
|
* tmux runs a ONE-argument command through the remote default-shell, the user's login shell, and
|
||||||
|
* bypasses it for two or more arguments. Codeman's own text carries `"${SHELL:-/bin/sh}"`, a parse
|
||||||
|
* error in fish that kills the pane with status 127, so it must reach `/bin/sh -c` as separate
|
||||||
|
* arguments. A per-host `commands.*` override is the user's text for their own shell: `source` and
|
||||||
|
* `[[` work in bash and zsh and die under a dash `/bin/sh`, so it must stay one argument.
|
||||||
|
*
|
||||||
|
* CI has no fish, so this reads the argv tmux would receive instead: the real builder's string goes
|
||||||
|
* through a real sh with `ssh` and `tmux` defined as functions that report their arguments.
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { listClis } from '../src/config/cli-registry/registry.js';
|
||||||
|
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||||
|
import type { SessionMode, SessionRemote } from '../src/types.js';
|
||||||
|
|
||||||
|
const SESSION_ID = 'abcd1234-0000-0000-0000-000000000000';
|
||||||
|
const remote: SessionRemote = {
|
||||||
|
hostId: 'gpu',
|
||||||
|
label: 'GPU',
|
||||||
|
host: '10.0.0.42',
|
||||||
|
username: 'ubuntu',
|
||||||
|
remotePath: '/home/ubuntu/it is work',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The one argument ssh would send, as the login shell on the far end receives it. */
|
||||||
|
function sshArgument(launch: string): string {
|
||||||
|
return execFileSync('/bin/sh', ['-c', `ssh() { for a; do l="$a"; done; printf '%s' "$l"; }; ${launch}`], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What follows `new-session … -c <dir>`, up to the end of that tmux command. */
|
||||||
|
function paneArgv(launch: string): string[] {
|
||||||
|
const argv = execFileSync('/bin/sh', ['-c', `tmux() { printf '%s\\0' "$@"; }; ${sshArgument(launch)}`], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
.split('\0')
|
||||||
|
.slice(0, -1);
|
||||||
|
const head = argv.slice(0, argv.indexOf(';'));
|
||||||
|
return head.slice(head.indexOf('-c', head.indexOf('new-session')) + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const launches: Array<[string, Parameters<typeof buildRemoteLaunchCommand>[0]]> = [
|
||||||
|
...listClis().map((cli): [string, Parameters<typeof buildRemoteLaunchCommand>[0]] => [
|
||||||
|
cli.id,
|
||||||
|
{ mode: cli.id as SessionMode, remote, sessionId: SESSION_ID },
|
||||||
|
]),
|
||||||
|
['shell', { mode: 'shell', remote, sessionId: SESSION_ID }],
|
||||||
|
['claude resume', { mode: 'claude', remote, sessionId: SESSION_ID, resumeSessionId: SESSION_ID }],
|
||||||
|
['omp resume', { mode: 'omp', remote, sessionId: SESSION_ID, resumeSessionId: 'abc123' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('the shell that parses a remote pane command', () => {
|
||||||
|
it.each(launches)('%s: Codeman text goes to /bin/sh, never the default-shell', (_name, options) => {
|
||||||
|
const argv = paneArgv(buildRemoteLaunchCommand(options));
|
||||||
|
expect(argv.slice(0, 2)).toEqual(['/bin/sh', '-c']);
|
||||||
|
expect(argv).toHaveLength(3);
|
||||||
|
expect(argv[2]).toContain(`cd '/home/ubuntu/it is work' && `);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an override as one argument, parsed by the remote default-shell', () => {
|
||||||
|
const override = 'source ~/env.sh && [[ -x ~/.local/bin/codx ]] && exec codx';
|
||||||
|
const argv = paneArgv(
|
||||||
|
buildRemoteLaunchCommand({
|
||||||
|
mode: 'codex',
|
||||||
|
remote: { ...remote, commands: { codex: override } },
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(argv).toEqual([`cd '/home/ubuntu/it is work' && ${override}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the rest of the tmux chain behind the longer pane command', () => {
|
||||||
|
const sent = sshArgument(buildRemoteLaunchCommand({ mode: 'shell', remote, sessionId: SESSION_ID }));
|
||||||
|
expect(sent).toContain(String.raw` \; set -t codeman-ssh-abcd1234 status off \; `);
|
||||||
|
expect(sent).toMatch(/set -t codeman-ssh-abcd1234 remain-on-exit failed$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -159,7 +159,7 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
|||||||
const path = sh('/home/ubuntu/work');
|
const path = sh('/home/ubuntu/work');
|
||||||
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
|
const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`;
|
||||||
const tmuxInvocation = [
|
const tmuxInvocation = [
|
||||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} /bin/sh -c ${sh(paneCommand)}`,
|
||||||
`set -t ${remoteName} status off`,
|
`set -t ${remoteName} status off`,
|
||||||
`set -t ${remoteName} mouse off`,
|
`set -t ${remoteName} mouse off`,
|
||||||
`set -t ${remoteName} prefix C-q`,
|
`set -t ${remoteName} prefix C-q`,
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ import type { MuxSession } from '../src/mux-interface.js';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Mock child_process
|
// Mock child_process
|
||||||
|
/**
|
||||||
|
* Runs a captured `… respawn-pane … bash -c <arg>` line through a real local sh (with a
|
||||||
|
* foreign HOME/PATH) and returns the string bash would get, i.e. what ssh sends on.
|
||||||
|
*/
|
||||||
|
async function bashArgAfterLocalShell(respawnCmd: string): Promise<string> {
|
||||||
|
const real = await vi.importActual<typeof import('node:child_process')>('node:child_process');
|
||||||
|
const at = respawnCmd.indexOf(' bash -c ');
|
||||||
|
expect(at).toBeGreaterThan(0);
|
||||||
|
return real.execFileSync('/bin/sh', ['-c', `printf '%s' ${respawnCmd.slice(at + ' bash -c '.length)}`], {
|
||||||
|
env: { HOME: '/local-home-xyz', PATH: '/local-path-xyz:/usr/bin:/bin' },
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('node:child_process', async () => {
|
vi.mock('node:child_process', async () => {
|
||||||
const actual = await vi.importActual('node:child_process');
|
const actual = await vi.importActual('node:child_process');
|
||||||
return {
|
return {
|
||||||
@@ -865,6 +879,98 @@ describe('TmuxManager (unit)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hands a remote launch to ssh with $HOME and $PATH unexpanded', async () => {
|
||||||
|
// respawn-pane runs `bash -c <arg>` through the local sh. In double quotes that sh
|
||||||
|
// expanded withRemoteTmuxPath's `$HOME/…:$PATH` to the Codeman host's values, so a
|
||||||
|
// remote user with another home found no tmux and the pane exited 127.
|
||||||
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nonTestManager.createSession({
|
||||||
|
sessionId: 'abc12345-1234-5678-90ab-cdef12345679',
|
||||||
|
workingDir: '/w',
|
||||||
|
mode: 'shell',
|
||||||
|
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||||
|
});
|
||||||
|
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
|
||||||
|
.map(([cmd]) => cmd)
|
||||||
|
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
|
||||||
|
const sent = await bashArgAfterLocalShell(launch as string);
|
||||||
|
expect(sent).toContain('ssh ');
|
||||||
|
expect(sent).toContain('export PATH="$HOME/');
|
||||||
|
expect(sent).toContain(':$PATH"; tmux ');
|
||||||
|
expect(sent).not.toContain('local-home-xyz');
|
||||||
|
expect(sent).not.toContain('local-path-xyz');
|
||||||
|
} finally {
|
||||||
|
nonTestManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respawns a remote pane with $HOME unexpanded as well', async () => {
|
||||||
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
nonTestManager.registerSession({
|
||||||
|
sessionId: 'respawnremote2',
|
||||||
|
muxName: 'codeman-abcd8888',
|
||||||
|
pid: 1000,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
workingDir: '/w',
|
||||||
|
mode: 'shell',
|
||||||
|
attached: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nonTestManager.respawnPane({
|
||||||
|
sessionId: 'respawnremote2',
|
||||||
|
workingDir: '/w',
|
||||||
|
mode: 'shell',
|
||||||
|
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||||
|
});
|
||||||
|
const { exec: currentExec } = await import('node:child_process');
|
||||||
|
const respawnCall = vi
|
||||||
|
.mocked(currentExec)
|
||||||
|
.mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
|
||||||
|
const sent = await bashArgAfterLocalShell(respawnCall?.[0] as string);
|
||||||
|
expect(sent).toContain('export PATH="$HOME/');
|
||||||
|
expect(sent).not.toContain('local-home-xyz');
|
||||||
|
} finally {
|
||||||
|
nonTestManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands a docker override to bash with $HOME unexpanded', async () => {
|
||||||
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nonTestManager.createSession({
|
||||||
|
sessionId: 'abc12345-1234-5678-90ab-cdef12345680',
|
||||||
|
workingDir: '/w',
|
||||||
|
mode: 'shell',
|
||||||
|
docker: {
|
||||||
|
hostId: 'h',
|
||||||
|
label: 'L',
|
||||||
|
engine: 'docker',
|
||||||
|
image: 'codeman/agent:base',
|
||||||
|
containerName: 'codeman-case-x',
|
||||||
|
hostWorkspacePath: '/w',
|
||||||
|
containerWorkdir: '/w',
|
||||||
|
commands: { shell: 'echo CONTAINER_HOME=$HOME; exec sleep 30' },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
const launch = [...mockedExecSync.mock.calls, ...vi.mocked(exec).mock.calls]
|
||||||
|
.map(([cmd]) => cmd)
|
||||||
|
.find((cmd) => typeof cmd === 'string' && cmd.includes(' respawn-pane '));
|
||||||
|
const sent = await bashArgAfterLocalShell(launch as string);
|
||||||
|
expect(sent).toContain('docker');
|
||||||
|
expect(sent).toContain('CONTAINER_HOME=$HOME');
|
||||||
|
expect(sent).not.toContain('local-home-xyz');
|
||||||
|
} finally {
|
||||||
|
nonTestManager.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('changes the global history default on tmux versions that cannot resize panes', async () => {
|
it('changes the global history default on tmux versions that cannot resize panes', async () => {
|
||||||
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
const NonTestTmuxManager = await importWithTmuxCommandsEnabled();
|
||||||
const nonTestManager = new NonTestTmuxManager();
|
const nonTestManager = new NonTestTmuxManager();
|
||||||
|
|||||||
Reference in new issue
Block a user