mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 20:52:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edf7eea7c | ||
|
|
bf9dea429b | ||
|
|
629ca2f9b6 | ||
|
|
b27a29c32f | ||
|
|
32ddc6215a | ||
|
|
808024c95f | ||
|
|
2a630cf98c |
@@ -493,6 +493,16 @@ 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`
|
||||
|
||||
@@ -38,7 +38,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
|
||||
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
|
||||
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
|
||||
+1
-1
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
||||
|
||||
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
|
||||
Generated
+24
@@ -40690,6 +40690,7 @@
|
||||
"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",
|
||||
@@ -40707,6 +40708,7 @@
|
||||
"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",
|
||||
@@ -41280,6 +41282,15 @@
|
||||
"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",
|
||||
@@ -41349,6 +41360,19 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
claudeAndCodexReports,
|
||||
openUsageFromIcon,
|
||||
openUsageOptions,
|
||||
closeUsageOptions,
|
||||
closeUsage,
|
||||
expectNoUsageItem,
|
||||
expectPinnedUsage,
|
||||
} from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
test("Check usage applies to the host and Turn on restores cards", async ({
|
||||
page,
|
||||
e2eWorkerClient,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
|
||||
try {
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
await openUsageOptions(page);
|
||||
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
|
||||
await closeUsageOptions(page);
|
||||
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
|
||||
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
|
||||
await expectNoUsageItem(page);
|
||||
const before = usage.listRequests().length;
|
||||
await closeUsage(page);
|
||||
await page.reload();
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
|
||||
expect(usage.listRequests()).toHaveLength(before);
|
||||
await page.getByRole("button", { name: "Turn on", exact: true }).click();
|
||||
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
|
||||
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
|
||||
// A separate client changes the policy, as another device would.
|
||||
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
|
||||
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
|
||||
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
|
||||
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
|
||||
}
|
||||
});
|
||||
|
||||
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await installUsageReportsFixture(page, {
|
||||
usageSettingSupported: false,
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
});
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
|
||||
await openUsageOptions(page);
|
||||
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -441,31 +441,3 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
).toHaveAttribute("d", codexPath);
|
||||
await qaScreenshot(page, "released-host-usage-modal");
|
||||
});
|
||||
|
||||
test("hidden usage sends no requests until opened", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
|
||||
timeout: 90_000,
|
||||
});
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterItemSetting(page, "usage", false);
|
||||
await leaveSettings(page);
|
||||
expect(usage.listRequests()).toHaveLength(0);
|
||||
await openUsageFromIcon(page);
|
||||
await usage.waitForListRequests(1);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("visible usage summary requests reports on load", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
expect(usage.listRequests()).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -28,10 +28,6 @@ interface SeedProjectDescriptor {
|
||||
* prefer those wrappers over reaching for this client directly.
|
||||
*/
|
||||
export interface SeedDaemonClient {
|
||||
patchDaemonConfig(
|
||||
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
|
||||
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
|
||||
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
addProject(cwd: string): Promise<{
|
||||
|
||||
@@ -25,7 +25,6 @@ interface UsageReportsFixtureOptions {
|
||||
* app connects, so a reload can switch it.
|
||||
*/
|
||||
usageSupported?: boolean | (() => boolean);
|
||||
usageSettingSupported?: boolean;
|
||||
/** Released hosts expose provider.usage.list with no source icons. */
|
||||
providerUsageListOnly?: boolean;
|
||||
/** The host daemon's port; defaults to the E2E daemon. */
|
||||
@@ -64,7 +63,6 @@ function withUsageSupportFeature(
|
||||
message: WebSocketMessage,
|
||||
enabled: boolean,
|
||||
providerUsageListOnly: boolean,
|
||||
usageSettingSupported: boolean,
|
||||
): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
@@ -88,7 +86,6 @@ function withUsageSupportFeature(
|
||||
...payload,
|
||||
features: {
|
||||
...features,
|
||||
usageSetting: usageSettingSupported ? true : undefined,
|
||||
usageSources: enabled && !providerUsageListOnly,
|
||||
providerUsageList: enabled,
|
||||
},
|
||||
@@ -232,12 +229,7 @@ export async function installUsageReportsFixture(
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(
|
||||
message,
|
||||
isUsageSupported(),
|
||||
providerUsageListOnly,
|
||||
options.usageSettingSupported ?? true,
|
||||
)
|
||||
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
|
||||
@@ -64,7 +64,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||
import {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
useHasUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -475,10 +480,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
|
||||
*/
|
||||
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const { items } = useSidebarNavItems("footer");
|
||||
const usageSummary = useUsageSummary();
|
||||
const hasUsageSummary = useHasUsageSummary();
|
||||
const rowsRef = useRef<View | null>(null);
|
||||
const visibleItems = items.filter(
|
||||
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
|
||||
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
@@ -494,7 +499,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
) : (
|
||||
<UsageSidebarItem key={item.key} sources={usageSummary} />
|
||||
<UsageSidebarItem key={item.key} />
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -2092,11 +2092,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "التحقق من الاستخدام",
|
||||
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
|
||||
off: "التحقق من الاستخدام متوقف على {{host}}.",
|
||||
turnOn: "تشغيل",
|
||||
|
||||
options: "الإعدادات",
|
||||
refreshFailed: "تعذّر تحديث الاستخدام",
|
||||
noHosts: "لا توجد مضيفات متصلة",
|
||||
|
||||
@@ -2117,11 +2117,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Check usage",
|
||||
checkUsageHint: "Applies to {{host}} on every device",
|
||||
off: "Usage is off on {{host}}.",
|
||||
turnOn: "Turn on",
|
||||
|
||||
options: "Settings",
|
||||
refreshFailed: "Unable to refresh usage",
|
||||
noHosts: "No connected hosts",
|
||||
|
||||
@@ -2140,11 +2140,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Consultar uso",
|
||||
checkUsageHint: "Se aplica a {{host}} en todos los dispositivos",
|
||||
off: "El uso está desactivado en {{host}}.",
|
||||
turnOn: "Activar",
|
||||
|
||||
options: "Configuración",
|
||||
refreshFailed: "No se pudo actualizar el uso",
|
||||
noHosts: "No hay hosts conectados",
|
||||
|
||||
@@ -2140,11 +2140,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Vérifier l’utilisation",
|
||||
checkUsageHint: "S’applique à {{host}} sur tous les appareils",
|
||||
off: "L’utilisation est désactivée sur {{host}}.",
|
||||
turnOn: "Activer",
|
||||
|
||||
options: "Paramètres",
|
||||
refreshFailed: "Impossible d’actualiser l’utilisation",
|
||||
noHosts: "Aucun hôte connecté",
|
||||
|
||||
@@ -2113,11 +2113,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "使用状況を確認",
|
||||
checkUsageHint: "すべてのデバイスで {{host}} に適用",
|
||||
off: "{{host}} の使用状況確認はオフです。",
|
||||
turnOn: "オンにする",
|
||||
|
||||
options: "設定",
|
||||
refreshFailed: "使用状況を更新できません",
|
||||
noHosts: "接続中のホストがありません",
|
||||
|
||||
@@ -2102,11 +2102,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "사용량 확인",
|
||||
checkUsageHint: "모든 기기에서 {{host}}에 적용",
|
||||
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
|
||||
turnOn: "켜기",
|
||||
|
||||
options: "설정",
|
||||
refreshFailed: "사용량을 새로 고칠 수 없습니다",
|
||||
noHosts: "연결된 호스트가 없습니다",
|
||||
|
||||
@@ -2125,11 +2125,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Consultar uso",
|
||||
checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos",
|
||||
off: "O uso está desativado em {{host}}.",
|
||||
turnOn: "Ativar",
|
||||
|
||||
options: "Configurações",
|
||||
refreshFailed: "Não foi possível atualizar o uso",
|
||||
noHosts: "Nenhum host conectado",
|
||||
|
||||
@@ -2125,11 +2125,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Проверять использование",
|
||||
checkUsageHint: "Применяется к {{host}} на всех устройствах",
|
||||
off: "Проверка использования отключена на {{host}}.",
|
||||
turnOn: "Включить",
|
||||
|
||||
options: "Настройки",
|
||||
refreshFailed: "Не удалось обновить использование",
|
||||
noHosts: "Нет подключённых хостов",
|
||||
|
||||
@@ -2070,11 +2070,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "检查用量",
|
||||
checkUsageHint: "在所有设备上应用于 {{host}}",
|
||||
off: "{{host}} 的用量检查已关闭。",
|
||||
turnOn: "开启",
|
||||
|
||||
options: "设置",
|
||||
refreshFailed: "无法刷新用量",
|
||||
noHosts: "没有已连接的主机",
|
||||
|
||||
@@ -21,12 +21,10 @@ export interface UsageHostSelection {
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
serverId,
|
||||
onRefresh,
|
||||
hostSelection,
|
||||
}: {
|
||||
view: UsageView;
|
||||
serverId: string;
|
||||
onRefresh: () => void;
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
@@ -45,8 +43,8 @@ export function UsageControls({
|
||||
) : null}
|
||||
{view.kind === "unavailable" ? null : (
|
||||
<>
|
||||
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
|
||||
<UsageOptions serverId={serverId} />
|
||||
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
|
||||
<UsageOptions />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -86,12 +84,7 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||
const controls = useMemo(
|
||||
() =>
|
||||
hasControls ? (
|
||||
<UsageControls
|
||||
serverId={hostSelection.serverId}
|
||||
view={view}
|
||||
onRefresh={refresh}
|
||||
hostSelection={hostSelection}
|
||||
/>
|
||||
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
|
||||
) : null,
|
||||
[hasControls, hostSelection, refresh, view],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useUsageHostId } from "./hosts";
|
||||
import { useUsageHostReports } from "./queries";
|
||||
import {
|
||||
isUsagePinned,
|
||||
setUsageDisplayAs,
|
||||
@@ -23,6 +25,12 @@ interface UsagePreferencesState {
|
||||
display: UsageDisplay;
|
||||
}
|
||||
|
||||
/** The device's usage preferences, with pins resolved against a host's reports. */
|
||||
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
|
||||
const defaultServerId = useUsageHostId();
|
||||
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
|
||||
}
|
||||
|
||||
/** The device's usage preferences, with pins resolved against the given reports. */
|
||||
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useHostUsage } from "./queries";
|
||||
import { UsageSection } from "./usage-section";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section";
|
||||
export function HostUsageSection({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { view, refresh } = useHostUsage(serverId);
|
||||
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
|
||||
const { display } = useUsagePreferences(serverId);
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
|
||||
@@ -4,6 +4,6 @@ export { useHostReportsUsage } from "./queries";
|
||||
export {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
useUsageSummary,
|
||||
useHasUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "./sidebar-item";
|
||||
|
||||
@@ -405,15 +405,3 @@ it("available and retained usage show the same successful completion time", () =
|
||||
);
|
||||
expect(upsertReport([failure], success)).toEqual([failure]);
|
||||
});
|
||||
|
||||
it("disabled host shows off even with cached reports", () => {
|
||||
expect(
|
||||
resolveUsageView({
|
||||
hostLabel: "Laptop",
|
||||
isConnected: true,
|
||||
supportsUsage: true,
|
||||
enabled: false,
|
||||
query: { data: [], error: null, isFetching: false },
|
||||
}),
|
||||
).toMatchObject({ kind: "off" });
|
||||
});
|
||||
|
||||
@@ -106,7 +106,6 @@ export interface UsageQueryState {
|
||||
|
||||
export function resolveUsageView(
|
||||
input: {
|
||||
enabled?: boolean;
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
@@ -126,8 +125,6 @@ export function resolveUsageView(
|
||||
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
|
||||
};
|
||||
}
|
||||
if (input.enabled === false)
|
||||
return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) };
|
||||
if (query?.data) {
|
||||
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
|
||||
}
|
||||
|
||||
@@ -8,20 +8,15 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { SettingsRow, SettingsSwitch } from "@/components/settings";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useUsageSetting } from "./setting";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
|
||||
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
|
||||
export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
const setting = useUsageSetting(serverId);
|
||||
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
export function UsageOptions() {
|
||||
const { t } = useTranslation();
|
||||
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
|
||||
@@ -32,7 +27,7 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
},
|
||||
];
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { display } = useUsageDisplay([]);
|
||||
const { display } = useUsagePreferences();
|
||||
const { inSidebar, setInSidebar } = useUsageInSidebar();
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -54,25 +49,13 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
testID="usage-options-surface"
|
||||
>
|
||||
<View testID="usage-options-fields">
|
||||
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
|
||||
{setting.supported ? (
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.checkUsage")}
|
||||
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
|
||||
disabled={!setting.ready}
|
||||
value={setting.enabled}
|
||||
onValueChange={setting.setEnabled}
|
||||
/>
|
||||
) : null}
|
||||
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.showInSidebar")}
|
||||
hint={t("providerUsage.showInSidebarHint")}
|
||||
value={inSidebar}
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
</View>
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.showInSidebar")}
|
||||
hint={t("providerUsage.showInSidebarHint")}
|
||||
value={inSidebar}
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
<View style={settingsStyles.rowBorder}>
|
||||
<SettingsRow label={t("providerUsage.displayAs")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { useUsageSetting } from "./setting";
|
||||
import { usageCopy } from "./copy";
|
||||
import {
|
||||
replaceReport,
|
||||
@@ -133,14 +132,13 @@ function toQueryState(query: {
|
||||
|
||||
/** Usage reports for one host, as shown on its settings page. */
|
||||
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
|
||||
const setting = useUsageSetting(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
const query = useFetchQuery({
|
||||
queryKey: usageReportsQueryKey(serverId),
|
||||
queryFn: () => listReports(queryClient, serverId),
|
||||
enabled: isConnected && isSupported && setting.enabled,
|
||||
enabled: isConnected && isSupported,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
@@ -152,7 +150,6 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
const view = resolveUsageView(
|
||||
{
|
||||
hostLabel,
|
||||
enabled: setting.ready ? setting.enabled : undefined,
|
||||
isConnected,
|
||||
supportsUsage: isSupported,
|
||||
query: toQueryState(query),
|
||||
@@ -169,16 +166,14 @@ const NO_REPORTS: UsageReportEntry[] = [];
|
||||
* load, or without a host.
|
||||
*/
|
||||
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
|
||||
const setting = useUsageSetting(serverId);
|
||||
const queryClient = useQueryClient();
|
||||
const query = useFetchQuery({
|
||||
enabled: setting.enabled,
|
||||
queryKey: usageReportsQueryKey(serverId ?? ""),
|
||||
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
|
||||
return query.data ?? NO_REPORTS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,8 +209,7 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
|
||||
export function useHostReportsUsage(serverId: string): boolean {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
const setting = useUsageSetting(serverId);
|
||||
return isConnected && isSupported && setting.enabled;
|
||||
return isConnected && isSupported;
|
||||
}
|
||||
|
||||
/** Every host with whether it is connected and reports usage, in host order. */
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
/** Host policy comes from the live daemon config replica. */
|
||||
export function useUsageSetting(serverId: string | null) {
|
||||
const supported = useSessionStore((state) =>
|
||||
Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting),
|
||||
);
|
||||
const { config, patchConfig } = useDaemonConfig(supported ? serverId : null);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }),
|
||||
});
|
||||
const setEnabled = mutation.mutate;
|
||||
const turnOn = useCallback(() => setEnabled(true), [setEnabled]);
|
||||
return {
|
||||
supported,
|
||||
error: mutation.error,
|
||||
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
|
||||
enabled: !supported || (config !== null && config.usage?.enabled !== false),
|
||||
ready: !supported || config !== null,
|
||||
setEnabled,
|
||||
turnOn,
|
||||
};
|
||||
}
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostId } from "./hosts";
|
||||
import { useUsageHostReports } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
@@ -34,24 +33,28 @@ import {
|
||||
import { UsageModal } from "./usage-modal";
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
export function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
const { t } = useTranslation();
|
||||
const { inSidebar } = useUsageInSidebar();
|
||||
const serverId = useUsageHostId();
|
||||
const reports = useUsageHostReports(inSidebar ? serverId : null);
|
||||
const { preferences } = useUsageDisplay(reports);
|
||||
const { preferences } = useUsagePreferences();
|
||||
const reports = useUsageHostReports(useUsageHostId());
|
||||
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
|
||||
}
|
||||
|
||||
/** Whether the sidebar Usage item has anything to show. */
|
||||
export function useHasUsageSummary(): boolean {
|
||||
return useUsageSummary().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
|
||||
* while no summary window has data, since the footer's Usage icon already opens Usage.
|
||||
* Pressing it opens the Usage modal.
|
||||
*/
|
||||
export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) {
|
||||
export function UsageSidebarItem() {
|
||||
const { t } = useTranslation();
|
||||
const label = t(builtinSidebarNavLabelKey("usage"));
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
const sources = useUsageSummary();
|
||||
if (sources.length === 0) return null;
|
||||
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export type UsageBalanceUnit = UsageBalance["unit"];
|
||||
|
||||
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
|
||||
export type UsageView =
|
||||
| { kind: "off"; message: string }
|
||||
| { kind: "unavailable"; message: string }
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { View } from "react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useHostUsageWithControls } from "./controls";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageHostSelection } from "./hosts";
|
||||
import type { UsageHost } from "./model";
|
||||
import { UsageBody, UsageMessage } from "./usage-section";
|
||||
@@ -52,12 +52,12 @@ function HostUsageModal({
|
||||
hosts: UsageHost[];
|
||||
onSelectHost: (serverId: string) => void;
|
||||
}) {
|
||||
const { display } = useUsagePreferences();
|
||||
const hostSelection = useMemo(
|
||||
() => ({ hosts, serverId, onSelect: onSelectHost }),
|
||||
[hosts, onSelectHost, serverId],
|
||||
);
|
||||
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
|
||||
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
|
||||
return (
|
||||
<UsageModalFrame {...props} actions={controls}>
|
||||
<View testID={`usage-host-${serverId}`}>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { useUsageSetting } from "./setting";
|
||||
import { UsageControls } from "./controls";
|
||||
import { UsageList } from "./list";
|
||||
import type { UsageView } from "./types";
|
||||
@@ -28,8 +27,8 @@ export function UsageSection({
|
||||
testID?: string;
|
||||
}) {
|
||||
const trailing = useMemo(
|
||||
() => <UsageControls serverId={serverId} view={view} onRefresh={onRefresh} />,
|
||||
[onRefresh, view, serverId],
|
||||
() => <UsageControls view={view} onRefresh={onRefresh} />,
|
||||
[onRefresh, view],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -52,15 +51,6 @@ export function UsageBody({
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const setting = useUsageSetting(serverId);
|
||||
if (view.kind === "off")
|
||||
return (
|
||||
<View>
|
||||
<UsageMessage text={view.message} />
|
||||
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
|
||||
<Button onPress={setting.turnOn}>{t("providerUsage.turnOn")}</Button>
|
||||
</View>
|
||||
);
|
||||
if (view.kind === "unavailable") {
|
||||
return <UsageMessage text={view.message} />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -19,10 +20,15 @@ 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
|
||||
@@ -68,12 +74,13 @@ export function createHostTransportFactory(
|
||||
return;
|
||||
}
|
||||
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
|
||||
let factory = createWebSocketTransportFactory((url, options) => {
|
||||
let factory = createWebSocketTransportFactory((url, socketOptions) => {
|
||||
const socket = new WebSocket(
|
||||
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
|
||||
options?.protocols,
|
||||
socketOptions?.protocols,
|
||||
{
|
||||
headers: options?.headers,
|
||||
headers: socketOptions?.headers,
|
||||
agent: socketPath ? undefined : options.webSocketAgent?.(url),
|
||||
...(socketPath ? { socketPath } : {}),
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
},
|
||||
@@ -191,14 +198,15 @@ export function createHostTransportFactory(
|
||||
}
|
||||
export function createHostClient(
|
||||
connection: string,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
||||
budget?: HostTransportBudget,
|
||||
): DaemonClient {
|
||||
const target = parseHostTarget(connection);
|
||||
const { webSocketAgent, ...clientConfig } = config;
|
||||
return new DaemonClient({
|
||||
...config,
|
||||
...clientConfig,
|
||||
url: "ws://host-connector/ws",
|
||||
...(target.password ? { password: target.password } : {}),
|
||||
transportFactory: createHostTransportFactory(connection, budget),
|
||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,8 +229,6 @@ export const MutableDaemonConfigSchema = z
|
||||
.optional(),
|
||||
app: z.object({ baseUrl: z.string() }).optional(),
|
||||
catalogRefreshTimeoutMs: z.number().int().positive().optional(),
|
||||
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
|
||||
usage: z.object({ enabled: z.boolean() }).optional(),
|
||||
browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }),
|
||||
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
|
||||
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
|
||||
@@ -258,7 +256,6 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
.object({
|
||||
relay: MutableRelayConfigSchema.partial().optional(),
|
||||
mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(),
|
||||
usage: z.object({ enabled: z.boolean() }).optional(),
|
||||
browserTools: MutableBrowserToolsConfigSchema.partial().optional(),
|
||||
providers: z
|
||||
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
|
||||
@@ -3676,8 +3673,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
hostMultiplexing: z.boolean().optional(),
|
||||
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
|
||||
usageSetting: z.boolean().optional(),
|
||||
usageSources: z.boolean().optional(),
|
||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||
providersSnapshotCwd: z.boolean().optional(),
|
||||
|
||||
@@ -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/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/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: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,6 +124,7 @@
|
||||
"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",
|
||||
@@ -141,6 +142,7 @@
|
||||
"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",
|
||||
|
||||
@@ -420,7 +420,6 @@ export interface PaseoDaemonConfig {
|
||||
trustedProxies?: true | string[];
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
usageEnabled?: boolean;
|
||||
browserToolsEnabled?: boolean;
|
||||
git?: {
|
||||
maxProcessesPerSecond: number;
|
||||
@@ -591,7 +590,6 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
|
||||
...(config.providerCatalogRefreshTimeoutMs !== undefined
|
||||
? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs }
|
||||
: {}),
|
||||
usage: { enabled: config.usageEnabled !== false },
|
||||
browserTools: { enabled: config.browserToolsEnabled ?? false },
|
||||
providers,
|
||||
metadataGeneration: {
|
||||
|
||||
@@ -523,10 +523,6 @@ function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedCon
|
||||
return persisted.daemon?.appendSystemPrompt ?? "";
|
||||
}
|
||||
|
||||
function resolveUsageEnabled(persisted: ReturnType<typeof loadPersistedConfig>): boolean {
|
||||
return persisted.daemon?.usage?.enabled !== false;
|
||||
}
|
||||
|
||||
function resolveBrowserToolsEnabled(persisted: ReturnType<typeof loadPersistedConfig>): boolean {
|
||||
return persisted.daemon?.browserTools?.enabled ?? false;
|
||||
}
|
||||
@@ -552,7 +548,6 @@ function resolveStaticLoadConfigSettings(
|
||||
mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true,
|
||||
mcpInjectIntoAgents:
|
||||
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
|
||||
usageEnabled: resolveUsageEnabled(persisted),
|
||||
browserToolsEnabled: resolveBrowserToolsEnabled(persisted),
|
||||
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
|
||||
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
|
||||
@@ -581,7 +576,6 @@ export type ResolvedDaemonSettings = Pick<
|
||||
| "trustedProxies"
|
||||
| "mcpEnabled"
|
||||
| "mcpInjectIntoAgents"
|
||||
| "usageEnabled"
|
||||
| "browserToolsEnabled"
|
||||
| "git"
|
||||
| "autoArchiveAfterMerge"
|
||||
|
||||
@@ -21,6 +21,7 @@ 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(
|
||||
@@ -242,6 +243,7 @@ export class ConnectedHosts {
|
||||
{
|
||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||
connectTimeoutMs: 10000,
|
||||
webSocketAgent: webSocketProxyAgent,
|
||||
},
|
||||
this.budget,
|
||||
);
|
||||
|
||||
@@ -10,10 +10,6 @@ import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
function usageEnabled(persisted: PersistedConfig): boolean {
|
||||
return persisted.daemon?.usage?.enabled !== false;
|
||||
}
|
||||
|
||||
function reloadableConfig(
|
||||
persisted: PersistedConfig,
|
||||
options: { relayEnabledFallback?: boolean } = {},
|
||||
@@ -27,7 +23,6 @@ function reloadableConfig(
|
||||
enabled: relay.enabled ?? options.relayEnabledFallback ?? true,
|
||||
},
|
||||
mcp: { enabled: true, injectIntoAgents: false },
|
||||
usage: { enabled: usageEnabled(persisted) },
|
||||
browserTools: { enabled: daemon.browserTools?.enabled ?? false },
|
||||
providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"],
|
||||
metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] },
|
||||
@@ -1577,31 +1572,3 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
expect(JSON.stringify(other.get())).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
test("usage policy persists across restart and live reload", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "usage-policy-"));
|
||||
try {
|
||||
const initial = reloadableConfig({});
|
||||
const store = new DaemonConfigStore(paseoHome, initial, undefined, {
|
||||
reloadSource: {
|
||||
resolve: (persisted) => ({
|
||||
mutable: reloadableConfig(persisted),
|
||||
overrideControlledPaths: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
await store.patch({ usage: { enabled: false } });
|
||||
expect(loadConfig(paseoHome, { env: {} }).usageEnabled).toBe(false);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const restarted = new DaemonConfigStore(paseoHome, reloadableConfig(persisted));
|
||||
expect(restarted.get().usage).toEqual({ enabled: false });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ daemon: { usage: { enabled: true } } }),
|
||||
);
|
||||
await store.reload();
|
||||
expect(store.get().usage).toEqual({ enabled: true });
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOver
|
||||
interface SupportedMutableConfigPatch {
|
||||
relay?: { enabled?: boolean; requireProtectedSecurity?: boolean };
|
||||
mcp?: { injectIntoAgents?: boolean };
|
||||
usage?: { enabled: boolean };
|
||||
browserTools?: { enabled?: boolean };
|
||||
providers?: MutableDaemonConfig["providers"];
|
||||
removeProviders?: string[];
|
||||
@@ -183,7 +182,6 @@ const RELOADABLE_PATHS = [
|
||||
"daemon.relay.requireProtectedSecurity",
|
||||
"daemon.mcp.enabled",
|
||||
"daemon.mcp.injectIntoAgents",
|
||||
"daemon.usage.enabled",
|
||||
"daemon.browserTools.enabled",
|
||||
"daemon.hostnames",
|
||||
"daemon.cors.allowedOrigins",
|
||||
@@ -211,7 +209,6 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
||||
["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"],
|
||||
["daemon.mcp.enabled", "mcp.enabled"],
|
||||
["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"],
|
||||
["daemon.usage.enabled", "usage.enabled"],
|
||||
["daemon.browserTools.enabled", "browserTools.enabled"],
|
||||
["daemon.hostnames", "hostnames"],
|
||||
["daemon.cors.allowedOrigins", "cors.allowedOrigins"],
|
||||
@@ -284,7 +281,6 @@ function pickRelayPatch(
|
||||
function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch {
|
||||
return {
|
||||
...pickRelayPatch(patch.relay),
|
||||
usage: patch.usage,
|
||||
...(patch.mcp?.injectIntoAgents !== undefined
|
||||
? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } }
|
||||
: {}),
|
||||
@@ -771,7 +767,6 @@ function mergeMutableDaemonPatch(
|
||||
if (patch.mcp?.injectIntoAgents !== undefined) {
|
||||
next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
|
||||
}
|
||||
if (patch.usage) next.usage = patch.usage;
|
||||
if (patch.browserTools?.enabled !== undefined) {
|
||||
next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -132,6 +133,11 @@ 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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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;
|
||||
@@ -166,6 +167,7 @@ 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}`,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -259,7 +259,6 @@ export const PersistedConfigSchema = z
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
usage: z.object({ enabled: z.boolean().optional() }).optional(),
|
||||
browserTools: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -24,6 +24,7 @@ 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);
|
||||
}
|
||||
@@ -606,6 +607,7 @@ 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) });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,7 +1,11 @@
|
||||
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";
|
||||
@@ -335,3 +339,74 @@ 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,6 +2,7 @@
|
||||
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 {
|
||||
@@ -56,7 +57,7 @@ const RELAY_WEBSOCKET_OPTIONS = {
|
||||
} as const;
|
||||
|
||||
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
|
||||
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
|
||||
return new WebSocket(url, { ...RELAY_WEBSOCKET_OPTIONS, agent: webSocketProxyAgent(url) });
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
@@ -1042,7 +1042,6 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.usageSession = new UsageSession({
|
||||
isEnabled: () => daemonConfigStore.get().usage?.enabled !== false,
|
||||
emit: (msg) => this.emit(msg),
|
||||
runtime: pluginRuntime,
|
||||
logger: this.sessionLogger,
|
||||
|
||||
@@ -15,7 +15,6 @@ test("lists reports from usage sources", async () => {
|
||||
report: { status: "available" as const, windows: [] },
|
||||
};
|
||||
const usage = new UsageSession({
|
||||
isEnabled: () => true,
|
||||
emit: (message) => emitted.push(message),
|
||||
runtime: {
|
||||
async listUsageReports(options) {
|
||||
@@ -41,7 +40,6 @@ test("lists reports from usage sources", async () => {
|
||||
test("surfaces a legacy usage-list failure as an rpc_error envelope", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const usage = new UsageSession({
|
||||
isEnabled: () => true,
|
||||
emit: (message) => emitted.push(message),
|
||||
runtime: {
|
||||
async listUsageReports() {
|
||||
@@ -63,7 +61,6 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
|
||||
test("request failures terminate with an error response and no updates", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const usage = new UsageSession({
|
||||
isEnabled: () => true,
|
||||
emit: (message) => emitted.push(message),
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
@@ -75,36 +72,3 @@ test("request failures terminate with an error response and no updates", async (
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("off rejects both protocols before runtime discovery and turning on restores reports", async () => {
|
||||
let enabled = false;
|
||||
let calls = 0;
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const usage = new UsageSession({
|
||||
isEnabled: () => enabled,
|
||||
emit: (message) => emitted.push(message),
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: {
|
||||
async listUsageReports() {
|
||||
calls++;
|
||||
return [];
|
||||
},
|
||||
async listLegacyUsage() {
|
||||
calls++;
|
||||
return { fetchedAt: "now", providers: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await usage.handleListReports({ type: "usage.list_reports.request", requestId: "off" });
|
||||
await usage.handleLegacyList({ type: "provider.usage.list.request", requestId: "old" });
|
||||
expect(calls).toBe(0);
|
||||
expect(emitted[0]).toMatchObject({ payload: { error: "Usage is off on this host." } });
|
||||
expect(emitted[1]).toMatchObject({
|
||||
type: "rpc_error",
|
||||
payload: { error: "Usage is off on this host." },
|
||||
});
|
||||
enabled = true;
|
||||
await usage.handleListReports({ type: "usage.list_reports.request", requestId: "on" });
|
||||
expect(calls).toBe(1);
|
||||
expect(emitted[2]).toMatchObject({ payload: { error: null } });
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface UsageSessionOptions {
|
||||
listUsageReports(options: ListUsageReportsOptions): Promise<UsageReportEntry[]>;
|
||||
listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>;
|
||||
};
|
||||
isEnabled: () => boolean;
|
||||
logger: pino.Logger;
|
||||
}
|
||||
|
||||
@@ -20,7 +19,6 @@ export class UsageSession {
|
||||
msg: Extract<SessionInboundMessage, { type: "usage.list_reports.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.options.isEnabled()) throw new Error("Usage is off on this host.");
|
||||
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
|
||||
await this.options.runtime.listUsageReports({
|
||||
forceRefresh: msg.forceRefresh,
|
||||
@@ -51,18 +49,6 @@ export class UsageSession {
|
||||
async handleLegacyList(
|
||||
msg: Extract<SessionInboundMessage, { type: "provider.usage.list.request" }>,
|
||||
): Promise<void> {
|
||||
if (!this.options.isEnabled()) {
|
||||
this.options.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: msg.type,
|
||||
error: "Usage is off on this host.",
|
||||
code: "provider_usage_list_failed",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
|
||||
const usage = await this.options.runtime.listLegacyUsage();
|
||||
|
||||
@@ -1949,7 +1949,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
hostMultiplexing: true,
|
||||
// COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21.
|
||||
relayProtectedSecurity: true,
|
||||
usageSetting: true,
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
agentRequestReceipts: true,
|
||||
|
||||
@@ -70,14 +70,6 @@
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"usage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browserTools": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user