mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529d8fa8ea | ||
|
|
19af37977a |
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### 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.
|
||||
|
||||
**Version**: 1.9.8 (must match `package.json`)
|
||||
**Version**: 1.9.9 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.9.8",
|
||||
"version": "1.9.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+4
-2
@@ -68,6 +68,7 @@ import {
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
spawnPtyWithHelperRepair,
|
||||
resolveLocalShell,
|
||||
} from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -1898,8 +1899,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
this._resetBuffers();
|
||||
|
||||
// Use user's default shell or bash
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
// Use user's default shell, falling back to a shell that actually exists.
|
||||
// Shared with the tmux pane command so both paths launch the same binary.
|
||||
const shell = resolveLocalShell();
|
||||
console.log(
|
||||
'[Session] Starting shell session with:',
|
||||
shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')
|
||||
|
||||
+8
-1
@@ -71,6 +71,7 @@ import {
|
||||
resolveCodexDir,
|
||||
resolveGeminiDir,
|
||||
resolveAntigravityDir,
|
||||
resolveLocalShell,
|
||||
} from './utils/index.js';
|
||||
import type {
|
||||
TerminalMultiplexer,
|
||||
@@ -773,7 +774,13 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'antigravity') {
|
||||
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 { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell } from './shell-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.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';
|
||||
}
|
||||
@@ -3290,6 +3290,13 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
_renderSessionTabsImmediate() {
|
||||
// Same guard as renderSessionTabs()/_fullRenderSessionTabs(): the incremental
|
||||
// branch below rewrites .tab-name's innerHTML, which destroys the inline rename
|
||||
// <input> mid-keystroke. Guarding only the scheduler is not enough: a render
|
||||
// debounced just BEFORE the rename opened still fires ~100ms later and lands
|
||||
// here directly. finishRename() re-renders on both commit and cancel, so a
|
||||
// render dropped here is picked back up when the rename settles.
|
||||
if (this._inlineRenameActive) return;
|
||||
const container = this.$('sessionTabs');
|
||||
const existingTabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
|
||||
@@ -848,6 +848,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
closeSessionOptions() {
|
||||
// Commit the field the user was still editing BEFORE editingSessionId is
|
||||
// cleared. The Session Name input saves on blur (and the auto-compact prompt
|
||||
// on change), and every autosave handler bails out on `!this.editingSessionId`.
|
||||
// Hiding the modal blurs the focused input on its own, but that happens after
|
||||
// the id is gone, so Escape / backdrop-click silently dropped what was typed.
|
||||
// (Clicking the X worked only because mousedown blurs the input first.)
|
||||
const modal = document.getElementById('sessionOptionsModal');
|
||||
const focused = document.activeElement;
|
||||
if (focused && modal && modal.contains(focused) && typeof focused.blur === 'function') {
|
||||
focused.blur();
|
||||
}
|
||||
|
||||
this.editingSessionId = null;
|
||||
// Stop run summary auto-refresh if it was running
|
||||
this.stopRunSummaryAutoRefresh();
|
||||
|
||||
@@ -245,6 +245,119 @@ describe('Inline rename input', () => {
|
||||
expect(result.threw).toBe(false);
|
||||
});
|
||||
|
||||
it('Render guard: _renderSessionTabsImmediate() does not destroy an open rename input', async () => {
|
||||
await resetState();
|
||||
|
||||
// The debounced tab render is scheduled by renderSessionTabs() but EXECUTED by
|
||||
// _renderSessionTabsImmediate(). A render queued just before the rename opened
|
||||
// still fires ~100ms later and lands in the executor directly, so the guard has
|
||||
// to live there too, otherwise the incremental branch rewrites .tab-name's
|
||||
// innerHTML and the user's half-typed description is lost.
|
||||
//
|
||||
// The tab MUST live inside the real #sessionTabs container and be the only
|
||||
// session in app.sessions: the renderer walks that container, so a synthetic
|
||||
// node parked on <body> would make this test pass with the guard removed.
|
||||
const result = await page.evaluate(() => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
sessions: Map<string, { id: string; name: string; status: string }>;
|
||||
sessionOrder: string[];
|
||||
startInlineRename: (id: string) => void;
|
||||
_renderSessionTabsImmediate: () => void;
|
||||
_activeRename: unknown;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
const id = 'render-race';
|
||||
app.sessions.set(id, { id, name: 'w9-case', status: 'idle' });
|
||||
app.sessionOrder = [id];
|
||||
|
||||
const container = document.getElementById('sessionTabs') as HTMLElement;
|
||||
const tab = document.createElement('div');
|
||||
tab.setAttribute('data-test-tab', '1');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.id = id;
|
||||
tab.innerHTML =
|
||||
'<span class="tab-status idle"></span><span class="tab-info"><span class="tab-name-row">' +
|
||||
`<span class="tab-name" data-session-id="${id}">w9-case</span>` +
|
||||
'</span></span>';
|
||||
container.appendChild(tab);
|
||||
|
||||
app.startInlineRename(id);
|
||||
const input = document.querySelector('input.tab-rename-input') as HTMLInputElement | null;
|
||||
if (!input) return { opened: false };
|
||||
input.value = 'half-typed';
|
||||
|
||||
// Exactly what a debounce timer queued before the rename would do.
|
||||
app._renderSessionTabsImmediate();
|
||||
|
||||
const after = document.querySelector('input.tab-rename-input') as HTMLInputElement | null;
|
||||
return {
|
||||
opened: true,
|
||||
stillInDom: !!after && document.body.contains(after),
|
||||
value: after?.value ?? null,
|
||||
renameStillActive: !!app._activeRename,
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.opened).toBe(true);
|
||||
expect(result.stillInDom).toBe(true);
|
||||
expect(result.value).toBe('half-typed');
|
||||
expect(result.renameStillActive).toBe(true);
|
||||
});
|
||||
|
||||
it('Modal: closeSessionOptions() commits the Session Name field before clearing the id', async () => {
|
||||
await resetState();
|
||||
|
||||
// Every autosave handler in the session-options modal bails on a null
|
||||
// editingSessionId, and hiding the modal blurs the focused input. If the id is
|
||||
// cleared first, the blur-driven save is dropped and the typed name vanishes,
|
||||
// which is what Escape and backdrop-click used to do.
|
||||
const result = await page.evaluate(async () => {
|
||||
const app = (
|
||||
window as unknown as {
|
||||
app: {
|
||||
editingSessionId: string | null;
|
||||
sessions: Map<string, { id: string; name: string }>;
|
||||
closeSessionOptions: () => void;
|
||||
};
|
||||
}
|
||||
).app;
|
||||
app.sessions.set('modal-id', { id: 'modal-id', name: 'w9-case' });
|
||||
app.editingSessionId = 'modal-id';
|
||||
|
||||
const nameInput = document.getElementById('modalSessionName') as HTMLInputElement;
|
||||
const modal = document.getElementById('sessionOptionsModal') as HTMLElement;
|
||||
modal.classList.add('active');
|
||||
// The Session Name field lives on the modal's Context tab, which is hidden
|
||||
// until selected: a hidden input cannot take focus.
|
||||
document.getElementById('context-tab')?.classList.remove('hidden');
|
||||
nameInput.value = 'mydesc';
|
||||
nameInput.focus();
|
||||
const wasFocused = document.activeElement === nameInput;
|
||||
|
||||
let putBody: string | null = null;
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).includes('/api/sessions/modal-id/name')) putBody = String(init?.body ?? '');
|
||||
return new Response('{"success":true}', { status: 200 });
|
||||
}) as typeof window.fetch;
|
||||
|
||||
app.closeSessionOptions();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
window.fetch = origFetch;
|
||||
modal.classList.remove('active');
|
||||
|
||||
return { wasFocused, putBody, editingAfter: app.editingSessionId };
|
||||
});
|
||||
|
||||
expect(result.wasFocused).toBe(true);
|
||||
// Prefixed session: the suffix the user typed is appended to the w9-case prefix.
|
||||
expect(result.putBody).toContain('w9-case: mydesc');
|
||||
expect(result.editingAfter).toBe(null);
|
||||
});
|
||||
|
||||
it('Re-entry: starting rename while one is active aborts the previous one', async () => {
|
||||
await resetState();
|
||||
expect(await startRename('first-id', 'First')).toBe(true);
|
||||
|
||||
@@ -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