From dac0abae52020e572769dbb431ec68ca03d01d0c Mon Sep 17 00:00:00 2001 From: ying-hua <60057611+ying-hua@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:02:00 +0800 Subject: [PATCH] fix(bots): apply the configured network proxy to Client-owned bot traffic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `setActiveProxy` had no production caller, so `resolveActiveProxy()` always returned null and the bot bridges' `proxiedFetch` never built a proxy dispatcher. Every bot request went direct whatever Network settings said, which on a blocked network surfaces as the `Fetch timeout` in #5091. Model execution is unaffected because it resolves the proxy inside the Host and injects a transport per connection. The bot bridges cannot: `BotRegistry` is constructed in the Client process, and the resolved proxy — including its credential — only exists Host-side. Add `network-proxy.resolve` so the Host can serve that resolved proxy, and apply it in the Client at Host registration and whenever a settings patch writes the proxy policy. A non-local Host describes a different machine's network, so its policy is never adopted; the Client stays direct instead. Refs #5091 Generated-by: Claude Code --- .../__tests__/client-network-proxy.test.ts | 177 +++++++++++++++ .../runtime-host-settings-ipc-main.test.ts | 17 ++ apps/desktop/src/main/client-network-proxy.ts | 117 ++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 13 ++ apps/desktop/src/main/runtime-host-client.ts | 4 + .../main/runtime-host-settings-ipc-main.ts | 6 + .../network-proxy-coordinator.test.ts | 203 ++++++++++++++++++ packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/network-proxy.ts | 93 +++++++- .../runtime-host/src/protocol/operations.ts | 1 + .../src/server/network-proxy-coordinator.ts | 40 ++++ packages/runtime/package.json | 1 + 12 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/client-network-proxy.test.ts create mode 100644 apps/desktop/src/main/client-network-proxy.ts create mode 100644 packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts diff --git a/apps/desktop/src/main/__tests__/client-network-proxy.test.ts b/apps/desktop/src/main/__tests__/client-network-proxy.test.ts new file mode 100644 index 0000000000..7c7b11b010 --- /dev/null +++ b/apps/desktop/src/main/__tests__/client-network-proxy.test.ts @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import type { ProxySettings } from "@maka/core/settings/network-settings"; +import type { NetworkProxyResolveResult } from "@maka/runtime-host/protocol"; +import { createClientNetworkProxyApplier } from "../client-network-proxy.js"; + +const RESOLVED: NetworkProxyResolveResult = { + kind: "ready", + proxy: { + enabled: true, + type: "http", + host: "127.0.0.1", + port: 7897, + bypassList: ["localhost"], + }, +}; + +function harness( + profileKind: "local" | "environment" | "remote", + resolve: () => Promise, +) { + const applied: (ProxySettings | null)[] = []; + const errors: unknown[] = []; + const scheduled: { run: () => void; delayMs: number }[] = []; + const applier = createClientNetworkProxyApplier({ + profileKind, + resolve, + apply: (proxy) => applied.push(proxy), + onError: (error) => errors.push(error), + schedule: (run, delayMs) => scheduled.push({ run, delayMs }), + }); + return { applier, applied, errors, scheduled }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe("createClientNetworkProxyApplier", () => { + test("applies the resolved proxy for a local Host", async () => { + const { applier, applied } = harness("local", async () => RESOLVED); + await applier.refresh(); + assert.deepStrictEqual(applied, [RESOLVED.proxy]); + }); + + test("applies direct when the policy disables the proxy", async () => { + const { applier, applied } = harness("local", async () => ({ kind: "ready" })); + await applier.refresh(); + assert.deepStrictEqual(applied, [null]); + }); + + test("applies direct when the proxy credential is missing", async () => { + const { applier, applied } = harness("local", async () => ({ + kind: "credential_not_configured", + })); + await applier.refresh(); + assert.deepStrictEqual(applied, [null]); + }); + + test("never adopts a non-local Host's proxy policy", async () => { + for (const kind of ["remote", "environment"] as const) { + let resolved = false; + const { applier, applied } = harness(kind, async () => { + resolved = true; + return RESOLVED; + }); + await applier.refresh(); + // The bot bridges dial out from this machine, so a Host describing a + // different network must not be asked, let alone applied. + assert.strictEqual(resolved, false, `${kind} resolved the proxy`); + assert.deepStrictEqual(applied, [null]); + } + }); + + test("keeps the last applied proxy when resolution fails", async () => { + let fail = false; + const { applier, applied, errors } = harness("local", async () => { + if (fail) throw new Error("Host unreachable"); + return RESOLVED; + }); + await applier.refresh(); + fail = true; + await applier.refresh(); + // A brief Host outage, or an older Host without the operation, is not + // evidence that the user wants direct connections. + assert.deepStrictEqual(applied, [RESOLVED.proxy]); + assert.strictEqual(errors.length, 1); + }); + + test("reports a repeated resolution failure once", async () => { + const { applier, errors } = harness("local", async () => { + throw new Error("Host unreachable"); + }); + await applier.refresh(); + await applier.refresh(); + assert.strictEqual(errors.length, 1); + }); + + test("retries a failed first resolution instead of settling on direct", async () => { + // The first refresh runs while the Host connection is still settling, so + // losing that race must not leave the bot bridges direct until the user + // next edits the proxy. + let fail = true; + const { applier, applied, scheduled } = harness("local", async () => { + if (fail) throw new Error("host_not_ready"); + return RESOLVED; + }); + await applier.refresh(); + assert.deepStrictEqual(applied, []); + assert.strictEqual(scheduled.length, 1); + + fail = false; + scheduled[0]?.run(); + await flush(); + assert.deepStrictEqual(applied, [RESOLVED.proxy]); + }); + + test("bounds the retry budget instead of reconnecting forever", async () => { + const { applier, scheduled } = harness("local", async () => { + throw new Error("host_not_ready"); + }); + await applier.refresh(); + for (let index = 0; index < 6; index += 1) { + const pending = scheduled[index]; + if (!pending) break; + pending.run(); + await flush(); + } + assert.deepStrictEqual( + scheduled.map((entry) => entry.delayMs), + [1_000, 5_000, 15_000], + ); + }); + + test("serializes concurrent refreshes so the last resolution wins", async () => { + const gates: (() => void)[] = []; + const order: string[] = []; + let call = 0; + const { applier, applied } = harness("local", async () => { + const index = call++; + order.push(`start:${index}`); + await new Promise((resolve) => gates.push(resolve)); + order.push(`end:${index}`); + return index === 0 ? RESOLVED : { kind: "ready" }; + }); + + const first = applier.refresh(); + const second = applier.refresh(); + await flush(); + // The second resolution must not have begun while the first is in flight. + assert.deepStrictEqual(order, ["start:0"]); + gates[0]?.(); + await first; + await flush(); + gates[1]?.(); + await second; + assert.deepStrictEqual(order, ["start:0", "end:0", "start:1", "end:1"]); + assert.deepStrictEqual(applied, [RESOLVED.proxy, null]); + }); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts index 63bd715226..c3928e7aa1 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts @@ -235,6 +235,7 @@ function createModuleFixture(options: { }, }; + let networkProxyChanges = 0; const module = createRuntimeHostSettingsModule({ client: client as never, settingsStore: { @@ -246,6 +247,9 @@ function createModuleFixture(options: { }, } as never, async applyClientSettings() {}, + onNetworkProxyChanged: () => { + networkProxyChanges += 1; + }, }); return { @@ -253,9 +257,22 @@ function createModuleFixture(options: { events, policy: () => policy, secret: () => secret, + networkProxyChanges: () => networkProxyChanges, }; } +test("a proxy patch notifies Client-owned traffic so it re-resolves", async () => { + const fixture = createModuleFixture(); + + // Without this the bot bridges keep the proxy they were started with, which + // in practice means none at all (apache/maka#5091). + await fixture.module.update({ network: { proxy: { host: "127.0.0.1" } } }); + assert.equal(fixture.networkProxyChanges(), 1); + + await fixture.module.update({ personalization: { displayName: "Operator" } }); + assert.equal(fixture.networkProxyChanges(), 1); +}); + test("runtime settings project credential status without a password value", async () => { const fixture = createModuleFixture({ configured: true }); diff --git a/apps/desktop/src/main/client-network-proxy.ts b/apps/desktop/src/main/client-network-proxy.ts new file mode 100644 index 0000000000..5aec5aca81 --- /dev/null +++ b/apps/desktop/src/main/client-network-proxy.ts @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Applies the Runtime Policy network proxy to the Client's own outbound + * traffic. + * + * Model execution resolves the proxy inside the Host and injects a transport + * per connection. The bot bridges do not: `BotRegistry` is constructed in this + * process, so its `proxiedFetch` reads process-wide state that only this module + * writes. Without it the seam stays empty and every bot request goes direct, + * whatever the Network settings say (apache/maka#5091). + */ + +import type { ProxySettings } from "@maka/core/settings/network-settings"; +import type { RuntimeHostProfileKind } from "@maka/runtime-host/profile-kind"; +import type { NetworkProxyResolveResult } from "@maka/runtime-host/protocol"; +import { setActiveProxy } from "@maka/runtime/network/active-proxy-state"; + +/** + * The first resolution runs while the Host connection is still settling, so a + * `host_not_ready` refusal is expected rather than terminal. Without a retry a + * Client that loses that race stays direct until the user next edits the proxy. + */ +const RETRY_DELAYS_MS = [1_000, 5_000, 15_000] as const; + +export interface ClientNetworkProxyDeps { + readonly profileKind: RuntimeHostProfileKind; + readonly resolve: () => Promise; + readonly apply?: (proxy: ProxySettings | null) => void; + readonly onError?: (error: unknown) => void; + readonly schedule?: (run: () => void, delayMs: number) => void; +} + +export interface ClientNetworkProxyApplier { + /** Re-resolves and applies. Safe to call concurrently; calls are serialized. */ + refresh(): Promise; +} + +export function createClientNetworkProxyApplier( + deps: ClientNetworkProxyDeps, +): ClientNetworkProxyApplier { + const apply = deps.apply ?? setActiveProxy; + const schedule = + deps.schedule ?? + ((run, delayMs) => { + setTimeout(run, delayMs).unref?.(); + }); + let lane: Promise = Promise.resolve(); + let lastReportedError: string | undefined; + let attempt = 0; + + const refreshWithoutLane = async (): Promise => { + // A non-local Host describes a different machine's network. The bot + // bridges dial out from this one, so its proxy policy does not apply and + // guessing would be worse than staying direct. + if (deps.profileKind !== "local") { + apply(null); + return; + } + let resolved: NetworkProxyResolveResult; + try { + resolved = await deps.resolve(); + } catch (error) { + // Keep the last applied proxy. A Host that is briefly unreachable is not + // evidence that the user wants direct connections. + const message = error instanceof Error ? error.message : String(error); + if (message !== lastReportedError) { + lastReportedError = message; + deps.onError?.(error); + } + const delayMs = RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + attempt += 1; + schedule(() => void enqueue(), delayMs); + } + return; + } + lastReportedError = undefined; + attempt = 0; + apply(resolved.kind === "ready" ? (resolved.proxy ?? null) : null); + }; + + const enqueue = (): Promise => { + const result = lane.then(refreshWithoutLane, refreshWithoutLane); + lane = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + return { + refresh() { + // An explicit refresh means the policy changed, so the pending retry + // budget from an earlier failure no longer applies. + attempt = 0; + return enqueue(); + }, + }; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index df1792febf..b92934b667 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -242,6 +242,7 @@ import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ip import { registerRuntimeHostRendererIpc } from "./runtime-host-renderer-ipc-main.js"; import { registerRuntimeHostSearchIpc } from "./runtime-host-search-ipc-main.js"; import { createRuntimeHostProjectCatalog } from "./runtime-host-project-catalog.js"; +import { createClientNetworkProxyApplier } from "./client-network-proxy.js"; import { createRuntimeHostDefaultRecovery } from "./runtime-host-default-recovery.js"; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { @@ -1652,12 +1653,24 @@ function registerHostClientIpc( openPath: (path) => shell.openPath(path), allowLocalPaths: !usesHostWorkspace, }); + // Client-owned outbound traffic (the bot bridges) is proxied here, not in the + // Host: it runs in this process and the Host never sees it. + const clientNetworkProxy = createClientNetworkProxyApplier({ + profileKind: target.kind, + resolve: () => client.resolveNetworkProxy(), + onError: (error) => + console.error("[runtime-host] Client network proxy resolution failed:", error), + }); + void clientNetworkProxy.refresh(); const runtimeHostSettings = createRuntimeHostSettingsModule({ client, settingsStore, applyClientSettings: async (settings) => { await clientSettingsEffects.apply(settings, true); }, + onNetworkProxyChanged: () => { + void clientNetworkProxy.refresh(); + }, }); registerRuntimeHostSettingsIpc({ ipcMain: scopedIpc, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fcf4a57e8..9e9e05e9e0 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1307,6 +1307,10 @@ export class DesktopRuntimeHostClient { return this.request("network-proxy.test", input); } + resolveNetworkProxy(): Promise> { + return this.request("network-proxy.resolve", {}); + } + exportConfigurationCredentials( input: OperationInput<"configuration.credentials.export">, ): Promise> { diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index 369ec810e0..0bedd4f611 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -76,6 +76,11 @@ export interface RuntimeHostSettingsIpcDeps { readonly client: RuntimeHostSettingsClient; readonly settingsStore: SettingsStore; readonly applyClientSettings: (settings: AppSettings) => Promise; + /** + * Called after a patch writes the network proxy policy, so Client-owned + * traffic can pick the new proxy up without a restart. + */ + readonly onNetworkProxyChanged?: () => void; } export type RuntimeHostSettingsModuleDeps = Omit< @@ -300,6 +305,7 @@ async function updateRuntimeHostSettingsForImportWithoutLane( ): Promise { validateProxyPatch(patch.network?.proxy); const skippedCredentials = await applyHostPatchWithoutLane(deps.client, patch); + if (patch.network?.proxy) deps.onNetworkProxyChanged?.(); const clientPatch = clientOwnedSettingsPatch(patch); const local = hasSettingsPatch(clientPatch) ? await deps.settingsStore.update(clientPatch) diff --git a/packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts b/packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts new file mode 100644 index 0000000000..dcd76d76c5 --- /dev/null +++ b/packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { createDefaultRuntimePolicy, type RuntimePolicy } from '@maka/core/runtime-policy'; +import { HOST_OPERATION_SPECS } from '../protocol/operations.js'; +import { HostNetworkProxyCoordinator } from '../server/network-proxy-coordinator.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const CONNECTION: ConnectionContext = { + hostEpoch: 'host-epoch-1', + connectionId: 'connection-1', + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), +}; + +function coordinatorFor( + networkProxy: Partial, + secret?: string, +): HostNetworkProxyCoordinator { + const policy = createDefaultRuntimePolicy(); + return new HostNetworkProxyCoordinator({ + async resolveNetworkProxyExecution() { + return { + kind: 'ready', + networkProxy: { ...policy.networkProxy, ...networkProxy }, + secretMaterial: secret === undefined ? {} : { networkProxy: { secret } }, + }; + }, + } as never); +} + +async function resolve(coordinator: HostNetworkProxyCoordinator) { + const outcome = await coordinator.handlers['network-proxy.resolve']({}, CONNECTION); + assert.ok(outcome.ok, 'network-proxy.resolve failed'); + return outcome.result; +} + +describe('network-proxy.resolve', () => { + test('serves the enabled proxy with its merged bypass list', async () => { + const result = await resolve( + coordinatorFor({ + enabled: true, + protocol: 'http', + host: '127.0.0.1', + port: 7897, + authEnabled: false, + username: '', + bypassList: ['localhost'], + autoBypassDomains: ['metaso.cn', 'localhost'], + }), + ); + assert.strictEqual(result.kind, 'ready'); + assert.strictEqual(result.proxy?.host, '127.0.0.1'); + assert.strictEqual(result.proxy?.port, 7897); + // The Client applies the list verbatim, so the automatic domains have to be + // merged here and duplicates dropped. + assert.deepStrictEqual(result.proxy?.bypassList, ['localhost', 'metaso.cn']); + }); + + test('carries the credential an authenticated proxy cannot be dialled without', async () => { + const result = await resolve( + coordinatorFor( + { + enabled: true, + protocol: 'http', + host: 'proxy.test', + port: 8080, + authEnabled: true, + username: 'operator', + bypassList: [], + autoBypassDomains: [], + }, + 'secret-value', + ), + ); + assert.strictEqual(result.proxy?.username, 'operator'); + assert.strictEqual(result.proxy?.password, 'secret-value'); + }); + + test('omits the proxy when the policy disables it', async () => { + const result = await resolve(coordinatorFor({ enabled: false })); + assert.strictEqual(result.kind, 'ready'); + assert.strictEqual(result.proxy, undefined); + }); + + test('reports an unconfigured credential instead of a proxy', async () => { + const coordinator = new HostNetworkProxyCoordinator({ + async resolveNetworkProxyExecution() { + return { kind: 'credential_not_configured' }; + }, + } as never); + const result = await resolve(coordinator); + assert.strictEqual(result.kind, 'credential_not_configured'); + assert.strictEqual(result.proxy, undefined); + }); + + test('never reports the underlying failure, which can carry the credential', async () => { + const coordinator = new HostNetworkProxyCoordinator({ + async resolveNetworkProxyExecution() { + throw new Error('proxy://operator:secret-value@proxy.test:8080 is unreadable'); + }, + } as never); + const outcome = await coordinator.handlers['network-proxy.resolve']({}, CONNECTION); + assert.strictEqual(outcome.ok, false); + assert.ok(!outcome.ok && !outcome.error.message.includes('secret-value')); + }); +}); + +describe('network-proxy.resolve codec', () => { + const spec = HOST_OPERATION_SPECS['network-proxy.resolve']; + + test('round-trips a resolved proxy', () => { + const decoded = spec.decodeOutput({ + kind: 'ready', + proxy: { + enabled: true, + type: 'socks5', + host: '127.0.0.1', + port: 7897, + username: 'operator', + password: 'secret-value', + bypassList: ['localhost'], + }, + }); + assert.deepStrictEqual(decoded, { + kind: 'ready', + proxy: { + enabled: true, + type: 'socks5', + host: '127.0.0.1', + port: 7897, + username: 'operator', + password: 'secret-value', + bypassList: ['localhost'], + }, + }); + }); + + test('rejects a proxy that is not usable', () => { + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { + enabled: false, + type: 'http', + host: '127.0.0.1', + port: 7897, + bypassList: [], + }, + }), + ); + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { enabled: true, type: 'ftp', host: 'h', port: 1, bypassList: [] }, + }), + ); + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { enabled: true, type: 'http', host: '', port: 1, bypassList: [] }, + }), + ); + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { enabled: true, type: 'http', host: 'h', port: 0, bypassList: [] }, + }), + ); + }); + + test('rejects an unresolved result that still carries a configuration', () => { + assert.throws(() => + spec.decodeOutput({ + kind: 'credential_not_configured', + proxy: { enabled: true, type: 'http', host: 'h', port: 1, bypassList: [] }, + }), + ); + }); + + test('rejects an unknown kind and unexpected input', () => { + assert.throws(() => spec.decodeOutput({ kind: 'ready_ish' })); + assert.throws(() => spec.decodeInput({ networkProxy: {} })); + }); +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d8f285e2c6..47fce2e9af 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 140 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 141 as const; +// 141: `network-proxy.resolve` serves the effective proxy to Clients that own +// outbound traffic the Host never sees. Epoch-140 peers do not answer it, and +// a Client cannot tell that apart from a proxy that is genuinely unset. // 140: Plugin Platform queries expose scoped Command contribution projections. // Epoch-139 peers reject the added query view and result shape. // 139: WorkHub recovery preserves the Host-authenticated Desktop capability binding. diff --git a/packages/runtime-host/src/protocol/network-proxy.ts b/packages/runtime-host/src/protocol/network-proxy.ts index 0fb0e1818a..b2277e8caa 100644 --- a/packages/runtime-host/src/protocol/network-proxy.ts +++ b/packages/runtime-host/src/protocol/network-proxy.ts @@ -18,7 +18,7 @@ */ import type { RuntimePolicy } from '@maka/core/runtime-policy'; -import type { TestProxyResult } from '@maka/core/settings/network-settings'; +import type { ProxyType, TestProxyResult } from '@maka/core/settings/network-settings'; import { requireEncodedByteLimit, requireExactRecord, @@ -46,6 +46,36 @@ export interface NetworkProxyTestInput { export type NetworkProxyTestResult = TestProxyResult; +export type NetworkProxyResolveInput = Record; + +/** + * The effective proxy a Client must apply to the network it owns, already + * resolved against Runtime Policy. `bypassList` is the merged configured and + * automatic list, so the Client never re-derives policy. Carries the secret: + * only the Host can read it, and a Client that runs its own outbound traffic + * (bot bridges) cannot dial an authenticated proxy without it. + */ +export interface ResolvedNetworkProxy { + readonly enabled: true; + readonly type: ProxyType; + readonly host: string; + readonly port: number; + readonly username?: string; + readonly password?: string; + readonly bypassList: string[]; +} + +/** + * `proxy` is absent when the policy disables the proxy — a positive "send + * everything direct", distinct from `credential_not_configured`, which means + * the policy wants an authenticated proxy whose secret is missing and so + * cannot be honoured. + */ +export interface NetworkProxyResolveResult { + readonly kind: 'ready' | 'credential_not_configured'; + readonly proxy?: ResolvedNetworkProxy; +} + export const NETWORK_PROXY_OPERATION_SPECS = { 'network-proxy.test': defineOperation< NetworkProxyTestInput, @@ -58,6 +88,20 @@ export const NETWORK_PROXY_OPERATION_SPECS = { decodeInput: decodeNetworkProxyTestInput, decodeOutput: decodeNetworkProxyTestResult, }), + 'network-proxy.resolve': defineOperation< + NetworkProxyResolveInput, + NetworkProxyResolveResult, + (typeof ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: ERRORS, + decodeInput: (value) => { + requireExactRecord(value, 'network proxy resolve input', []); + return {}; + }, + decodeOutput: decodeNetworkProxyResolveResult, + }), } as const; function decodeNetworkProxyTestInput(value: unknown): NetworkProxyTestInput { @@ -137,6 +181,53 @@ function decodeNetworkProxyTestResult(value: unknown): NetworkProxyTestResult { return decoded; } +function decodeNetworkProxyResolveResult(value: unknown): NetworkProxyResolveResult { + const result = requireShapedRecord(value, 'network proxy resolve result', ['kind'], ['proxy']); + if (result.kind !== 'ready' && result.kind !== 'credential_not_configured') { + throw invalidProtocolFrame('Invalid network proxy resolve kind'); + } + if (result.kind === 'credential_not_configured' && result.proxy !== undefined) { + throw invalidProtocolFrame('Unresolved network proxy must not carry a configuration'); + } + const decoded: NetworkProxyResolveResult = { + kind: result.kind, + ...(result.proxy === undefined ? {} : { proxy: decodeResolvedNetworkProxy(result.proxy) }), + }; + requireEncodedByteLimit(decoded, 'network proxy resolve result', RESULT_MAX_BYTES); + return decoded; +} + +function decodeResolvedNetworkProxy(value: unknown): ResolvedNetworkProxy { + const proxy = requireShapedRecord( + value, + 'resolved network proxy', + ['enabled', 'type', 'host', 'port', 'bypassList'], + ['username', 'password'], + ); + if ( + proxy.enabled !== true || + (proxy.type !== 'http' && proxy.type !== 'https' && proxy.type !== 'socks5') || + typeof proxy.host !== 'string' || + proxy.host.length === 0 || + proxy.host.length > 255 + ) { + throw invalidProtocolFrame('Invalid resolved network proxy'); + } + return { + enabled: true, + type: proxy.type, + host: proxy.host, + port: boundedInteger(proxy.port, 1, 65_535, 'resolved network proxy port'), + ...(proxy.username === undefined + ? {} + : { username: requireUtf8String(proxy.username, 'resolved network proxy username', 256) }), + ...(proxy.password === undefined + ? {} + : { password: requireUtf8String(proxy.password, 'resolved network proxy password', 1_024) }), + bypassList: stringList(proxy.bypassList, 'resolved network proxy bypass list'), + }; +} + function decodeProbeUrl(value: unknown): string { const raw = requireUtf8String(value, 'network proxy probe URL', 2_048); let parsed: URL; diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 1e7fec8135..cc7f1ce265 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -286,6 +286,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'interaction.query', 'memory.mutate', 'memory.query', + 'network-proxy.resolve', 'network-proxy.test', 'oauth.enrollment.query', 'oauth.login.cancel', diff --git a/packages/runtime-host/src/server/network-proxy-coordinator.ts b/packages/runtime-host/src/server/network-proxy-coordinator.ts index 19008b95f6..1021c09c86 100644 --- a/packages/runtime-host/src/server/network-proxy-coordinator.ts +++ b/packages/runtime-host/src/server/network-proxy-coordinator.ts @@ -23,10 +23,12 @@ import { testProxyConnection } from '@maka/runtime/network/proxy-test'; import type { RuntimePolicyOperationCoordinator } from '@maka/storage/runtime-policy-stores'; import type { NetworkProxyTestInput, OperationOutcome } from '../protocol/index.js'; import type { NetworkProxyOperationHandlerMap } from './operation-dispatcher.js'; +import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; export class HostNetworkProxyCoordinator { readonly handlers: NetworkProxyOperationHandlerMap = { 'network-proxy.test': (input) => this.#test(input), + 'network-proxy.resolve': () => this.#resolve(), }; constructor( @@ -73,6 +75,44 @@ export class HostNetworkProxyCoordinator { }; } } + + /** + * Serves the effective proxy to a Client that owns outbound traffic the Host + * never sees. Model execution resolves this Host-side and injects a + * transport; the bot bridges run in the Client process, so without this the + * configured proxy cannot reach them at all. + */ + async #resolve(): Promise> { + try { + const resolved = await this.policy.resolveNetworkProxyExecution(); + if (resolved.kind === 'credential_not_configured') { + return { ok: true, result: { kind: 'credential_not_configured' } }; + } + const proxy = toRuntimePolicyProxy( + resolved.networkProxy, + resolved.secretMaterial.networkProxy?.secret, + ); + return { + ok: true, + result: { + kind: 'ready', + ...(proxy === null + ? {} + : { proxy: { ...proxy, enabled: true, bypassList: [...proxy.bypassList] } }), + }, + }; + } catch { + // The message is deliberately fixed: the underlying failure can carry + // proxy credential material. + return { + ok: false, + error: { + code: 'internal_failure', + message: 'Network proxy resolution failed', + }, + }; + } + } } function toProxySettings( diff --git a/packages/runtime/package.json b/packages/runtime/package.json index db3077f2d9..e21258d16e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -27,6 +27,7 @@ "./test-only/invocation-fixture": "./dist/__tests__/invocation-fixture.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", "./sandbox": "./dist/sandbox/index.js", + "./network/active-proxy-state": "./dist/network/active-proxy-state.js", "./network/proxy-test": "./dist/network/proxy-test.js", "./telemetry": "./dist/telemetry/index.js", "./bots": "./dist/bots/index.js",