mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
chore: version packages
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.9.9
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Two bug fixes.
|
||||||
|
|
||||||
|
**Plain shell sessions could not start when the server process had no `SHELL` (#208).** The tmux pane command for `mode: 'shell'` was the literal string `$SHELL`. That string is embedded in the `bash -c "..."` argument of the `respawn-pane` line, which is run through `/bin/sh -c`, so it was expanded by the _server_ process's shell against the _server_ process's environment rather than inside the pane. Containers and system-level systemd units do not set `SHELL`, so it expanded to nothing and the pane command ended in a dangling `&&`, giving `bash: -c: line 1: syntax error: unexpected end of file` and a pane that died instantly (status 2) while tmux session creation still reported success. The shell is now resolved in Node (`$SHELL`, then the passwd entry, then `/bin/bash`, `/bin/zsh`, `/bin/sh`), requiring an absolute path to an executable and skipping `nologin`-style stubs, then shell-quoted. Only local shell sessions were affected: agent CLI modes emit a real command, and Docker/remote-SSH cases already used a literal `exec bash -l`.
|
||||||
|
|
||||||
|
**A session name typed into the tab options could be silently dropped.** Two independent paths. In the Session Options modal, the Session Name input saves on blur while every autosave handler bails on a null `editingSessionId`, and `closeSessionOptions()` cleared that id before hiding the modal (hiding is what blurs the input), so the save always ran too late; Escape and backdrop-click lost the name with no PUT at all, and only the X button worked because mousedown blurs first. The focused modal field is now blurred before the id is cleared, which also covers the auto-compact prompt. Separately, the right-click inline rename could be destroyed mid-keystroke: the `_inlineRenameActive` guard was missing from `_renderSessionTabsImmediate()`, so a render queued just before the rename opened still rewrote the tab name's innerHTML, committing a truncated name or closing the rename outright. The debounced executor is now guarded too.
|
||||||
|
|
||||||
## 1.9.8
|
## 1.9.8
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.9.8 (must match `package.json`)
|
**Version**: 1.9.9 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.8",
|
"version": "1.9.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.8",
|
"version": "1.9.9",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.9.8",
|
"version": "1.9.9",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+4
-2
@@ -68,6 +68,7 @@ import {
|
|||||||
getClaudeCliVersion,
|
getClaudeCliVersion,
|
||||||
getClaudeBinaryPath,
|
getClaudeBinaryPath,
|
||||||
spawnPtyWithHelperRepair,
|
spawnPtyWithHelperRepair,
|
||||||
|
resolveLocalShell,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
import {
|
import {
|
||||||
MAX_TERMINAL_BUFFER_SIZE,
|
MAX_TERMINAL_BUFFER_SIZE,
|
||||||
@@ -1898,8 +1899,9 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this._resetBuffers();
|
this._resetBuffers();
|
||||||
|
|
||||||
// Use user's default shell or bash
|
// Use user's default shell, falling back to a shell that actually exists.
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
// Shared with the tmux pane command so both paths launch the same binary.
|
||||||
|
const shell = resolveLocalShell();
|
||||||
console.log(
|
console.log(
|
||||||
'[Session] Starting shell session with:',
|
'[Session] Starting shell session with:',
|
||||||
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||||
|
|||||||
+8
-1
@@ -71,6 +71,7 @@ import {
|
|||||||
resolveCodexDir,
|
resolveCodexDir,
|
||||||
resolveGeminiDir,
|
resolveGeminiDir,
|
||||||
resolveAntigravityDir,
|
resolveAntigravityDir,
|
||||||
|
resolveLocalShell,
|
||||||
} from './utils/index.js';
|
} from './utils/index.js';
|
||||||
import type {
|
import type {
|
||||||
TerminalMultiplexer,
|
TerminalMultiplexer,
|
||||||
@@ -773,7 +774,13 @@ export function buildSpawnCommand(options: {
|
|||||||
if (options.mode === 'antigravity') {
|
if (options.mode === 'antigravity') {
|
||||||
return buildAntigravityCommand(options.antigravityConfig);
|
return buildAntigravityCommand(options.antigravityConfig);
|
||||||
}
|
}
|
||||||
return '$SHELL';
|
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
||||||
|
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
||||||
|
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
||||||
|
// SERVER process's env — empty in containers and system systemd units, leaving
|
||||||
|
// the pane command ending in a dangling `&&` ("syntax error: unexpected end of
|
||||||
|
// file", pane dead on arrival). Resolve it in Node and quote the result.
|
||||||
|
return shellescape(resolveLocalShell());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export { isSafePushEndpoint } from './push-endpoint-validation.js';
|
|||||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
|
export { resolveLocalShell } from './shell-resolver.js';
|
||||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Resolve a real, launchable login shell for `mode: 'shell'` sessions.
|
||||||
|
*
|
||||||
|
* The tmux pane command for a local shell session used to be the literal string
|
||||||
|
* `$SHELL`. That string is embedded in the `bash -c "…"` argument of the
|
||||||
|
* `respawn-pane` line, which `execSync` hands to `/bin/sh -c` — so `$SHELL` was
|
||||||
|
* expanded by the SERVER process's shell (not the pane's), against the SERVER
|
||||||
|
* process's env. Containers and system-level systemd units do not set `SHELL`,
|
||||||
|
* so the expansion produced an empty string and the pane command ended in a
|
||||||
|
* dangling `&&`:
|
||||||
|
*
|
||||||
|
* bash -c "cd \"/case\" && ulimit … && export … && "
|
||||||
|
* -> bash: -c: line 1: syntax error: unexpected end of file
|
||||||
|
*
|
||||||
|
* The pane then died instantly (status 2) while tmux creation itself reported
|
||||||
|
* success, which is exactly what issue #208 saw. Resolving the shell HERE, in
|
||||||
|
* Node, removes the shell-expansion layer entirely and guarantees a non-empty
|
||||||
|
* absolute path.
|
||||||
|
*
|
||||||
|
* @module utils/shell-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { accessSync, constants } from 'node:fs';
|
||||||
|
import { userInfo } from 'node:os';
|
||||||
|
|
||||||
|
/** Last-resort shells, in preference order. `/bin/sh` exists on every POSIX host. */
|
||||||
|
const FALLBACK_SHELLS = ['/bin/bash', '/bin/zsh', '/bin/sh'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shells that exist and are executable but immediately exit — a service account's
|
||||||
|
* passwd entry commonly points at one, which would look identical to the crash
|
||||||
|
* this module exists to prevent.
|
||||||
|
*/
|
||||||
|
const NON_INTERACTIVE_SHELLS = new Set(['nologin', 'false', 'true', 'sync']);
|
||||||
|
|
||||||
|
function isUsableShell(candidate: string): boolean {
|
||||||
|
if (!candidate.startsWith('/')) return false;
|
||||||
|
const base = candidate.slice(candidate.lastIndexOf('/') + 1);
|
||||||
|
if (NON_INTERACTIVE_SHELLS.has(base)) return false;
|
||||||
|
try {
|
||||||
|
accessSync(candidate, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an absolute path to an interactive shell, preferring the user's own.
|
||||||
|
*
|
||||||
|
* Order: `$SHELL` -> the passwd entry -> `/bin/bash` -> `/bin/zsh` -> `/bin/sh`.
|
||||||
|
* Every candidate must be an absolute path to an executable that is not a
|
||||||
|
* nologin-style stub. Always returns a non-empty string.
|
||||||
|
*/
|
||||||
|
export function resolveLocalShell(): string {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
const envShell = process.env.SHELL?.trim();
|
||||||
|
if (envShell) candidates.push(envShell);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Throws when the uid has no /etc/passwd entry (common for `--user` containers).
|
||||||
|
const passwdShell = userInfo().shell?.trim();
|
||||||
|
if (passwdShell) candidates.push(passwdShell);
|
||||||
|
} catch {
|
||||||
|
/* no passwd entry — fall through to the static fallbacks */
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(...FALLBACK_SHELLS);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (isUsableShell(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing was verifiable (exotic/read-restricted image). /bin/sh is still the
|
||||||
|
// best guess and is far better than emitting an empty command.
|
||||||
|
return '/bin/sh';
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for issue #208 — "Plain shell PTY exits with code 1 after
|
||||||
|
* successful tmux creation in Docker".
|
||||||
|
*
|
||||||
|
* The shell-mode pane command used to be the literal string `$SHELL`. It ends up
|
||||||
|
* inside the `bash -c "…"` argument of the respawn-pane line, which execSync runs
|
||||||
|
* through `/bin/sh -c`, so it was expanded by the SERVER process's shell against
|
||||||
|
* the SERVER process's env. Containers (and system-level systemd units) do not set
|
||||||
|
* SHELL, so it expanded to nothing and the pane command ended in a dangling `&&`:
|
||||||
|
*
|
||||||
|
* bash: -c: line 1: syntax error: unexpected end of file
|
||||||
|
*
|
||||||
|
* These tests pin the resolver's guarantees and assert that a shell launch command
|
||||||
|
* survives the outer `sh -c` layer with an unset SHELL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||||
|
import { resolveLocalShell } from '../src/utils/shell-resolver.js';
|
||||||
|
|
||||||
|
describe('resolveLocalShell', () => {
|
||||||
|
const originalShell = process.env.SHELL;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalShell === undefined) delete process.env.SHELL;
|
||||||
|
else process.env.SHELL = originalShell;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an absolute executable path when SHELL is unset (container case)', () => {
|
||||||
|
delete process.env.SHELL;
|
||||||
|
const shell = resolveLocalShell();
|
||||||
|
expect(shell).not.toBe('');
|
||||||
|
expect(shell.startsWith('/')).toBe(true);
|
||||||
|
// Proves the resolved path is really launchable, not just a plausible string.
|
||||||
|
expect(execFileSync(shell, ['-c', 'echo ok'], { encoding: 'utf8' }).trim()).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an absolute executable path when SHELL is empty or whitespace', () => {
|
||||||
|
for (const value of ['', ' ']) {
|
||||||
|
process.env.SHELL = value;
|
||||||
|
const shell = resolveLocalShell();
|
||||||
|
expect(shell.startsWith('/')).toBe(true);
|
||||||
|
expect(execFileSync(shell, ['-c', 'echo ok'], { encoding: 'utf8' }).trim()).toBe('ok');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a valid $SHELL', () => {
|
||||||
|
process.env.SHELL = '/bin/sh';
|
||||||
|
expect(resolveLocalShell()).toBe('/bin/sh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a $SHELL that does not exist', () => {
|
||||||
|
process.env.SHELL = '/nonexistent/shell-that-is-not-here';
|
||||||
|
const shell = resolveLocalShell();
|
||||||
|
expect(shell).not.toBe('/nonexistent/shell-that-is-not-here');
|
||||||
|
expect(shell.startsWith('/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a relative $SHELL (never emits a bare word into the launch command)', () => {
|
||||||
|
process.env.SHELL = 'bash';
|
||||||
|
expect(resolveLocalShell().startsWith('/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores nologin-style stubs that would exit instantly', () => {
|
||||||
|
process.env.SHELL = '/usr/sbin/nologin';
|
||||||
|
expect(resolveLocalShell()).not.toContain('nologin');
|
||||||
|
process.env.SHELL = '/bin/false';
|
||||||
|
expect(resolveLocalShell()).not.toBe('/bin/false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shell-mode spawn command (issue #208)', () => {
|
||||||
|
const originalShell = process.env.SHELL;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.SHELL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalShell === undefined) delete process.env.SHELL;
|
||||||
|
else process.env.SHELL = originalShell;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never emits an unexpanded $SHELL into the pane command', () => {
|
||||||
|
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||||
|
expect(cmd).not.toContain('$SHELL');
|
||||||
|
expect(cmd.trim()).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a launch command that parses after the outer sh -c expansion layer', () => {
|
||||||
|
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: 'abc123de-0000-0000-0000-000000000000' });
|
||||||
|
// Mirrors tmux-manager: `… bash -c ${JSON.stringify(launchCmd)}` handed to `sh -c`.
|
||||||
|
const launchCmd = `cd ${JSON.stringify('/tmp')} && export CODEMAN_MUX=1 && ${cmd}`;
|
||||||
|
const outer = `bash -n -c ${JSON.stringify(launchCmd)}`;
|
||||||
|
|
||||||
|
// `bash -n` parses without executing: exits 0 on the fix, 2 with the dangling `&&`.
|
||||||
|
const result = execFileSync('/bin/sh', ['-c', `${outer}; echo "rc=$?"`], { encoding: 'utf8' });
|
||||||
|
expect(result).toContain('rc=0');
|
||||||
|
expect(result).not.toContain('unexpected end of file');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user