aegis-bridge 2.15.4 → 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 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 = path.replace(/^\//, '').split('/');
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.replace(/^\/home\/[^/]+\/projects\//, '~/');
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,7 +5,7 @@
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 { execSync } from 'node:child_process';
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';
@@ -15,9 +15,9 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')
15
15
  /** Current aegis-bridge version read from package.json at startup. */
16
16
  const VERSION = pkg.version;
17
17
  /** Check whether a required external dependency can be executed. */
18
- function checkDependency(name, command) {
18
+ function checkDependency(command, args) {
19
19
  try {
20
- execSync(`${command} 2>/dev/null`, { stdio: 'ignore' });
20
+ execFileSync(command, args, { stdio: 'ignore', timeout: 5000 });
21
21
  return true;
22
22
  }
23
23
  catch { /* command not found or exited non-zero */
@@ -188,8 +188,8 @@ async function main() {
188
188
  process.env.AEGIS_PORT = args[portIdx + 1];
189
189
  }
190
190
  // Check dependencies
191
- const hasTmux = checkDependency('tmux', 'tmux -V');
192
- const hasClaude = checkDependency('claude', 'claude --version');
191
+ const hasTmux = checkDependency('tmux', ['-V']);
192
+ const hasClaude = checkDependency('claude', ['--version']);
193
193
  if (!hasTmux) {
194
194
  console.error(`
195
195
  ❌ tmux not found.
@@ -0,0 +1,2 @@
1
+ export declare function buildWindowsIcaclsArgs(filePath: string, account: string): string[];
2
+ export declare function secureFilePermissions(filePath: string, platform?: NodeJS.Platform): Promise<void>;
@@ -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
+ }
@@ -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
  }
@@ -192,6 +193,7 @@ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, work
192
193
  await mkdir(settingsDir, { recursive: true, mode: 0o700 });
193
194
  const filePath = join(settingsDir, `hooks-${sessionId}.json`);
194
195
  await writeFile(filePath, JSON.stringify(combined, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
196
+ await secureFilePermissions(filePath);
195
197
  return filePath;
196
198
  }
197
199
  /**
@@ -260,6 +262,7 @@ export async function cleanupStaleSessionHooks(workDir, activeSessionIds) {
260
262
  }
261
263
  if (changed) {
262
264
  await writeFile(projectSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
265
+ await secureFilePermissions(projectSettingsPath);
263
266
  }
264
267
  }
265
268
  catch {
@@ -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 === filter.workDir || s.workDir?.startsWith(filter.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
@@ -7,4 +7,4 @@
7
7
  * Notification channels (Telegram, webhooks, etc.) are pluggable —
8
8
  * the server doesn't know which channels are active.
9
9
  */
10
- export { readPpid } from './startup.js';
10
+ export { readParentPid as readPpid } from './process-utils.js';
package/dist/server.js CHANGED
@@ -1677,7 +1677,7 @@ function registerChannels(cfg) {
1677
1677
  }
1678
1678
  }
1679
1679
  // Preserve public export used by tests and external imports.
1680
- export { readPpid } from './startup.js';
1680
+ export { readParentPid as readPpid } from './process-utils.js';
1681
1681
  async function main() {
1682
1682
  // Load configuration
1683
1683
  config = await loadConfig();
package/dist/startup.d.ts CHANGED
@@ -1,6 +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
- /** Read the parent PID for a Linux process from /proc. */
5
- export declare function readPpid(pid: number): number;
4
+ /** Read parent PID with cross-platform fallback. */
5
+ export declare function readPpid(pid: number): Promise<number>;
6
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 { readFileSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { writeFileSync, unlinkSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { execFileSync } from 'node:child_process';
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,22 +41,22 @@ function pidExists(pid) {
41
41
  return false;
42
42
  }
43
43
  }
44
- /** Read the parent PID for a Linux process from /proc. */
45
- export function readPpid(pid) {
46
- const status = readFileSync(`/proc/${pid}/status`, 'utf-8');
47
- const match = status.match(/^PPid:\s+(\d+)/m);
48
- if (!match)
49
- throw new Error(`no PPid line in /proc/${pid}/status`);
50
- return parseInt(match[1], 10);
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;
51
51
  }
52
- function isAncestorPid(pid) {
52
+ async function isAncestorPid(pid) {
53
53
  try {
54
54
  let current = process.ppid;
55
55
  for (let depth = 0; depth < 10 && current > 1; depth++) {
56
56
  if (current === pid)
57
57
  return true;
58
58
  try {
59
- current = readPpid(current);
59
+ current = await readPpid(current);
60
60
  }
61
61
  catch {
62
62
  break;
@@ -94,17 +94,14 @@ async function waitForPortRelease(port, maxWaitMs = 5000) {
94
94
  async function killStalePortHolder(port, stateDir) {
95
95
  await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 400));
96
96
  try {
97
- const output = execFileSync('lsof', ['-ti', `tcp:${port}`], { encoding: 'utf-8', timeout: 5000 }).trim();
98
- if (!output)
99
- return false;
100
- const pids = output.split('\n').map(s => parseInt(s.trim(), 10)).filter(n => !Number.isNaN(n));
97
+ const pids = await findPidOnPort(port);
101
98
  if (pids.length === 0)
102
99
  return false;
103
100
  let killed = false;
104
101
  for (const pid of pids) {
105
102
  if (pid === process.pid)
106
103
  continue;
107
- if (isAncestorPid(pid)) {
104
+ if (await isAncestorPid(pid)) {
108
105
  console.warn(`EADDRINUSE recovery: skipping ancestor PID ${pid} on port ${port}`);
109
106
  continue;
110
107
  }
@@ -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. */
@@ -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.js CHANGED
@@ -12,6 +12,7 @@ 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, "'\\''")}'`;
@@ -420,6 +421,7 @@ export class TmuxManager {
420
421
  return `export ${key}='${escaped}'`;
421
422
  });
422
423
  await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
424
+ await secureFilePermissions(tmpFile);
423
425
  // Source the file and delete it — all in one command so the values
424
426
  // appear in the process environment but not in the terminal history.
425
427
  // The 'source' line is visible but only shows the temp file path, not the values.
@@ -489,6 +491,7 @@ export class TmuxManager {
489
491
  return `export ${key}='${escaped}'`;
490
492
  });
491
493
  await fs.writeFile(tmpFile, lines.join('\n') + '\n', { mode: 0o600 });
494
+ await secureFilePermissions(tmpFile);
492
495
  // Use sendKeysDirectInternal to avoid re-entering serialize()
493
496
  const cmd = `source ${shellEscape(tmpFile)} && rm -f ${shellEscape(tmpFile)}`;
494
497
  await this.sendKeysDirectInternal(windowId, cmd, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aegis-bridge",
3
- "version": "2.15.4",
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",
@@ -32,6 +32,7 @@
32
32
  "dev": "tsc && node dist/cli.js",
33
33
  "prepublishOnly": "npm run build:dashboard && npm run build",
34
34
  "test": "vitest run",
35
+ "test:smoke": "node scripts/uat-smoke.mjs",
35
36
  "test:fault-harness": "vitest run src/__tests__/fault-injection-harness-901.test.ts"
36
37
  },
37
38
  "keywords": [