Compare commits

...

13 Commits

Author SHA1 Message Date
Mohamed Boudra
fd894dc3d7 chore(release): cut 0.1.44 2026-04-03 16:55:14 +07:00
Mohamed Boudra
9ea181a072 docs: add 0.1.44 changelog entry 2026-04-03 16:55:05 +07:00
Mohamed Boudra
a96f2d7652 fix(desktop): stop daemon before auto-update restart
The daemon is a detached process that survives Electron restarts,
so after an auto-update the old daemon version would keep running.
Now we stop it before quitAndInstall so the new app instance
starts a fresh daemon with the updated binary.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
44da0c67b2 fix(server): disable claude-acp and copilot providers from registry
These providers cause old mobile clients (<=0.1.40) to fail parsing
the list_available_providers_response because their AgentProviderSchema
enum rejects unknown provider IDs, dropping the entire message and
leaving the model picker empty.

The provider implementations remain in the codebase — only the manifest
entries and factory registrations are removed until the updated mobile
app (0.1.43) is live in the App Store.
2026-04-03 16:53:14 +07:00
Mohamed Boudra
55c4e58aa3 fix(desktop): disable npmRebuild in electron-builder 2026-04-03 16:53:14 +07:00
Mohamed Boudra
a2b1498c3f fix(app): broaden keyboard focus scope resolution to check multiple candidates
Check target, parentElement, and document.activeElement as focus
candidates instead of relying solely on the direct event target.
Fixes scope misdetection when the keyboard event target is a text
node or non-element.
2026-04-03 16:53:14 +07:00
github-actions[bot]
df617a4c8f fix: update lockfile signatures and Nix hash 2026-04-03 07:39:11 +00:00
Mohamed Boudra
a64292f2b0 fix: use cross-env for cross-platform NODE_ENV in server scripts 2026-04-03 14:38:07 +07:00
Mohamed Boudra
7ff5933b08 chore: update og-image.png 2026-04-03 11:16:57 +07:00
Mohamed Boudra
bb9ef76017 Fix OpenCode interrupt tool-call terminal state parity 2026-04-03 11:15:02 +07:00
Mohamed Boudra
d6413404e0 ci(desktop): add checkout step before running release tag script 2026-04-03 09:46:57 +07:00
Mohamed Boudra
8a585e60f2 fix(security): shell injection, symlink escape, remove /pairing endpoint, harden defaults
- Replace all execAsync shell-interpolated git calls with execFileAsync + array args in session.ts
- Add symlink resolution in file-explorer resolveScopedPath to prevent workspace escape
- Remove /pairing HTTP endpoint from server; desktop now uses `paseo daemon pair --json` via CLI
- Disable MCP HTTP endpoint by default (opt-in via config)
- Correct SECURITY.md: fix cipher name (XSalsa20-Poly1305), accurate replay resistance claims, add local daemon trust boundary docs
2026-04-02 23:58:38 +07:00
github-actions[bot]
1d795f6c32 fix: update lockfile signatures and Nix hash 2026-04-02 16:14:40 +00:00
30 changed files with 874 additions and 192 deletions

View File

@@ -130,6 +130,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"

View File

@@ -1,5 +1,14 @@
# Changelog
## 0.1.44 - 2026-04-03
### Fixed
- Desktop app now stops the daemon cleanly before auto-update restarts.
- Disabled claude-acp and copilot providers from the agent registry.
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
- Shell injection, symlink escape, and pairing endpoint security hardening.
## 0.1.43 - 2026-04-02
### Added

View File

@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
1. The daemon generates a persistent ECDH keypair and stores it locally
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
- **Send commands** — Without your phone's private key, it cannot complete the handshake
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
- **Replay old messages** — Each session derives fresh encryption keys
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
### Trust model
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
## DNS rebinding protection
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-0fzdnz2LQ0IRk2wbe0/wORylp7mgU0gl2fAs8my4Eok=";
npmDepsHash = "sha256-v8ArSIil8F9dalo+Z+IKcYvGVVgDlLMsSJemI7HT14Q=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

