mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 |
5
.github/workflows/desktop-release.yml
vendored
5
.github/workflows/desktop-release.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
20
SECURITY.md
20
SECURITY.md
@@ -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).
|
||||
|
||||
@@ -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
64
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.44",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -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",
|
||||
|
||||
80
packages/app/src/keyboard/focus-scope.test.ts
Normal file
80
packages/app/src/keyboard/focus-scope.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
npmRebuild: false
|
||||
appId: sh.paseo.desktop
|
||||
productName: Paseo
|
||||
executableName: Paseo
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.44",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user