Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions apps/desktop/src/main/__tests__/client-network-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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<NetworkProxyResolveResult>,
) {
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<void>((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<void>((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]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ function createModuleFixture(options: {
},
};

let networkProxyChanges = 0;
const module = createRuntimeHostSettingsModule({
client: client as never,
settingsStore: {
Expand All @@ -246,16 +247,32 @@ function createModuleFixture(options: {
},
} as never,
async applyClientSettings() {},
onNetworkProxyChanged: () => {
networkProxyChanges += 1;
},
});

return {
module,
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 });

Expand Down
117 changes: 117 additions & 0 deletions apps/desktop/src/main/client-network-proxy.ts
Original file line number Diff line number Diff line change
@@ -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<NetworkProxyResolveResult>;
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<void>;
}

export function createClientNetworkProxyApplier(
deps: ClientNetworkProxyDeps,
): ClientNetworkProxyApplier {
const apply = deps.apply ?? setActiveProxy;
const schedule =
deps.schedule ??
((run, delayMs) => {
setTimeout(run, delayMs).unref?.();
});
let lane: Promise<void> = Promise.resolve();
let lastReportedError: string | undefined;
let attempt = 0;

const refreshWithoutLane = async (): Promise<void> => {
// 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<void> => {
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();
},
};
}
13 changes: 13 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,10 @@ export class DesktopRuntimeHostClient {
return this.request("network-proxy.test", input);
}

resolveNetworkProxy(): Promise<OperationOutput<"network-proxy.resolve">> {
return this.request("network-proxy.resolve", {});
}

exportConfigurationCredentials(
input: OperationInput<"configuration.credentials.export">,
): Promise<OperationOutput<"configuration.credentials.export">> {
Expand Down
Loading