Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e2a2d1588d fix(agents): validate optional MCP requirements before plugin injection
Keep optional approval decisions in the provider registry and preserve required plugin servers. Remove requirements alongside stripped internal servers so persistence cannot retain dangling metadata.
2026-10-29 09:14:58 +01:00
Mohamed Boudra 2325a2f32d test(agents): cover optional MCP through registered provider contracts 2026-10-29 09:04:20 +01:00
Mohamed Boudra 12a33ea21a fix(agents): restore optional MCP requirements on daemon hydration 2026-10-29 08:59:45 +01:00
Mohamed Boudra e1684aece0 fix(agents): make injected MCP launch requirements explicit
Optional injected servers must survive persistence and reload without turning missing transport support into launch refusal. Required external and plugin servers keep their existing refusal. Advertise the optional MCP contract so older hosts retain established strict launch behavior.
2026-10-29 08:58:25 +01:00
Mohamed BoudraandRyan Winograd c47e1f178b fix(providers): allow optional exact MCP preapproval
Separate automatic approval convenience from strict tool-policy requirements so Hub can launch any available provider. Unsupported providers retain ordinary MCP injection and native permissions; strict requests still fail closed.

Co-authored-by: Ryan Winograd <ryan@thewinograds.com>
2026-10-29 08:51:20 +01:00
Mohamed Boudra 7c88a48be4 Publish store screenshots from the repo on each stable release (#8186)
* feat(release): publish store screenshots from the repo

Stable releases now upload the committed store renders: the App Store
review lane attaches the iPhone and iPad sets to the version it submits,
and a new Play lane replaces the phone and tablet screenshots after the
Android submit. F-Droid reads the same Android files, which now link to
the store renders instead of the website images.

The Play lane needs its own copy of the submit service-account key
(GOOGLE_PLAY_JSON_KEY in the EAS production environment) because the key
Expo stores for eas submit is not available to custom jobs, and EAS
Metadata does not handle screenshots for either store.

* fix(release): rewrite the Android screenshot links on every render

Google Play and F-Droid read numbered links to the Google renders. The
renderer now rewrites them from the panels it exported, so adding or
renaming a panel reaches all three stores.
2026-10-29 14:39:42 +07:00
paseo-bot[bot]andZeno_ 3196260bd4 Show completed edxeth Pi children as finished, live and after restart (#8176)
* fix(pi): settle edxeth subagent lifecycle events

* test(pi): preserve completed children when restoring captured histories

Verify the completed child and assistant transcript through history replay and the store consumed by clients. Both captured completion protocols otherwise leave a running descriptor.

Co-authored-by: Zeno_ <lin1071833138@gmail.com>

---------

Co-authored-by: Zeno_ <lin1071833138@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 07:06:46 +00:00
paseo-bot[bot] 870a44bc7f Deliver a steer message that arrives while the previous turn is still starting (#6192)
* Deliver a steer message that arrives while the previous turn is still starting

A steer send that reached an agent before its previous turn had a turn id
found no turn to steer and replaced the starting turn, so the earlier
message never reached the provider. Steering now waits for the starting
turn to be accepted (or fail) and steers the turn it became.

Fixes #6186

* Wake a waiting steer only once the starting turn has an id or its run has ended

A failed start used to wake the waiting steer before its run was cleared,
leaving the later send to replace a run that had already failed.

* Clear a failed start's run even when reporting the failure throws

A steer waiting on that start is released only when the run is cleared.

* Test that a failed start keeps a waiting steer until its run is cleared

* fix(server): steer Codex turns while native identification is pending

Wait for the accepted foreground turn to receive its native ID before steering it. Returning unavailable in this window replaced the turn even after the manager had waited for startup.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 05:55:50 +00:00
Mohamed Boudra e8201ab66b Make Claude session entrypoint relabeling optional (#8164)
* fix(claude): make session entrypoint relabeling opt-in

Keep inherited SDK and parent identities cleared. Honor the explicit session-open entrypoint contribution so users can opt into the label through a plugin instead of every session receiving it unconditionally.

* test(hub): use standard budget for restart and worktree recovery

The Windows CI scenario timed out at its custom 20-second limit while the other Hub cases took 4–10 seconds each. This scenario starts two daemons and creates, archives and restores a worktree. Use the existing 30-second suite budget without changing any assertions.
2026-10-29 12:45:41 +07:00
62 changed files with 1729 additions and 591 deletions
+4
View File
@@ -95,4 +95,8 @@ Callout crops are in native capture pixels. To find them, overlay a 100 px grid
6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview.
## Publish
The stable release uploads these exports to the App Store and Google Play, and F-Droid reads the Google ones from the repo. See [Store screenshots](../../docs/release.md#store-screenshots). The Android metadata holds links to the Google exports, and every render rewrites them, so commit the links with the exports.
Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en).
+19 -2
View File
@@ -9,7 +9,7 @@
// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac.
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,6 +23,7 @@ const root = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(root, "../..");
const raw = (device, name) => path.join(root, "raw", device, `${name}.png`);
const output = path.join(root, "output");
const androidImages = path.join(repo, "fastlane/metadata/android/en-US/images");
// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at
// 2x. The frame's screen radius and Dynamic Island are in capture pixels.
@@ -116,6 +117,7 @@ const STORES = {
},
google: {
device: "iphone",
androidScreenshots: "phoneScreenshots",
width: 1440,
height: 2560,
textTop: 150,
@@ -134,6 +136,7 @@ const STORES = {
},
"google-tablet": {
device: "ipad",
androidScreenshots: "tenInchScreenshots",
width: 1800,
height: 3200,
textTop: 200,
@@ -352,6 +355,18 @@ async function expectLines(view, selector, expected, label) {
if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`);
}
// Google Play and F-Droid read their screenshots from the Android metadata,
// which holds links to these renders. Rewriting the links on every render keeps
// added, removed and renamed panels in step with the App Store.
async function linkAndroidScreenshots(folder, files) {
const dir = path.join(androidImages, folder);
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
for (const [index, file] of files.entries()) {
await symlink(path.relative(dir, file), path.join(dir, `${index + 1}.png`));
}
}
async function renderPreview(rows) {
const tile = 440;
const gap = 40;
@@ -387,7 +402,9 @@ try {
const marks = await loadAgentMarks(work);
const rows = [];
for (const [name, store] of Object.entries(STORES)) {
rows.push(await renderStore(browser, name, store, font, marks, work));
const files = await renderStore(browser, name, store, font, marks, work);
if (store.androidScreenshots) await linkAndroidScreenshots(store.androidScreenshots, files);
rows.push(files);
}
await renderPreview(rows);
} finally {
+4 -1
View File
@@ -215,7 +215,8 @@ fastlane/metadata/android/
│ ├── images/
│ │ ├── icon.png (512x512)
│ │ ├── featureGraphic.png (1024x500)
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
│ │ ├── phoneScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ │ └── tenInchScreenshots/ (1.png, 2.png, ... linked to the store renders)
│ └── changelogs/ (generated — see below)
├── ja/
└── zh-CN/
@@ -223,6 +224,8 @@ fastlane/metadata/android/
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
Google Play reads its screenshots from the same folders; see [Store screenshots](release.md#store-screenshots).
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
-10
View File
@@ -493,16 +493,6 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Outbound networking
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
are treated as unset, with one warning naming the variable in `daemon.log`.
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
`ALL_PROXY` configurations are not supported.
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
+9
View File
@@ -44,6 +44,15 @@ fall back to creating a fresh agent when continuation was requested.
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
`server_info.features.optionalMcpSupport` advertises optional MCP launch requirements.
Hub sends `toolPolicy.enforcement: "if-supported"` and
`mcpServerRequirements: { hub: "optional" }` only when this flag is true.
Without it, Hub preserves its existing strict tool-policy request. Available
providers remain selectable; providers lacking strict approval support need an
updated daemon to launch from Hub. Required external/plugin servers and explicitly
required tool policies keep their existing refusal semantics.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).
+10 -3
View File
@@ -30,9 +30,16 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
Exact MCP preapproval is a separate daemon-owned contract. A required tool policy
(the default) fails closed unless the provider can approve exact injected server/tool
identities without approving native tools. Native agent creation also accepts
`toolPolicy.enforcement: "if-supported"` for automatic approval convenience:
unsupported providers retain ordinary MCP configuration and native permissions.
Hub uses that optional request; MCP support and exact approval are not provider
eligibility requirements. Optional injection is declared separately with
`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers
(including plugin-owned servers) keep their required semantics. The daemon
advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only.
## Core adapter patterns
+13 -2
View File
@@ -305,8 +305,8 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials). A Fastlane lane then replaces the store screenshots.
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane uploads the store screenshots and submits that same build for App Store review.
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
@@ -317,6 +317,16 @@ Stable iOS releases use one binary for two Apple review paths: Beta App Review f
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
### Store screenshots
Each stable release publishes the screenshots committed under `assets/store-screenshots/output/` (see [its README](../assets/store-screenshots/README.md) for how they are made). To change the store screenshots, re-render and commit them before you tag.
- **App Store:** `submit_review` uploads `output/apple/` and `output/apple-ipad/` to the version it submits, replacing that version's `en-US` screenshots. Apple ties screenshots to an app version, so a version that is already live keeps the ones it shipped with. `Resubmit iOS for App Store review` uploads them again.
- **Google Play:** `upload_android_screenshots` runs `fastlane android upload_screenshots` after `submit_android`. It replaces only the phone and 10-inch tablet screenshots, never the listing text or changelogs, and skips images whose checksum already matches.
- **F-Droid:** reads the same files as Google Play. `fastlane/metadata/android/en-US/images/phoneScreenshots/` and `tenInchScreenshots/` hold symlinks to the Google renders. `render.mjs` rewrites them on every render, so added or renamed panels reach Google Play and F-Droid as well.
Fastlane cannot use the Play key Expo stores for `eas submit`. The Play lane reads its own copy of the same service-account key from the `GOOGLE_PLAY_JSON_KEY` file secret in the EAS `production` environment, and that service account needs the Play Console permission **Manage store presence**.
### Watching mobile builds from the terminal
Use the EAS CLI from `packages/app/`:
@@ -359,6 +369,7 @@ For the `Release Mobile` EAS workflow, these jobs must pass:
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
- `build_android` — Android store binary built
- `submit_android` — Android binary submitted to the Play Store
- `upload_android_screenshots` — Google Play screenshots replaced from the repo
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
@@ -1 +1 @@
../../../../../../packages/website/public/phone-1.png
../../../../../../assets/store-screenshots/output/google/01-sidebar.png
@@ -1 +1 @@
../../../../../../packages/website/public/phone-2.png
../../../../../../assets/store-screenshots/output/google/02-chat.png
@@ -1 +1 @@
../../../../../../packages/website/public/phone-3.png
../../../../../../assets/store-screenshots/output/google/03-plugins.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google/04-review.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google/05-terminal.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/01-sidebar.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/02-chat.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/03-plugins.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/04-review.png
@@ -0,0 +1 @@
../../../../../../assets/store-screenshots/output/google-tablet/05-terminal.png
-24
View File
@@ -40690,7 +40690,6 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -40708,7 +40707,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -41282,15 +41280,6 @@
"url": "https://opencollective.com/vitest"
}
},
"packages/server/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -41360,19 +41349,6 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
@@ -42,6 +42,20 @@ jobs:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production
upload_android_screenshots:
name: Upload Google Play screenshots
needs: [build_android, submit_android]
environment: production
runs_on: macos-medium
steps:
- uses: eas/checkout
- name: Install fastlane
run: bundle install
- name: Upload screenshots
run: |
export ANDROID_VERSION_CODE="${{ needs.build_android.outputs.app_build_version }}"
bundle exec fastlane android upload_screenshots
submit_ios_for_review:
name: Submit iOS for App Store review
needs: [build_ios, distribute_ios]
+48 -1
View File
@@ -1,7 +1,31 @@
require "fileutils"
require "tmpdir"
default_platform(:ios)
APP_IDENTIFIER = "sh.paseo"
# Store screenshots are rendered into assets/store-screenshots/output; see the
# README there. Google Play and F-Droid both read fastlane/metadata/android at
# the repo root, whose screenshot files link to those renders.
REPO_ROOT = File.expand_path("../../..", __dir__)
STORE_SCREENSHOTS = File.join(REPO_ROOT, "assets/store-screenshots/output")
ANDROID_METADATA = File.join(REPO_ROOT, "fastlane/metadata/android")
# deliver reads screenshots from one folder per language and picks the device
# slot from each image's size, so both Apple sets go into en-US together.
def stage_app_store_screenshots
root = Dir.mktmpdir("app-store-screenshots")
locale = File.join(root, "en-US")
FileUtils.mkdir_p(locale)
{ "apple" => "iphone", "apple-ipad" => "ipad" }.each do |set, device|
Dir.glob(File.join(STORE_SCREENSHOTS, set, "*.png")).sort.each do |file|
FileUtils.cp(file, File.join(locale, "#{device}-#{File.basename(file)}"))
end
end
root
end
platform :ios do
desc "Submit the latest TestFlight build to App Store Review"
lane :submit_review do
@@ -87,7 +111,8 @@ platform :ios do
},
force: true,
skip_binary_upload: true,
skip_screenshots: true,
screenshots_path: stage_app_store_screenshots,
overwrite_screenshots: true,
precheck_include_in_app_purchases: false,
submission_information: {
add_id_info_uses_idfa: false,
@@ -96,3 +121,25 @@ platform :ios do
)
end
end
platform :android do
desc "Replace the Google Play store screenshots with the ones in the repo"
lane :upload_screenshots do
# supply attaches listing changes to a release on the production track.
# The release workflow passes the version code it just submitted; without
# it, supply uses the track's only release.
version_code = ENV["ANDROID_VERSION_CODE"].to_s.strip
supply(
package_name: APP_IDENTIFIER,
json_key: ENV.fetch("GOOGLE_PLAY_JSON_KEY"),
metadata_path: ANDROID_METADATA,
version_code: version_code.empty? ? nil : version_code.to_i,
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: true,
sync_image_upload: true,
)
end
end
+6 -14
View File
@@ -2,7 +2,6 @@ export { createHostChannelTransport } from "../hosts/index.js";
import { HostTransportBudget } from "./buffering.js";
export { HostTransportBudget } from "./buffering.js";
import { homedir } from "node:os";
import type { Agent } from "node:http";
import { WebSocket } from "ws";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
@@ -20,15 +19,10 @@ export { parseHostTarget };
export type { SshTunnel } from "./ssh-tunnel.js";
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
export interface HostTransportOptions {
webSocketAgent?: (url: string) => Agent | undefined;
}
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
export function createHostTransportFactory(
connection: string,
budget = new HostTransportBudget(),
options: HostTransportOptions = {},
): DaemonTransportFactory {
const target = parseHostTarget(connection);
// Rises to "protected" once the daemon negotiates it, so a later attempt
@@ -74,13 +68,12 @@ export function createHostTransportFactory(
return;
}
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
let factory = createWebSocketTransportFactory((url, socketOptions) => {
let factory = createWebSocketTransportFactory((url, options) => {
const socket = new WebSocket(
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
socketOptions?.protocols,
options?.protocols,
{
headers: socketOptions?.headers,
agent: socketPath ? undefined : options.webSocketAgent?.(url),
headers: options?.headers,
...(socketPath ? { socketPath } : {}),
maxPayload: 100 * 1024 * 1024,
},
@@ -198,15 +191,14 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
...config,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
transportFactory: createHostTransportFactory(connection, budget),
});
}
+4
View File
@@ -509,10 +509,14 @@ export interface McpToolRef {
}
export interface ToolPolicy {
/** Missing or required fails closed; if-supported requests approval convenience only. */
enforcement?: "required" | "if-supported";
preapproved: McpToolRef[];
}
export interface AgentSessionConfig {
/** Per-server launch requirement; unspecified servers are required. */
mcpServerRequirements?: Record<string, "optional" | "required">;
provider: AgentProvider;
cwd: string;
/**
+35
View File
@@ -571,3 +571,38 @@ test.each(["all", "base", "uncommitted"] as const)(
expect(parsed.attachments).toEqual([attachment]);
},
);
test("native agent creation accepts optional approval without weakening legacy strict acknowledgement", () => {
const toolPolicy = {
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
enforcement: "if-supported",
};
const parsed = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId: "req",
config: { provider: "pi", cwd: "/tmp", toolPolicy },
});
expect(parsed).toMatchObject({ config: { toolPolicy } });
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy: { preapproved: toolPolicy.preapproved },
}).success,
).toBe(true);
expect(
SessionInboundMessageSchema.safeParse({
type: "hub.execution.agent.create.request",
requestId: "req",
executionId: "exec",
provider: "pi",
cwd: "/tmp",
prompt: "test",
toolPolicy,
}).success,
).toBe(false);
});
+5 -1
View File
@@ -490,8 +490,11 @@ const AgentSessionConfigSchema = z.object({
featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.optional(),
toolPolicy: ToolPolicySchema.extend({
enforcement: z.enum(["required", "if-supported"]).optional(),
}).optional(),
systemPrompt: z.string().optional(),
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
});
@@ -3671,6 +3674,7 @@ export const ServerInfoStatusPayloadSchema = z
internalWorkspaces: z.boolean().optional(),
backgroundWorkspaces: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
optionalMcpSupport: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
usageSources: z.boolean().optional(),
+1 -3
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -124,7 +124,6 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -142,7 +141,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -19,6 +19,7 @@ import {
type AgentManagerEvent,
type ManagedAgent,
} from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { toAgentPayload } from "./agent-projections.js";
@@ -445,7 +446,7 @@ class EnvProbeAgentClient extends TestAgentClient {
}
class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly provider: AgentProvider;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private runtimeModel: string | null = null;
@@ -453,7 +454,9 @@ class TestAgentSession implements AgentSession {
private turnIdCounter = 0;
private interrupted = false;
constructor(private readonly config: AgentSessionConfig) {}
constructor(private readonly config: AgentSessionConfig) {
this.provider = config.provider;
}
async run(): Promise<AgentRunResult> {
return {
@@ -956,6 +959,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
}
});
test("steers a send that arrives while the previous turn is still starting", async () => {
const startHeld = deferred<void>();
class SlowStartSteeringSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
await startHeld.promise;
return await super.startTurn(prompt);
}
}
const session = new SlowStartSteeringSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "steered" });
expect(session.startPrompts).toEqual(["FIRST"]);
expect(session.interruptCount).toBe(0);
expect(
manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.filter((row) => row.item.type === "user_message")
.map((row) => ({
text: row.item.type === "user_message" ? row.item.text : null,
turnId: row.turnId,
})),
).toEqual([
{ text: "FIRST", turnId: "active-turn-1" },
{ text: "SECOND", turnId: "active-turn-1" },
]);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
const startHeld = deferred<void>();
class FailingFirstStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "FIRST") {
this.startPrompts.push(prompt);
await startHeld.promise;
throw new Error("first start failed");
}
return await super.startTurn(prompt);
}
}
const session = new FailingFirstStartSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "turn_started" });
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
expect(session.interruptCount).toBe(0);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -3697,54 +3825,85 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
expect(snapshot.config.mcpServers).toBeUndefined();
});
test("createAgent preserves a user-provided paseo MCP config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
test.each([
{ provider: "pi", supportsMcp: true },
{ provider: "pi", supportsMcp: false },
{ provider: "codex", supportsMcp: false },
])(
"createAgent permits optional approval for $provider with MCP capability $supportsMcp",
async ({ provider, supportsMcp }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new McpCapableTestAgentSession(config);
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
}
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
const client = new CaptureClient(provider);
const manager = new AgentManager({
clients: {
[provider]: client,
},
providerDefinitions: {
[provider]: {
enabled: true,
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
},
},
},
undefined,
{ workspaceId: undefined },
);
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
});
const snapshot = await manager.createAgent(
{
provider,
mcpServerRequirements: { paseo: "optional" },
cwd: workdir,
toolPolicy: {
enforcement: "if-supported",
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
},
mcpServers: {
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
},
},
undefined,
{ workspaceId: undefined },
);
expect(snapshot.config.mcpServers).toEqual({
paseo: {
type: "http",
url: "https://example.com/custom-paseo",
},
});
expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({
paseo: "optional",
});
await manager.reloadAgentSession(snapshot.id);
expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({
paseo: "optional",
});
const expectedPolicy =
provider === "codex"
? { preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }] }
: undefined;
expect(snapshot.config.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.toolPolicy).toEqual(expectedPolicy);
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
},
);
test("createAgent fails when cwd does not exist", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -12450,3 +12609,21 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
test("MCP requirements cannot mark a later plugin server optional", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
await expect(
manager.createAgent(
{ provider: "codex", cwd: workdir, mcpServerRequirements: { plugin: "optional" } },
undefined,
{ workspaceId: undefined },
),
).rejects.toThrow(
"MCP server requirement 'plugin' requires MCP server 'plugin' in the same agent request",
);
});
@@ -216,6 +216,8 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions;
}
if (record.config.mcpServerRequirements != null)
config.mcpServerRequirements = record.config.mcpServerRequirements;
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
if (record.config.systemPrompt != null) {
config.systemPrompt = record.config.systemPrompt;
@@ -1686,7 +1688,7 @@ export class AgentManager {
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
@@ -2734,7 +2736,7 @@ export class AgentManager {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
@@ -2742,20 +2744,24 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
try {
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
});
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
} finally {
// A steer waiting on this start is released only once the run is cleared.
this.runs.settleForegroundRun(agentId, pendingRun.token);
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
}
@@ -2832,7 +2838,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.start = { status: "started", turnId };
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -3041,6 +3047,9 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<ActiveTurnSteerDispatchResult> {
// A turn that is still starting has no id to steer yet. Replacing it would drop its
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
await this.runs.waitForForegroundStart(agentId);
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId) {
@@ -3881,13 +3890,19 @@ export class AgentManager {
}
}
private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean {
return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some(
(name) => config.mcpServerRequirements?.[name] !== "optional",
);
}
private async requireExternalMcpSupport(
session: AgentSession,
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): Promise<void> {
if (
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
!this.hasRequiredExternalMcpServers(launchConfig) ||
session.capabilities.supportsMcpServers === true
) {
return;
@@ -5450,6 +5465,7 @@ export class AgentManager {
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
const definition = this.providerDefinitions.get(config.provider);
this.validateToolPolicyServers(config);
this.validateMcpServerRequirements(config);
if (config.toolPolicy && !definition?.applyToolPolicy) {
throw new Error(
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
@@ -5460,6 +5476,16 @@ export class AgentManager {
: config;
}
private validateMcpServerRequirements(config: AgentSessionConfig): void {
for (const name of Object.keys(config.mcpServerRequirements ?? {})) {
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
throw new Error(
`MCP server requirement '${name}' requires MCP server '${name}' in the same agent request`,
);
}
}
}
private validateToolPolicyServers(config: AgentSessionConfig): void {
if (!config.toolPolicy) return;
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
@@ -395,6 +395,9 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.systemPrompt) {
serializable.systemPrompt = config.systemPrompt;
}
if (config.mcpServerRequirements) {
serializable.mcpServerRequirements = config.mcpServerRequirements;
}
if (config.mcpServers) {
serializable.mcpServers = config.mcpServers;
}
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { AgentRunState } from "./agent-run-state.js";
function track(promise: Promise<void>): { readonly resolved: boolean } {
const state = { resolved: false };
void (async () => {
await promise;
state.resolved = true;
})();
return state;
}
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe("waitForForegroundStart", () => {
test("resolves once the starting turn has an id", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
await flushMicrotasks();
expect(wait.resolved).toBe(false);
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("waits for a failed start's run to be cleared", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
await flushMicrotasks();
expect(wait.resolved).toBe(false);
expect(runs.hasPendingRun("agent")).toBe(true);
runs.settleForegroundRun("agent", run.token);
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("resolves at once when no turn is starting", async () => {
const runs = new AgentRunState();
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
});
});
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
resolveSettled: () => void;
}
type ForegroundRunStart =
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
start: ForegroundRunStart;
turnStartedPromise: Promise<void>;
resolveTurnStarted: () => void;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -54,6 +58,28 @@ export class AgentRunState {
return run?.kind === "foreground" ? run : null;
}
settleForegroundStart(
run: PendingForegroundRun,
start: Exclude<ForegroundRunStart, { status: "pending" }>,
): void {
run.start = start;
if (start.status === "started") {
run.resolveTurnStarted();
}
}
/**
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
* resolves only once its run is cleared, so callers never see a failed run still tracked.
*/
async waitForForegroundStart(agentId: string): Promise<void> {
const run = this.getPendingRun(agentId);
if (run?.start.status !== "pending") {
return;
}
await Promise.race([run.turnStartedPromise, run.settledPromise]);
}
hasPendingRun(agentId: string): boolean {
return this.getPendingRun(agentId) !== null;
}
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
}
function createPendingForegroundRun(): PendingForegroundRun {
let resolveTurnStarted!: () => void;
const turnStartedPromise = new Promise<void>((resolvePromise) => {
resolveTurnStarted = resolvePromise;
});
return {
...createTrackedRunState(),
kind: "foreground",
start: { status: "pending" },
turnStartedPromise,
resolveTurnStarted,
stagedEvents: [],
};
}
@@ -607,6 +607,7 @@ export interface AgentSessionConfig {
title?: string | null;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
mcpServerRequirements?: Record<string, "optional" | "required">;
mcpServers?: Record<string, McpServerConfig>;
/**
* Internal agents are hidden from listings and don't trigger notifications.
@@ -27,6 +27,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
.nullable()
.optional(),
systemPrompt: z.string().nullable().optional(),
mcpServerRequirements: z
.record(z.string(), z.enum(["optional", "required"]))
.nullable()
.optional(),
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
})
.nullable()
@@ -89,6 +93,7 @@ export type SerializableAgentConfig = Pick<
| "toolPolicy"
| "systemPrompt"
| "mcpServers"
| "mcpServerRequirements"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -135,6 +135,29 @@ describe("provider-owned option schemas", () => {
});
describe("exact MCP preapproval mappings", () => {
test("optional exact preapproval permits unsupported providers without changing their MCP config", () => {
const registry = buildProviderRegistry(createTestLogger());
const config = {
provider: "pi",
cwd: "/tmp",
mcpServers: { hub: { type: "http" as const, url: "http://localhost/hub" } },
};
expect(
registry.pi.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" }),
).toEqual({ ...config, toolPolicy: undefined });
});
test.each(["claude", "codex", "opencode"])(
"optional grants retain exact approval for %s",
(provider) => {
const registry = buildProviderRegistry(createTestLogger());
const config = { provider, cwd: "/tmp" };
expect(
registry[provider]!.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" })
.toolPolicy,
).toEqual(hubPolicy);
},
);
test("unsupported providers fail closed with an unattended-execution error", () => {
const registry = buildProviderRegistry(createTestLogger());
expect(() =>
@@ -652,12 +652,15 @@ function createRegistryEntry(
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
applyToolPolicy: (config, toolPolicy) => {
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
throw new ToolPolicyUnsupportedError(provider);
if (toolPolicy.enforcement !== "if-supported")
throw new ToolPolicyUnsupportedError(provider);
return { ...config, toolPolicy: undefined };
}
const exactPolicy = toolPolicy ? { preapproved: toolPolicy.preapproved } : undefined;
return {
...config,
toolPolicy: toolPolicy
? (resolved.contract.applyToolPolicy?.(provider, toolPolicy) ?? toolPolicy)
toolPolicy: exactPolicy
? (resolved.contract.applyToolPolicy?.(provider, exactPolicy) ?? exactPolicy)
: undefined,
};
},
@@ -30,16 +30,20 @@ function createQueryMock(events: unknown[]): Query {
}
describe("Claude SDK env", () => {
test.each(["node", "bun", process.execPath])(
"identifies the spawned %s process as Paseo after clearing parent session env",
async (command) => {
test.each(
["node", "bun", process.execPath].flatMap((command) =>
[undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })),
),
)(
"preserves explicit launch identity $entrypoint in $command while clearing inherited session env",
async ({ command, entrypoint }) => {
let output = "";
let exited = Promise.resolve<unknown[]>([]);
const query = claudeQuery(
{ prompt: "", options: {} },
{
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
queryFactory: ({ options }) => {
if (!options.spawnClaudeCodeProcess) {
throw new Error("Missing Claude process launcher");
@@ -65,7 +69,10 @@ describe("Claude SDK env", () => {
try {
expect(await exited).toEqual([0, null]);
expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null });
expect(JSON.parse(output)).toEqual({
...(entrypoint === undefined ? {} : { entrypoint }),
parentSession: null,
});
} finally {
query.close();
}
@@ -73,8 +73,9 @@ function applyRuntimeSettingsToClaudeOptions(
runtimeSettings,
overlays: [launchEnv],
});
// Set our identity after sanitization clears the parent session's entrypoint.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
// Session-open hooks may explicitly supply a client identity. Keep inherited
// SDK/parent identities cleared while honoring this launch-only contribution.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
@@ -226,6 +226,64 @@ function deferred<T>() {
}
describe("Codex active-turn steering admission", () => {
test("steers an accepted turn whose native id arrives after startTurn returns", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const { turnId } = await session.startTurn("FIRST");
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
await expect(steer).resolves.toEqual({ status: "accepted" });
expect(
appServer.requests().filter((request) => request.method === "turn/start"),
).toHaveLength(1);
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
[],
);
expect(appServer.requests().find((request) => request.method === "turn/steer")).toMatchObject(
{
params: {
threadId: "thread-1",
expectedTurnId: "native-A",
input: [{ type: "text", text: "SECOND" }],
},
},
);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("releases a waiting steer when the accepted turn ends before identification", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const { turnId } = await session.startTurn("FIRST");
const steer = session.steerActiveTurn("SECOND", { expectedTurnId: turnId });
appServer.completeTurn();
await expect(steer).resolves.toEqual({ status: "unavailable" });
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toEqual([]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("a steer without the clearing contract leaves permissions open", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
@@ -4456,9 +4456,18 @@ export class CodexAppServerAgentSession implements AgentSession {
): Promise<SteerResult> {
const client = this.client;
const threadId = this.currentThreadId;
const nativeTurnId = this.currentTurnId;
let nativeTurnId = this.currentTurnId;
const foregroundTurnId = this.activeForegroundTurnId;
if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) {
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const pendingIdentification = this.pendingForegroundTurnIdentification;
// The start acknowledgement precedes the notification that identifies the native
// turn. Keep this steer attached to the accepted foreground turn while it arrives.
if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) {
nativeTurnId = await pendingIdentification.promise;
}
if (!nativeTurnId) {
return { status: "unavailable" };
}
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
@@ -0,0 +1,8 @@
{"type": "session", "version": 3, "id": "e56a0006-f421-45be-9aef-e4019db1cf24", "timestamp": "2026-10-21T13:05:57.384Z", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/lean/sessions/2026-10-21T13-05-53-085Z_01a15e91-73bd-7282-82c6-d3974711a941.jsonl"}
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:57.385Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": true, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/lean/agent", "cwd": "/tmp/paseo-edxeth-fixture/lean/workspace", "boundarySystemPrompt": false}, "id": "57355eeb", "parentId": null, "timestamp": "2026-10-21T13:05:57.386Z"}
{"type": "session_info", "id": "847415e4", "parentId": "57355eeb", "timestamp": "2026-10-21T13:05:57.566Z", "name": "[marker] Fixture marker verification"}
{"type": "model_change", "id": "d4ad59be", "parentId": "847415e4", "timestamp": "2026-10-21T13:05:57.672Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
{"type": "thinking_level_change", "id": "7f6bf9d2", "parentId": "d4ad59be", "timestamp": "2026-10-21T13:05:57.672Z", "thinkingLevel": "high"}
{"type": "message", "id": "737b762e", "parentId": "6b436446", "timestamp": "2026-10-21T13:05:57.679Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e91-73bd-7282-82c6-d3974711a941/context/fixture-marker-2026-10-21T13-05-57.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_LEAN_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587957678}}
{"type": "message", "id": "100b110d", "parentId": "737b762e", "timestamp": "2026-10-21T13:06:02.050Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "PASEO_EDXETH_LEAN_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1713, "output": 13, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalTokens": 1726, "cost": {"input": 0.006852, "output": 0.00026000000000000003, "cacheRead": 0, "cacheWrite": 0, "total": 0.007111999999999999}}, "stopReason": "stop", "timestamp": 1792587957684, "responseId": "resp_0bc8bfaee079148a016ad8b8b7edb08191924676ed8dcfd447", "rawStopReason": "completed"}}
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "80bbd3bb", "parentId": "100b110d", "timestamp": "2026-10-21T13:06:02.054Z"}
@@ -0,0 +1,263 @@
{
"provenance": {
"package": "Zeno2019/pi-subagents-lean",
"version": "2.9.2-lean.1",
"sourceCommit": "e10fe855808abcae193a205ea98f8ccd70d25620",
"piVersion": "0.87.1",
"captureDate": "2026-10-21"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"args": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
}
],
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json"
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
"noSession": false,
"status": "started",
"mode": "background",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"autoExit": true
}
},
"isError": false
},
{
"type": "message_end",
"message": {
"role": "custom",
"customType": "subagent_result",
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
"display": true,
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"mode": "background",
"status": "completed",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 13,
"contextTokens": 1775,
"contextWindow": 372000,
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
},
"timestamp": 1792587964414
}
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"name": "subagent",
"arguments": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK"
}
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 2020,
"output": 43,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 2063,
"cost": {
"input": 0.00808,
"output": 0.0008600000000000001,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.00894
}
},
"stopReason": "toolUse",
"timestamp": 1792587953256,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b3698481918f82962b6eaf2788",
"rawStopReason": "completed"
},
{
"role": "toolResult",
"toolCallId": "call_f28b261de3c6440b953f0f56c3fec7d0|fc_0d35cf51b3ed48b9016ad8b8b4a63c81918a55903d0a4f12cb",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" launched async with id 1bfdf3b3. Results will be delivered automatically as a steer message when it finishes. Use this exact id if you need to resume or stop this child.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict."
}
],
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl",
"noSession": false,
"status": "started",
"mode": "background",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"autoExit": true
},
"isError": false,
"timestamp": 1792587957399
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "**Identifying need for await keyword**"
},
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 2258,
"output": 18,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 11,
"totalTokens": 2276,
"cost": {
"input": 0.009032,
"output": 0.00036,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.009392
}
},
"stopReason": "stop",
"timestamp": 1792587957402,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8b602108191a8e8f7fd77fbe5b5",
"rawStopReason": "completed"
},
{
"role": "custom",
"customType": "subagent_result",
"content": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_LEAN_OK\n\nSession: /tmp/paseo-edxeth-fixture/background/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/background/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish.\n\nActivity: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl\nRun evidence: /home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json\nRead these on demand for progress or failure diagnosis; activity is evidence, not a completion verdict.",
"display": true,
"details": {
"evidence": {
"runId": "1bfdf3b3",
"cwd": "/tmp/paseo-edxeth-fixture/lean/workspace",
"activityFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/activity.jsonl",
"resultFile": "/home/pi-user/.pi/history/workspace/artifacts/2026-10-21T13-05-57-380Z_db802a71-7bb32177-573971ee-535c/subagents/1bfdf3b3/result.json",
"finalized": true
},
"id": "1bfdf3b3",
"name": "fixture-marker",
"task": "Return only PASEO_EDXETH_LEAN_OK",
"agent": "marker",
"mode": "background",
"status": "completed",
"deliveryState": "detached",
"parentClosePolicy": "terminate",
"async": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 13,
"contextTokens": 1775,
"contextWindow": 372000,
"sessionFile": "/tmp/paseo-edxeth-fixture/background/child-session.jsonl"
},
"timestamp": 1792587964414
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 389,
"output": 5,
"cacheRead": 2176,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 2570,
"cost": {
"input": 0.0015559999999999999,
"output": 0.0001,
"cacheRead": 0.0008704000000000001,
"cacheWrite": 0,
"total": 0.0025264
}
},
"stopReason": "stop",
"timestamp": 1792587964417,
"responseId": "resp_0d35cf51b3ed48b9016ad8b8bd0c548191bddd1fe3410a1c82",
"rawStopReason": "completed"
}
]
}
@@ -0,0 +1,8 @@
{"type": "session", "version": 3, "id": "afbea6e0-7858-4ecf-8ef1-e26af9b7d6e0", "timestamp": "2026-10-21T13:05:17.628Z", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "name": "[marker] Fixture marker verification", "parentSession": "/tmp/paseo-edxeth-fixture/upstream/sessions/2026-10-21T13-05-12-212Z_01a15e90-d414-7243-a701-4eb4b0bec880.jsonl"}
{"type": "custom", "customType": "pi-subagents_launch_metadata", "data": {"version": 1, "timestamp": "2026-10-21T13:05:17.628Z", "name": "fixture-marker", "title": "Fixture marker verification", "sessionTitle": "[marker] Fixture marker verification", "agent": "marker", "mode": "background", "sessionMode": "lineage-only", "autoExit": true, "parentClosePolicy": "terminate", "reportContextUsage": true, "async": false, "model": "openai-codex/gpt-5.6-sol", "thinking": "high", "modelRef": "openai-codex/gpt-5.6-sol:high", "definitionModel": "openai-codex/gpt-5.6-sol", "definitionThinking": "high", "allowModelOverride": true, "modelSource": "agent", "tools": "read", "denyTools": ["subagent", "subagent_resume", "subagent_kill"], "spawnableAgents": [], "spawnBudget": null, "extensions": [], "noContextFiles": false, "inheritAppendSystem": false, "noSession": false, "trustProject": false, "agentConfigDir": "/tmp/paseo-edxeth-fixture/upstream/agent", "cwd": "/tmp/paseo-edxeth-fixture/upstream/workspace", "boundarySystemPrompt": false}, "id": "3f7d3c3f", "parentId": null, "timestamp": "2026-10-21T13:05:17.629Z"}
{"type": "session_info", "id": "6e198450", "parentId": "3f7d3c3f", "timestamp": "2026-10-21T13:05:17.825Z", "name": "[marker] Fixture marker verification"}
{"type": "model_change", "id": "94bf4c0b", "parentId": "6e198450", "timestamp": "2026-10-21T13:05:17.962Z", "provider": "openai-codex", "modelId": "gpt-5.6-sol"}
{"type": "thinking_level_change", "id": "57cc6428", "parentId": "94bf4c0b", "timestamp": "2026-10-21T13:05:17.962Z", "thinkingLevel": "high"}
{"type": "message", "id": "a9e47f08", "parentId": "8c186624", "timestamp": "2026-10-21T13:05:17.970Z", "message": {"role": "user", "content": [{"type": "text", "text": "<file name=\"/home/pi-user/.pi/history/workspace/artifacts/01a15e90-d414-7243-a701-4eb4b0bec880/context/fixture-marker-2026-10-21T13-05-17.md\">\n\n\nReturn only the exact marker supplied by the user. Do not use tools.\n\nComplete your task autonomously.\n\nReturn only PASEO_EDXETH_UPSTREAM_OK\n\nYour FINAL assistant message should summarize what you accomplished.\n</file>\n"}], "timestamp": 1792587917968}}
{"type": "message", "id": "bc221a13", "parentId": "a9e47f08", "timestamp": "2026-10-21T13:05:23.476Z", "message": {"role": "assistant", "content": [{"type": "thinking", "thinking": "**Following user file marker exactly**"}, {"type": "text", "text": "PASEO_EDXETH_UPSTREAM_OK"}], "api": "openai-codex-responses", "provider": "openai-codex", "model": "gpt-5.6-sol", "usage": {"input": 1673, "output": 49, "cacheRead": 0, "cacheWrite": 0, "reasoning": 34, "totalTokens": 1722, "cost": {"input": 0.006692, "output": 0.0009800000000000002, "cacheRead": 0, "cacheWrite": 0, "total": 0.007672}}, "stopReason": "stop", "timestamp": 1792587917974, "responseId": "resp_09445c1733d532e7016ad8b8909c908191ba803c059497945e", "rawStopReason": "completed"}}
{"type": "custom", "customType": "pi-subagent-completion", "data": {"reason": "normal"}, "id": "832fc8c9", "parentId": "bc221a13", "timestamp": "2026-10-21T13:05:23.477Z"}
@@ -0,0 +1,156 @@
{
"provenance": {
"package": "edxeth/pi-subagents",
"version": "2.9.2",
"sourceCommit": "cf6dbf41c17986f3882e6804a68e8fb8282d25e6",
"piVersion": "0.87.1",
"captureDate": "2026-10-21"
},
"events": [
{
"type": "tool_execution_start",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"args": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
}
},
{
"type": "tool_execution_end",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"result": {
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
}
],
"details": {
"id": "dd7f94e8",
"name": "fixture-marker",
"agent": "marker",
"status": "completed",
"mode": "background",
"parentClosePolicy": "terminate",
"deliveryState": "awaited",
"async": false,
"autoExit": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 49,
"contextTokens": 1777,
"contextWindow": 372000,
"summary": "PASEO_EDXETH_UPSTREAM_OK",
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
}
},
"isError": false
}
],
"messages": [
{
"role": "assistant",
"content": [
{
"type": "toolCall",
"id": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"name": "subagent",
"arguments": {
"agent": "marker",
"name": "fixture-marker",
"title": "Fixture marker verification",
"task": "Return only PASEO_EDXETH_UPSTREAM_OK"
}
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 3256,
"output": 43,
"cacheRead": 0,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 3299,
"cost": {
"input": 0.013023999999999999,
"output": 0.0008600000000000001,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0.013883999999999999
}
},
"stopReason": "toolUse",
"timestamp": 1792587912428,
"responseId": "resp_0a0ca4aa78cd296a016ad8b88bd52881918cbb9b8326b3060a",
"rawStopReason": "completed"
},
{
"role": "toolResult",
"toolCallId": "call_0bc6fc2213cd49aa92950947bde5dd8a|fc_0a0ca4aa78cd296a016ad8b88d018481919eadbf09eb191a1a",
"toolName": "subagent",
"content": [
{
"type": "text",
"text": "Sub-agent \"fixture-marker\" completed (7s).\n\nPASEO_EDXETH_UPSTREAM_OK\n\nSession: /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\nResume: pi --session /tmp/paseo-edxeth-fixture/foreground/child-session.jsonl\n\nSub-agent context: 2K/372K tokens (0%) used at finish."
}
],
"details": {
"id": "dd7f94e8",
"name": "fixture-marker",
"agent": "marker",
"status": "completed",
"mode": "background",
"parentClosePolicy": "terminate",
"deliveryState": "awaited",
"async": false,
"autoExit": true,
"exitCode": 0,
"elapsed": 7,
"outputTokens": 49,
"contextTokens": 1777,
"contextWindow": 372000,
"summary": "PASEO_EDXETH_UPSTREAM_OK",
"sessionFile": "/tmp/paseo-edxeth-fixture/foreground/child-session.jsonl"
},
"isError": false,
"timestamp": 1792587924671
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "DONE"
}
],
"api": "openai-codex-responses",
"provider": "openai-codex",
"model": "gpt-5.6-sol",
"usage": {
"input": 388,
"output": 5,
"cacheRead": 3072,
"cacheWrite": 0,
"reasoning": 0,
"totalTokens": 3465,
"cost": {
"input": 0.001552,
"output": 0.0001,
"cacheRead": 0.0012288000000000002,
"cacheWrite": 0,
"total": 0.0028808
}
},
"stopReason": "stop",
"timestamp": 1792587924675,
"responseId": "resp_0a0ca4aa78cd296a016ad8b896af2c81919c0ef586dd50f2f0",
"rawStopReason": "completed"
}
]
}
@@ -0,0 +1,336 @@
import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { edxethPiSubagents } from "./index.js";
import { streamPiHistory } from "../../history-mapper.js";
import { ProviderSubagentStore } from "../../../../provider-subagents/store.js";
const args = {
agent: "scout",
name: "status-scout",
title: "Check child status",
task: "Return the completion marker",
};
describe("edxeth/pi-subagents adapter", () => {
test.each(["tool", "notification"])("ends a child that requested help via %s", (delivery) => {
const host = createPiExtensionHost();
const call = { callId: "spawn-1", toolName: "subagent", args, status: "completed" as const };
host.mapToolCall({
...call,
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
const details = { id: "run-1", name: args.name, message: "Please clarify the task" };
const result =
delivery === "tool"
? host.mapToolCall({ ...call, result: { details: { ...details, status: "pinged" } } })
: host.mapCustomMessage({
role: "custom",
customType: "subagent_ping",
content: "Child requested help and exited",
details,
});
expect(result?.subagents).toEqual([
expect.objectContaining({
type: "upsert",
id: "spawn-1",
status: "canceled",
subtitle: "Needs help",
}),
]);
});
test("leaves unrelated tools and other subagent protocols to their adapters", () => {
const host = createPiExtensionHost(undefined, [edxethPiSubagents]);
const call = { callId: "other", toolName: "subagent", args, status: "completed" as const };
for (const details of [null, {}, { mode: "single", results: [] }, { id: "run-1" }]) {
expect(host.mapToolCall({ ...call, result: { details } })).toBeUndefined();
}
expect(
host.mapToolCall({
...call,
toolName: "unrelated",
result: { details: { id: "run-1", name: args.name, status: "completed" } },
}),
).toBeUndefined();
for (const details of [{ id: "run-1" }, { id: "run-1", status: "unknown" }]) {
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Unrecognized result",
details,
}),
).toBeUndefined();
}
});
test("waits for cancellation after a kill acknowledgement reports stopping", () => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
const stopping = host.mapToolCall({
callId: "kill-1",
toolName: "subagent_kill",
args: { id: "run-1" },
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "stopping" } },
});
expect(stopping?.subagents ?? []).toEqual([]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Cancelled",
details: { id: "run-1", status: "cancelled" },
})?.subagents,
).toEqual([{ type: "upsert", id: "spawn-1", status: "canceled" }]);
});
test("preserves a completion that arrives before its launch acknowledgement", () => {
const host = createPiExtensionHost();
const early = host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Already done",
details: { id: "fast-run", status: "completed" },
});
expect(early?.subagents ?? []).toEqual([]);
const started = host.mapToolCall({
callId: "fast-call",
toolName: "subagent",
args,
status: "completed",
result: {
details: { id: "fast-run", name: args.name, agent: args.agent, status: "started" },
},
});
expect(started?.subagents).toEqual([
{
type: "upsert",
id: "fast-call",
title: "scout",
description: args.task,
toolCallId: "fast-call",
status: "completed",
},
]);
});
test.each([
{ details: { status: "completed" }, expected: "completed" },
{ details: { status: "failed" }, expected: "failed" },
{ details: { status: "cancelled" }, expected: "canceled" },
{ details: { error: "Child watcher exited unexpectedly" }, expected: "failed" },
])("maps completion $details to $expected", ({ details, expected }) => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: { details: { id: "run-1", name: args.name, status: "started" } },
});
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Child result",
details: { id: "run-1", ...details },
})?.subagents,
).toEqual([{ type: "upsert", id: "spawn-1", status: expected }]);
});
test("a resume gets its own run and reads the retained child session", async () => {
const dir = await mkdtemp(join(tmpdir(), "paseo-edxeth-resume-"));
const file = join(dir, "child.jsonl");
const host = createPiExtensionHost();
try {
await writeFile(
file,
JSON.stringify({
type: "message",
message: { role: "assistant", content: [{ type: "text", text: "RESUMED" }] },
}) + "\n",
);
const original = host.mapToolCall({
callId: "spawn-1",
toolName: "subagent",
args,
status: "completed",
result: {
details: { id: "run-1", name: args.name, status: "completed", sessionFile: file },
},
});
await original?.hydration;
const resumed = host.mapToolCall({
callId: "resume-1",
toolName: "subagent_resume",
args: { sessionFile: file, task: "Continue" },
status: "completed",
result: {
details: { id: "run-2", name: args.name, status: "started", sessionFile: file },
},
});
expect(resumed?.childSessions).toEqual([{ id: "resume-1", file }]);
expect(await resumed?.hydration).toEqual([
{
type: "provider_subagent",
provider: "pi",
event: {
type: "timeline",
id: "resume-1",
item: expect.objectContaining({ type: "assistant_message", text: "RESUMED" }),
},
},
]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Resumed run complete",
details: { id: "run-2", status: "completed", sessionFile: file },
})?.subagents,
).toEqual([{ type: "upsert", id: "resume-1", status: "completed" }]);
} finally {
host.close();
await rm(dir, { recursive: true, force: true });
}
});
test("settles each batched child by run id without creating a parent-call card", () => {
const host = createPiExtensionHost();
const result = host.mapToolCall({
callId: "batch-1",
toolName: "subagent",
args: { children: [args, { ...args, agent: "reviewer", name: "status-reviewer" }] },
status: "completed",
result: {
details: {
status: "batch",
children: [
{ id: "run-1", ...args, mode: "background", status: "completed" },
{ id: "run-2", name: "status-reviewer", agent: "reviewer", status: "started" },
],
},
},
});
expect(result?.subagents).toEqual([
{
type: "upsert",
id: "batch-1:0",
title: "scout",
description: args.task,
toolCallId: "batch-1",
status: "completed",
},
{
type: "upsert",
id: "batch-1:1",
title: "reviewer",
description: undefined,
toolCallId: "batch-1",
status: "running",
},
]);
expect(
host.mapCustomMessage({
role: "custom",
customType: "subagent_result",
content: "Done",
details: { id: "run-2", status: "completed" },
})?.subagents,
).toEqual([{ type: "upsert", id: "batch-1:1", status: "completed" }]);
});
test.each([
{ mode: "foreground", statuses: ["running", "completed"] },
{ mode: "background", statuses: ["running", "running", "completed"] },
])("settles the captured $mode run live and on replay", async ({ mode, statuses }) => {
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const events = await verifySubagentFixture(fixture);
const updates = events.flatMap(({ event }) => (event.type === "upsert" ? [event] : []));
expect(updates.map((event) => event.status)).toEqual(statuses);
expect(new Set(updates.map((event) => event.id)).size).toBe(1);
expect(events.some(({ event }) => event.type === "timeline")).toBe(true);
});
test.each(["foreground", "background"])(
"restores a completed %s child and its transcript to the consumer store",
async (mode) => {
const fixture = readSubagentFixture(new URL(`./fixtures/${mode}.json`, import.meta.url), {
from: `/tmp/paseo-edxeth-fixture/${mode}/child-session.jsonl`,
to: fileURLToPath(new URL(`./fixtures/${mode}-child-session.jsonl`, import.meta.url)),
});
const store = new ProviderSubagentStore();
for await (const event of streamPiHistory("pi", fixture.messages)) {
if (event.type === "provider_subagent") store.apply("parent", "pi", event.event);
}
const children = store.list("parent");
expect(children).toEqual([expect.objectContaining({ status: "completed", title: "marker" })]);
const transcript = store.fetchTimeline("parent", children[0]!.id);
expect(transcript.rows).toContainEqual(
expect.objectContaining({
item: expect.objectContaining({
type: "assistant_message",
text: mode === "foreground" ? "PASEO_EDXETH_UPSTREAM_OK" : "PASEO_EDXETH_LEAN_OK",
}),
}),
);
},
);
test("settles a synchronous result on the card created when the tool started", () => {
const host = createPiExtensionHost();
const call = { callId: "spawn-1", toolName: "subagent", args };
const start = host.mapToolCall({ ...call, status: "running", result: null });
expect(start?.subagents).toEqual([
{
type: "upsert",
id: "spawn-1",
title: "scout",
description: args.task,
toolCallId: "spawn-1",
status: "running",
},
]);
const end = host.mapToolCall({
...call,
status: "completed",
result: {
content: [{ type: "text", text: "DONE" }],
details: {
id: "run-1",
name: args.name,
agent: args.agent,
mode: "background",
status: "completed",
deliveryState: "awaited",
exitCode: 0,
},
},
});
expect(end?.subagents).toEqual([
{
type: "upsert",
id: "spawn-1",
title: "scout",
description: args.task,
toolCallId: "spawn-1",
status: "completed",
},
]);
});
});
@@ -0,0 +1,119 @@
import { z } from "zod";
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
import type { PiChildSessionFile, PiExtension } from "../contract.js";
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
const Run = z
.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
agent: z.string().trim().min(1).optional(),
task: z.string().optional(),
status: z.enum(["started", "completed", "failed", "cancelled", "pinged"]),
sessionFile: z.string().trim().min(1).optional(),
})
.passthrough();
const Args = z.object({ task: z.string().optional() }).passthrough();
const Batch = z.object({ status: z.enum(["started", "batch"]), children: z.array(Run).min(1) });
const Completion = z
.object({
id: z.string().trim().min(1),
status: z.enum(["completed", "failed", "cancelled", "pinged"]).optional(),
error: z.string().optional(),
sessionFile: z.string().trim().min(1).optional(),
})
.passthrough();
const Ping = Completion.extend({ message: z.string() });
function childStatus(status: z.infer<typeof Run>["status"]) {
if (status === "started") return "running";
// A ping ends the child process and needs a new resume run. It is not success.
if (status === "cancelled" || status === "pinged") return "canceled";
return status;
}
export const edxethPiSubagents: PiExtension = {
id: "edxeth/pi-subagents",
createSession: () => {
const callsByRun = new Map<string, string>();
const completions = new Map<string, z.infer<typeof Completion>>();
const readSessions = new Map<string, string>();
const childSession = (id: string, file: string | undefined): PiChildSessionFile[] => {
if (!file || readSessions.get(id) === file) return [];
readSessions.set(id, file);
return [{ id, file }];
};
const completeRun = (result: z.infer<typeof Completion>) => {
const status = result.status ?? (result.error === undefined ? undefined : "failed");
if (!status) return undefined;
// A fast child can finish before the tool returns its run id. Keep the
// terminal outcome so a later "started" acknowledgement cannot regress it.
completions.set(result.id, { ...result, status });
const id = callsByRun.get(result.id);
if (!id) return undefined;
const subagents: ProviderSubagentInputEvent[] = [
{
type: "upsert",
id,
status: childStatus(status),
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
},
];
return { subagents, childSessions: childSession(id, result.sessionFile) };
};
return {
mapToolCall(call) {
const isLaunch = call.toolName === "subagent" || call.toolName === "subagent_resume";
if (!isLaunch || call.status === "running") return undefined;
const details = typeof call.result === "object" ? call.result?.details : null;
const single = Run.safeParse(details);
let runs: z.infer<typeof Run>[];
if (single.success) {
runs = [single.data];
} else {
const batch = Batch.safeParse(details);
if (!batch.success) return undefined;
runs = batch.data.children;
}
const args = Args.safeParse(call.args);
const description = args.success ? args.data.task : undefined;
const subagents: ProviderSubagentInputEvent[] = [];
const childSessions: PiChildSessionFile[] = [];
for (const [index, run] of runs.entries()) {
const id = runs.length === 1 ? call.callId : `${call.callId}:${index}`;
const completion = completions.get(run.id);
const status = completion?.status ?? run.status;
callsByRun.set(run.id, id);
subagents.push({
type: "upsert",
id,
title: run.agent ?? run.name,
description: run.task ?? description,
toolCallId: call.callId,
status: childStatus(status),
...(status === "pinged" ? { subtitle: "Needs help" } : {}),
});
childSessions.push(...childSession(id, completion?.sessionFile ?? run.sessionFile));
}
return {
detail: {
type: "sub_agent",
description,
log: extractTextFromToolResult(call.result)?.trim() ?? "",
},
subagents,
childSessions,
};
},
mapCustomMessage(message) {
if (message.customType === "subagent_ping") {
const ping = Ping.safeParse(message.details);
return ping.success ? completeRun({ ...ping.data, status: "pinged" }) : undefined;
}
if (message.customType !== "subagent_result") return undefined;
const parsed = Completion.safeParse(message.details);
return parsed.success ? completeRun(parsed.data) : undefined;
},
};
},
};
@@ -1,5 +1,6 @@
import { piAskUser } from "./pi-ask-user/index.js";
import { piSubagents } from "./pi-subagents/index.js";
import { edxethPiSubagents } from "./edxeth-pi-subagents/index.js";
import { piMcpAdapter } from "./pi-mcp-adapter/index.js";
import { rpivTodo } from "./rpiv-todo/index.js";
import { piExampleTodo } from "./pi-example-todo/index.js";
@@ -10,6 +11,7 @@ import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js";
export const piExtensions = [
piAskUser, // Dialog correlation
edxethPiSubagents, // Structured results before the generic subagent tool matcher
piSubagents, // Delegation calls
tintinwebPiSubagents,
gotgenesPiSubagents,
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
const BASE_CONFIG: AgentSessionConfig = {
provider: "claude",
@@ -49,3 +49,15 @@ describe("withRuntimePaseoMcpServer", () => {
expect(result.mcpServers).toBeUndefined();
});
});
test("stripping an internal MCP server also strips its launch requirement", () => {
const result = stripInternalPaseoMcpServer({
...BASE_CONFIG,
mcpServerRequirements: { paseo: "optional", hub: "optional" },
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1/mcp/agents?callerAgentId=agent-1" },
hub: { type: "http", url: "https://hub.test/mcp" },
},
});
expect(result.mcpServerRequirements).toEqual({ hub: "optional" });
});
@@ -18,6 +18,11 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
const next = { ...config };
if (next.mcpServerRequirements?.[PASEO_MCP_SERVER_NAME]) {
next.mcpServerRequirements = { ...next.mcpServerRequirements };
delete next.mcpServerRequirements[PASEO_MCP_SERVER_NAME];
if (Object.keys(next.mcpServerRequirements).length === 0) delete next.mcpServerRequirements;
}
if (Object.keys(nextMcpServers).length > 0) {
next.mcpServers = nextMcpServers;
} else {
@@ -21,7 +21,6 @@ import {
type HostUpdate,
} from "@getpaseo/protocol/hosts";
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
import { webSocketProxyAgent } from "../network/index.js";
export class HostError extends Error {
constructor(
@@ -243,7 +242,6 @@ export class ConnectedHosts {
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
webSocketAgent: webSocketProxyAgent,
},
this.budget,
);
@@ -0,0 +1,80 @@
import { 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 { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { canRunRealProvider } from "./real-provider-test-config.js";
test("keeps two concurrent startup steer messages in one real Codex turn", async (context) => {
if (!(await canRunRealProvider("codex"))) {
context.skip();
return;
}
const cwd = mkdtempSync(path.join(tmpdir(), "codex-steer-starting-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const collector = createMessageCollector(client);
const subscriptions: ReturnType<DaemonClient["subscribeAgentTimeline"]>[] = [];
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (let attempt = 0; attempt < 3; attempt += 1) {
const agent = await client.createAgent({
cwd,
provider: "codex",
modeId: "full-access",
});
const subscription = client.subscribeAgentTimeline(agent.id, () => {});
subscriptions.push(subscription);
await subscription.ready;
await Promise.all([
client.sendAgentMessage(agent.id, "Reply exactly: FIRST", {
messageId: `first-${attempt}`,
activeTurnBehavior: "steer",
}),
client.sendAgentMessage(agent.id, "Reply exactly: SECOND", {
messageId: `second-${attempt}`,
activeTurnBehavior: "steer",
}),
]);
await client.waitForFinish(agent.id, 90_000);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
const users = timeline.entries.filter((entry) => entry.item.type === "user_message");
const events = collector.messages.flatMap((message) =>
message.type === "agent_stream" && message.payload.agentId === agent.id
? [message.payload.event]
: [],
);
expect(users.map((entry) => entry.item.text).sort()).toEqual([
"Reply exactly: FIRST",
"Reply exactly: SECOND",
]);
expect(new Set(users.map((entry) => entry.turnId)).size).toBe(1);
expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1);
expect(
events.filter((event) => event.type === "turn_canceled" || event.type === "turn_failed"),
).toHaveLength(0);
expect(
timeline.entries.some(
(entry) =>
entry.item.type === "assistant_message" && entry.item.text.includes("[System Error]"),
),
).toBe(false);
}
} finally {
await Promise.all(subscriptions.map((subscription) => subscription.release()));
collector.unsubscribe();
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
@@ -1,6 +1,5 @@
import { appendFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { initializeEnvironmentProxy } from "./network/index.js";
import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
@@ -133,11 +132,6 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
async function main() {
const { paseoHome, logger, config } = bootstrapFromEnvironment();
for (const variable of initializeEnvironmentProxy()) {
writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", {
variable,
});
}
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
@@ -248,7 +248,7 @@ test("ordinary Hub requests survive daemon restart and restore an archived works
}),
).toMatchObject({ payload: { agentId, accepted: true } });
expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1);
}, 20_000);
});
test("Hub completes the standard hello before rejecting a second hello", async () => {
const hub = await launchRelationship();
@@ -1,6 +1,5 @@
import { WebSocket } from "ws";
import { z } from "zod";
import { webSocketProxyAgent } from "../network/index.js";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
@@ -167,7 +166,6 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
let sessionProtocol: "legacy" | "session-v1" = "legacy";
const socket = new WebSocket(input.webSocketUrl, {
agent: webSocketProxyAgent(input.webSocketUrl),
handshakeTimeout: this.requestTimeoutMs,
headers: {
authorization: `Bearer ${input.credential}`,
@@ -1,85 +0,0 @@
import type { Agent } from "node:http";
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
import { HttpsProxyAgent } from "https-proxy-agent";
interface ProxyEnvironment {
httpProxy: string;
httpsProxy: string;
noProxy: string;
}
let environment = readEnvironment(process.env).environment;
function readEnvironment(env: NodeJS.ProcessEnv): {
environment: ProxyEnvironment;
ignored: string[];
} {
// process.env lookups are case-insensitive on Windows. Read each actual entry once.
const values = new Map(Object.entries(env));
const ignored: string[] = [];
function proxy(variable: string): string | undefined {
const value = values.get(variable);
if (value === undefined) return undefined;
if (!value.trim()) return undefined;
try {
const url = new URL(value.includes("://") ? value : `http://${value}`);
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error("Unsupported proxy protocol");
decodeURIComponent(url.username);
decodeURIComponent(url.password);
return url.href;
} catch {
ignored.push(variable);
return undefined;
}
}
const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? "";
return {
environment: {
httpProxy,
httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy,
noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "",
},
ignored,
};
}
function bypassList(noProxy: string): string {
return noProxy.trim() === "*" ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`;
}
/** Install once at startup; return ignored variable names for logging without exposing credentials. */
export function initializeEnvironmentProxy(env: NodeJS.ProcessEnv = process.env): string[] {
const resolved = readEnvironment(env);
environment = resolved.environment;
setGlobalDispatcher(
new EnvHttpProxyAgent({
httpProxy: environment.httpProxy,
httpsProxy: environment.httpsProxy,
noProxy: bypassList(environment.noProxy),
}),
);
return resolved.ignored;
}
/** ws uses Node HTTP agents rather than the fetch dispatcher. */
export function webSocketProxyAgent(address: string): Agent | undefined {
const url = new URL(address);
const hostname = url.hostname.toLowerCase();
const port = url.port || (url.protocol === "wss:" ? "443" : "80");
const entries = bypassList(environment.noProxy)
.toLowerCase()
.split(/[,\s]+/);
if (
entries.some((entry) => {
if (entry === "*") return true;
const match = entry.match(/^(.+):(\d+)$/);
if (match && match[2] !== port) return false;
const host = (match ? match[1]! : entry).replace(/^\*?\./, "");
return host !== "" && (hostname === host || hostname.endsWith(`.${host}`));
})
)
return undefined;
const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy;
return proxy ? new HttpsProxyAgent(proxy) : undefined;
}
@@ -1,243 +0,0 @@
import { fork } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer, type Server } from "node:http";
import { once } from "node:events";
import { afterEach, expect, test } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { getGlobalDispatcher, setGlobalDispatcher } from "undici";
import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js";
import { ConnectedHosts } from "../connected-hosts/index.js";
const original = getGlobalDispatcher();
const servers: Server[] = [];
afterEach(async () => {
const installed = getGlobalDispatcher();
setGlobalDispatcher(original);
if (installed !== original) await installed.close();
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
),
);
});
async function listen(server: Server) {
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing address");
return `127.0.0.1:${address.port}`;
}
async function proxy() {
const tunnels: string[] = [];
const server = createServer();
server.on("connect", (request, socket) => {
tunnels.push(request.url!);
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
return { url: `http://${await listen(server)}`, tunnels };
}
test("loopback fetch and WebSockets bypass HTTP_PROXY with empty NO_PROXY", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, NO_PROXY: "" });
const server = createServer((_, response) => response.end("direct"));
const sockets = new WebSocketServer({ server });
sockets.on("connection", (socket) => socket.send("direct"));
const address = await listen(server);
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
const socket = new WebSocket(`ws://${address}`, {
agent: webSocketProxyAgent(`ws://${address}`),
});
expect((await once(socket, "message"))[0].toString()).toBe("direct");
socket.close();
await once(socket, "close");
await new Promise<void>((resolve) => sockets.close(() => resolve()));
for (const host of ["localhost", "127.0.0.1", "[::1]"])
expect(webSocketProxyAgent(`wss://${host}:1234`)).toBeUndefined();
expect(recorder.tunnels).toEqual([]);
});
test("fetch and WebSockets send non-loopback traffic through CONNECT", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({ http_proxy: recorder.url });
await expect(
fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(
recorder.tunnels.filter((host) => host === "vendor.invalid:443").length,
).toBeGreaterThanOrEqual(2);
});
test("NO_PROXY and absent variables keep matching WebSockets direct", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({
HTTPS_PROXY: recorder.url,
NO_PROXY: "vendor.invalid,.example.invalid:443",
});
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
await expect(
fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
expect(webSocketProxyAgent("wss://sub.example.invalid")).toBeUndefined();
expect(webSocketProxyAgent("wss://example.invalid:444")).toBeDefined();
initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, no_proxy: "*" });
expect(webSocketProxyAgent("ws://vendor.invalid")).toBeUndefined();
initializeEnvironmentProxy({});
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
const address = await listen(createServer((_, response) => response.end("direct")));
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
expect(recorder.tunnels).toEqual([]);
});
test.each(["127.0.0.1", "localhost"])(
"scheme-less %s proxies work for fetch and WebSockets",
async (host) => {
const recorder = await proxy();
const address = new URL(recorder.url);
initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` });
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toContain("vendor.invalid:443");
},
);
test.each([
"http://[",
"http://user%zz:secret@proxy.corp",
"socks5://127.0.0.1:7890",
"ftp://proxy.corp:8080",
])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => {
const recorder = await proxy();
expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([
"https_proxy",
]);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
});
test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-"));
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) => !["http_proxy", "https_proxy"].includes(key.toLowerCase()),
),
);
const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], {
execArgv: ["--import", "tsx"],
silent: true,
env: {
...env,
PASEO_HOME: home,
HOME: home,
USERPROFILE: home,
PASEO_LISTEN: "127.0.0.1:0",
PASEO_RELAY_ENABLED: "0",
https_proxy: "socks5://private:secret@127.0.0.1:7890",
},
});
worker.stdout?.resume();
let stderr = "";
worker.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
try {
await new Promise<void>((resolve, reject) => {
worker.on("message", (message) => {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "paseo:ready"
)
resolve();
});
worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`)));
});
const log = await readFile(path.join(home, "daemon.log"), "utf8");
const warnings = log
.split("\n")
.filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable"));
expect(warnings).toHaveLength(1);
expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy");
expect(warnings[0]).not.toContain("secret");
} finally {
if (worker.exitCode === null && worker.signalCode === null) {
const exited = once(worker, "exit");
worker.kill("SIGTERM");
await exited;
}
await rm(home, { recursive: true, force: true });
}
}, 60_000);
test("accepts a scheme-less corporate proxy hostname", () => {
expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
});
test("an ignored lowercase variable falls back to the uppercase variable", async () => {
const recorder = await proxy();
expect(
initializeEnvironmentProxy({
https_proxy: "socks5://proxy.invalid",
HTTPS_PROXY: recorder.url,
}),
).toEqual(["https_proxy"]);
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
});
test("case-insensitive environment access reports a variable only once", () => {
const env = new Proxy(
{ https_proxy: "socks5://127.0.0.1:7890" },
{
get(target, key) {
return Reflect.get(target, typeof key === "string" ? key.toLowerCase() : key);
},
},
);
expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]);
});
test("connected-host links send remote WebSockets through the environment proxy", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url });
const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-"));
const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" });
try {
await hosts.add(
{ connection: "wss://remote.invalid/ws" },
{ sourceId: "test", manageSources: true },
);
await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443");
} finally {
await hosts.close();
await rm(home, { recursive: true, force: true });
}
});
@@ -214,3 +214,14 @@ describe("persistence hooks", () => {
expect(handle).toBeNull();
});
});
test("restores optional MCP requirements for continuation", () => {
const record = createRecord({
config: {
mcpServerRequirements: { hub: "optional" },
mcpServers: { hub: { type: "http", url: "https://hub.test/mcp" } },
},
});
expect(buildConfigOverrides(record).mcpServerRequirements).toEqual({ hub: "optional" });
expect(buildSessionConfig(record)?.mcpServerRequirements).toEqual({ hub: "optional" });
});
@@ -78,6 +78,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
toolPolicy: record.config?.toolPolicy ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
mcpServers: record.config?.mcpServers ?? undefined,
mcpServerRequirements: record.config?.mcpServerRequirements ?? undefined,
});
}
@@ -100,6 +101,7 @@ export function buildSessionConfig(
toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt,
mcpServers: overrides.mcpServers,
mcpServerRequirements: overrides.mcpServerRequirements,
});
}
@@ -1,10 +1,10 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -72,3 +72,71 @@ test("the configuration example saves Codex options and injects launch-only MCP
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("session-open entrypoint contributions are optional and rederived for existing agents", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-entrypoint-hook-"));
const launches: { provider: string; env?: Record<string, string> }[] = [];
function recordingProvider(provider: "claude" | "codex"): AgentClient {
const inner = createTestAgentClient(provider);
return {
provider: inner.provider,
capabilities: inner.capabilities,
isAvailable: () => inner.isAvailable(),
fetchCatalog: (options) => inner.fetchCatalog(options),
createSession: async (...args: Parameters<typeof inner.createSession>) => {
launches.push({ provider, env: args[1]?.env });
return inner.createSession(...args);
},
resumeSession: async (...args: Parameters<typeof inner.resumeSession>) => {
launches.push({ provider, env: args[2]?.env });
return inner.resumeSession(...args);
},
};
}
const daemon = await createTestPaseoDaemon({
daemonVersion: "0.14.0-beta.2",
agentClients: { claude: recordingProvider("claude"), codex: recordingProvider("codex") },
mcpEnabled: false,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.14.0-beta.2",
});
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "entrypoint-test", requirements: { paseo: ">=0.14.0-beta.2" } }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
return server.before("agent.session_open", ({ request }) => {
if (request.provider !== "claude") return;
return { ...request, env: { ...request.env, CLAUDE_CODE_ENTRYPOINT: "custom-client" } };
});
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const existing = await client.createAgent({ provider: "claude", cwd: directory });
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await daemon.daemon.agentManager.reloadAgentSession(existing.id);
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client");
await client.createAgent({ provider: "claude", cwd: directory });
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBe("custom-client");
await client.createAgent({ provider: "codex", cwd: directory });
expect(launches.at(-1)).toMatchObject({ provider: "codex" });
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
await client.disablePlugin("entrypoint-test");
await daemon.daemon.agentManager.reloadAgentSession(existing.id);
expect(launches.at(-1)?.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
@@ -24,7 +24,6 @@ import { createPluginClientId } from "./plugin-session-identity.js";
import { PluginSettingsStore } from "./settings/index.js";
import { readPluginProviderIcon } from "./provider-icon.js";
import { initializeEnvironmentProxy } from "../network/index.js";
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -607,7 +606,6 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
const parsed = PluginProcessRequestSchema.safeParse(raw);
if (!worker && parsed.success && parsed.data.type === "initialize") {
try {
initializeEnvironmentProxy();
worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) });
} catch (error) {
channel.send({ type: "fatal", error: describeError(error) });
@@ -1,31 +0,0 @@
import path from "node:path";
import { initializeEnvironmentProxy } from "../../network/index.js";
import { DaemonClient } from "../../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js";
import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "../builtin/index.js";
const mode = process.argv[2];
if (mode !== "builtin" && mode !== "directory") throw new Error("Expected builtin or directory");
const sources = ["claude-usage-source", "codex-usage-source"];
// Built-ins run inside the daemon; directory plugins initialize their own subprocess network.
if (mode === "builtin") initializeEnvironmentProxy();
const daemon = await createTestPaseoDaemon({
pluginsEnabled: mode === "directory",
builtinPlugins: new BuiltinPluginLoader(
resolveBuiltinPluginsRoot(),
mode === "builtin" ? sources : [],
),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
if (mode === "directory") {
for (const source of sources) {
await client.installDirectoryPlugin(path.join(resolveBuiltinPluginsRoot(), source), source);
}
}
await client.listUsageReports();
} finally {
await client.close();
await daemon.close();
}
@@ -1,11 +1,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createServer } from "node:http";
import { once } from "node:events";
import { expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -339,74 +335,3 @@ export default function contribute(server) {
await rm(root, { recursive: true, force: true });
}
}, 60_000);
test.each(["builtin", "directory"])(
"%s usage plugins reach the environment proxy",
async (mode) => {
const root = await mkdtemp(path.join(os.tmpdir(), "usage-proxy-"));
const tunnels: string[] = [];
const proxy = createServer();
proxy.on("connect", (request, socket) => {
tunnels.push(request.url!);
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");
const address = proxy.address();
if (!address || typeof address === "string") throw new Error("Missing proxy address");
const guard = path.join(root, "network-guard.cjs");
// Prevent vendor DNS/network access even when proxy support is broken.
await writeFile(
guard,
`const dns = require('node:dns');
const lookup = dns.lookup;
dns.lookup = function(host, ...args) {
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return lookup.call(this, host, ...args);
args.at(-1)(Object.assign(new Error('External DNS forbidden in proxy test'), { code: 'ENOTFOUND' }));
};`,
);
await mkdir(path.join(root, ".claude"));
await mkdir(path.join(root, ".codex"));
await writeFile(
path.join(root, ".claude", ".credentials.json"),
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-claude" } }),
);
await writeFile(
path.join(root, ".codex", "auth.json"),
JSON.stringify({ tokens: { access_token: "fixture-codex", account_id: "fixture-account" } }),
);
try {
await promisify(execFile)(
process.execPath,
[
"--import",
"tsx",
fileURLToPath(new URL("./test-fixtures/usage-proxy-daemon.ts", import.meta.url)),
mode,
],
{
timeout: 45_000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
HOME: root,
USERPROFILE: root,
CODEX_HOME: path.join(root, ".codex"),
CLAUDE_CONFIG_DIR: path.join(root, ".claude"),
NODE_OPTIONS: `--require=${JSON.stringify(guard.replaceAll("\\", "/"))}`,
https_proxy: `http://127.0.0.1:${address.port}`,
no_proxy: "",
NO_PROXY: "",
},
},
);
expect(tunnels).toContain("api.anthropic.com:443");
expect(tunnels).toContain("chatgpt.com:443");
} finally {
proxy.closeAllConnections();
await new Promise<void>((resolve) => proxy.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
},
60_000,
);
@@ -2,7 +2,6 @@
import { WebSocket } from "ws";
import type pino from "pino";
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import { webSocketProxyAgent } from "./network/index.js";
import type { ExternalSocketMetadata } from "./websocket-server.js";
export interface RelayTransportOptions {
@@ -57,7 +56,7 @@ const RELAY_WEBSOCKET_OPTIONS = {
} as const;
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
return new WebSocket(url, { ...RELAY_WEBSOCKET_OPTIONS, agent: webSocketProxyAgent(url) });
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -1966,6 +1966,7 @@ export class VoiceAssistantWebSocketServer {
workspaceSortTimes: true,
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
optionalMcpSupport: true,
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: true,
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the