Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
6328c84
feat(mcp): preview_open goes straight to a dev-server port (#28027)
maria-rcks Nov 1, 2026
ce9e016
fix(server): interrupted scheduled task runs no longer stay busy (#28…
t3dotgg Nov 1, 2026
37d7615
fix(desktop): preserve host focus on preview pointer presses (#28549)
Yash-Singh1 Nov 1, 2026
b50d8a1
fix(web): add spacing above PR detail metadata (#28573)
Yash-Singh1 Nov 1, 2026
0bc95fc
feat(dev): add startup profiling for cold-start diagnosis (#28566)
Yash-Singh1 Nov 1, 2026
70f475f
feat(clients): surface child approvals in parent threads (#28516)
Yash-Singh1 Nov 1, 2026
460b88b
feat(release): update notes lead with up to 7 highlights (#28593)
t3dotgg Nov 1, 2026
1280ccd
test(server): pin clock in saved-usage window test (#28603)
t3dotgg Nov 1, 2026
da21606
fix(web): sidebar project filter no longer refocuses after a pick (#2…
t3dotgg Nov 1, 2026
58a407c
feat(desktop): prepare server bytecode cache in native builds (#28572)
Yash-Singh1 Nov 1, 2026
8b4de61
fix(desktop): discover shell environment without blocking startup (#2…
Yash-Singh1 Nov 1, 2026
8760d06
fix(server): refresh Changes totals after remote polls (#28600)
Yash-Singh1 Nov 1, 2026
2b1021c
perf(codex): trace app server request latency (#28666)
Yash-Singh1 Nov 1, 2026
82b10d5
fix(web): align pull request file viewed checkboxes (#28665)
Yash-Singh1 Nov 1, 2026
80120b1
fix(desktop): skip prepared server bytecode on Linux (#28700)
Yash-Singh1 Nov 1, 2026
1d626d0
fix(cli): close legacy database before deleting it on Windows (#27629)
UtkarshUsername Nov 1, 2026
3fd2b85
perf(cursor): load the agent SDK only when needed (#28718)
Yash-Singh1 Nov 1, 2026
965291e
fix(mobile): restore the reserved compose space on Android (#28819)
PixPMusic Nov 1, 2026
976a231
fix(mobile): thread links show their thread's title (#26485)
juliusmarminge Nov 1, 2026
57d764e
fix(web): ChatView browser test no longer times out on CI (#28834)
juliusmarminge Nov 1, 2026
1db31d0
fix(mobile): sent messages rise into place instead of dropping in (#2…
mozzius Nov 1, 2026
d897d3f
feat: managed policy for organization-wide T3 Code configuration (#27…
juliusmarminge Nov 1, 2026
123e5ba
fix(server): force-stop timed out login shell probes (#7295)
hbw00111 Nov 1, 2026
88483a5
fix(server): finish short reads before returning workspace text (#14989)
saphid Nov 1, 2026
1915569
perf(client-runtime): merge shell deltas once per received batch (#18…
saphid Nov 1, 2026
2148331
perf(web): coalesce overlapping file tree refreshes (#18421)
saphid Nov 1, 2026
3f105e0
perf(clients): bound subagent preview processing to the visible prefi…
luke2x Nov 1, 2026
ad38be3
fix(server): avoid transient GitHub mergeability sync churn (#22022)
saphid Nov 1, 2026
ba49642
test(server): stopped-queue steering test no longer races queue promo…
tris203 Nov 1, 2026
5e19105
fix(mobile): single-comment PR discussions say "1 comment", not "1 co…
MarcoDotIO Nov 1, 2026
4ed4950
fix(client): reject incomplete GitHub owner paths before lookup (#23024)
naveed949 Nov 1, 2026
a8a32bb
fix(server): accept case-insensitive MCP bearer schemes (#23782)
Rish-it Nov 1, 2026
7a633ce
fix(shared): keep Azure clone URLs valid when switching protocols (#2…
Zacharon Nov 1, 2026
fcb1d0b
fix(web): honor editableFocus in shortcut conditions (#24134)
unmanbearpig Nov 1, 2026
bc57ebf
fix(scripts): launch check:changed on Windows without a vp shim (#24164)
Zacharon Nov 1, 2026
35c7696
fix(web,mobile): time out client activity reports that never settle (…
skyhong2002 Nov 1, 2026
aa056f7
fix(web): the welcome wizard keeps the project selection when you go …
Jacksondr5 Nov 1, 2026
cf4514d
fix(forgejo): merged and closed pull request lists only return their …
rishabhx29 Nov 1, 2026
41b3b0b
fix(clients): background work Stop stays pending until the stopped ta…
Srijan-D Nov 1, 2026
4306e77
fix(server): device list warnings keep the hub's error detail (#25248)
Jacksondr5 Nov 1, 2026
ea95364
perf(client-runtime): index checkpoint answers in one bounded scan (#…
saphid Nov 1, 2026
a48a076
fix(desktop): allow pointer lock in the built-in browser (#25275)
msfeldstein Nov 1, 2026
e7c375d
fix(desktop): keep preview download settings out of app exports (#25428)
mayesidev Nov 1, 2026
f482dc5
fix(web): PDF previews keep their page across thread switches (#25496)
ashx-j Nov 1, 2026
6856500
fix(server): unlink_pull_request reports a dismissed stack PR as not …
MarcoDotIO Nov 1, 2026
aa46f7a
fix(client-runtime): re-pairing at a new address uses the new token (…
abdulsalamgs Nov 1, 2026
d14074f
docs(orchestration-v2): Codex rollback uses thread/revert on paginate…
SouthCarpet Nov 1, 2026
b5d9f85
fix(web): keep inward mini browser resizes from growing (#26027)
diddletime Nov 1, 2026
5b5bd16
fix(web): PR merge buttons stay in place while the stack loads (#26077)
GisTypical Nov 1, 2026
510aa5a
fix(web): Arabic paragraphs with many English terms now read right to…
ahmedverona34-bot Nov 1, 2026
b46a4ed
fix(web): dark-mode <picture> images load instead of showing "Image u…
Vikas-Maur Nov 1, 2026
4f8b6ee
fix(server): foreground Claude forks no longer show as finished the m…
henrychu04 Nov 1, 2026
b65a64b
perf(server): hydrate only the requested project page (#26655)
Adamulek123 Nov 1, 2026
4d7cb9b
perf(effect-acp): frame large ACP stdout lines in linear time (#26686)
hieu-lee Nov 1, 2026
c2985a7
perf(shared): fold command output without copying scrollback per chun…
hieu-lee Nov 1, 2026
0da2bb6
perf(web): composer serializes each draft doc once per keystroke (#26…
hieu-lee Nov 1, 2026
7039d90
fix(gitlab): merge requests show their approval state (#26877)
oss96 Nov 1, 2026
f2dd1a4
test: remove unreachable sidebar helper suites (#26920)
t3-code-bot[bot] Nov 1, 2026
9504a09
perf(clients): thread search reuses per-thread fields across keystrok…
hieu-lee Nov 1, 2026
0366a19
perf(mobile): reuse chat lists across navigation renders (#27026)
PixPMusic Nov 1, 2026
01a93e7
test(web): pass stale to the stack menu refusal test (#28887)
maria-rcks Nov 1, 2026
88f6113
fix(server): keep a gateway's Claude reset time on a direct 429 (#25438)
tyler-b4innova Nov 1, 2026
ac6a5c7
chore(fork): integrate upstream v0.1.7-nightly.20261101.4048
github-actions[bot] Nov 2, 2026
5e8e2db
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
0f49cb9
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
0729c1c
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
23116ec
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
6d5d621
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
af123e5
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
3b888a0
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
89cb3e0
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
0052725
fix(fork): repair round 1 after upstream v0.1.7-nightly.20261101.4048…
github-actions[bot] Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 6 additions & 7 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ The shared plumbing packets register into, specified in
| ----------------------------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx` | `ext-settings` | Mount project agent-tool choices for each selected computer. |
| `apps/server/src/provider/AgentContextBudget.test.ts` | `ext-mcp` | Measure the ordinary session catalog through production capability filtering. |
| `apps/server/src/orchestration-v2/ProviderSessionManager.ts` | `ext-mcp` | Prepare and reuse credentials with project tool-family capabilities. |
| `apps/server/src/orchestration-v2/ProviderSessionManager.test.ts` | `ext-mcp` | Verify stable credential reuse and replacement when tool families change. |
| `apps/server/src/orchestration-v2/ProviderSessionManager.ts` | `ext-mcp` | Prepare project tool-family capabilities and keep credentials when they change. |
| `apps/server/src/orchestration-v2/ProviderSessionManager.test.ts` | `ext-mcp` | Verify the credential stays stable when tool families change. |
| `apps/server/src/mcp/McpInvocationContext.ts` | `ext-mcp` | Admit Hardware, Reach and Sites session capabilities. |
| `apps/server/src/mcp/McpToolAccess.ts` | `ext-mcp` | Check family capabilities on direct tool calls. |
| `apps/server/src/server.ts` | `ext-mcp` | Supply the shared tool policy before orchestration without a fork-runtime cycle. |
Expand Down Expand Up @@ -249,6 +249,8 @@ The renderer follows the web app's React version, which comes from the workspace
| File | Packet | Why |
| --------------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------- |
| `apps/web/src/hooks/useThreadActionMenu.ts` | `attention-status` | Retry failed runs and resume held queues from the conversation header. |
| `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` | `project-profiles` | Pre-approve the read-only project settings tool in read-only sandboxes. |
| `evals/mcp-tool-use/devices.test.ts` | `project-profiles` | Expect the project-scoped device access denial agents can resolve from chat. |
| `apps/web/src/components/Sidebar.tsx` | `library-projects` | Use the Library retention setting in single and bulk delete confirmations. |
| `apps/web/src/hooks/useThreadActions.permissions.test.ts` | `library-projects` | Await async confirmation while preserving permission rechecks. |
| `apps/web/src/hooks/useThreadActions.ts` | `library-projects` | Explain saved Library retention before deleting an archived thread. |
Expand Down Expand Up @@ -280,7 +282,6 @@ The renderer follows the web app's React version, which comes from the workspace
| `apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx` | `cloudflare-access` | Keep the native Access keychain out of markdown image tests. |
| `apps/server/src/orchestration-v2/ThreadFork.execution.test.ts` | `deleted-history` | Expect the deleted-source refusal that rejects a side chat first. |
| `apps/server/src/process/externalLauncher.test.ts` | `hermetic-tests` | Keep the host's installed editors out of the Insiders bundle case. |
| `apps/server/src/usage/UsageService.test.ts` | `hermetic-tests` | Pin the clock so removed-directory usage stays inside retention. |

L46 adds the print check worker to the server pack entries in `apps/server/vite.config.ts`
(`fork: model-print-check`) so the worker ships beside `bin.mjs`. The single-file executable
Expand Down Expand Up @@ -593,11 +594,9 @@ The `test-temp-isolation` seam in `apps/server/src/git/GitManager.test.ts` (9) c
test's extra worktree inside its own scoped temp directory. Upstream placed them beside the
scoped repository, so every run left orphaned worktree folders in `$TMPDIR`.

The `hermetic-tests` seams keep upstream tests independent of the host and today's date.
The `hermetic-tests` seams keep upstream tests independent of the host.
`externalLauncher.test.ts` (3 marker lines) hides the host's real editor installs from the
macOS VS Code Insiders case, and `UsageService.test.ts` (2) pins the clock in the
removed-directory usage case so its saved usage is not pruned. Upstream's `main` pins that
clock itself after this nightly; drop the `UsageService.test.ts` seam once that lands.
macOS VS Code Insiders case.

The `agent-work` seams add one Delete all for archived subagent and bot run conversations to
Settings > Archived. Parent conversations keep their agent receipts and runs keep their results,
Expand Down
11 changes: 5 additions & 6 deletions apps/desktop/scripts/main-process-bundle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeVM from "node:vm";
import * as NodeURL from "node:url";
import { build } from "vite-plus/pack";
import { assert, it } from "vite-plus/test";

Expand Down Expand Up @@ -114,18 +115,16 @@ it("loads the emitted packaged boot entry and backend cache preload", async () =
const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-"));
try {
const entries = ["src/boot.ts", "src/compileCache.ts"];
await NodeFSP.mkdir(NodePath.join(directory, "src"));
await Promise.all(
entries.map((entry) =>
NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)),
),
);
assert.ok(Array.isArray(desktopConfig.pack));
for (const packConfig of desktopConfig.pack) {
if (!Array.isArray(packConfig.entry)) continue;
if (!packConfig.entry.some((entry) => entries.includes(entry))) continue;
await build({
...packConfig,
entry: packConfig.entry.map((entry) =>
NodeURL.fileURLToPath(new URL(`../${entry}`, import.meta.url)),
),
outDir: NodePath.join(directory, "dist-electron"),
config: false,
cwd: directory,
tsconfig: false,
Expand Down
65 changes: 65 additions & 0 deletions apps/desktop/src/app/DesktopManagedPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* DesktopManagedPolicy - the administrator's managed policy as the desktop app reads it, at most
* once: every desktop check needs the same answer.
*
* @module DesktopManagedPolicy
*/
import type { ManagedPolicyDocument } from "@t3tools/contracts";
import {
layerWindowsRegistry,
ManagedPolicyFileError,
readManagedPolicy,
WindowsRegistry,
} from "@t3tools/shared/managedPolicyFile";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { ChildProcessSpawner } from "effect/process";

import * as DesktopEnvironment from "./DesktopEnvironment.ts";

type FoundPolicy = Effect.Success<ReturnType<typeof readManagedPolicy>>;
const isManagedPolicyFileError = Schema.is(ManagedPolicyFileError);

export class DesktopManagedPolicy extends Context.Service<
DesktopManagedPolicy,
{
/** The policy on this machine, undefined without one; fails when it cannot be used. */
readonly read: Effect.Effect<FoundPolicy, ManagedPolicyFileError>;
}
>()("@t3tools/desktop/app/DesktopManagedPolicy") {}

export const layer = Layer.effect(
DesktopManagedPolicy,
Effect.gen(function* () {
const { platform } = yield* DesktopEnvironment.DesktopEnvironment;
const read = yield* Effect.cached(
readManagedPolicy(platform).pipe(
Effect.provideService(FileSystem.FileSystem, yield* FileSystem.FileSystem),
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
yield* ChildProcessSpawner.ChildProcessSpawner,
),
Effect.provideService(WindowsRegistry, yield* WindowsRegistry),
),
);
return DesktopManagedPolicy.of({ read });
}),
).pipe(Layer.provide(layerWindowsRegistry(() => import("ffi-rs"))));

/** A machine with `policy` deployed, one whose policy cannot be used, or none. */
export const layerTest = (policy?: ManagedPolicyDocument | ManagedPolicyFileError) =>
Layer.succeed(
DesktopManagedPolicy,
DesktopManagedPolicy.of({
read:
policy === undefined
? // @effect-diagnostics-next-line effectSucceedWithVoid:off - No policy is undefined, not void.
Effect.succeed(undefined)
: isManagedPolicyFileError(policy)
? Effect.fail(policy)
: Effect.succeed({ source: "/test/policy.json", document: policy }),
}),
);
6 changes: 6 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/process";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts";
import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
Expand All @@ -22,6 +24,8 @@ import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts";
const layerShellEnvironment = (prepared = true) =>
Layer.succeed(DesktopShellEnvironment.DesktopShellEnvironment, {
installIntoProcess: Effect.succeed(prepared),
awaitDiscovery: Effect.succeed(prepared),
updates: Stream.empty,
});

const layerBackendConfiguration = (prepared = true) =>
Expand Down Expand Up @@ -245,6 +249,7 @@ describe("DesktopBackendConfiguration", () => {
assert.equal(first.cwd, environment.backendCwd);
assert.equal(first.captureOutput, true);
assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
assert.equal(first.env.T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING, "1");
assert.isUndefined(first.env.T3CODE_PORT);
assert.isUndefined(first.env.T3CODE_MODE);
assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
Expand Down Expand Up @@ -1010,6 +1015,7 @@ describe("DesktopBackendConfiguration", () => {
Layer.provideMerge(DesktopAppSettings.layerTest()),
Layer.provideMerge(DesktopWslServerTree.layerTest()),
Layer.provideMerge(DesktopWslEnvironment.layer),
Layer.provideMerge(DesktopManagedPolicy.layerTest()),
Layer.provideMerge(
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
env: {
...backendChildEnvPatch(),
ELECTRON_RUN_AS_NODE: "1",
T3CODE_DESKTOP_SHELL_DISCOVERY_PENDING: "1",
T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath),
},
extendEnv: true,
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcess from "effect/process/ChildProcess"; // fork: tailnet-ownership
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts";
import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
Expand Down Expand Up @@ -133,6 +135,7 @@ function layer(input: {
readonly env?: Record<string, string | undefined>;
readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
readonly desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>;
readonly managedPolicyLayer?: Layer.Layer<DesktopManagedPolicy.DesktopManagedPolicy>;
}) {
const env = { T3CODE_HOME: input.baseDir, ...input.env };
const layerEnvironment = layerEnvironmentFor(input.baseDir, env);
Expand All @@ -142,6 +145,7 @@ function layer(input: {

return DesktopServerExposure.layer.pipe(
Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer),
Layer.provideMerge(input.managedPolicyLayer ?? DesktopManagedPolicy.layerTest()),
Layer.provideMerge(NodeFileSystem.layer),
Layer.provideMerge(NodeHttpClient.layerUndici),
Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()),
Expand All @@ -165,6 +169,7 @@ const withHarness = <A, E, R>(
env: Record<string, string | undefined> = {},
spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>,
managedPolicyLayer?: Layer.Layer<DesktopManagedPolicy.DesktopManagedPolicy>,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -179,6 +184,7 @@ const withHarness = <A, E, R>(
env,
...(spawnerLayer ? { spawnerLayer } : {}),
...(desktopSettingsLayer ? { desktopSettingsLayer } : {}),
...(managedPolicyLayer ? { managedPolicyLayer } : {}),
}),
),
);
Expand Down Expand Up @@ -234,6 +240,41 @@ describe("DesktopServerExposure", () => {
),
);

it.effect("starts local-only, Serve off, when a managed policy keeps clients local", () =>
withHarness(
lanNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const settings = yield* DesktopAppSettings.DesktopAppSettings;
yield* settings.setServerExposureMode("network-accessible");
yield* settings.setTailscaleServe({ enabled: true, port: Option.none() });

const state = yield* serverExposure.configureFromSettings({ port: 4173 });
assert.equal(state.mode, "local-only");
const backendConfig = yield* serverExposure.backendConfig;
assert.equal(backendConfig.bindHost, "127.0.0.1");
assert.isFalse(backendConfig.tailscaleServeEnabled);
// Choosing them again while the policy applies does not widen the backend either.
yield* serverExposure.setMode("network-accessible");
yield* serverExposure.setTailscaleServeEnabled({ enabled: true });
assert.equal((yield* serverExposure.backendConfig).bindHost, "127.0.0.1");
assert.isFalse((yield* serverExposure.backendConfig).tailscaleServeEnabled);
// The saved choice returns once the policy allows it.
const saved = yield* settings.get;
assert.equal(saved.serverExposureMode, "network-accessible");
assert.isTrue(saved.tailscaleServeEnabled);
}),
{},
undefined,
undefined,
DesktopManagedPolicy.layerTest({
schemaVersion: 1,
organization: "Example Corp",
limits: { clients: ["desktop"] },
}),
),
);

it.effect("persists network-accessible mode and updates backend binding state", () =>
withHarness(
lanNetworkInterfaces,
Expand Down
32 changes: 24 additions & 8 deletions apps/desktop/src/backend/DesktopServerExposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import * as Semaphore from "effect/Semaphore";
import * as HttpClient from "effect/http/HttpClient";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as DesktopManagedPolicy from "../app/DesktopManagedPolicy.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; // fork: tailnet-ownership
Expand Down Expand Up @@ -586,6 +587,18 @@ export const make = Effect.gen(function* () {
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
);

// The server refuses to bind wider than the policy's clients allow, so network access or Serve
// saved before the policy would keep the backend from starting. The backend runs without them;
// the saved choice stays for when the policy allows it.
const remoteAllowed = yield* (yield* DesktopManagedPolicy.DesktopManagedPolicy).read.pipe(
Effect.map((found) => found?.document.limits?.clients?.includes("remote") ?? true),
Effect.orElseSucceed(() => false),
);
const withinPolicy = (settings: DesktopAppSettings.DesktopSettings) =>
remoteAllowed
? settings
: { ...settings, serverExposureMode: "local-only" as const, tailscaleServeEnabled: false };

const readCurrentState = (currentNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces) =>
Ref.get(stateRef).pipe(
Effect.map((state) =>
Expand All @@ -611,8 +624,8 @@ export const make = Effect.gen(function* () {
const settings = yield* desktopSettings.get;
const currentNetworkInterfaces = yield* readNetworkInterfaces;
const resolved = resolveRuntimeState({
requestedMode: settings.serverExposureMode,
settings,
requestedMode: withinPolicy(settings).serverExposureMode,
settings: withinPolicy(settings),
port,
networkInterfaces: currentNetworkInterfaces,
advertisedHostOverride: config.desktopLanHostOverride,
Expand All @@ -634,8 +647,8 @@ export const make = Effect.gen(function* () {
};
const currentNetworkInterfaces = yield* readNetworkInterfaces;
const resolved = resolveRuntimeState({
requestedMode: mode,
settings: nextSettings,
requestedMode: withinPolicy(nextSettings).serverExposureMode,
settings: withinPolicy(nextSettings),
port: previous.port,
networkInterfaces: currentNetworkInterfaces,
advertisedHostOverride: config.desktopLanHostOverride,
Expand Down Expand Up @@ -679,9 +692,12 @@ export const make = Effect.gen(function* () {
});
}
// Refuse before saving: the backend would only fail to claim the port on every launch.
const inspection = input.enabled
? yield* inspectServe({ port: current.port, servePort })
: null;
// A policy that keeps clients local saves the choice without applying it, so skip the check.
// fork: tailnet-ownership
const inspection =
input.enabled && remoteAllowed
? yield* inspectServe({ port: current.port, servePort })
: null;
if (inspection !== null && REFUSED_TAILSCALE_SERVE_STATES.has(inspection.state)) {
return yield* new DesktopTailscaleServeRefusedError({
port: servePort,
Expand All @@ -707,7 +723,7 @@ export const make = Effect.gen(function* () {

const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({
...current,
tailscaleServeEnabled: result.settings.tailscaleServeEnabled,
tailscaleServeEnabled: withinPolicy(result.settings).tailscaleServeEnabled,
tailscaleServePort: result.settings.tailscaleServePort,
}));

Expand Down
Loading