Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
114 commits
Select commit Hold shift + click to select a range
8149aa7
feat(tailscale): your own devices find and connect to your boxes with…
t3dotgg Oct 31, 2026
1dd1a87
fix(cli): t3 up's fallback serve command states --no-tailscale-serve
t3dotgg Oct 31, 2026
aa0890a
fix(tailscale): t3 up keeps Tailscale when it is down, stops Serve wi…
t3dotgg Oct 31, 2026
66d02cb
fix(tailscale): live Tailscale sockets renew by signing in again, so …
t3dotgg Nov 1, 2026
83805c5
fix(tailscale): a peer with no ID still counts in the policy scan, an…
t3dotgg Nov 1, 2026
16aaf9b
fix(tailscale): turning sign-in off ends Tailscale sessions, and t3 u…
t3dotgg Nov 1, 2026
ef6ea57
fix(tailscale): turning sign-in off also ends unused Tailscale sign-i…
t3dotgg Nov 1, 2026
2b16c23
fix(tailscale): explicit off switches are written and applied, and th…
t3dotgg Nov 1, 2026
7959901
fix(tailscale): Serve mappings from a stopped run are removed, and of…
t3dotgg Nov 1, 2026
d2306d9
test(cli): --no-tailscale-serve writes Serve off with no service to run
t3dotgg Nov 1, 2026
6c79c1a
docs(tailscale): say what turning sign-in off does, and that a revoke…
t3dotgg Nov 1, 2026
980ce14
fix(server): shutdown removes a Serve mapping whose claim landed but …
t3dotgg Nov 1, 2026
0726cb6
fix(tailscale): a Serve port change cleans up the old mapping, and of…
t3dotgg Nov 1, 2026
51ce5b8
fix(server): Serve cleanup records survive a local port change, a fai…
t3dotgg Nov 1, 2026
5a8c2e9
fix(cli): name the desktop app's own off switch, and say why a pin-on…
t3dotgg Nov 1, 2026
623ffcd
fix(tailscale): Serve cleanup records follow the Tailscale account, a…
t3dotgg Nov 1, 2026
cee80e2
fix(server): Serve cleanup errors keep their remedy and name file err…
t3dotgg Nov 1, 2026
17b272f
fix(tailscale): a policy scan from another Tailscale account never de…
t3dotgg Nov 1, 2026
25c372f
fix(tailscale): Serve records follow the login profile, and cleanup e…
t3dotgg Nov 1, 2026
3b3c76f
fix(tailscale): whois answers and Serve records never cross Tailscale…
t3dotgg Nov 1, 2026
64b6b43
fix(cli): a leftover on another account says the off worked and the m…
t3dotgg Nov 1, 2026
6ee0a9d
fix(tailscale): whois is never cached across accounts, and Serve reco…
t3dotgg Nov 1, 2026
9d21849
fix(tailscale): a Serve record for a logged-out profile is dropped, a…
t3dotgg Nov 1, 2026
cc2a617
fix(tailscale): sign-in decides on a fresh status read, and one bad p…
t3dotgg Nov 1, 2026
b509471
fix(tailscale): Serve records match by any key their account had, and…
t3dotgg Nov 1, 2026
4024675
fix(tailscale): a policy scan across an account switch decides nothin…
t3dotgg Nov 1, 2026
ab2c309
fix(cli): an unlisted Serve record gives the way out, and its doc com…
t3dotgg Nov 1, 2026
307cd3f
fix(tailscale): a cold sign-in reads status twice, not four times, an…
t3dotgg Nov 1, 2026
8a594c0
fix(tailscale): sign-in refuses a caller the fresh status does not na…
t3dotgg Nov 1, 2026
95206a1
fix(tailscale): a fresh status read is always its own, and a connecti…
t3dotgg Nov 1, 2026
076aa2a
fix(cli): the same-account Serve leftover names Windows users only on…
t3dotgg Nov 1, 2026
4289395
fix(tailscale): a device that joins during a policy scan keeps the sc…
t3dotgg Nov 1, 2026
9993663
fix(cli): the same-account Serve leftover reads cleanly on every plat…
t3dotgg Nov 1, 2026
56d5fa0
fix(tailscale): a grant a policy scan found stands when a device join…
t3dotgg Nov 1, 2026
3bd2a00
test(server): table test for the policy scan's join check
t3dotgg Nov 1, 2026
22c2d3e
fix(tailscale): a grant a scan sees is kept at once, and peers with n…
t3dotgg Nov 1, 2026
1968841
test(server): say why the found grant stands after the laptop goes un…
t3dotgg Nov 1, 2026
56cbb8a
fix(tailscale): a grant a policy scan reads is kept the moment its lo…
t3dotgg Nov 1, 2026
d42f6a4
test(server): share the refused-device fixture, and tidy the join-che…
t3dotgg Nov 1, 2026
fa83b49
fix(tailscale): a policy scan keeps only a grant the listed node holds
t3dotgg Nov 1, 2026
7ce2edc
fix(tailscale): the grant sign-in count is kept per Tailscale account
t3dotgg Nov 1, 2026
472bd47
fix(tailscale): a policy scan keeps a grant only while the host is on…
t3dotgg Nov 1, 2026
b8b9557
fix(tailscale): confirming a grant drops an older "no grants" until i…
t3dotgg Nov 1, 2026
43991da
fix(tailscale): confirming a grant is not cut short, and a failed con…
t3dotgg Nov 1, 2026
210387f
test(server): a failed grant confirmation leaves no older "no grants"…
t3dotgg Nov 1, 2026
d4f88b9
fix(tailscale): a grant the session recheck sees turns the owner rule…
t3dotgg Nov 1, 2026
1e6abcd
fix(server): the grant confirmation comment matches its retry
t3dotgg Nov 1, 2026
083efd2
fix(tailscale): the session recheck counts any grant it sees, on the …
t3dotgg Nov 1, 2026
c31d6d7
fix(tailscale): a sign-in refused for an unusable grant still turns t…
t3dotgg Nov 1, 2026
7a7d51c
fix(tailscale): every grant the recheck confirms counts, so a scan ru…
t3dotgg Nov 1, 2026
e4c0d14
refactor(server): the grant counter is named for what it counts
t3dotgg Nov 1, 2026
b8e3547
fix(tailscale): a grant the recheck cannot tie to an account keeps a …
t3dotgg Nov 1, 2026
fc108de
fix(tailscale): no owner sign-in gets in while the recheck confirms a…
t3dotgg Nov 1, 2026
c2c1315
refactor(server): the recheck's account comments cover a failed statu…
t3dotgg Nov 1, 2026
0f7716a
fix(tailscale): a grant a refused sign-in saw, or a code exchanged du…
t3dotgg Nov 1, 2026
9b6cc9e
fix(tailscale): the recheck lists sign-in codes before sessions, so n…
t3dotgg Nov 1, 2026
d39afd6
fix(tailscale): a sign-in dropped partway still drops the older "no g…
t3dotgg Nov 1, 2026
d86399b
fix(tailscale): a recheck that first sees a grant runs again, and kee…
t3dotgg Nov 1, 2026
62e3f7d
refactor(server): the recheck's label comment says a code's label com…
t3dotgg Nov 1, 2026
c4bb226
fix(tailscale): a grant a policy scan finds also makes the recheck ru…
t3dotgg Nov 1, 2026
4c45d21
fix(tailscale): blocks publish committed revocations, rechecks count …
t3dotgg Nov 1, 2026
4d7e96f
fix(tailscale): a revocation by device always notifies open sockets, …
t3dotgg Nov 1, 2026
c85a4c5
fix(tailscale): the recheck runs again when it saw a grant and an own…
t3dotgg Nov 1, 2026
1908d99
fix(tailscale): a blocked device hears it is blocked, and single-sess…
t3dotgg Nov 1, 2026
08183d7
fix(tailscale): an owner-rule sign-in counts once its code exists
t3dotgg Nov 1, 2026
b656eb6
fix(tailscale): a dropped block request still revokes, and the rechec…
t3dotgg Nov 1, 2026
57401ae
fix(tailscale): a refusal's suggested grant says to remove it when ei…
t3dotgg Nov 1, 2026
850c39e
fix(tailscale): blocks hold when Tailscale cannot be read, and a bloc…
t3dotgg Nov 1, 2026
ce8a582
docs(tailscale): the grant note and the guide both say to remove it w…
t3dotgg Nov 1, 2026
09252d6
fix(tailscale): a sign-in that raced a block gets no code
t3dotgg Nov 1, 2026
cc1dd9e
fix(tailscale): rechecks distrust a Tailscale too old for sign-in, an…
t3dotgg Nov 1, 2026
dec93da
fix(tailscale): sessions a too-old Tailscale ends are logged as outdated
t3dotgg Nov 1, 2026
bbd20ff
fix(tailscale): rechecks end devices whose node key expired, and requ…
t3dotgg Nov 1, 2026
20a5abd
fix(tailscale): sign-in refuses a peer whose key expired, and require…
t3dotgg Nov 1, 2026
80369ef
fix(tailscale): Tailscale health flags a version too old for sign-in,…
t3dotgg Nov 1, 2026
44f3554
fix(tailscale): an expired device key asks that device to log in agai…
t3dotgg Nov 1, 2026
cf4c9d7
fix(tailscale): sign-in host checks name the Tailscale state in plain…
t3dotgg Nov 1, 2026
936041e
fix(tailscale): host checks word read failures and missing IPv4 the s…
t3dotgg Nov 1, 2026
4abee7f
fix(tailscale): a host too old or without IPv4 leads its health issue…
t3dotgg Nov 1, 2026
13e6fc0
fix(tailscale): require-grant reports a failed second read, and refus…
t3dotgg Nov 1, 2026
cbf9625
fix(tailscale): every sign-in blocker leads health, and require-grant…
t3dotgg Nov 1, 2026
d2255bb
fix(tailscale): an unusable grant asks for the intended scope, and re…
t3dotgg Nov 1, 2026
387159e
refactor(tailscale): health and the CLI share one list of what stops …
t3dotgg Nov 1, 2026
24bff7d
fix(tailscale): require-grant reads tags from its last status, and st…
t3dotgg Nov 1, 2026
57e241b
fix(tailscale): require-grant's no-grant answer and delay line read c…
t3dotgg Nov 1, 2026
f6649c4
fix(tailscale): a host tagged during a policy scan does not admit its…
t3dotgg Nov 1, 2026
9e88e9d
docs(tailscale): say "your own untagged devices", not "owner rule", a…
t3dotgg Nov 1, 2026
3442e41
fix(tailscale): require-grant on an ownerless host with grants says g…
t3dotgg Nov 1, 2026
30c58e0
fix(tailscale): a device tagged during its sign-in is refused, and se…
t3dotgg Nov 1, 2026
d461ca5
fix(tailscale): sign-in compares the caller's full tag set, and check…
t3dotgg Nov 1, 2026
d47e910
docs(tailscale): grant delays print only where a grant change moves a…
t3dotgg Nov 1, 2026
fdc45ba
fix(tailscale): an owner-rule sign-in rechecks its host and the requi…
t3dotgg Nov 1, 2026
9388640
fix(tailscale): the post-scan read also runs the host checks, and two…
t3dotgg Nov 1, 2026
fe6e701
fix(tailscale): a sign-in refused by require-grant mid-scan carries t…
t3dotgg Nov 1, 2026
2d96159
fix(tailscale): a host change found after the scan refuses with the g…
t3dotgg Nov 1, 2026
480ea6a
fix(tailscale): a block landing during the post-scan read refuses as …
t3dotgg Nov 1, 2026
aa37dd2
docs(tailscale): require-grant's help says pairing codes still work, …
t3dotgg Nov 1, 2026
d377e08
docs(tailscale): block and unblock name who can manage access, and t3…
t3dotgg Nov 1, 2026
8ba8365
test(tailscale): a block landing after an owner-rule scan answers blo…
t3dotgg Nov 1, 2026
bb402fb
docs(tailscale): name who really manages access, and t3 up's discover…
t3dotgg Nov 1, 2026
ac064e2
fix(cli): t3 up's discovery line fits a tagged host, which every tail…
t3dotgg Nov 1, 2026
a593274
fix(connection): a pairing code to a one-word name keeps HTTPS, a QR …
t3dotgg Nov 1, 2026
145cc6d
fix(connection): an address with a pairing code resolves as on main, …
t3dotgg Nov 1, 2026
c2c7c14
docs(tailscale): unblock copy names a default t3 pair code and reads …
t3dotgg Nov 1, 2026
ea09b4b
docs(tailscale): reflow the block paragraph
t3dotgg Nov 1, 2026
a058198
fix(connection): an address with and without its default port is the …
t3dotgg Nov 1, 2026
b140cb3
fix(connection): discovery treats an address with its default port as…
t3dotgg Nov 1, 2026
30e5aab
fix(connection): a saved tailnet address with no port stays on port 8…
t3dotgg Nov 1, 2026
b7d1c85
docs(connection): the unblock steps name Sharing & access where Setti…
t3dotgg Nov 1, 2026
cfe2cd4
docs(connection): recovery pairs with only the scopes it needs, and t…
t3dotgg Nov 1, 2026
440e1c4
docs(connection): block says only Tailscale sessions end, and recover…
t3dotgg Nov 1, 2026
017160b
docs(connection): recovery works when the pairing link names a LAN ad…
t3dotgg Nov 1, 2026
bf9af4b
docs(connection): unblock steps say how to retry and find the Tailsca…
t3dotgg Nov 1, 2026
ae97b61
docs(connection): recovery adds a route to a saved machine, retry nam…
t3dotgg Nov 1, 2026
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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ const migrateSavedEnvironmentRecords = Effect.fn(
credentials,
remoteDpopTokens: [],
disabledEnvironmentIds: [],
dismissedEnvironmentIds: [],
};
});

