Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/array/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import {
shutdownPostHog,
trackAppEvent,
} from "./services/posthog-analytics.js";
import { registerSettingsIpc } from "./services/settings.js";
import { registerShellIpc } from "./services/shell.js";
import { registerAutoUpdater } from "./services/updates.js";
import { registerWorkspaceIpc } from "./services/workspace/index.js";
import { registerWorktreeIpc } from "./services/worktree.js";

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -229,7 +231,9 @@ registerGitIpc(() => mainWindow);
registerAgentIpc(taskControllers, () => mainWindow);
registerFsIpc();
registerFileWatcherIpc(() => mainWindow);
registerFoldersIpc();
registerFoldersIpc(() => mainWindow);
registerWorktreeIpc();
registerShellIpc();
registerExternalAppsIpc();
registerWorkspaceIpc(() => mainWindow);
registerSettingsIpc();
37 changes: 37 additions & 0 deletions apps/array/src/main/lib/ipcHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type IpcMainInvokeEvent, ipcMain } from "electron";
import { logger } from "./logger";

type IpcHandler<T extends unknown[], R> = (
event: IpcMainInvokeEvent,
...args: T
) => Promise<R> | R;

interface HandleOptions {
scope?: string;
rethrow?: boolean;
fallback?: unknown;
}

export function createIpcHandler(scope: string) {
const log = logger.scope(scope);

return function handle<T extends unknown[], R>(
channel: string,
handler: IpcHandler<T, R>,
options: HandleOptions = {},
): void {
const { rethrow = true, fallback } = options;

ipcMain.handle(channel, async (event: IpcMainInvokeEvent, ...args: T) => {
try {
return await handler(event, ...args);
} catch (error) {
log.error(`Failed to handle ${channel}:`, error);
if (rethrow) {
throw error;
}
return fallback as R;
}
});
};
}
177 changes: 177 additions & 0 deletions apps/array/src/main/lib/shellManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import * as fs from "node:fs";
import * as os from "node:os";
import type { WebContents } from "electron";
import * as pty from "node-pty";
import { logger } from "./logger";

const log = logger.scope("shell");

export interface ShellSession {
pty: pty.IPty;
webContents: WebContents;
exitPromise: Promise<{ exitCode: number }>;
command?: string;
}

function getDefaultShell(): string {
const platform = os.platform();
if (platform === "win32") {
return process.env.COMSPEC || "cmd.exe";
}
return process.env.SHELL || "/bin/bash";
}

function buildShellEnv(): Record<string, string> {
const env = { ...process.env } as Record<string, string>;

if (os.platform() === "darwin" && !process.env.LC_ALL) {
const locale = process.env.LC_CTYPE || "en_US.UTF-8";
env.LANG = locale;
env.LC_ALL = locale;
env.LC_MESSAGES = locale;
env.LC_NUMERIC = locale;
env.LC_COLLATE = locale;
env.LC_MONETARY = locale;
}

env.TERM_PROGRAM = "Array";
env.COLORTERM = "truecolor";
env.FORCE_COLOR = "3";

return env;
}

export interface CreateSessionOptions {
sessionId: string;
webContents: WebContents;
cwd?: string;
initialCommand?: string;
}

class ShellManagerImpl {
private sessions = new Map<string, ShellSession>();

createSession(options: CreateSessionOptions): ShellSession {
const { sessionId, webContents, cwd, initialCommand } = options;

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI 3 days ago

To fix this issue, replace all uses of Math.random() for selecting random elements in the worktree name generation logic (WorktreeManager.randomElement) with a cryptographically secure alternative. In Node.js, this means using crypto.randomInt or deriving randomness from crypto.randomBytes.

Specifically, in packages/agent/src/worktree-manager.ts, update the randomElement selection function to utilize crypto.randomInt to generate a random index for the array, rather than using a value derived from Math.random(). This update applies wherever random unique names are generated for worktrees.

  1. Import the Node.js crypto module.
  2. Modify the randomElement method (not fully shown above, but implied from usage) to use crypto.randomInt to select the random array index.
  3. Ensure this applies to all places where pseudo-random selection of elements for worktree names is performed.

Only the file packages/agent/src/worktree-manager.ts requires modification, as the taint originates here.


Suggested changeset 1
packages/agent/src/worktree-manager.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/agent/src/worktree-manager.ts b/packages/agent/src/worktree-manager.ts
--- a/packages/agent/src/worktree-manager.ts
+++ b/packages/agent/src/worktree-manager.ts
@@ -2,6 +2,7 @@
 import * as fs from "node:fs/promises";
 import * as path from "node:path";
 import { promisify } from "node:util";
+import * as crypto from "node:crypto";
 import type { WorktreeInfo } from "./types.js";
 import { Logger } from "./utils/logger.js";
 
EOF
@@ -2,6 +2,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";
import * as crypto from "node:crypto";
import type { WorktreeInfo } from "./types.js";
import { Logger } from "./utils/logger.js";

Copilot is powered by AI and may make mistakes. Always verify output.

const existing = this.sessions.get(sessionId);
if (existing) {
return existing;
}

const shell = getDefaultShell();
const homeDir = os.homedir();
let workingDir = cwd || homeDir;

if (!fs.existsSync(workingDir)) {
log.warn(
`Shell session ${sessionId}: cwd "${workingDir}" does not exist, falling back to home`,
);
workingDir = homeDir;
}

log.info(
`Creating shell session ${sessionId}: shell=${shell}, cwd=${workingDir}`,
);

const env = buildShellEnv();
const ptyProcess = pty.spawn(shell, ["-l"], {
name: "xterm-256color",
cols: 80,
rows: 24,
cwd: workingDir,
env,
encoding: null,
});

let resolveExit: (result: { exitCode: number }) => void;
const exitPromise = new Promise<{ exitCode: number }>((resolve) => {
resolveExit = resolve;
});

ptyProcess.onData((data: string) => {
webContents.send(`shell:data:${sessionId}`, data);
});

ptyProcess.onExit(({ exitCode }) => {
log.info(`Shell session ${sessionId} exited with code ${exitCode}`);
webContents.send(`shell:exit:${sessionId}`, { exitCode });
this.sessions.delete(sessionId);
resolveExit({ exitCode });
});

if (initialCommand) {
setTimeout(() => {
ptyProcess.write(`${initialCommand}\n`);
}, 100);
}

const session: ShellSession = {
pty: ptyProcess,
webContents,
exitPromise,
command: initialCommand,
};

this.sessions.set(sessionId, session);
return session;
}

getSession(sessionId: string): ShellSession | undefined {
return this.sessions.get(sessionId);
}

hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId);
}

write(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Shell session ${sessionId} not found`);
}
session.pty.write(data);
}

resize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Shell session ${sessionId} not found`);
}
session.pty.resize(cols, rows);
}

destroy(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.pty.kill();
this.sessions.delete(sessionId);
}

getProcess(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.pty.process ?? null;
}

getSessionsByPrefix(prefix: string): string[] {
const result: string[] = [];
for (const sessionId of this.sessions.keys()) {
if (sessionId.startsWith(prefix)) {
result.push(sessionId);
}
}
return result;
}

destroyByPrefix(prefix: string): void {
for (const sessionId of this.sessions.keys()) {
if (sessionId.startsWith(prefix)) {
this.destroy(sessionId);
}
}
}
}

export const shellManager = new ShellManagerImpl();
Loading