mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
952e9e2974 | ||
|
|
887845418e | ||
|
|
f7c4ef774d | ||
|
|
08a0d0c28d | ||
|
|
61df450366 | ||
|
|
5d25ed6bf8 |
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
|
||||
|
||||
---
|
||||
|
||||
## Runtime PID Lock
|
||||
|
||||
**Path:** `$PASEO_HOME/paseo.pid`
|
||||
|
||||
The daemon writes this file when it starts and removes it when it exits cleanly.
|
||||
It is a runtime lock, not a durable settings file.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `pid` | `number` | Owner process PID for stop/status operations |
|
||||
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
|
||||
| `hostname` | `string` | Host that created the lock |
|
||||
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
|
||||
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
|
||||
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
|
||||
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
|
||||
|
||||
Desktop-managed is a read-time classification by the desktop app: the daemon
|
||||
executable must be the desktop app executable or live inside the same desktop
|
||||
install. The CLI reports the raw `executablePath` but does not try to classify
|
||||
it because an npm-installed CLI has no authoritative desktop install root.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
|
||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||
|
||||
@@ -178,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
Lifecycle commands run in the worktree through a stable script shell: `bash`
|
||||
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
|
||||
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
|
||||
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
|
||||
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
|
||||
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
|
||||
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
|
||||
they launch in a terminal and receive the service environment described below.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-roNSM3Vzqqb9pMuA1UKuYbrWK5XvRXYbEgNvJb1bl7U=
|
||||
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
|
||||
|
||||
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
|
||||
description: "ByteDance's official TRAE coding agent with native ACP support",
|
||||
version: "manual",
|
||||
iconId: "traecli",
|
||||
installLink: "https://docs.trae.cn/cli",
|
||||
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
|
||||
command: ["traecli", "acp", "serve"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
executablePath?: string;
|
||||
desktopManaged?: boolean;
|
||||
}
|
||||
|
||||
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
|
||||
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
|
||||
listen: resolveListenField(parsed.listen, parsed.sockPath),
|
||||
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Command } from "commander";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import { runStatusCommand } from "./status.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
|
||||
findExecutable: vi.fn(async () => null),
|
||||
getOrCreateServerId: vi.fn(() => "srv_test"),
|
||||
loadConfig: vi.fn(() => ({
|
||||
listen: "127.0.0.1:6767",
|
||||
relayEnabled: true,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: false,
|
||||
relayPublicUseTls: false,
|
||||
})),
|
||||
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
|
||||
spawnProcess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/server", () => ({
|
||||
execCommand: mocks.execCommand,
|
||||
findExecutable: mocks.findExecutable,
|
||||
getOrCreateServerId: mocks.getOrCreateServerId,
|
||||
loadConfig: mocks.loadConfig,
|
||||
resolvePaseoHome: mocks.resolvePaseoHome,
|
||||
spawnProcess: mocks.spawnProcess,
|
||||
}));
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
|
||||
tempRoots.push(root);
|
||||
const home = path.join(root, ".paseo");
|
||||
await mkdir(home, { recursive: true });
|
||||
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
|
||||
return home;
|
||||
}
|
||||
|
||||
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
|
||||
const previousPath = process.env.PATH;
|
||||
try {
|
||||
process.env.PATH = "";
|
||||
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
|
||||
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
|
||||
} finally {
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = previousPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("daemon status desktop management fields", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("reports executable identity without deriving desktop management in the CLI", async () => {
|
||||
const executablePath =
|
||||
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
|
||||
const home = await createStatusHome({
|
||||
pid: process.pid,
|
||||
startedAt: "2026-07-06T00:00:00.000Z",
|
||||
hostname: "dev-host",
|
||||
uid: 501,
|
||||
listen: "/tmp/paseo-status.sock",
|
||||
executablePath,
|
||||
});
|
||||
|
||||
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||
daemonExecutablePath: executablePath,
|
||||
desktopManaged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
|
||||
const home = await createStatusHome({
|
||||
pid: process.pid,
|
||||
startedAt: "2026-07-06T00:00:00.000Z",
|
||||
hostname: "dev-host",
|
||||
uid: 501,
|
||||
listen: "/tmp/paseo-status.sock",
|
||||
desktopManaged: true,
|
||||
});
|
||||
|
||||
await expect(readStatusJson(home)).resolves.toMatchObject({
|
||||
daemonExecutablePath: null,
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ interface DaemonStatus {
|
||||
owner: string | null;
|
||||
logPath: string;
|
||||
daemonNode: string;
|
||||
daemonExecutablePath: string | null;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
daemonVersion: string | null;
|
||||
@@ -126,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
{ key: "Owner", value: status.owner ?? "-" },
|
||||
{ key: "Logs", value: status.logPath },
|
||||
{ key: "Daemon Node", value: status.daemonNode },
|
||||
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
|
||||
{ key: "CLI Node", value: status.cliNode },
|
||||
{ key: "CLI", value: status.cliVersion },
|
||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||
@@ -405,9 +407,11 @@ export async function runStatusCommand(
|
||||
owner,
|
||||
logPath: state.logPath,
|
||||
daemonNode,
|
||||
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
|
||||
cliNode,
|
||||
cliVersion,
|
||||
daemonVersion,
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||
providers,
|
||||
note,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { createDaemonCommandHandlers } from "./daemon-manager";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
|
||||
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||
managedDaemonExecutablePath: "/opt/Paseo/resources/Paseo",
|
||||
settings: {
|
||||
releaseChannel: "stable",
|
||||
daemon: {
|
||||
@@ -23,7 +25,9 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp/paseo-user-data"),
|
||||
getPath: vi.fn((name: string) =>
|
||||
name === "exe" ? mocks.desktopExecutablePath : "/tmp/paseo-user-data",
|
||||
),
|
||||
getVersion: vi.fn(() => "1.2.3"),
|
||||
isPackaged: true,
|
||||
},
|
||||
@@ -91,6 +95,30 @@ function createMockChildProcess(): MockChildProcess {
|
||||
return child;
|
||||
}
|
||||
|
||||
function pidLockPath(): string {
|
||||
return `${mocks.paseoHome}/paseo.pid`;
|
||||
}
|
||||
|
||||
function writePidLock(input: {
|
||||
pid: number;
|
||||
executablePath?: string;
|
||||
desktopManaged?: boolean;
|
||||
}): void {
|
||||
mkdirSync(mocks.paseoHome, { recursive: true });
|
||||
writeFileSync(pidLockPath(), JSON.stringify(input));
|
||||
}
|
||||
|
||||
function writeManagedPidLock(pid: number): void {
|
||||
writePidLock({
|
||||
pid,
|
||||
executablePath: mocks.managedDaemonExecutablePath,
|
||||
});
|
||||
}
|
||||
|
||||
function removePidLock(): void {
|
||||
rmSync(pidLockPath(), { force: true });
|
||||
}
|
||||
|
||||
function scheduleFailedStartup(child: MockChildProcess): void {
|
||||
setImmediate(() => {
|
||||
child.emit("exit", 1, null);
|
||||
@@ -150,7 +178,54 @@ describe("daemon-manager commands", () => {
|
||||
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
|
||||
});
|
||||
|
||||
it("derives desktop management from the pid lock executable path", async () => {
|
||||
writeManagedPidLock(4242);
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 4242,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: false,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.desktop_daemon_status()).resolves.toEqual({
|
||||
serverId: "server-1",
|
||||
status: "running",
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: null,
|
||||
pid: 4242,
|
||||
home: mocks.paseoHome,
|
||||
version: "1.2.3",
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the legacy desktopManaged lock field for old locks", async () => {
|
||||
writePidLock({ pid: 4242, desktopManaged: true });
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-1",
|
||||
pid: 4242,
|
||||
listen: "127.0.0.1:6767",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: false,
|
||||
});
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
await expect(handlers.desktop_daemon_status()).resolves.toMatchObject({
|
||||
status: "running",
|
||||
pid: 4242,
|
||||
desktopManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
|
||||
writeManagedPidLock(4242);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
@@ -159,7 +234,10 @@ describe("daemon-manager commands", () => {
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
serverId: "",
|
||||
@@ -226,6 +304,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
|
||||
writeManagedPidLock(7675);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
@@ -236,7 +315,10 @@ describe("daemon-manager commands", () => {
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
@@ -269,6 +351,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("records the renderer stop reason when stopping the desktop daemon", async () => {
|
||||
writeManagedPidLock(4242);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
@@ -277,7 +360,10 @@ describe("daemon-manager commands", () => {
|
||||
listen: "127.0.0.1:6767",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped", reason: "lifecycle_shutdown_rpc" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
serverId: "",
|
||||
@@ -302,6 +388,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("uses a stale reachable desktop daemon when the version matches", async () => {
|
||||
writeManagedPidLock(7675);
|
||||
mocks.runExternalCliJsonCommand.mockResolvedValue({
|
||||
localDaemon: "stale_pid",
|
||||
connectedDaemon: "reachable",
|
||||
@@ -330,6 +417,7 @@ describe("daemon-manager commands", () => {
|
||||
});
|
||||
|
||||
it("restarts a stale reachable desktop daemon when the version differs", async () => {
|
||||
writeManagedPidLock(7675);
|
||||
mocks.runExternalCliJsonCommand
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stale_pid",
|
||||
@@ -350,21 +438,27 @@ describe("daemon-manager commands", () => {
|
||||
daemonVersion: "1.2.2",
|
||||
desktopManaged: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ action: "stopped" })
|
||||
.mockImplementationOnce(async () => {
|
||||
removePidLock();
|
||||
return { action: "stopped" };
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "stopped",
|
||||
connectedDaemon: "unreachable",
|
||||
serverId: "",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-2",
|
||||
pid: 8888,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
.mockImplementationOnce(async () => {
|
||||
writeManagedPidLock(8888);
|
||||
return {
|
||||
localDaemon: "running",
|
||||
connectedDaemon: "reachable",
|
||||
serverId: "server-2",
|
||||
pid: 8888,
|
||||
listen: "127.0.0.1:6767",
|
||||
hostname: "dev-host",
|
||||
daemonVersion: "1.2.3",
|
||||
desktopManaged: true,
|
||||
};
|
||||
});
|
||||
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
|
||||
const handlers = createDaemonCommandHandlers();
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import type { DesktopSettings } from "../settings/desktop-settings.js";
|
||||
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
|
||||
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
|
||||
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
@@ -123,18 +124,43 @@ function logFilePath(): string {
|
||||
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
||||
}
|
||||
|
||||
export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||
function deriveDesktopManagedFromPidLock(lock: Record<string, unknown>): boolean {
|
||||
const executablePath = toTrimmedString(lock.executablePath);
|
||||
if (executablePath) {
|
||||
return deriveDesktopManagedFromExecutablePath({
|
||||
daemonExecutablePath: executablePath,
|
||||
desktopExecutablePath: app.getPath("exe"),
|
||||
platform: process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
return lock.desktopManaged === true;
|
||||
}
|
||||
|
||||
function readPidLockRecord(): Record<string, unknown> | null {
|
||||
try {
|
||||
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
|
||||
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
|
||||
if (lock.desktopManaged !== true) return false;
|
||||
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
||||
return isProcessRunning(lock.pid);
|
||||
const lock = JSON.parse(raw) as unknown;
|
||||
return isRecord(lock) ? lock : null;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopManagedFromPidLock(): boolean {
|
||||
const lock = readPidLockRecord();
|
||||
return lock !== null && deriveDesktopManagedFromPidLock(lock);
|
||||
}
|
||||
|
||||
export function isDesktopManagedDaemonRunningSync(): boolean {
|
||||
const lock = readPidLockRecord();
|
||||
if (lock === null) return false;
|
||||
if (!deriveDesktopManagedFromPidLock(lock)) return false;
|
||||
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
|
||||
return isProcessRunning(lock.pid);
|
||||
}
|
||||
|
||||
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
|
||||
return {
|
||||
status: status.status,
|
||||
@@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
|
||||
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
|
||||
const hasRunningLocalProcess = localDaemon === "running";
|
||||
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
|
||||
const desktopManaged = payload.desktopManaged === true;
|
||||
const desktopManaged = readDesktopManagedFromPidLock();
|
||||
const apiReachable = connectedDaemon === "reachable";
|
||||
let status: DesktopDaemonState = "stopped";
|
||||
if (apiReachable || hasRunningLocalProcess) {
|
||||
@@ -418,7 +444,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
detached: true,
|
||||
envMode: "internal",
|
||||
env: invocation.env,
|
||||
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
|
||||
envOverlay: { PASEO_WEB_UI_ENABLED: "false" },
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed";
|
||||
|
||||
describe("desktop managed daemon executable derivation", () => {
|
||||
it("treats the macOS Helper inside the app bundle as desktop managed", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
daemonExecutablePath:
|
||||
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
|
||||
platform: "darwin",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a macOS Helper from a different app bundle", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
daemonExecutablePath:
|
||||
"/Applications/Other.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
|
||||
platform: "darwin",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats executables inside the Windows install directory as desktop managed", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||
daemonExecutablePath:
|
||||
"c:\\users\\me\\appdata\\local\\programs\\paseo\\resources\\Paseo.exe",
|
||||
platform: "win32",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects system Node on Windows", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||
daemonExecutablePath: "C:\\Program Files\\nodejs\\node.exe",
|
||||
platform: "win32",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats Linux executables from the same install directory as desktop managed", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||
daemonExecutablePath: "/opt/Paseo/resources/Paseo",
|
||||
platform: "linux",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects npm-installed CLI daemon executables", () => {
|
||||
expect(
|
||||
deriveDesktopManagedFromExecutablePath({
|
||||
desktopExecutablePath: "/opt/Paseo/Paseo",
|
||||
daemonExecutablePath: "/usr/local/bin/node",
|
||||
platform: "linux",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import path from "node:path";
|
||||
|
||||
interface DesktopManagedInput {
|
||||
daemonExecutablePath: string | null | undefined;
|
||||
desktopExecutablePath: string;
|
||||
platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.win32 | typeof path.posix {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
function normalizeForComparison(filePath: string, platform: NodeJS.Platform): string {
|
||||
const pathModule = pathModuleForPlatform(platform);
|
||||
const normalized = pathModule.normalize(filePath.trim());
|
||||
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function resolveMacAppRoot(filePath: string): string | null {
|
||||
const marker = ".app/";
|
||||
const markerIndex = filePath.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return filePath.endsWith(".app") ? filePath : null;
|
||||
}
|
||||
return filePath.slice(0, markerIndex + ".app".length);
|
||||
}
|
||||
|
||||
function resolveDesktopInstallRoot(input: {
|
||||
desktopExecutablePath: string;
|
||||
platform: NodeJS.Platform;
|
||||
}): string {
|
||||
const normalized = normalizeForComparison(input.desktopExecutablePath, input.platform);
|
||||
if (input.platform === "darwin") {
|
||||
return resolveMacAppRoot(normalized) ?? path.posix.dirname(normalized);
|
||||
}
|
||||
|
||||
return pathModuleForPlatform(input.platform).dirname(normalized);
|
||||
}
|
||||
|
||||
function isSamePathOrInside(input: {
|
||||
candidatePath: string;
|
||||
parentPath: string;
|
||||
platform: NodeJS.Platform;
|
||||
}): boolean {
|
||||
const pathModule = pathModuleForPlatform(input.platform);
|
||||
const candidatePath = normalizeForComparison(input.candidatePath, input.platform);
|
||||
const parentPath = normalizeForComparison(input.parentPath, input.platform);
|
||||
const relative = pathModule.relative(parentPath, candidatePath);
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative.length > 0 && !relative.startsWith("..") && !pathModule.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveDesktopManagedFromExecutablePath(input: DesktopManagedInput): boolean {
|
||||
if (!input.daemonExecutablePath?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desktopInstallRoot = resolveDesktopInstallRoot({
|
||||
desktopExecutablePath: input.desktopExecutablePath,
|
||||
platform: input.platform,
|
||||
});
|
||||
return isSamePathOrInside({
|
||||
candidatePath: input.daemonExecutablePath,
|
||||
parentPath: desktopInstallRoot,
|
||||
platform: input.platform,
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const mockState = vi.hoisted(() => {
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}>,
|
||||
trae: [] as Array<{
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}>,
|
||||
pi: [] as ConstructorEntry[],
|
||||
genericAcp: [] as Array<{
|
||||
command: string[];
|
||||
@@ -41,6 +46,7 @@ const mockState = vi.hoisted(() => {
|
||||
this.constructorArgs.codex = [];
|
||||
this.constructorArgs.copilot = [];
|
||||
this.constructorArgs.cursor = [];
|
||||
this.constructorArgs.trae = [];
|
||||
this.constructorArgs.pi = [];
|
||||
this.constructorArgs.genericAcp = [];
|
||||
this.isCommandAvailable.mockReset();
|
||||
@@ -375,6 +381,59 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/trae-acp-agent.js", () => ({
|
||||
TraeACPAgentClient: class TraeACPAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
readonly provider = "acp";
|
||||
readonly runtimeSettings?: unknown;
|
||||
|
||||
constructor(options: {
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
providerParams?: unknown;
|
||||
}) {
|
||||
this.runtimeSettings = {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: options.command,
|
||||
},
|
||||
env: options.env,
|
||||
};
|
||||
mockState.constructorArgs.trae.push({
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerParams: options.providerParams,
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<never> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
async fetchCatalog(): Promise<ProviderCatalog> {
|
||||
return {
|
||||
models: mockState.runtimeModels.get(this.provider) ?? [],
|
||||
modes: [],
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
buildProviderRegistry,
|
||||
@@ -638,6 +697,33 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test("traecli provider extending acp uses TraeACPAgentClient", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
traecli: {
|
||||
extends: "acp",
|
||||
label: "TRAE CLI",
|
||||
command: ["traecli", "acp", "serve"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.traecli.createClient(logger).provider).toBe("traecli");
|
||||
expect(mockState.constructorArgs.trae).toEqual([
|
||||
{
|
||||
command: ["traecli", "acp", "serve"],
|
||||
env: undefined,
|
||||
providerParams: undefined,
|
||||
},
|
||||
{
|
||||
command: ["traecli", "acp", "serve"],
|
||||
env: undefined,
|
||||
providerParams: undefined,
|
||||
},
|
||||
]);
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test('extends: "acp" without command throws', () => {
|
||||
expect(() =>
|
||||
buildProviderRegistry(logger, {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
||||
import {
|
||||
@@ -660,6 +661,9 @@ function addDerivedProviders(
|
||||
if (providerId === "kiro") {
|
||||
return new KiroACPAgentClient(acpOptions);
|
||||
}
|
||||
if (providerId === "traecli") {
|
||||
return new TraeACPAgentClient(acpOptions);
|
||||
}
|
||||
return new GenericACPAgentClient(acpOptions);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ import { transformPiModels } from "./pi/agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
import * as spawnUtils from "../../../utils/spawn.js";
|
||||
|
||||
@@ -540,7 +541,10 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
const shell = spawnUtils.platformShell();
|
||||
const shell = buildStringCommandShellInvocation({
|
||||
command: "git -C /repo status --short",
|
||||
windowsShell: "cmd",
|
||||
});
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
@@ -549,12 +553,50 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
shell.command,
|
||||
[...shell.flag, "git -C /repo status --short"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
shell.shell,
|
||||
shell.args,
|
||||
expect.objectContaining({
|
||||
cwd: "/repo",
|
||||
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves cmd semantics for single-string terminal commands on Windows", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
try {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
|
||||
await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: "echo %TEMP% && echo ok",
|
||||
cwd: "C:\\repo",
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
"cmd.exe",
|
||||
["/c", "echo %TEMP% && echo ok"],
|
||||
expect.objectContaining({
|
||||
cwd: "C:\\repo",
|
||||
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves explicit terminal argv", async () => {
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
|
||||
@@ -102,7 +102,11 @@ import {
|
||||
} from "../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
|
||||
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
} from "../../../utils/string-command-shell.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import {
|
||||
type DiagnosticEntry,
|
||||
toDiagnosticErrorMessage,
|
||||
@@ -181,7 +185,7 @@ function toACPRequestError(error: unknown): Error {
|
||||
function resolveTerminalCommand(
|
||||
command: string,
|
||||
args?: string[],
|
||||
): { command: string; args: string[] } {
|
||||
): { command: string; args: string[]; shell?: boolean } {
|
||||
if (args && args.length > 0) {
|
||||
return { command, args };
|
||||
}
|
||||
@@ -190,8 +194,8 @@ function resolveTerminalCommand(
|
||||
return { command, args: [] };
|
||||
}
|
||||
|
||||
const shell = platformShell();
|
||||
return { command: shell.command, args: [...shell.flag, command] };
|
||||
const shell = buildStringCommandShellInvocation({ command, windowsShell: "cmd" });
|
||||
return { command: shell.shell, args: shell.args, shell: false };
|
||||
}
|
||||
|
||||
function formatDurationMs(startedAt: number): string {
|
||||
@@ -2159,12 +2163,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
|
||||
);
|
||||
const terminalCommand = resolveTerminalCommand(params.command, params.args);
|
||||
const commandEnvOverlays =
|
||||
terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env];
|
||||
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
|
||||
cwd: params.cwd ?? this.config.cwd,
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [env],
|
||||
overlays: commandEnvOverlays,
|
||||
}),
|
||||
shell: terminalCommand.shell,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
interface TraeACPAgentClientOptions {
|
||||
logger: Logger;
|
||||
command: [string, ...string[]];
|
||||
env?: Record<string, string>;
|
||||
providerId?: string;
|
||||
label?: string;
|
||||
providerParams?: unknown;
|
||||
}
|
||||
|
||||
const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class TraeACPAgentClient extends GenericACPAgentClient {
|
||||
constructor(options: TraeACPAgentClientOptions) {
|
||||
super({
|
||||
logger: options.logger,
|
||||
command: options.command,
|
||||
env: options.env,
|
||||
providerId: options.providerId,
|
||||
label: options.label,
|
||||
providerParams: options.providerParams,
|
||||
// traecli publishes slash commands and skills asynchronously via available_commands_update.
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,11 @@ import type {
|
||||
AgentTimelineItem,
|
||||
AgentProvider,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { execCommand, platformShell } from "../utils/spawn.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnvOverlay,
|
||||
} from "../utils/string-command-shell.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import type {
|
||||
ProviderSnapshotManager,
|
||||
ResolvedProviderCreateConfig,
|
||||
@@ -266,10 +270,15 @@ async function runVerifyCheck(options: {
|
||||
}): Promise<LoopVerifyCheckResult> {
|
||||
const startedAt = nowIso();
|
||||
try {
|
||||
const shell = platformShell();
|
||||
const result = await execCommand(shell.command, [...shell.flag, options.command], {
|
||||
const shell = buildStringCommandShellInvocation({
|
||||
command: options.command,
|
||||
windowsShell: "cmd",
|
||||
});
|
||||
const result = await execCommand(shell.shell, shell.args, {
|
||||
cwd: options.cwd,
|
||||
envOverlay: createStringCommandShellEnvOverlay(),
|
||||
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
|
||||
shell: false,
|
||||
});
|
||||
return {
|
||||
command: options.command,
|
||||
|
||||
@@ -6,6 +6,29 @@ import { describe, expect, test } from "vitest";
|
||||
import { acquirePidLock, getPidLockInfo, releasePidLock, updatePidLock } from "./pid-lock.js";
|
||||
|
||||
describe("pid-lock ownership", () => {
|
||||
test("records executable identity without writing desktop management intent", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-exec-"));
|
||||
const previousDesktopManaged = process.env.PASEO_DESKTOP_MANAGED;
|
||||
|
||||
try {
|
||||
process.env.PASEO_DESKTOP_MANAGED = "1";
|
||||
|
||||
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
|
||||
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.executablePath).toBe(process.execPath);
|
||||
expect(lock?.desktopManaged).toBeUndefined();
|
||||
} finally {
|
||||
if (previousDesktopManaged === undefined) {
|
||||
delete process.env.PASEO_DESKTOP_MANAGED;
|
||||
} else {
|
||||
process.env.PASEO_DESKTOP_MANAGED = previousDesktopManaged;
|
||||
}
|
||||
await releasePidLock(paseoHome, { ownerPid: process.pid });
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writes and releases lock for explicit owner pid", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-"));
|
||||
const ownerPid = process.pid + 10_000;
|
||||
@@ -22,6 +45,7 @@ describe("pid-lock ownership", () => {
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.pid).toBe(ownerPid);
|
||||
expect(lock?.listen).toBeNull();
|
||||
expect(lock?.executablePath).toBe(process.execPath);
|
||||
|
||||
await (
|
||||
updatePidLock as unknown as (
|
||||
|
||||
@@ -10,6 +10,8 @@ export const pidLockInfoSchema = z.object({
|
||||
hostname: z.string(),
|
||||
uid: z.number(),
|
||||
listen: z.string().nullable(),
|
||||
executablePath: z.string().optional(),
|
||||
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
|
||||
desktopManaged: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -99,7 +101,7 @@ export async function acquirePidLock(
|
||||
hostname: hostname(),
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen,
|
||||
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
|
||||
executablePath: process.execPath,
|
||||
};
|
||||
|
||||
let fd;
|
||||
|
||||
@@ -113,19 +113,3 @@ export async function execCommand(
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
export function platformShell(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/sh", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
export function platformBash(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/bash", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
@@ -1,21 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
} from "./string-command-shell.js";
|
||||
|
||||
function hasBashOnPath(): boolean {
|
||||
const result = spawnSync("bash", ["-c", "true"], { stdio: "ignore" });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
describe("buildStringCommandShellInvocation", () => {
|
||||
it("uses bash login-command semantics on unix platforms", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it("uses bash script semantics on unix platforms", () => {
|
||||
expect(
|
||||
buildStringCommandShellInvocation({
|
||||
command: 'echo "hello"',
|
||||
platform: "darwin",
|
||||
}),
|
||||
).toEqual({
|
||||
shell: "/bin/bash",
|
||||
args: ["-lc", 'echo "hello"'],
|
||||
shell: "bash",
|
||||
args: ["-c", 'echo "hello"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses powershell command semantics on windows", () => {
|
||||
it.skipIf(process.platform === "win32" || !hasBashOnPath())(
|
||||
"preserves the supplied PATH when login profiles rewrite it",
|
||||
() => {
|
||||
const home = mkdtempSync(join(tmpdir(), "paseo-shell-home-"));
|
||||
tempDirs.push(home);
|
||||
const binDir = join(home, "bin");
|
||||
mkdirSync(binDir);
|
||||
|
||||
const shimPath = join(binDir, "paseo-shim");
|
||||
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
|
||||
chmodSync(shimPath, 0o755);
|
||||
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
|
||||
const bashEnvPath = join(home, "bash-env");
|
||||
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
|
||||
|
||||
const invocation = buildStringCommandShellInvocation({
|
||||
command: "command -v paseo-shim >/dev/null && paseo-shim ok",
|
||||
});
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
PATH: `${binDir}${delimiter}${process.env.PATH ?? "/usr/bin:/bin"}`,
|
||||
BASH_ENV: bashEnvPath,
|
||||
};
|
||||
|
||||
const stdout = execFileSync(invocation.shell, invocation.args, {
|
||||
encoding: "utf8",
|
||||
env: createStringCommandShellEnv(env),
|
||||
});
|
||||
|
||||
expect(stdout.trim()).toBe("shim:ok");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses powershell command semantics on windows by default", () => {
|
||||
expect(
|
||||
buildStringCommandShellInvocation({
|
||||
command: "Write-Output 'hello'",
|
||||
@@ -33,4 +88,17 @@ describe("buildStringCommandShellInvocation", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("can preserve cmd command semantics on windows", () => {
|
||||
expect(
|
||||
buildStringCommandShellInvocation({
|
||||
command: "echo %TEMP% && echo ok",
|
||||
platform: "win32",
|
||||
windowsShell: "cmd",
|
||||
}),
|
||||
).toEqual({
|
||||
shell: "cmd.exe",
|
||||
args: ["/c", "echo %TEMP% && echo ok"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface BuildStringCommandShellInvocationOptions {
|
||||
command: string;
|
||||
platform?: NodeJS.Platform;
|
||||
windowsShell?: "powershell" | "cmd";
|
||||
}
|
||||
|
||||
export interface StringCommandShellInvocation {
|
||||
@@ -8,12 +9,31 @@ export interface StringCommandShellInvocation {
|
||||
args: string[];
|
||||
}
|
||||
|
||||
export function createStringCommandShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const sanitized = { ...env };
|
||||
delete sanitized.BASH_ENV;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function createStringCommandShellEnvOverlay(): Record<string, string | undefined> {
|
||||
return { BASH_ENV: undefined };
|
||||
}
|
||||
|
||||
export function buildStringCommandShellInvocation(
|
||||
options: BuildStringCommandShellInvocationOptions,
|
||||
): StringCommandShellInvocation {
|
||||
const platform = options.platform ?? process.platform;
|
||||
|
||||
// Project-authored command strings use a stable script shell. The caller supplies
|
||||
// the environment; shell startup files should not rewrite it behind our back.
|
||||
if (platform === "win32") {
|
||||
if (options.windowsShell === "cmd") {
|
||||
return {
|
||||
shell: "cmd.exe",
|
||||
args: ["/c", options.command],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shell: "powershell",
|
||||
args: [
|
||||
@@ -28,7 +48,7 @@ export function buildStringCommandShellInvocation(
|
||||
}
|
||||
|
||||
return {
|
||||
shell: "/bin/bash",
|
||||
args: ["-lc", options.command],
|
||||
shell: "bash",
|
||||
args: ["-c", options.command],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const execFileMock = vi.hoisted(() => vi.fn());
|
||||
const spawnProcessMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("child_process")>("child_process");
|
||||
@@ -13,11 +16,34 @@ vi.mock("child_process", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./spawn.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./spawn.js")>("./spawn.js");
|
||||
return {
|
||||
...actual,
|
||||
spawnProcess: spawnProcessMock,
|
||||
};
|
||||
});
|
||||
|
||||
function emitSuccessfulClose(child: ChildProcess): void {
|
||||
child.emit("close", 0);
|
||||
}
|
||||
|
||||
function createSpawnChildStub(): ChildProcess {
|
||||
const child = new EventEmitter() as ChildProcess;
|
||||
Object.assign(child, {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
});
|
||||
queueMicrotask(() => emitSuccessfulClose(child));
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("worktree shell selection", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
spawnProcessMock.mockReset();
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
@@ -29,6 +55,7 @@ describe("worktree shell selection", () => {
|
||||
return {};
|
||||
},
|
||||
);
|
||||
spawnProcessMock.mockImplementation(createSpawnChildStub);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -46,6 +73,8 @@ describe("worktree shell selection", () => {
|
||||
});
|
||||
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.BASH_ENV = "should-not-leak";
|
||||
try {
|
||||
mkdirSync(join(worktreePath, ".git"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -79,7 +108,74 @@ describe("worktree shell selection", () => {
|
||||
expect.objectContaining({ cwd: worktreePath }),
|
||||
expect.any(Function),
|
||||
);
|
||||
const execOptions = execFileMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
|
||||
expect(execOptions.env?.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("routes streamed setup command execution through powershell on win32", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.BASH_ENV = "should-not-leak";
|
||||
try {
|
||||
writeFileSync(
|
||||
join(worktreePath, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["Write-Output 'setup'"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { runWorktreeSetupCommands } = await import("./worktree.js");
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
|
||||
PASEO_ROOT_PATH: worktreePath,
|
||||
PASEO_WORKTREE_PATH: worktreePath,
|
||||
PASEO_BRANCH_NAME: "main",
|
||||
PASEO_WORKTREE_PORT: "12345",
|
||||
},
|
||||
onEvent: () => {},
|
||||
});
|
||||
|
||||
expect(spawnProcessMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnProcessMock).toHaveBeenCalledWith(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Write-Output 'setup'",
|
||||
],
|
||||
expect.objectContaining({ cwd: worktreePath, shell: false }),
|
||||
);
|
||||
const spawnOptions = spawnProcessMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
|
||||
expect(spawnOptions.env?.BASH_ENV).toBeUndefined();
|
||||
} finally {
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,8 +33,9 @@ import {
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
chmodSync,
|
||||
} from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { delimiter, dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import net from "node:net";
|
||||
|
||||
@@ -637,6 +638,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs setup commands with the daemon PATH instead of login profile PATH", async () => {
|
||||
const home = join(tempDir, "host-home");
|
||||
const binDir = join(tempDir, "daemon-bin");
|
||||
mkdirSync(home);
|
||||
mkdirSync(binDir);
|
||||
|
||||
const shimPath = join(binDir, "paseo-shim");
|
||||
writeFileSync(shimPath, "#!/bin/sh\nprintf 'shim:%s\\n' \"$1\"\n");
|
||||
chmodSync(shimPath, 0o755);
|
||||
writeFileSync(join(home, ".bash_profile"), "export PATH=/usr/bin:/bin\n");
|
||||
const bashEnvPath = join(home, "bash-env");
|
||||
writeFileSync(bashEnvPath, "export PATH=/usr/bin:/bin\n");
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: "command -v paseo-shim >/dev/null && paseo-shim ok > setup-path.log",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalBashEnv = process.env.BASH_ENV;
|
||||
process.env.HOME = home;
|
||||
process.env.PATH = `${binDir}${delimiter}${originalPath ?? "/usr/bin:/bin"}`;
|
||||
process.env.BASH_ENV = bashEnvPath;
|
||||
|
||||
try {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath: repoDir,
|
||||
branchName: "main",
|
||||
cleanupOnFailure: false,
|
||||
runtimeEnv: {
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoDir,
|
||||
PASEO_ROOT_PATH: repoDir,
|
||||
PASEO_WORKTREE_PATH: repoDir,
|
||||
PASEO_BRANCH_NAME: "main",
|
||||
PASEO_WORKTREE_PORT: "12345",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
if (originalBashEnv === undefined) {
|
||||
delete process.env.BASH_ENV;
|
||||
} else {
|
||||
process.env.BASH_ENV = originalBashEnv;
|
||||
}
|
||||
}
|
||||
|
||||
expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok");
|
||||
});
|
||||
|
||||
it("treats blank lifecycle strings as empty", () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
|
||||
@@ -6,7 +6,10 @@ import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
} from "./string-command-shell.js";
|
||||
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
@@ -489,6 +492,7 @@ async function execSetupCommandStreamed(options: {
|
||||
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -607,7 +611,7 @@ export async function runWorktreeSetupCommands(options: {
|
||||
branchName: options.branchName,
|
||||
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
|
||||
}));
|
||||
const setupEnv = createExternalProcessEnv(process.env, runtimeEnv);
|
||||
const setupEnv = createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv));
|
||||
|
||||
const results: WorktreeSetupCommandResult[] = [];
|
||||
for (const [index, cmd] of setupCommands.entries()) {
|
||||
@@ -715,17 +719,19 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
|
||||
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
|
||||
|
||||
const teardownEnv: NodeJS.ProcessEnv = createExternalProcessEnv(process.env, {
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// from the source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
});
|
||||
const teardownEnv: NodeJS.ProcessEnv = createStringCommandShellEnv(
|
||||
createExternalProcessEnv(process.env, {
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
|
||||
// from the source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const results: WorktreeTeardownCommandResult[] = [];
|
||||
for (const cmd of teardownCommands) {
|
||||
|
||||
@@ -56,7 +56,7 @@ Pick any of these from the in-app provider catalog. Each entry is a one-click in
|
||||
- [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/users/overview), Alibaba's Qwen coding assistant.
|
||||
- [siGit Code](https://github.com/getsigit/sigit), local-first coding agent with optional on-device LLM.
|
||||
- [Stakpak](https://stakpak.dev/), Rust-based DevOps agent.
|
||||
- [TRAE CLI](https://docs.trae.cn/cli), ByteDance's official TRAE coding agent.
|
||||
- [TRAE CLI](https://docs.trae.cn/cli_get-started-with-trae-cli), ByteDance's official TRAE coding agent.
|
||||
- [VT Code](https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md), open-source multi-provider coding agent.
|
||||
|
||||
The in-app catalog is the canonical, version-pinned source. Anything not listed here can still be added manually, see [Custom providers](/docs/custom-providers).
|
||||
|
||||
Reference in New Issue
Block a user