64
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.43",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.43",
"version": "0.1.44",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -3519,6 +3519,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -15434,6 +15441,24 @@
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
@@ -34971,16 +34996,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.43",
"version": "0.1.44",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.43",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/expo-two-way-audio": "0.1.44",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/server": "0.1.44",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35097,11 +35122,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.43",
"version": "0.1.44",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/relay": "0.1.44",
"@getpaseo/server": "0.1.44",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35142,11 +35167,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.43",
"version": "0.1.44",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/cli": "0.1.44",
"@getpaseo/server": "0.1.44",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35180,7 +35205,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.43",
"version": "0.1.44",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35381,7 +35406,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.43",
"version": "0.1.44",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35407,7 +35432,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.43",
"version": "0.1.44",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35423,14 +35448,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.43",
"version": "0.1.44",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/relay": "0.1.43",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/relay": "0.1.44",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35468,6 +35493,7 @@
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"playwright": "^1.56.1",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
@@ -35828,7 +35854,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.43",
"version": "0.1.44",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.43",
"version": "0.1.44",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.43",
"version": "0.1.44",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.43",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/expo-two-way-audio": "0.1.44",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/server": "0.1.44",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveKeyboardFocusScope } from "./focus-scope";
class FakeNode {
parentElement: FakeElement | null = null;
}
class FakeElement extends FakeNode {
tagName: string;
isContentEditable = false;
private selectors: Set<string>;
constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) {
super();
this.tagName = (input?.tagName ?? "div").toUpperCase();
this.selectors = new Set(input?.selectors ?? []);
if (input?.isContentEditable) {
this.isContentEditable = true;
}
}
closest(selector: string): FakeElement | null {
if (this.selectors.has(selector)) {
return this;
}
return this.parentElement?.closest(selector) ?? null;
}
}
describe("resolveKeyboardFocusScope", () => {
const globalRef = globalThis as {
Element?: unknown;
Node?: unknown;
document?: { activeElement?: unknown };
};
const originalElement = globalRef.Element;
const originalNode = globalRef.Node;
const originalDocument = globalRef.document;
beforeEach(() => {
globalRef.Element = FakeElement;
globalRef.Node = FakeNode;
globalRef.document = { activeElement: null };
});
afterEach(() => {
globalRef.Element = originalElement;
globalRef.Node = originalNode;
globalRef.document = originalDocument;
});
it("resolves terminal scope from the direct keyboard event target", () => {
const target = new FakeElement({ selectors: [".xterm"] });
const scope = resolveKeyboardFocusScope({
target: target as unknown as EventTarget,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("falls back to activeElement when target is not an Element", () => {
const activeElement = new FakeElement({ selectors: [".xterm"] });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("terminal");
});
it("detects editable scope from activeElement fallback", () => {
const activeElement = new FakeElement({ tagName: "input" });
globalRef.document = { activeElement };
const scope = resolveKeyboardFocusScope({
target: null,
commandCenterOpen: false,
});
expect(scope).toBe("editable");
});
});

View File

@@ -1,37 +1,77 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
function isElement(value: unknown): value is Element {
return typeof Element !== "undefined" && value instanceof Element;
}
function getFocusCandidateElements(target: EventTarget | null): Element[] {
const candidates: Element[] = [];
const pushUnique = (element: Element | null) => {
if (!element || candidates.includes(element)) {
return;
}
candidates.push(element);
};
if (isElement(target)) {
pushUnique(target);
}
if (typeof Node !== "undefined" && target instanceof Node) {
pushUnique(isElement(target.parentElement) ? target.parentElement : null);
}
if (typeof document !== "undefined" && isElement(document.activeElement)) {
pushUnique(document.activeElement);
}
return candidates;
}
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
const candidates = getFocusCandidateElements(target);
if (candidates.length === 0) {
return commandCenterOpen ? "command-center" : "other";
}
if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) {
if (
candidates.some((element) =>
Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")),
)
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
candidates.some((element) =>
Boolean(
element.closest("[data-testid='command-center-panel']") ||
element.closest("[data-testid='command-center-input']"),
),
)
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
if (
candidates.some((element) => {
const editable = element as HTMLElement;
if (editable.isContentEditable) {
return true;
}
const tag = element.tagName.toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select";
})
) {
return commandCenterOpen ? "command-center" : "editable";
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.43",
"version": "0.1.44",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/relay": "0.1.44",
"@getpaseo/server": "0.1.44",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,3 +1,4 @@
npmRebuild: false
appId: sh.paseo.desktop
productName: Paseo
executableName: Paseo

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.43",
"version": "0.1.44",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.43",
"@getpaseo/server": "0.1.43",
"@getpaseo/cli": "0.1.44",
"@getpaseo/server": "0.1.44",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

View File

@@ -17,7 +17,11 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import {
createNodeEntrypointInvocation,
resolveDaemonRunnerEntrypoint,
runCliJsonCommand,
} from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
@@ -405,20 +409,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
}
try {
if (!status.listen) {
throw new Error("Daemon listen target is unavailable.");
}
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
if (!baseUrl) {
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
}
const response = await fetch(`${baseUrl}/pairing`);
if (!response.ok) {
throw new Error(`Daemon pairing request failed with ${response.status}`);
}
const payload = (await response.json()) as unknown;
const payload = runCliJsonCommand(["daemon", "pair", "--json"]);
if (!isRecord(payload)) {
throw new Error("Daemon pairing response was not an object.");
}
@@ -557,7 +548,9 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
},
install_app_update: async () => {
const currentVersion = await resolveCurrentUpdateVersion();
return downloadAndInstallUpdate(currentVersion);
return downloadAndInstallUpdate(currentVersion, async () => {
await stopDaemon();
});
},
get_local_daemon_version: () => getLocalDaemonVersion(),
};

View File

@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
@@ -219,23 +219,22 @@ export function createNodeEntrypointInvocation(input: {
});
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
function createCliInvocation(args: string[]): NodeEntrypointInvocation {
const cli = resolveCliEntrypoint();
const invocation = createNodeEntrypointInvocation({
return createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
args,
baseEnv: process.env,
});
return spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
}
export function runCliPassthroughCommand(args: string[]): number {
const result = spawnCliProcess(args);
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
@@ -246,3 +245,34 @@ export function runCliPassthroughCommand(args: string[]): number {
return result.signal ? 1 : 0;
}
export function runCliJsonCommand(args: string[]): unknown {
const invocation = createCliInvocation(args);
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
}
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
if (stdout.length === 0) {
throw new Error("CLI command did not produce JSON output.");
}
try {
return JSON.parse(stdout) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -98,6 +98,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
export async function downloadAndInstallUpdate(
currentVersion: string,
onBeforeQuit?: () => Promise<void>,
): Promise<AppUpdateInstallResult> {
if (!app.isPackaged) {
return {
@@ -131,8 +132,9 @@ export async function downloadAndInstallUpdate(
await autoUpdater.downloadUpdate();
// quitAndInstall restarts the app with the new version.
// Use a short delay to allow the renderer to receive the response.
setTimeout(() => {
setTimeout(async () => {
try {
if (onBeforeQuit) await onBeforeQuit();
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
} catch (error) {
console.error("[auto-updater] quitAndInstall failed:", error);

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.43",
"version": "0.1.44",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.43",
"version": "0.1.44",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.43",
"version": "0.1.44",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.43",
"version": "0.1.44",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -32,13 +32,13 @@
}
},
"scripts": {
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "NODE_ENV=production node dist/server/server/index.js",
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
"typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
"speech:models": "tsx scripts/list-speech-models.ts",
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.43",
"@getpaseo/relay": "0.1.43",
"@getpaseo/highlight": "0.1.44",
"@getpaseo/relay": "0.1.44",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -103,6 +103,7 @@
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"playwright": "^1.56.1",
"tsx": "^4.6.0",
"typescript": "^5.2.2",

View File

@@ -83,30 +83,6 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
},
];
const COPILOT_MODES: AgentProviderModeDefinition[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent",
description: "Default agent mode for conversational interactions",
icon: "ShieldAlert",
colorTier: "moderate",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
icon: "ShieldCheck",
colorTier: "planning",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
label: "Autopilot",
description: "Autonomous mode that runs until task completion without user interaction",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{
id: "build",
@@ -137,18 +113,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "haiku",
},
},
{
id: "claude-acp",
label: "Claude ACP",
description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "haiku",
},
},
{
id: "codex",
label: "Codex",
@@ -161,13 +125,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModel: "gpt-5.1-codex-mini",
},
},
{
id: "copilot",
label: "Copilot",
description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support",
defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
modes: COPILOT_MODES,
},
{
id: "opencode",
label: "OpenCode",

View File

@@ -8,9 +8,7 @@ import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.j
import type { Logger } from "pino";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import {
@@ -43,17 +41,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
logger,
runtimeSettings: runtimeSettings?.claude,
}),
"claude-acp": (logger, runtimeSettings) =>
new ClaudeACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.["claude-acp"],
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.copilot,
}),
opencode: (logger, runtimeSettings) =>
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
};

View File

@@ -29,6 +29,7 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
@@ -201,6 +202,11 @@ function stringifyUnknownError(error: unknown): string {
}
}
function normalizeTurnFailureError(error: unknown): string {
const normalized = stringifyUnknownError(error).trim();
return normalized.length > 0 ? normalized : "Unknown error";
}
function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -955,11 +961,10 @@ export function translateOpenCodeEvent(
if (sessionId === state.sessionId) {
state.streamedPartKeys.clear();
state.partTypes.clear();
const error = props.error as string | undefined;
events.push({
type: "turn_failed",
provider: "opencode",
error: error ?? "Unknown error",
error: normalizeTurnFailureError(props.error),
});
}
break;
@@ -995,6 +1000,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
constructor(
config: OpenCodeAgentConfig,
@@ -1112,11 +1118,19 @@ class OpenCodeAgentSession implements AgentSession {
}
async interrupt(): Promise<void> {
this.abortController?.abort();
const turnId = this.activeForegroundTurnId;
const turnAbortController = this.abortController;
turnAbortController?.abort();
await this.client.session.abort({
sessionID: this.sessionId,
directory: this.config.cwd,
});
if (turnId) {
this.finishForegroundTurn(
{ type: "turn_canceled", provider: "opencode", reason: "interrupted" },
turnId,
);
}
}
async startTurn(
@@ -1127,7 +1141,9 @@ class OpenCodeAgentSession implements AgentSession {
throw new Error("A foreground turn is already active");
}
this.abortController = new AbortController();
this.runningToolCalls.clear();
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const parts = this.buildPromptParts(prompt);
@@ -1170,7 +1186,7 @@ class OpenCodeAgentSession implements AgentSession {
}
if (promptResponse.error) {
const errorMsg = JSON.stringify(promptResponse.error);
const errorMsg = normalizeTurnFailureError(promptResponse.error);
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
@@ -1181,7 +1197,7 @@ class OpenCodeAgentSession implements AgentSession {
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
void this.consumeEventStream();
void this.consumeEventStream(turnId, turnAbortController);
return { turnId };
}
@@ -1193,40 +1209,118 @@ class OpenCodeAgentSession implements AgentSession {
};
}
private async consumeEventStream(): Promise<void> {
private async consumeEventStream(
turnId: string,
turnAbortController: AbortController,
): Promise<void> {
const eventsResult = await this.client.event.subscribe({
directory: this.config.cwd,
});
try {
for await (const event of eventsResult.stream) {
if (this.abortController?.signal.aborted) {
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
break;
}
const translated = this.translateEvent(event);
for (const e of translated) {
this.notifySubscribers(e);
if (e.type === "turn_completed" || e.type === "turn_failed") {
this.activeForegroundTurnId = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
if (e.type === "turn_completed" || e.type === "turn_failed" || e.type === "turn_canceled") {
if (e.type === "turn_failed") {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(e.error),
},
turnId,
);
} else {
this.finishForegroundTurn(e, turnId);
}
return;
}
this.notifySubscribers(e, turnId);
}
}
} catch (error) {
if (!this.abortController?.signal.aborted) {
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
error: error instanceof Error ? error.message : "Stream error",
});
this.activeForegroundTurnId = null;
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(error),
},
turnId,
);
}
} finally {
if (turnAbortController.signal.aborted) {
this.finishForegroundTurn(
{
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
},
turnId,
);
}
if (this.abortController === turnAbortController && this.activeForegroundTurnId !== turnId) {
this.abortController = null;
}
}
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string,
): void {
if (this.activeForegroundTurnId !== turnId) {
return;
}
if (event.type === "turn_canceled" || event.type === "turn_failed") {
this.synthesizeInterruptedToolCalls(turnId);
} else {
this.runningToolCalls.clear();
}
this.activeForegroundTurnId = null;
this.notifySubscribers(event, turnId);
}
private trackToolCall(item: ToolCallTimelineItem): void {
if (item.status === "running") {
this.runningToolCalls.set(item.callId, item);
return;
}
this.runningToolCalls.delete(item.callId);
}
private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) {
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: {
...item,
status: "failed",
error: { message: "Tool execution aborted" },
},
},
turnId,
);
}
this.runningToolCalls.clear();
}
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
for (const callback of this.subscribers) {
try {

View File

@@ -106,7 +106,6 @@ import { ScheduleService } from "./schedule/service.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
@@ -282,27 +281,6 @@ export async function createPaseoDaemon(
});
});
app.get("/pairing", async (_req, res) => {
try {
const offer = await generateLocalPairingOffer({
paseoHome: config.paseoHome,
relayEnabled: config.relayEnabled,
relayEndpoint: config.relayEndpoint,
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
logger,
});
res.json(offer);
} catch (error) {
logger.error({ err: error }, "Failed to generate pairing offer");
res.status(500).json({
relayEnabled: false,
url: null,
qr: null,
});
}
});
app.get("/api/files/download", async (req, res) => {
const token =
typeof req.query.token === "string" && req.query.token.trim().length > 0

View File

@@ -65,7 +65,7 @@ export function loadConfig(
options?.cli?.allowedHosts,
]);
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;

View File

@@ -0,0 +1,424 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient, type WaitForFinishResult } from "../test-utils/daemon-client.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import { isProviderAvailable } from "./agent-configs.js";
import type { AgentPermissionRequest } from "../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../messages.js";
const SYSTEM_ERROR_SNIPPET = "A foreground turn is already active";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-send-interrupt-"));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = [
"minimax-m2.5-free",
"kimi-k2.5-free",
"glm-5-free",
"free",
"mini",
"gpt-5-nano",
],
): string {
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0]!.id;
}
function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
return messages.some(
(message) =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "tool_call" &&
message.payload.event.item.status === "running" &&
["bash", "shell"].includes(message.payload.event.item.name.toLowerCase()),
);
}
function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] {
return messages
.filter(
(message) =>
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message",
)
.map((message) => message.payload.event.item.text);
}
function findSystemErrorText(texts: string[]): string | null {
return texts.find((text) => text.includes("[System Error]")) ?? null;
}
function getTimelineAssistantTexts(
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
): string[] {
return timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text);
}
function findSleepToolCall(
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
): { status: "running" | "completed" | "failed" | "canceled"; callId: string } | null {
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
const entry = timeline.entries[idx];
if (entry?.item.type !== "tool_call") {
continue;
}
if (entry.item.detail.type !== "shell") {
continue;
}
if (!entry.item.detail.command.includes("sleep 60")) {
continue;
}
return {
status: entry.item.status,
callId: entry.item.callId,
};
}
return null;
}
async function allowPermission(
client: DaemonClient,
agentId: string,
permission: AgentPermissionRequest,
): Promise<void> {
if (permission.kind === "question") {
throw new Error(
`Unexpected question permission while waiting for tool call: ${permission.id} ${permission.title}`,
);
}
await client.respondToPermission(agentId, permission.id, {
behavior: "allow",
message: "Approved by integration test",
});
}
async function approvePendingPermissions(
client: DaemonClient,
agentId: string,
handledPermissionIds: Set<string>,
): Promise<void> {
const snapshot = await client.fetchAgent(agentId).catch(() => null);
const pending = snapshot?.agent.pendingPermissions ?? [];
for (const permission of pending) {
if (handledPermissionIds.has(permission.id)) {
continue;
}
handledPermissionIds.add(permission.id);
await allowPermission(client, agentId, permission);
}
}
async function waitForRunningBashToolCall(
client: DaemonClient,
collector: ReturnType<typeof createMessageCollector>,
agentId: string,
timeoutMs = 120_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
const handledPermissionIds = new Set<string>();
while (Date.now() < deadline) {
await approvePendingPermissions(client, agentId, handledPermissionIds);
const streamSystemError = findSystemErrorText(getAssistantTexts(collector.messages, agentId));
if (streamSystemError) {
throw new Error(`OpenCode failed before tool call started: ${streamSystemError}`);
}
if (hasRunningBashToolCall(collector.messages, agentId)) {
return;
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null);
const timelineSystemError = timeline
? findSystemErrorText(getTimelineAssistantTexts(timeline).slice(-8))
: null;
if (timelineSystemError) {
throw new Error(`OpenCode failed before tool call started: ${timelineSystemError}`);
}
if (
timeline?.entries.some(
(entry) =>
entry.item.type === "tool_call" &&
entry.item.status === "running" &&
["bash", "shell"].includes(entry.item.name.toLowerCase()),
)
) {
return;
}
await sleep(500);
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null);
const recentToolCalls =
timeline?.entries
.filter((entry) => entry.item.type === "tool_call")
.slice(-10)
.map((entry) => ({
name: entry.item.name,
status: entry.item.status,
callId: entry.item.callId,
})) ?? [];
const recentAssistantTexts = timeline ? getTimelineAssistantTexts(timeline).slice(-6) : [];
throw new Error(
`Timed out waiting for running bash/shell tool call. recentToolCalls=${JSON.stringify(recentToolCalls)} recentAssistantTexts=${JSON.stringify(recentAssistantTexts)}`,
);
}
async function waitForSleepToolCallTerminal(
client: DaemonClient,
agentId: string,
timeoutMs = 30_000,
): Promise<{ status: "completed" | "failed" | "canceled"; callId: string }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 });
const sleepToolCall = findSleepToolCall(timeline);
if (sleepToolCall && sleepToolCall.status !== "running") {
return {
status: sleepToolCall.status,
callId: sleepToolCall.callId,
};
}
await sleep(300);
}
const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 }).catch(() => null);
const recentToolCalls =
timeline?.entries
.filter((entry) => entry.item.type === "tool_call")
.slice(-10)
.map((entry) => ({
callId: entry.item.callId,
name: entry.item.name,
status: entry.item.status,
})) ?? [];
throw new Error(
`Timed out waiting for interrupted sleep tool call to become terminal. recentToolCalls=${JSON.stringify(recentToolCalls)}`,
);
}
async function waitForIdleResolvingPermissions(
client: DaemonClient,
agentId: string,
timeoutMs: number,
): Promise<WaitForFinishResult> {
const deadline = Date.now() + timeoutMs;
const handledPermissionIds = new Set<string>();
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const result = await client.waitForFinish(agentId, Math.min(remaining, 45_000));
if (result.status !== "permission") {
return result;
}
const pendingPermissions = result.final?.pendingPermissions ?? [];
if (pendingPermissions.length === 0) {
throw new Error("waitForFinish reported permission but no pending permissions were present");
}
let resolvedAny = false;
for (const permission of pendingPermissions) {
if (handledPermissionIds.has(permission.id)) {
continue;
}
handledPermissionIds.add(permission.id);
await allowPermission(client, agentId, permission);
resolvedAny = true;
}
if (!resolvedAny) {
throw new Error(
"Permission wait loop made no progress; all permissions were already handled",
);
}
}
return {
status: "timeout",
final: null,
error: `Timed out waiting for idle after ${timeoutMs}ms`,
lastMessage: null,
};
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
}> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } });
return { client, daemon };
}
describe("daemon E2E (real opencode) - send while working and interrupt", () => {
test.runIf(isProviderAvailable("opencode"))(
"send_message while sleep tool call is running starts a clean replacement turn",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK";
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode send while working",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
expect(finish.status).toBe("idle");
const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id);
expect(
postSendAssistantTexts.some((text) => text.includes("[System Error]")),
).toBe(false);
expect(
postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
360_000,
);
test.runIf(isProviderAvailable("opencode"))(
"explicit interrupt during sleep tool call still allows the next turn to complete",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK";
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode explicit interrupt",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
await client.cancelAgent(agent.id);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
90_000,
);
const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000);
expect(interruptedToolCall.status).toBe("failed");
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
expect(finish.status).toBe("idle");
const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id);
expect(
postInterruptAssistantTexts.some((text) => text.includes("[System Error]")),
).toBe(false);
expect(
postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
360_000,
);
});

