aegis-bridge 2.15.3 → 2.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +2 -0
- package/dist/channels/telegram.js +13 -2
- package/dist/cli.js +9 -5
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/hook-settings.d.ts +2 -0
- package/dist/hook-settings.js +22 -2
- package/dist/hook.d.ts +2 -1
- package/dist/hook.js +27 -3
- package/dist/mcp-server.js +16 -2
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +66 -46
- package/dist/session.d.ts +11 -0
- package/dist/session.js +4 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/startup.d.ts +2 -1
- package/dist/startup.js +13 -15
- package/dist/swarm-monitor.d.ts +3 -0
- package/dist/swarm-monitor.js +35 -0
- package/dist/tmux.d.ts +6 -0
- package/dist/tmux.js +84 -7
- package/package.json +4 -1
package/dist/auth.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
10
10
|
import { authStoreSchema } from './validation.js';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
|
+
import { secureFilePermissions } from './file-utils.js';
|
|
13
14
|
/** Default SSE token lifetime: 60 seconds. */
|
|
14
15
|
const SSE_TOKEN_TTL_MS = 60_000;
|
|
15
16
|
/** Max SSE tokens per bearer token to prevent abuse. */
|
|
@@ -61,6 +62,7 @@ export class AuthManager {
|
|
|
61
62
|
await mkdir(dir, { recursive: true });
|
|
62
63
|
}
|
|
63
64
|
await writeFile(this.keysFile, JSON.stringify(this.store, null, 2), { mode: 0o600 });
|
|
65
|
+
await secureFilePermissions(this.keysFile);
|
|
64
66
|
}
|
|
65
67
|
/** Create a new API key. Returns the plaintext key (only shown once). */
|
|
66
68
|
async createKey(name, rateLimit = 100) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Formatting: HTML parse_mode with structured, clean messages.
|
|
8
8
|
*/
|
|
9
|
+
import { homedir } from 'node:os';
|
|
9
10
|
import { esc, bold, code, italic, quickUpdate, taskComplete, alert as styleAlert, } from './telegram-style.js';
|
|
10
11
|
/** Call Telegram Bot API with retry on 429. */
|
|
11
12
|
// ── HTML Helpers ────────────────────────────────────────────────────────────
|
|
@@ -25,12 +26,22 @@ function elapsed(ms) {
|
|
|
25
26
|
return `${h}h ${m % 60}m`;
|
|
26
27
|
}
|
|
27
28
|
function shortPath(path) {
|
|
29
|
+
const normalized = path.replace(/\\/g, '/');
|
|
28
30
|
// Keep only filename or last 2 segments
|
|
29
|
-
const parts =
|
|
31
|
+
const parts = normalized.replace(/^\//, '').split('/');
|
|
30
32
|
if (parts.length <= 2)
|
|
31
33
|
return parts.join('/');
|
|
32
34
|
return '…/' + parts.slice(-2).join('/');
|
|
33
35
|
}
|
|
36
|
+
function shortenHomePath(workDir) {
|
|
37
|
+
const normalized = workDir.replace(/\\/g, '/');
|
|
38
|
+
const home = homedir().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
39
|
+
if (normalized === home)
|
|
40
|
+
return '~';
|
|
41
|
+
if (normalized.startsWith(`${home}/`))
|
|
42
|
+
return `~${normalized.slice(home.length)}`;
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
34
45
|
/**
|
|
35
46
|
* Strip Claude Code internal XML tags from assistant messages.
|
|
36
47
|
* These tags (local-command-*, antml:*, etc.) are CC's internal markup
|
|
@@ -244,7 +255,7 @@ function md2html(md) {
|
|
|
244
255
|
// ── Message Formatting ──────────────────────────────────────────────────────
|
|
245
256
|
function formatSessionCreated(name, workDir, id, meta) {
|
|
246
257
|
const shortId = id.slice(0, 8);
|
|
247
|
-
const shortDir = workDir
|
|
258
|
+
const shortDir = shortenHomePath(workDir);
|
|
248
259
|
const parts = [`${bold(name)} ${code(shortDir)} ${code(shortId)}`];
|
|
249
260
|
const flags = [];
|
|
250
261
|
if (meta?.permissionMode && meta.permissionMode !== 'default')
|
package/dist/cli.js
CHANGED
|
@@ -5,23 +5,26 @@
|
|
|
5
5
|
* `npx aegis-bridge` or `aegis-bridge` starts the server with sensible defaults.
|
|
6
6
|
* Auto-detects tmux and claude CLI, prints helpful startup message.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { parseIntSafe, getErrorMessage } from './validation.js';
|
|
13
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
|
|
15
|
+
/** Current aegis-bridge version read from package.json at startup. */
|
|
15
16
|
const VERSION = pkg.version;
|
|
16
|
-
|
|
17
|
+
/** Check whether a required external dependency can be executed. */
|
|
18
|
+
function checkDependency(command, args) {
|
|
17
19
|
try {
|
|
18
|
-
|
|
20
|
+
execFileSync(command, args, { stdio: 'ignore', timeout: 5000 });
|
|
19
21
|
return true;
|
|
20
22
|
}
|
|
21
23
|
catch { /* command not found or exited non-zero */
|
|
22
24
|
return false;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
27
|
+
/** Render the startup banner shown when launching the HTTP server. */
|
|
25
28
|
function printBanner(port) {
|
|
26
29
|
console.log(`
|
|
27
30
|
┌─────────────────────────────────────────┐
|
|
@@ -107,6 +110,7 @@ async function handleCreate(args) {
|
|
|
107
110
|
console.log(` Read: curl ${baseUrl}/v1/sessions/${sessionId}/read`);
|
|
108
111
|
console.log(` Kill: curl -X DELETE ${baseUrl}/v1/sessions/${sessionId}`);
|
|
109
112
|
}
|
|
113
|
+
/** Main CLI entry point that dispatches subcommands and bootstraps the server. */
|
|
110
114
|
async function main() {
|
|
111
115
|
const args = process.argv.slice(2);
|
|
112
116
|
// Help
|
|
@@ -184,8 +188,8 @@ async function main() {
|
|
|
184
188
|
process.env.AEGIS_PORT = args[portIdx + 1];
|
|
185
189
|
}
|
|
186
190
|
// Check dependencies
|
|
187
|
-
const hasTmux = checkDependency('tmux', '
|
|
188
|
-
const hasClaude = checkDependency('claude', '
|
|
191
|
+
const hasTmux = checkDependency('tmux', ['-V']);
|
|
192
|
+
const hasClaude = checkDependency('claude', ['--version']);
|
|
189
193
|
if (!hasTmux) {
|
|
190
194
|
console.error(`
|
|
191
195
|
❌ tmux not found.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { chmod } from 'node:fs/promises';
|
|
3
|
+
const PERMISSIONS_TIMEOUT_MS = 5_000;
|
|
4
|
+
export function buildWindowsIcaclsArgs(filePath, account) {
|
|
5
|
+
return [filePath, '/inheritance:r', '/grant:r', `${account}:(R,W)`];
|
|
6
|
+
}
|
|
7
|
+
function runIcacls(filePath, account) {
|
|
8
|
+
const args = buildWindowsIcaclsArgs(filePath, account);
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
execFile('icacls', args, { timeout: PERMISSIONS_TIMEOUT_MS }, (error) => {
|
|
11
|
+
if (error) {
|
|
12
|
+
reject(error);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
resolve();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function secureFilePermissions(filePath, platform = process.platform) {
|
|
20
|
+
if (platform !== 'win32') {
|
|
21
|
+
await chmod(filePath, 0o600);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const username = process.env.USERNAME;
|
|
25
|
+
if (!username) {
|
|
26
|
+
console.warn(`Windows permission hardening skipped for ${filePath}: USERNAME is not set`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const account = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${username}` : username;
|
|
30
|
+
try {
|
|
31
|
+
await runIcacls(filePath, account);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
35
|
+
console.warn(`Windows permission hardening failed for ${filePath}: ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/hook-settings.d.ts
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Issue #169: Phase 2 — Inject CC settings.json with HTTP hooks.
|
|
15
15
|
*/
|
|
16
|
+
/** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
|
|
17
|
+
export declare function buildProjectSettingsPath(workDir: string, platform?: NodeJS.Platform): string;
|
|
16
18
|
/** CC hook events that support `type: "http"`.
|
|
17
19
|
*
|
|
18
20
|
* All CC hook events support HTTP hooks. We register the most useful ones
|
package/dist/hook-settings.js
CHANGED
|
@@ -19,6 +19,7 @@ import { join, resolve } from 'node:path';
|
|
|
19
19
|
import { tmpdir } from 'node:os';
|
|
20
20
|
import { randomBytes } from 'node:crypto';
|
|
21
21
|
import { ccSettingsSchema, containsTraversalSegment } from './validation.js';
|
|
22
|
+
import { secureFilePermissions } from './file-utils.js';
|
|
22
23
|
function isRecord(value) {
|
|
23
24
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
25
|
}
|
|
@@ -45,6 +46,23 @@ function normalizeHookBaseUrl(baseUrl) {
|
|
|
45
46
|
return baseUrl.replace('0.0.0.0', '127.0.0.1');
|
|
46
47
|
}
|
|
47
48
|
}
|
|
49
|
+
/** Build a normalized path to .claude/settings.local.json for Unix and Windows workDirs. */
|
|
50
|
+
export function buildProjectSettingsPath(workDir, platform = process.platform) {
|
|
51
|
+
let normalizedWorkDir = platform === 'win32'
|
|
52
|
+
? workDir.replace(/\//g, '\\')
|
|
53
|
+
: workDir.replace(/\\/g, '/');
|
|
54
|
+
// On Linux, resolve() prepends CWD to Windows paths like "D:\Users\dev"
|
|
55
|
+
// because Linux doesn't understand Windows drive letters. Only resolve
|
|
56
|
+
// paths that are NOT already absolute on the target platform.
|
|
57
|
+
const isWinAbs = /^[A-Za-z]:\\/.test(normalizedWorkDir);
|
|
58
|
+
const isUnixAbs = /^\//.test(normalizedWorkDir);
|
|
59
|
+
const alreadyAbs = (platform === 'win32' && isWinAbs) || isUnixAbs;
|
|
60
|
+
if (!alreadyAbs)
|
|
61
|
+
normalizedWorkDir = resolve(normalizedWorkDir);
|
|
62
|
+
// Normalize separators to match the target platform.
|
|
63
|
+
const result = join(normalizedWorkDir, '.claude', 'settings.local.json');
|
|
64
|
+
return platform === 'win32' ? result.replace(/\//g, '\\') : result;
|
|
65
|
+
}
|
|
48
66
|
/**
|
|
49
67
|
* Validate a workDir path for use in hook settings resolution.
|
|
50
68
|
* Defense-in-depth against path traversal: rejects paths containing ".." segments
|
|
@@ -145,7 +163,7 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
|
|
|
145
163
|
let merged = {};
|
|
146
164
|
const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
|
|
147
165
|
if (safeWorkDir) {
|
|
148
|
-
const projectSettingsPath =
|
|
166
|
+
const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
|
|
149
167
|
if (existsSync(projectSettingsPath)) {
|
|
150
168
|
try {
|
|
151
169
|
const raw = await readFile(projectSettingsPath, 'utf-8');
|
|
@@ -175,6 +193,7 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
|
|
|
175
193
|
await mkdir(settingsDir, { recursive: true, mode: 0o700 });
|
|
176
194
|
const filePath = join(settingsDir, `hooks-${sessionId}.json`);
|
|
177
195
|
await writeFile(filePath, JSON.stringify(combined, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
196
|
+
await secureFilePermissions(filePath);
|
|
178
197
|
return filePath;
|
|
179
198
|
}
|
|
180
199
|
/**
|
|
@@ -210,7 +229,7 @@ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
|
|
|
210
229
|
const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
|
|
211
230
|
if (!safeWorkDir)
|
|
212
231
|
return;
|
|
213
|
-
const projectSettingsPath =
|
|
232
|
+
const projectSettingsPath = buildProjectSettingsPath(safeWorkDir);
|
|
214
233
|
if (!existsSync(projectSettingsPath))
|
|
215
234
|
return;
|
|
216
235
|
try {
|
|
@@ -243,6 +262,7 @@ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
|
|
|
243
262
|
}
|
|
244
263
|
if (changed) {
|
|
245
264
|
await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
265
|
+
await secureFilePermissions(projectSettingsPath);
|
|
246
266
|
}
|
|
247
267
|
}
|
|
248
268
|
catch {
|
package/dist/hook.d.ts
CHANGED
package/dist/hook.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* }
|
|
17
17
|
*/
|
|
18
18
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from 'node:fs';
|
|
19
|
-
import { join, dirname } from 'node:path';
|
|
19
|
+
import { join, dirname, resolve } from 'node:path';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { execFileSync } from 'node:child_process';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
@@ -32,6 +32,17 @@ const MAP_FILE = join(BRIDGE_DIR, 'session_map.json');
|
|
|
32
32
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
33
33
|
const TMUX_PANE_RE = /^%\d+$/;
|
|
34
34
|
const DEFAULT_POINTER_TTL_MS = 24 * 60 * 60 * 1000;
|
|
35
|
+
function normalizeCommandPath(pathValue, platform = process.platform) {
|
|
36
|
+
return platform === 'win32' ? pathValue.replace(/\//g, '\\') : pathValue.replace(/\\/g, '/');
|
|
37
|
+
}
|
|
38
|
+
function quoteCommandPath(pathValue, platform = process.platform) {
|
|
39
|
+
const normalized = normalizeCommandPath(pathValue, platform);
|
|
40
|
+
return `"${normalized.replace(/"/g, '\\"')}"`;
|
|
41
|
+
}
|
|
42
|
+
/** Build a shell-safe command string that invokes hook.js with an explicit Node executable. */
|
|
43
|
+
export function buildHookCommand(scriptPath, nodeExecutable = process.execPath, platform = process.platform) {
|
|
44
|
+
return `${quoteCommandPath(nodeExecutable, platform)} ${quoteCommandPath(scriptPath, platform)}`;
|
|
45
|
+
}
|
|
35
46
|
function getPointerTtlMs() {
|
|
36
47
|
const raw = process.env.AEGIS_CONTINUATION_POINTER_TTL_MS ?? process.env.MANUS_CONTINUATION_POINTER_TTL_MS;
|
|
37
48
|
const parsed = raw ? Number(raw) : NaN;
|
|
@@ -176,7 +187,7 @@ function install() {
|
|
|
176
187
|
}
|
|
177
188
|
settings = parsed.data;
|
|
178
189
|
}
|
|
179
|
-
const hookCommand =
|
|
190
|
+
const hookCommand = buildHookCommand(join(__dirname, 'hook.js'));
|
|
180
191
|
const hooks = (settings.hooks || {});
|
|
181
192
|
const sessionStart = (hooks.SessionStart || []);
|
|
182
193
|
// Check if already installed
|
|
@@ -204,4 +215,17 @@ function install() {
|
|
|
204
215
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
205
216
|
console.log(`Aegis hook installed in ${settingsPath}`);
|
|
206
217
|
}
|
|
207
|
-
|
|
218
|
+
const isDirectExecution = (() => {
|
|
219
|
+
const argv1 = process.argv[1];
|
|
220
|
+
if (!argv1)
|
|
221
|
+
return false;
|
|
222
|
+
try {
|
|
223
|
+
return resolve(argv1) === resolve(__filename);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
if (isDirectExecution) {
|
|
230
|
+
main();
|
|
231
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -15,13 +15,27 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
|
|
|
15
15
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
-
import { dirname, join } from 'node:path';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { isValidUUID } from './validation.js';
|
|
21
21
|
// Read version from package.json at startup (matches cli.ts pattern)
|
|
22
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8'));
|
|
24
24
|
const VERSION = pkg.version;
|
|
25
|
+
function normalizeWorkDirForCompare(workDir) {
|
|
26
|
+
const isWindowsLikePath = /^[a-zA-Z]:[\\/]/.test(workDir) || workDir.startsWith('\\\\');
|
|
27
|
+
const normalizedPath = (isWindowsLikePath ? workDir : resolve(workDir))
|
|
28
|
+
.replace(/\\/g, '/')
|
|
29
|
+
.replace(/\/+$/, '');
|
|
30
|
+
return process.platform === 'win32' || isWindowsLikePath
|
|
31
|
+
? normalizedPath.toLowerCase()
|
|
32
|
+
: normalizedPath;
|
|
33
|
+
}
|
|
34
|
+
function isSameOrChildWorkDir(candidate, parent) {
|
|
35
|
+
const normalizedCandidate = normalizeWorkDirForCompare(candidate);
|
|
36
|
+
const normalizedParent = normalizeWorkDirForCompare(parent);
|
|
37
|
+
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}/`);
|
|
38
|
+
}
|
|
25
39
|
// ── Aegis REST client ───────────────────────────────────────────────
|
|
26
40
|
export class AegisClient {
|
|
27
41
|
baseUrl;
|
|
@@ -65,7 +79,7 @@ export class AegisClient {
|
|
|
65
79
|
sessions = sessions.filter((s) => s.status === filter.status);
|
|
66
80
|
}
|
|
67
81
|
if (filter?.workDir) {
|
|
68
|
-
sessions = sessions.filter((s) => s.workDir
|
|
82
|
+
sessions = sessions.filter((s) => isSameOrChildWorkDir(s.workDir, filter.workDir));
|
|
69
83
|
}
|
|
70
84
|
return sessions;
|
|
71
85
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function buildWindowsFindPidOnPortScript(port: number): string;
|
|
2
|
+
export declare function buildWindowsReadParentPidScript(pid: number): string;
|
|
3
|
+
export declare function findPidOnPort(port: number): Promise<number[]>;
|
|
4
|
+
export declare function readParentPid(pid: number): Promise<number | null>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
const SUBPROCESS_TIMEOUT_MS = 5_000;
|
|
4
|
+
function runCommand(command, args) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
execFile(command, args, { encoding: 'utf-8', timeout: SUBPROCESS_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
7
|
+
if (error) {
|
|
8
|
+
reject(error);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
resolve(stdout);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function parsePidLines(output) {
|
|
16
|
+
return [...new Set(output
|
|
17
|
+
.trim()
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.map(line => parseInt(line.trim(), 10))
|
|
20
|
+
.filter(pid => Number.isInteger(pid) && pid > 0))];
|
|
21
|
+
}
|
|
22
|
+
export function buildWindowsFindPidOnPortScript(port) {
|
|
23
|
+
return [
|
|
24
|
+
`Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue`,
|
|
25
|
+
'Select-Object -ExpandProperty OwningProcess -Unique',
|
|
26
|
+
].join(' | ');
|
|
27
|
+
}
|
|
28
|
+
export function buildWindowsReadParentPidScript(pid) {
|
|
29
|
+
return [
|
|
30
|
+
`Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
|
|
31
|
+
'Select-Object -ExpandProperty ParentProcessId',
|
|
32
|
+
].join(' | ');
|
|
33
|
+
}
|
|
34
|
+
export async function findPidOnPort(port) {
|
|
35
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
36
|
+
return [];
|
|
37
|
+
try {
|
|
38
|
+
if (process.platform === 'win32') {
|
|
39
|
+
const script = buildWindowsFindPidOnPortScript(port);
|
|
40
|
+
const stdout = await runCommand('powershell', ['-NoProfile', '-Command', script]);
|
|
41
|
+
return parsePidLines(stdout);
|
|
42
|
+
}
|
|
43
|
+
const stdout = await runCommand('lsof', ['-ti', `tcp:${port}`]);
|
|
44
|
+
return parsePidLines(stdout);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function readParentPid(pid) {
|
|
51
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
if (process.platform === 'win32') {
|
|
55
|
+
const script = buildWindowsReadParentPidScript(pid);
|
|
56
|
+
const stdout = await runCommand('powershell', ['-NoProfile', '-Command', script]);
|
|
57
|
+
const parent = parseInt(stdout.trim(), 10);
|
|
58
|
+
return Number.isInteger(parent) && parent > 0 ? parent : null;
|
|
59
|
+
}
|
|
60
|
+
if (process.platform !== 'linux') {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const status = await readFile(`/proc/${pid}/status`, 'utf-8');
|
|
64
|
+
const match = status.match(/^PPid:\s+(\d+)/m);
|
|
65
|
+
if (!match)
|
|
66
|
+
return null;
|
|
67
|
+
const parent = parseInt(match[1], 10);
|
|
68
|
+
return Number.isInteger(parent) && parent > 0 ? parent : null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -49,6 +49,7 @@ import { MemoryBridge } from './memory-bridge.js';
|
|
|
49
49
|
import { cleanupTerminatedSessionState } from './session-cleanup.js';
|
|
50
50
|
import { normalizeApiErrorPayload } from './api-error-envelope.js';
|
|
51
51
|
import { listenWithRetry, removePidFile, writePidFile } from './startup.js';
|
|
52
|
+
import { isWindowsShutdownMessage, parseShutdownTimeoutMs } from './shutdown-utils.js';
|
|
52
53
|
import { authKeySchema, sendMessageSchema, commandSchema, bashSchema, screenshotSchema, permissionHookSchema, stopHookSchema, batchSessionSchema, pipelineSchema, handshakeRequestSchema, parseIntSafe, isValidUUID, compareSemver, extractCCVersion, MIN_CC_VERSION, permissionProfileSchema, } from './validation.js';
|
|
53
54
|
const __filename = fileURLToPath(import.meta.url);
|
|
54
55
|
const __dirname = path.dirname(__filename);
|
|
@@ -1676,7 +1677,7 @@ function registerChannels(cfg) {
|
|
|
1676
1677
|
}
|
|
1677
1678
|
}
|
|
1678
1679
|
// Preserve public export used by tests and external imports.
|
|
1679
|
-
export { readPpid } from './
|
|
1680
|
+
export { readParentPid as readPpid } from './process-utils.js';
|
|
1680
1681
|
async function main() {
|
|
1681
1682
|
// Load configuration
|
|
1682
1683
|
config = await loadConfig();
|
|
@@ -1765,56 +1766,67 @@ async function main() {
|
|
|
1765
1766
|
// Issue #361: Graceful shutdown handler
|
|
1766
1767
|
// Issue #415: Reentrance guard at handler level prevents double execution on rapid SIGINT
|
|
1767
1768
|
let shuttingDown = false;
|
|
1769
|
+
const shutdownTimeoutMs = parseShutdownTimeoutMs(process.env.AEGIS_SHUTDOWN_TIMEOUT_MS);
|
|
1768
1770
|
async function gracefulShutdown(signal) {
|
|
1769
1771
|
console.log(`${signal} received, shutting down gracefully...`);
|
|
1770
|
-
|
|
1772
|
+
const forceExitTimer = setTimeout(() => {
|
|
1773
|
+
console.error(`Graceful shutdown timed out after ${shutdownTimeoutMs}ms — forcing process exit`);
|
|
1774
|
+
process.exit(1);
|
|
1775
|
+
}, shutdownTimeoutMs);
|
|
1776
|
+
forceExitTimer.unref?.();
|
|
1771
1777
|
try {
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1778
|
+
// 1. Stop accepting new requests
|
|
1779
|
+
try {
|
|
1780
|
+
await app.close();
|
|
1781
|
+
}
|
|
1782
|
+
catch (e) {
|
|
1783
|
+
console.error('Error closing server:', e);
|
|
1784
|
+
}
|
|
1785
|
+
// 2. Stop background monitors and intervals
|
|
1786
|
+
monitor.stop();
|
|
1787
|
+
swarmMonitor.stop();
|
|
1788
|
+
clearInterval(reaperInterval);
|
|
1789
|
+
clearInterval(zombieReaperInterval);
|
|
1790
|
+
clearInterval(metricsSaveInterval);
|
|
1791
|
+
clearInterval(ipPruneInterval);
|
|
1792
|
+
clearInterval(authFailPruneInterval);
|
|
1793
|
+
clearInterval(authSweepInterval);
|
|
1794
|
+
// Issue #569: Kill all CC sessions and tmux windows before exit
|
|
1795
|
+
try {
|
|
1796
|
+
await killAllSessions(sessions, tmux);
|
|
1797
|
+
}
|
|
1798
|
+
catch (e) {
|
|
1799
|
+
console.error('Error killing sessions:', e);
|
|
1800
|
+
}
|
|
1801
|
+
// 3. Destroy channels (awaits Telegram poll loop)
|
|
1802
|
+
try {
|
|
1803
|
+
await channels.destroy();
|
|
1804
|
+
}
|
|
1805
|
+
catch (e) {
|
|
1806
|
+
console.error('Error destroying channels:', e);
|
|
1807
|
+
}
|
|
1808
|
+
// 4. Save session state
|
|
1809
|
+
try {
|
|
1810
|
+
await sessions.save();
|
|
1811
|
+
}
|
|
1812
|
+
catch (e) {
|
|
1813
|
+
console.error('Error saving sessions:', e);
|
|
1814
|
+
}
|
|
1815
|
+
// 5. Save metrics
|
|
1816
|
+
try {
|
|
1817
|
+
await metrics.save();
|
|
1818
|
+
}
|
|
1819
|
+
catch (e) {
|
|
1820
|
+
console.error('Error saving metrics:', e);
|
|
1821
|
+
}
|
|
1822
|
+
// 6. Cleanup PID file
|
|
1823
|
+
removePidFile(pidFilePath);
|
|
1824
|
+
console.log('Graceful shutdown complete');
|
|
1825
|
+
process.exit(0);
|
|
1810
1826
|
}
|
|
1811
|
-
|
|
1812
|
-
|
|
1827
|
+
finally {
|
|
1828
|
+
clearTimeout(forceExitTimer);
|
|
1813
1829
|
}
|
|
1814
|
-
// 6. Cleanup PID file
|
|
1815
|
-
removePidFile(pidFilePath);
|
|
1816
|
-
console.log('Graceful shutdown complete');
|
|
1817
|
-
process.exit(0);
|
|
1818
1830
|
}
|
|
1819
1831
|
process.on('SIGTERM', () => { if (!shuttingDown) {
|
|
1820
1832
|
shuttingDown = true;
|
|
@@ -1824,6 +1836,14 @@ async function main() {
|
|
|
1824
1836
|
shuttingDown = true;
|
|
1825
1837
|
void gracefulShutdown('SIGINT');
|
|
1826
1838
|
} });
|
|
1839
|
+
if (process.platform === 'win32') {
|
|
1840
|
+
process.on('message', (message) => {
|
|
1841
|
+
if (!shuttingDown && isWindowsShutdownMessage(message)) {
|
|
1842
|
+
shuttingDown = true;
|
|
1843
|
+
void gracefulShutdown('WINMSG');
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1827
1847
|
process.on('unhandledRejection', (reason) => {
|
|
1828
1848
|
console.error('unhandledRejection:', reason);
|
|
1829
1849
|
});
|
package/dist/session.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ import { type UIState } from './terminal-parser.js';
|
|
|
10
10
|
import type { Config } from './config.js';
|
|
11
11
|
import { type PermissionPolicy, type PermissionProfile } from './validation.js';
|
|
12
12
|
import { type PermissionDecision } from './permission-request-manager.js';
|
|
13
|
+
/**
|
|
14
|
+
* Canonical runtime metadata for an Aegis-managed Claude Code session.
|
|
15
|
+
*
|
|
16
|
+
* This structure is persisted to disk and reused by the REST API, SSE layer,
|
|
17
|
+
* monitoring loop, and session recovery logic.
|
|
18
|
+
*/
|
|
13
19
|
export interface SessionInfo {
|
|
14
20
|
id: string;
|
|
15
21
|
windowId: string;
|
|
@@ -43,6 +49,7 @@ export interface SessionInfo {
|
|
|
43
49
|
permissionProfile?: PermissionProfile;
|
|
44
50
|
prd?: string;
|
|
45
51
|
}
|
|
52
|
+
/** Persisted session store keyed by Aegis session ID. */
|
|
46
53
|
export interface SessionState {
|
|
47
54
|
sessions: Record<string, SessionInfo>;
|
|
48
55
|
}
|
|
@@ -57,6 +64,10 @@ export interface SessionState {
|
|
|
57
64
|
export declare function detectApprovalMethod(paneText: string): 'numbered' | 'yes';
|
|
58
65
|
/** Resolves a pending PermissionRequest hook with a decision. */
|
|
59
66
|
export type { PermissionDecision };
|
|
67
|
+
/**
|
|
68
|
+
* Coordinates session lifecycle, persistence, transcript discovery, and
|
|
69
|
+
* interactive approval/question flows for all managed Claude Code sessions.
|
|
70
|
+
*/
|
|
60
71
|
export declare class SessionManager {
|
|
61
72
|
private tmux;
|
|
62
73
|
private config;
|
package/dist/session.js
CHANGED
|
@@ -52,6 +52,10 @@ export function detectApprovalMethod(paneText) {
|
|
|
52
52
|
}
|
|
53
53
|
return 'yes';
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Coordinates session lifecycle, persistence, transcript discovery, and
|
|
57
|
+
* interactive approval/question flows for all managed Claude Code sessions.
|
|
58
|
+
*/
|
|
55
59
|
export class SessionManager {
|
|
56
60
|
tmux;
|
|
57
61
|
config;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shutdown-utils.ts — reusable shutdown helpers for server signal handling.
|
|
3
|
+
*/
|
|
4
|
+
export declare function parseShutdownTimeoutMs(rawValue: string | undefined, fallbackMs?: number): number;
|
|
5
|
+
export declare function isWindowsShutdownMessage(message: unknown): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shutdown-utils.ts — reusable shutdown helpers for server signal handling.
|
|
3
|
+
*/
|
|
4
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
5
|
+
export function parseShutdownTimeoutMs(rawValue, fallbackMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
6
|
+
const parsed = Number(rawValue);
|
|
7
|
+
if (!Number.isFinite(parsed) || parsed < 1_000)
|
|
8
|
+
return fallbackMs;
|
|
9
|
+
return Math.floor(parsed);
|
|
10
|
+
}
|
|
11
|
+
export function isWindowsShutdownMessage(message) {
|
|
12
|
+
if (typeof message === 'string') {
|
|
13
|
+
const normalized = message.trim().toLowerCase();
|
|
14
|
+
return normalized === 'shutdown' || normalized === 'graceful-shutdown';
|
|
15
|
+
}
|
|
16
|
+
if (typeof message === 'object' && message !== null && 'type' in message) {
|
|
17
|
+
const typeValue = message.type;
|
|
18
|
+
if (typeof typeValue === 'string') {
|
|
19
|
+
const normalized = typeValue.trim().toLowerCase();
|
|
20
|
+
return normalized === 'shutdown' || normalized === 'graceful-shutdown';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
package/dist/startup.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
2
|
export declare function writePidFile(stateDir: string): string;
|
|
3
3
|
export declare function removePidFile(pidFilePath: string): void;
|
|
4
|
-
|
|
4
|
+
/** Read parent PID with cross-platform fallback. */
|
|
5
|
+
export declare function readPpid(pid: number): Promise<number>;
|
|
5
6
|
export declare function listenWithRetry(app: ReturnType<typeof Fastify>, port: number, host: string, stateDir: string, maxRetries?: number): Promise<void>;
|
package/dist/startup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { findPidOnPort, readParentPid } from './process-utils.js';
|
|
5
5
|
export function writePidFile(stateDir) {
|
|
6
6
|
try {
|
|
7
7
|
const pidFilePath = path.join(stateDir, 'aegis.pid');
|
|
@@ -41,21 +41,22 @@ function pidExists(pid) {
|
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
throw new Error(`no
|
|
49
|
-
|
|
44
|
+
/** Read parent PID with cross-platform fallback. */
|
|
45
|
+
export async function readPpid(pid) {
|
|
46
|
+
const parent = await readParentPid(pid);
|
|
47
|
+
if (parent === null) {
|
|
48
|
+
throw new Error(`no parent PID available for process ${pid}`);
|
|
49
|
+
}
|
|
50
|
+
return parent;
|
|
50
51
|
}
|
|
51
|
-
function isAncestorPid(pid) {
|
|
52
|
+
async function isAncestorPid(pid) {
|
|
52
53
|
try {
|
|
53
54
|
let current = process.ppid;
|
|
54
55
|
for (let depth = 0; depth < 10 && current > 1; depth++) {
|
|
55
56
|
if (current === pid)
|
|
56
57
|
return true;
|
|
57
58
|
try {
|
|
58
|
-
current = readPpid(current);
|
|
59
|
+
current = await readPpid(current);
|
|
59
60
|
}
|
|
60
61
|
catch {
|
|
61
62
|
break;
|
|
@@ -93,17 +94,14 @@ async function waitForPortRelease(port, maxWaitMs = 5000) {
|
|
|
93
94
|
async function killStalePortHolder(port, stateDir) {
|
|
94
95
|
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 400));
|
|
95
96
|
try {
|
|
96
|
-
const
|
|
97
|
-
if (!output)
|
|
98
|
-
return false;
|
|
99
|
-
const pids = output.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => !Number.isNaN(n));
|
|
97
|
+
const pids = await findPidOnPort(port);
|
|
100
98
|
if (pids.length === 0)
|
|
101
99
|
return false;
|
|
102
100
|
let killed = false;
|
|
103
101
|
for (const pid of pids) {
|
|
104
102
|
if (pid === process.pid)
|
|
105
103
|
continue;
|
|
106
|
-
if (isAncestorPid(pid)) {
|
|
104
|
+
if (await isAncestorPid(pid)) {
|
|
107
105
|
console.warn(`EADDRINUSE recovery: skipping ancestor PID ${pid} on port ${port}`);
|
|
108
106
|
continue;
|
|
109
107
|
}
|
package/dist/swarm-monitor.d.ts
CHANGED
|
@@ -75,10 +75,13 @@ export declare class SwarmMonitor {
|
|
|
75
75
|
private lastResult;
|
|
76
76
|
private timer;
|
|
77
77
|
private eventHandlers;
|
|
78
|
+
private windowsDisabledLogged;
|
|
78
79
|
constructor(sessions: SessionManager, config?: SwarmMonitorConfig);
|
|
79
80
|
/** Register an event handler for teammate lifecycle events. */
|
|
80
81
|
onEvent(handler: SwarmEventHandler): void;
|
|
81
82
|
private emitEvent;
|
|
83
|
+
private isWindowsPlatform;
|
|
84
|
+
private logWindowsDisabled;
|
|
82
85
|
/** Start the periodic scan loop. */
|
|
83
86
|
start(): void;
|
|
84
87
|
/** Stop the periodic scan loop. */
|
package/dist/swarm-monitor.js
CHANGED
|
@@ -28,6 +28,7 @@ export class SwarmMonitor {
|
|
|
28
28
|
lastResult = null;
|
|
29
29
|
timer = null;
|
|
30
30
|
eventHandlers = [];
|
|
31
|
+
windowsDisabledLogged = false;
|
|
31
32
|
constructor(sessions, config = DEFAULT_SWARM_CONFIG) {
|
|
32
33
|
this.sessions = sessions;
|
|
33
34
|
this.config = config;
|
|
@@ -46,8 +47,21 @@ export class SwarmMonitor {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
isWindowsPlatform() {
|
|
51
|
+
return process.platform === 'win32';
|
|
52
|
+
}
|
|
53
|
+
logWindowsDisabled() {
|
|
54
|
+
if (this.windowsDisabledLogged)
|
|
55
|
+
return;
|
|
56
|
+
console.info('SwarmMonitor disabled on Windows: tmux swarm sockets are not supported on this platform.');
|
|
57
|
+
this.windowsDisabledLogged = true;
|
|
58
|
+
}
|
|
49
59
|
/** Start the periodic scan loop. */
|
|
50
60
|
start() {
|
|
61
|
+
if (this.isWindowsPlatform()) {
|
|
62
|
+
this.logWindowsDisabled();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
51
65
|
if (this.running)
|
|
52
66
|
return;
|
|
53
67
|
this.running = true;
|
|
@@ -70,6 +84,16 @@ export class SwarmMonitor {
|
|
|
70
84
|
}
|
|
71
85
|
/** Run a single scan and return the result. */
|
|
72
86
|
async scan() {
|
|
87
|
+
if (this.isWindowsPlatform()) {
|
|
88
|
+
this.logWindowsDisabled();
|
|
89
|
+
this.lastResult = {
|
|
90
|
+
swarms: [],
|
|
91
|
+
totalSockets: 0,
|
|
92
|
+
totalTeammates: 0,
|
|
93
|
+
scannedAt: Date.now(),
|
|
94
|
+
};
|
|
95
|
+
return this.lastResult;
|
|
96
|
+
}
|
|
73
97
|
try {
|
|
74
98
|
const sockets = await this.discoverSwarmSockets();
|
|
75
99
|
// Issue #353: Inspect sockets in parallel to avoid N×timeout accumulation.
|
|
@@ -176,6 +200,17 @@ export class SwarmMonitor {
|
|
|
176
200
|
}
|
|
177
201
|
/** Inspect a single swarm socket and return swarm info. */
|
|
178
202
|
async inspectSwarmSocket(socketName) {
|
|
203
|
+
if (this.isWindowsPlatform()) {
|
|
204
|
+
const pid = this.extractPid(socketName);
|
|
205
|
+
return {
|
|
206
|
+
socketName,
|
|
207
|
+
pid,
|
|
208
|
+
parentSession: null,
|
|
209
|
+
teammates: [],
|
|
210
|
+
aggregatedStatus: 'no_teammates',
|
|
211
|
+
lastScannedAt: Date.now(),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
179
214
|
const pid = this.extractPid(socketName);
|
|
180
215
|
const teammates = await this.listSwarmWindows(socketName);
|
|
181
216
|
const parentSession = this.findParentSession(pid, teammates);
|
package/dist/tmux.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Wraps tmux CLI commands to manage windows inside a named session.
|
|
5
5
|
* Port of CCBot's tmux_manager.py to TypeScript.
|
|
6
6
|
*/
|
|
7
|
+
/** Build the platform-specific launch wrapper that clears inherited tmux vars. */
|
|
8
|
+
export declare function buildClaudeLaunchCommand(baseCommand: string, platform?: NodeJS.Platform): string;
|
|
7
9
|
/** Thrown when a tmux command exceeds its timeout. */
|
|
8
10
|
export declare class TmuxTimeoutError extends Error {
|
|
9
11
|
constructor(args: string[], timeoutMs: number);
|
|
@@ -89,10 +91,14 @@ export declare class TmuxManager {
|
|
|
89
91
|
* Values never appear in terminal scrollback or capture-pane output.
|
|
90
92
|
*/
|
|
91
93
|
private setEnvSecure;
|
|
94
|
+
/** #909: Windows variant — set tmux env and dot-source a temp .ps1 in the active pane. */
|
|
95
|
+
private setEnvSecureWin32;
|
|
92
96
|
/** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
|
|
93
97
|
* sendKeys, safe to call from inside a serialize() callback without deadlocking.
|
|
94
98
|
* Identical logic otherwise. */
|
|
95
99
|
private setEnvSecureDirect;
|
|
100
|
+
/** #909: Direct Windows variant that avoids serialize() re-entry. */
|
|
101
|
+
private setEnvSecureDirectWin32;
|
|
96
102
|
/** P1 fix: Check if a window exists. Returns true if window is in the session.
|
|
97
103
|
* #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
|
|
98
104
|
windowExists(windowId: string): Promise<boolean>;
|
package/dist/tmux.js
CHANGED
|
@@ -12,10 +12,21 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { homedir, tmpdir } from 'node:os';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
14
|
import { computeProjectHash } from './path-utils.js';
|
|
15
|
+
import { secureFilePermissions } from './file-utils.js';
|
|
15
16
|
/** Shell-escape a string by wrapping in single quotes and escaping embedded single quotes. */
|
|
16
17
|
function shellEscape(s) {
|
|
17
18
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
18
19
|
}
|
|
20
|
+
function powerShellSingleQuote(value) {
|
|
21
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
22
|
+
}
|
|
23
|
+
/** Build the platform-specific launch wrapper that clears inherited tmux vars. */
|
|
24
|
+
export function buildClaudeLaunchCommand(baseCommand, platform = process.platform) {
|
|
25
|
+
if (platform === 'win32') {
|
|
26
|
+
return `Remove-Item Env:TMUX -ErrorAction SilentlyContinue; Remove-Item Env:TMUX_PANE -ErrorAction SilentlyContinue; ${baseCommand}`;
|
|
27
|
+
}
|
|
28
|
+
return `unset TMUX TMUX_PANE && exec ${baseCommand}`;
|
|
29
|
+
}
|
|
19
30
|
/** Validate that an env var key contains only safe characters (Issue #630: uppercase only, aligned with session.ts). */
|
|
20
31
|
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
21
32
|
const execFileAsync = promisify(execFile);
|
|
@@ -309,13 +320,9 @@ export class TmuxManager {
|
|
|
309
320
|
if (existsSync(settingsPath)) {
|
|
310
321
|
cmd += ` --settings ${shellEscape(settingsPath)}`;
|
|
311
322
|
}
|
|
312
|
-
// Issue #68:
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
// - Color capabilities reduced to 256
|
|
316
|
-
// - Clipboard passthrough via tmux load-buffer instead of OSC 52
|
|
317
|
-
// Prefixing with 'unset' ensures CC gets a clean environment.
|
|
318
|
-
cmd = `unset TMUX TMUX_PANE && exec ${cmd}`;
|
|
323
|
+
// Issue #68 / #909: Clear inherited tmux vars before launching CC.
|
|
324
|
+
// Linux/macOS uses `unset`; Windows uses PowerShell env removal.
|
|
325
|
+
cmd = buildClaudeLaunchCommand(cmd);
|
|
319
326
|
// Send the command to start Claude
|
|
320
327
|
await this.sendKeys(windowId, cmd, true);
|
|
321
328
|
// Issue #7: Verify Claude process started by checking pane command.
|
|
@@ -393,6 +400,10 @@ export class TmuxManager {
|
|
|
393
400
|
* Values never appear in terminal scrollback or capture-pane output.
|
|
394
401
|
*/
|
|
395
402
|
async setEnvSecure(windowId, env) {
|
|
403
|
+
if (process.platform === 'win32') {
|
|
404
|
+
await this.setEnvSecureWin32(windowId, env);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
396
407
|
const fs = await import('node:fs/promises');
|
|
397
408
|
const path = await import('node:path');
|
|
398
409
|
// Validate env var keys before interpolation
|
|
@@ -410,6 +421,7 @@ export class TmuxManager {
|
|
|
410
421
|
return `export ${key}='${escaped}'`;
|
|
411
422
|
});
|
|
412
423
|
await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
|
|
424
|
+
await secureFilePermissions(tmpFile);
|
|
413
425
|
// Source the file and delete it — all in one command so the values
|
|
414
426
|
// appear in the process environment but not in the terminal history.
|
|
415
427
|
// The 'source' line is visible but only shows the temp file path, not the values.
|
|
@@ -428,10 +440,44 @@ export class TmuxManager {
|
|
|
428
440
|
}
|
|
429
441
|
catch { /* already deleted by shell */ }
|
|
430
442
|
}
|
|
443
|
+
/** #909: Windows variant — set tmux env and dot-source a temp .ps1 in the active pane. */
|
|
444
|
+
async setEnvSecureWin32(windowId, env) {
|
|
445
|
+
const fs = await import('node:fs/promises');
|
|
446
|
+
const path = await import('node:path');
|
|
447
|
+
for (const key of Object.keys(env)) {
|
|
448
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
449
|
+
throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}.ps1`);
|
|
453
|
+
const lines = Object.entries(env).map(([key, val]) => `$env:${key} = ${powerShellSingleQuote(val)}`);
|
|
454
|
+
await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
|
|
455
|
+
for (const [key, val] of Object.entries(env)) {
|
|
456
|
+
await this.tmux('set-environment', '-t', this.sessionName, key, val);
|
|
457
|
+
}
|
|
458
|
+
const psPath = powerShellSingleQuote(tmpFile);
|
|
459
|
+
const cmd = `. ${psPath}; Remove-Item -LiteralPath ${psPath} -Force -ErrorAction SilentlyContinue`;
|
|
460
|
+
await this.sendKeys(windowId, cmd, true);
|
|
461
|
+
await this.pollUntil(async () => { try {
|
|
462
|
+
await stat(tmpFile);
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return true;
|
|
467
|
+
} }, 50, 750);
|
|
468
|
+
try {
|
|
469
|
+
await fs.unlink(tmpFile);
|
|
470
|
+
}
|
|
471
|
+
catch { /* already deleted by shell */ }
|
|
472
|
+
}
|
|
431
473
|
/** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
|
|
432
474
|
* sendKeys, safe to call from inside a serialize() callback without deadlocking.
|
|
433
475
|
* Identical logic otherwise. */
|
|
434
476
|
async setEnvSecureDirect(windowId, env) {
|
|
477
|
+
if (process.platform === 'win32') {
|
|
478
|
+
await this.setEnvSecureDirectWin32(windowId, env);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
435
481
|
const fs = await import('node:fs/promises');
|
|
436
482
|
const path = await import('node:path');
|
|
437
483
|
for (const key of Object.keys(env)) {
|
|
@@ -445,6 +491,7 @@ export class TmuxManager {
|
|
|
445
491
|
return `export ${key}='${escaped}'`;
|
|
446
492
|
});
|
|
447
493
|
await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
|
|
494
|
+
await secureFilePermissions(tmpFile);
|
|
448
495
|
// Use sendKeysDirectInternal to avoid re-entering serialize()
|
|
449
496
|
const cmd = `source ${shellEscape(tmpFile)} && rm -f ${shellEscape(tmpFile)}`;
|
|
450
497
|
await this.sendKeysDirectInternal(windowId, cmd, true);
|
|
@@ -460,6 +507,36 @@ export class TmuxManager {
|
|
|
460
507
|
}
|
|
461
508
|
catch { /* already deleted by shell */ }
|
|
462
509
|
}
|
|
510
|
+
/** #909: Direct Windows variant that avoids serialize() re-entry. */
|
|
511
|
+
async setEnvSecureDirectWin32(windowId, env) {
|
|
512
|
+
const fs = await import('node:fs/promises');
|
|
513
|
+
const path = await import('node:path');
|
|
514
|
+
for (const key of Object.keys(env)) {
|
|
515
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
516
|
+
throw new Error(`Invalid env var key: '${key}' — must match ${ENV_KEY_RE.source}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const tmpFile = path.join(tmpdir(), `.aegis-env-${randomBytes(16).toString('hex')}.ps1`);
|
|
520
|
+
const lines = Object.entries(env).map(([key, val]) => `$env:${key} = ${powerShellSingleQuote(val)}`);
|
|
521
|
+
await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
|
|
522
|
+
for (const [key, val] of Object.entries(env)) {
|
|
523
|
+
await this.tmuxInternal('set-environment', '-t', this.sessionName, key, val);
|
|
524
|
+
}
|
|
525
|
+
const psPath = powerShellSingleQuote(tmpFile);
|
|
526
|
+
const cmd = `. ${psPath}; Remove-Item -LiteralPath ${psPath} -Force -ErrorAction SilentlyContinue`;
|
|
527
|
+
await this.sendKeysDirectInternal(windowId, cmd, true);
|
|
528
|
+
await this.pollUntil(async () => { try {
|
|
529
|
+
await stat(tmpFile);
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return true;
|
|
534
|
+
} }, 50, 750);
|
|
535
|
+
try {
|
|
536
|
+
await fs.unlink(tmpFile);
|
|
537
|
+
}
|
|
538
|
+
catch { /* already deleted by shell */ }
|
|
539
|
+
}
|
|
463
540
|
/** P1 fix: Check if a window exists. Returns true if window is in the session.
|
|
464
541
|
* #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
|
|
465
542
|
async windowExists(windowId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-bridge",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Orchestrate Claude Code sessions via API. Create, brief, monitor, refine, ship.",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
"build": "tsc && npm run build:copy-dashboard",
|
|
28
28
|
"build:copy-dashboard": "node scripts/copy-dashboard.mjs",
|
|
29
29
|
"build:dashboard": "cd dashboard && npm ci && npm run build",
|
|
30
|
+
"docs": "typedoc",
|
|
30
31
|
"start": "node dist/cli.js",
|
|
31
32
|
"dev": "tsc && node dist/cli.js",
|
|
32
33
|
"prepublishOnly": "npm run build:dashboard && npm run build",
|
|
33
34
|
"test": "vitest run",
|
|
35
|
+
"test:smoke": "node scripts/uat-smoke.mjs",
|
|
34
36
|
"test:fault-harness": "vitest run src/__tests__/fault-injection-harness-901.test.ts"
|
|
35
37
|
},
|
|
36
38
|
"keywords": [
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"@types/ws": "^8.18.1",
|
|
73
75
|
"lockfile-lint": "5.0.0",
|
|
74
76
|
"ts-morph": "^27.0.2",
|
|
77
|
+
"typedoc": "^0.28.18",
|
|
75
78
|
"typescript": "^6.0.2",
|
|
76
79
|
"vitest": "^4.1.2"
|
|
77
80
|
}
|