Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 952e9e2974 fix(cli): clarify daemon executable status label 2026-07-06 16:40:40 +02:00
Mohamed Boudra 887845418e refactor(desktop): share daemon pid lock reader 2026-07-06 16:38:50 +02:00
Mohamed Boudra f7c4ef774d fix(daemon): derive desktop management from executable
Desktop management now follows the daemon runtime path, so daemons started through the bundled CLI remain eligible for desktop upgrade restarts. New locks record executablePath; the old desktopManaged lock bit is accepted only for compatibility with existing locks.
2026-07-06 16:19:00 +02:00
Mohamed Boudra 08a0d0c28d Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts

Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.

* fix(worktree): ignore Bash startup env hook

Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.

* fix(worktree): resolve Bash through PATH

* fix(worktree): preserve Windows cmd command strings

* test(acp): expect Windows cmd terminal strings
2026-07-06 20:34:42 +08:00
Xisheng Parker Zhao 61df450366 fix(server): surface TRAE CLI slash commands and skills over ACP (#1896)
traecli publishes its slash commands and skills asynchronously via the standard
ACP available_commands_update notification ~400ms after session/new resolves.
Because the catalog entry runs through the plain GenericACPAgentClient
(waitForInitialCommands defaults to false), listCommands() resolves before that
first batch arrives and the Paseo UI shows an empty slash menu — intermittently,
depending on whether the menu is opened inside the ~400ms race window.

Add a thin TraeACPAgentClient that sets waitForInitialCommands: true (10s
timeout) and wire it into the extends:"acp" derived-provider branch, mirroring
CursorACPAgentClient exactly. Cursor has the identical async-commands behavior;
this reuses that adopted pattern rather than introducing anything provider-
specific. Unlike Kiro (#1792), traecli uses the standard available_commands_update
update type, so no extensionCommandsParser is needed.
2026-07-06 20:30:22 +08:00
paseo-ai[bot] 5d25ed6bf8 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-06 08:03:12 +00:00
26 changed files with 911 additions and 76 deletions
+24
View File
@@ -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`
+9
View File
@@ -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
View File
@@ -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();
+34 -8
View File
@@ -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,
});
}
}
+12 -3
View File
@@ -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 (
+3 -1
View File
@@ -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;
-16
View File
@@ -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"),
+19 -13
View File
@@ -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) {
+1 -1
View File
@@ -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).