View File

@@ -96,4 +96,25 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true });
}
});
it("rejects symlinked files that resolve outside the workspace", async () => {
const root = await createTempDir("paseo-file-explorer-");
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
try {
const externalFile = path.join(outsideRoot, "secret.txt");
await writeFile(externalFile, "top secret\n", "utf-8");
await symlink(externalFile, path.join(root, "secret-link.txt"));
await expect(
readExplorerFile({
root,
relativePath: "secret-link.txt",
}),
).rejects.toThrow("Access outside of workspace is not allowed");
} finally {
await rm(root, { recursive: true, force: true });
await rm(outsideRoot, { recursive: true, force: true });
}
});
});

View File

@@ -210,11 +210,25 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
const requestedPath = path.resolve(normalizedRoot, relativePath);
const relative = path.relative(normalizedRoot, requestedPath);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
return requestedPath;
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
throw new Error("Access outside of workspace is not allowed");
}
throw new Error("Access outside of workspace is not allowed");
const realRoot = await fs.realpath(normalizedRoot);
try {
const realPath = await fs.realpath(requestedPath);
const realRelative = path.relative(realRoot, realPath);
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
throw new Error("Access outside of workspace is not allowed");
}
return requestedPath;
} catch (error) {
if (isMissingEntryError(error)) {
return requestedPath;
}
throw error;
}
}
async function buildEntryPayload({

View File

@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from "uuid";
import { watch, type FSWatcher } from "node:fs";
import { readFile, stat } from "fs/promises";
import { exec } from "child_process";
import { exec, execFile } from "node:child_process";
import { promisify } from "util";
import { join, resolve, sep } from "path";
import { homedir } from "node:os";
@@ -185,6 +185,7 @@ import {
} from "./worktree-session.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
@@ -3035,6 +3036,9 @@ export class Session {
}
private assertSafeGitRef(ref: string, label: string): void {
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
throw new Error(`Invalid ${label}: ${ref}`);
}
assertWorktreeSafeGitRef(ref, label);
}
@@ -3206,7 +3210,7 @@ export class Session {
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
this.assertSafeGitRef(branch, "branch");
try {
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd });
} catch (error) {
throw new Error(`Branch not found: ${branch}`);
}
@@ -3220,7 +3224,7 @@ export class Session {
}
await this.ensureCleanWorkingTree(cwd);
await execAsync(`git checkout ${branch}`, { cwd });
await execFileAsync("git", ["checkout", branch], { cwd });
}
private async createBranchFromBase(params: {
@@ -3230,9 +3234,10 @@ export class Session {
}): Promise<void> {
const { cwd, baseBranch, newBranchName } = params;
this.assertSafeGitRef(baseBranch, "base branch");
this.assertSafeGitRef(newBranchName, "new branch");
try {
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd });
await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd });
} catch (error) {
throw new Error(`Base branch not found: ${baseBranch}`);
}
@@ -3243,14 +3248,15 @@ export class Session {
}
await this.ensureCleanWorkingTree(cwd);
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], {
cwd,
});
}
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
this.assertSafeGitRef(branch, "branch");
try {
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
cwd,
});
return true;
@@ -3653,10 +3659,11 @@ export class Session {
try {
const resolvedCwd = expandTilde(cwd);
this.assertSafeGitRef(branchName, "branch");
// Try local branch first
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
await execFileAsync("git", ["rev-parse", "--verify", branchName], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
@@ -3677,7 +3684,7 @@ export class Session {
// Try remote branch (origin/{branchName})
try {
await execAsync(`git rev-parse --verify origin/${branchName}`, {
await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.43",
"version": "0.1.44",
"private": true,
"type": "module",
"scripts": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 172 KiB