Expand Down
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 @@ -35,10 +35,13 @@ const layerServerExposure = Layer.succeed(DesktopServerExposure.DesktopServerExp
httpBaseUrl: new URL("http://127.0.0.1:4888"),
tailscaleServeEnabled: true,
tailscaleServePort: 8443,
tailscaleSignInEnabled: false,
tailscaleOnly: false,
}),
configureFromSettings: () => Effect.die("unexpected configureFromSettings"),
setMode: () => Effect.die("unexpected setMode"),
setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"),
setTailscaleSignInEnabled: () => Effect.die("unexpected setTailscaleSignInEnabled"),
getAdvertisedEndpoints: Effect.succeed([]),
} satisfies DesktopServerExposure.DesktopServerExposure["Service"]);

Expand Down Expand Up @@ -240,6 +243,9 @@ describe("DesktopBackendConfiguration", () => {
assert.isUndefined(first.env.T3CODE_PORT);
assert.isUndefined(first.env.T3CODE_MODE);
assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
// Removed from what the backend inherits, so the desktop's own switch decides.
assert.property(first.env, "T3CODE_TAILSCALE_SIGN_IN");
assert.isUndefined(first.env.T3CODE_TAILSCALE_SIGN_IN);

assert.equal(first.bootstrap.mode, "desktop");
assert.equal(first.bootstrap.noBrowser, true);
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [
"T3CODE_DESKTOP_HTTPS_ENDPOINTS",
"T3CODE_TAILSCALE_SERVE",
"T3CODE_TAILSCALE_SERVE_PORT",
"T3CODE_TAILSCALE_SIGN_IN",
"T3CODE_TAILSCALE_OWNER",
] as const;

// Windows forwards these variables into WSL only through WSLENV.
Expand Down Expand Up @@ -507,6 +509,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
shellEnvironmentPrepared: input.shellEnvironmentPrepared,
tailscaleServeEnabled: backendExposure.tailscaleServeEnabled,
tailscaleServePort: backendExposure.tailscaleServePort,
...(backendExposure.tailscaleSignInEnabled ? { tailscaleSignInEnabled: true } : {}),
...(backendExposure.tailscaleOnly ? { tailscaleOnly: true } : {}),
desktopTelemetryFd: 4,
desktopTelemetryControlFd: 5,
desktopBrowserFd: 6,
Expand Down
98 changes: 97 additions & 1 deletion apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import { HttpClient, HttpClientResponse } from "effect/http";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopConfig from "../app/DesktopConfig.ts";
import * as DesktopNetworkInterfaces from "./DesktopNetworkInterfaces.ts";
import * as DesktopServerExposure from "./DesktopServerExposure.ts";
import * as DesktopTailscaleStatus from "./DesktopTailscaleStatus.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";

const encoder = new TextEncoder();
Expand Down Expand Up @@ -90,6 +92,7 @@ function layer(input: {
readonly env?: Record<string, string | undefined>;
readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
readonly desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>;
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
}) {
const env = { T3CODE_HOME: input.baseDir, ...input.env };
const layerEnvironment = layerEnvironmentFor(input.baseDir, env);
Expand All @@ -98,9 +101,10 @@ function layer(input: {
});

return DesktopServerExposure.layer.pipe(
Layer.provideMerge(DesktopTailscaleStatus.layer),
Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer),
Layer.provideMerge(NodeFileSystem.layer),
Layer.provideMerge(NodeHttpClient.layerUndici),
Layer.provideMerge(input.httpClientLayer ?? NodeHttpClient.layerUndici),
Layer.provideMerge(input.spawnerLayer ?? layerMockSpawner()),
Layer.provideMerge(layerNetwork),
Layer.provideMerge(DesktopConfig.layerTest(env)),
Expand All @@ -122,6 +126,7 @@ const withHarness = <A, E, R>(
env: Record<string, string | undefined> = {},
spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>,
httpClientLayer?: Layer.Layer<HttpClient.HttpClient>,
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -136,6 +141,7 @@ const withHarness = <A, E, R>(
env,
...(spawnerLayer ? { spawnerLayer } : {}),
...(desktopSettingsLayer ? { desktopSettingsLayer } : {}),
...(httpClientLayer ? { httpClientLayer } : {}),
}),
),
);
Expand Down Expand Up @@ -209,6 +215,8 @@ describe("DesktopServerExposure", () => {
advertisedHost: "192.168.1.20",
tailscaleServeEnabled: false,
tailscaleServePort: 443,
tailscaleSignInEnabled: false,
port: 4173,
});

const backendConfig = yield* serverExposure.backendConfig;
Expand Down Expand Up @@ -297,6 +305,47 @@ describe("DesktopServerExposure", () => {
),
);

it.effect(
"listens on the tailnet only while Tailscale sign-in is on and Network access is off",
() =>
withHarness(
{ ...tailnetNetworkInterfaces, ...lanNetworkInterfaces },
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* (yield* DesktopAppSettings.DesktopAppSettings).load;
yield* serverExposure.configureFromSettings({ port: 3773 });

const signIn = yield* serverExposure.setTailscaleSignInEnabled(true);
assert.equal(signIn.requiresRelaunch, true);
assert.equal(signIn.state.tailscaleSignInEnabled, true);
assert.equal(signIn.state.port, 3773);
assert.deepInclude(yield* serverExposure.backendConfig, {
bindHost: "0.0.0.0",
tailscaleSignInEnabled: true,
tailscaleOnly: true,
});
assert.deepEqual(
(yield* serverExposure.getAdvertisedEndpoints).map((endpoint) => endpoint.httpBaseUrl),
["http://127.0.0.1:3773/", "http://100.90.1.2:3773/"],
);

yield* serverExposure.setMode("network-accessible");
assert.deepInclude(yield* serverExposure.backendConfig, {
bindHost: "0.0.0.0",
tailscaleOnly: false,
});

yield* serverExposure.setMode("local-only");
yield* serverExposure.setTailscaleSignInEnabled(false);
assert.deepInclude(yield* serverExposure.backendConfig, {
bindHost: "127.0.0.1",
tailscaleSignInEnabled: false,
tailscaleOnly: false,
});
}),
),
);

it.effect("keeps a Tailscale Serve change made while a mode change is saving", () =>
Effect.gen(function* () {
const modeWriteStarted = yield* Deferred.make<void>();
Expand Down Expand Up @@ -448,6 +497,53 @@ describe("DesktopServerExposure", () => {
},
);

it.effect("reads this backend's environment ID once it answers, then keeps it", () => {
let localReads = 0;
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const local = request.url.startsWith("http://127.0.0.1:4173/");
if (local) localReads += 1;
// The backend is still starting on the first read.
const body =
local && localReads === 1
? new Response("starting", { status: 503 })
: Response.json({ environmentId: "this-desktop" });
return HttpClientResponse.fromWeb(request, body);
}),
),
);
const serveStatus = () =>
withHarness(
tailnetNetworkInterfaces,
Effect.gen(function* () {
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
yield* serverExposure.configureFromSettings({ port: 4173 });
yield* serverExposure.setTailscaleServeEnabled({ enabled: true });
const statuses: Array<string> = [];
for (let call = 0; call < 3; call += 1) {
const endpoints = yield* serverExposure.getAdvertisedEndpoints;
statuses.push(
endpoints.find((endpoint) => endpoint.httpBaseUrl.startsWith("https://"))?.status ??
"missing",
);
}
return statuses;
}),
{},
layerMockSpawner(
`{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`,
),
undefined,
httpClientLayer,
);
return Effect.gen(function* () {
assert.deepEqual(yield* serveStatus(), ["unavailable", "available", "available"]);
assert.equal(localReads, 2);
});
});

it.effect("preserves explicit Tailscale exposure overrides", () =>
withHarness(
lanNetworkInterfaces,
Expand Down
Loading
Loading