Skip to content
Source
Sign inSign up

pair.test.ts

795 lines31.7 KB

1// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
2import * as NodeHttp from "node:http";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6​
7import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { AuthStandardClientScopes, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
10import * as NetService from "@t3tools/shared/Net";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import { assert, describe, expect, it } from "@effect/vitest";
13import * as Cause from "effect/Cause";
14import * as ConfigProvider from "effect/ConfigProvider";
15import * as Effect from "effect/Effect";
16import * as Exit from "effect/Exit";
17import * as Layer from "effect/Layer";
18import * as Sink from "effect/Sink";
19import * as Stream from "effect/Stream";
20import { FetchHttpClient } from "effect/http";
21import { ChildProcess, ChildProcessSpawner } from "effect/process";
22import * as TestClock from "effect/testing/TestClock";
23import * as TestConsole from "effect/testing/TestConsole";
24import { Command, CliError } from "effect/cli";
25​
26import { cli } from "../binCli.ts";
27import {
28 SERVICE_LAUNCHER_CONTEXT_ENV,
29 SERVICE_LAUNCHER_PROTOCOL,
30} from "../cloud/serviceProtocol.ts";
31import * as ServiceLauncherClient from "../cloud/serviceLauncherClient.ts";
32import {
33 makePersistedServerRuntimeState,
34 persistServerRuntimeState,
35 type PersistedServerRuntimeState,
36} from "../serverRuntimeState.ts";
37import { resolveHostCliCommand } from "./invocation.ts";
38import {
39 DevServerNotProxiableError,
40 resolveTailscaleLocalTarget,
41 ServePortOccupiedError,
42 TailscaleServeTargetRefusedError,
43} from "./pair.ts";
44​
45import packageJson from "../../package.json" with { type: "json" };
46import { buildPairingAppLink } from "@t3tools/shared/remote";
47​
48const layerCliRuntime = Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer);
49​
50const baseState = {
51 version: 1,
52 pid: 123,
53 port: 3_773,
54 origin: "http://127.0.0.1:3773",
55 startedAt: "2026-06-20T00:00:00.000Z",
56} as const satisfies PersistedServerRuntimeState;
57​
58describe("pair tailscale local target", () => {
59 it("proxies the dev web port for dev servers", () => {
60 expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual(
61 { localPort: 5_733 },
62 );
63 // tailscale serve defaults to loopback, so non-loopback dev servers need an explicit target.
64 expect(
65 resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://192.168.1.10:5733/" }),
66 ).toEqual({ localPort: 5_733, localHost: "192.168.1.10" });
67 expect(
68 resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://[fd7a:115c::1]:5733/" }),
69 ).toEqual({ localPort: 5_733, localHost: "[fd7a:115c::1]" });
70 });
71​
72 it("rejects HTTPS dev URLs, which tailscale serve cannot proxy", () => {
73 expect(
74 resolveTailscaleLocalTarget({ ...baseState, devUrl: "https://localhost:5733/" }),
75 ).toBeInstanceOf(DevServerNotProxiableError);
76 });
77});
78​
79const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
80​
81const provideCliTestLayers = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
82 Effect.provide(effect, Layer.mergeAll(layerCliRuntime, TestConsole.layer));
83​
84const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
85 provideCliTestLayers(
86 Effect.gen(function* () {
87 yield* effect;
88 return (
89 (yield* TestConsole.logLines).findLast(
90 (line): line is string => typeof line === "string",
91 ) ?? ""
92 );
93 }),
94 );
95​
96const testDescriptor = {
97 environmentId: "pair-test-environment",
98 label: "pair-test",
99 platform: { os: "linux", arch: "x64" },
100 serverVersion: "0.0.1",
101 orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
102 capabilities: { repositoryIdentity: true },
103};
104​
105const withDescriptorServer = <A, E, R>(
106 run: (origin: string) => Effect.Effect<A, E, R>,
107 host = "127.0.0.1",
108 descriptor: object = testDescriptor,
109) =>
110 Effect.acquireUseRelease(
111 Effect.callback<NodeHttp.Server>((resume) => {
112 const server = NodeHttp.createServer((request, response) => {
113 if (request.url === "/.well-known/t3/environment") {
114 response.writeHead(200, { "content-type": "application/json" });
115 response.end(JSON.stringify(descriptor));
116 return;
117 }
118 response.writeHead(404);
119 response.end();
120 });
121 server.listen(0, host, () => resume(Effect.succeed(server)));
122 }),
123 (server) => {
124 const address = server.address();
125 if (address === null || typeof address === "string") {
126 return Effect.die(new Error("Expected a TCP address"));
127 }
128 return run(`http://${host === "::1" ? "[::1]" : host}:${String(address.port)}`);
129 },
130 (server) => Effect.sync(() => server.close()),
131 );
132​
133describe("t3 pair", () => {
134 it.effect("publishes an IPv6-only server through Tailscale Serve when pairing", () =>
135 withDescriptorServer(
136 (origin) =>
137 Effect.gen(function* () {
138 const fetch = yield* FetchHttpClient.Fetch;
139 let serving = false;
140 const spawner = ChildProcessSpawner.make((command) =>
141 Effect.gen(function* () {
142 if (!ChildProcess.isStandardCommand(command))
143 return yield* Effect.die("Unexpected pipeline");
144 if (command.args[0] === "serve" && command.args[1] === "--bg") {
145 const target = String(command.args.at(-1));
146 const descriptor = yield* Effect.promise(async () =>
147 (await fetch(new URL("/.well-known/t3/environment", target))).json(),
148 );
149 expect(descriptor).toEqual(testDescriptor);
150 serving = true;
151 }
152 return ChildProcessSpawner.makeHandle({
153 pid: ChildProcessSpawner.ProcessId(1),
154 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
155 isRunning: Effect.succeed(false),
156 kill: () => Effect.void,
157 unref: Effect.succeed(Effect.void),
158 stdin: Sink.drain,
159 stdout: Stream.make(
160 new TextEncoder().encode('{"Self":{"DNSName":"pair-test.tail.ts.net."}}'),
161 ),
162 stderr: Stream.empty,
163 all: Stream.empty,
164 getInputFd: () => Sink.drain,
165 getOutputFd: () => Stream.empty,
166 });
167 }),
168 );
169​
170 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ipv6-test-"));
171 yield* persistServerRuntimeState({
172 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
173 state: yield* makePersistedServerRuntimeState({
174 config: { host: "::1", devUrl: undefined },
175 port: Number(new URL(origin).port),
176 }),
177 });
178 const output = yield* captureStdout(
179 runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
180 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
181 Effect.provideService(HostProcess.Platform, "linux"),
182 Effect.provideService(FetchHttpClient.Fetch, async (input, init) => {
183 if (String(input).startsWith(origin)) return fetch(input, init);
184 assert.equal(
185 String(input),
186 "https://pair-test.tail.ts.net/.well-known/t3/environment",
187 );
188 return serving
189 ? Response.json(testDescriptor)
190 : new Response(null, { status: 503 });
191 }),
192 ),
193 );
194​
195 expect(serving).toBe(true);
196 assert.include(output, "Pairing URL: https://pair-test.tail.ts.net/pair#token=");
197 }),
198 "::1",
199 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
200 );
201​
202 // The MagicDNS probe fails whenever the host cannot reach its own *.ts.net name,
203 // so it cannot be what decides that the port is free.
204 it.effect(
205 "leaves another app's Serve route alone when the host cannot reach its MagicDNS URL",
206 () =>
207 withDescriptorServer((origin) =>
208 // Another live app on loopback; a dead port's route may be taken back.
209 withDescriptorServer((otherAppOrigin) =>
210 Effect.gen(function* () {
211 const fetch = yield* FetchHttpClient.Fetch;
212 const tailscaleCalls: Array<ReadonlyArray<string>> = [];
213 const spawner = ChildProcessSpawner.make((command) => {
214 const { args } = command as unknown as { readonly args: ReadonlyArray<string> };
215 tailscaleCalls.push(args);
216 const stdout =
217 args[0] === "status"
218 ? '{"BackendState":"Running","Self":{"DNSName":"host-a.tail.ts.net.","TailscaleIPs":["100.64.0.1"],"CapMap":{"https":null}}}'
219 : args[0] === "serve" && args[1] === "status"
220 ? JSON.stringify({
221 TCP: { "443": { HTTPS: true } },
222 Web: {
223 "host-a.tail.ts.net:443": {
224 Handlers: { "/": { Proxy: otherAppOrigin } },
225 },
226 },
227 })
228 : "";
229 return Effect.succeed(
230 ChildProcessSpawner.makeHandle({
231 pid: ChildProcessSpawner.ProcessId(1),
232 // A recorded takeover fails fast instead of waiting out the HTTPS retries.
233 exitCode: Effect.succeed(
234 ChildProcessSpawner.ExitCode(
235 args[0] === "serve" && args[1] !== "status" ? 1 : 0,
236 ),
237 ),
238 isRunning: Effect.succeed(false),
239 kill: () => Effect.void,
240 unref: Effect.succeed(Effect.void),
241 stdin: Sink.drain,
242 stdout: Stream.make(new TextEncoder().encode(stdout)),
243 stderr: Stream.empty,
244 all: Stream.empty,
245 getInputFd: () => Sink.drain,
246 getOutputFd: () => Stream.empty,
247 }),
248 );
249 });
250​
251 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ts-held-"));
252 yield* persistServerRuntimeState({
253 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
254 state: yield* makePersistedServerRuntimeState({
255 config: { host: "127.0.0.1", devUrl: undefined },
256 port: Number(new URL(origin).port),
257 }),
258 });
259​
260 const exit = yield* runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
261 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
262 Effect.provideService(HostProcess.Platform, "linux"),
263 Effect.provideService(FetchHttpClient.Fetch, async (input, init) => {
264 if (String(input).startsWith(origin)) return fetch(input, init);
265 throw new TypeError("getaddrinfo ENOTFOUND host-a.tail.ts.net");
266 }),
267 Effect.exit,
268 );
269​
270 assert.deepEqual(
271 tailscaleCalls.filter((args) => args[0] === "serve" && args[1] !== "status"),
272 [],
273 );
274 assert.isTrue(Exit.isFailure(exit));
275 if (Exit.isFailure(exit)) {
276 assert.instanceOf(Cause.squash(exit.cause), ServePortOccupiedError);
277 }
278 }),
279 ),
280 ).pipe(Effect.provide(layerCliRuntime)),
281 );
282​
283 it.effect("mints a token and prints a QR pairing URL for a live server", () =>
284 withDescriptorServer((origin) =>
285 Effect.gen(function* () {
286 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-test-"));
287 const port = Number(new URL(origin).port);
288 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
289 yield* persistServerRuntimeState({
290 path: statePath,
291 state: yield* makePersistedServerRuntimeState({
292 config: { host: "127.0.0.1", devUrl: undefined },
293 port,
294 }),
295 });
296​
297 const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir]));
298​
299 assert.include(output, `Pairing with pair-test (${origin})`);
300 assert.include(output, `Pairing URL: ${origin}/pair#token=`);
301 assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄"));
302 assert.include(output, "only reachable from this machine");
303​
304 const token = /#token=([A-Z2-9]+)/.exec(output)?.[1];
305 assert.isString(token);
306 assert.include(output, `App link: ${buildPairingAppLink(`${origin}/pair#token=${token}`)}`);
307​
308 const listed = yield* captureStdout(
309 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
310 );
311 const credentials = JSON.parse(listed) as ReadonlyArray<{
312 readonly label?: string;
313 readonly scopes: ReadonlyArray<string>;
314 }>;
315 assert.equal(credentials.length, 1);
316 assert.equal(credentials[0]?.label, "t3 pair");
317 assert.deepEqual(credentials[0]?.scopes, AuthStandardClientScopes);
318 }),
319 ).pipe(
320 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
321 Effect.provideService(HostProcess.Environment, {
322 ...process.env,
323 [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify({
324 protocol: SERVICE_LAUNCHER_PROTOCOL,
325 childVersion: packageJson.version,
326 }),
327 }),
328 Effect.provideService(ServiceLauncherClient.ServiceLauncherHostProcess, {
329 connected: false,
330 send: () => false,
331 on: () => undefined,
332 off: () => undefined,
333 }),
334 ),
335 );
336​
337 it.effect("pairs through the recorded dev web URL for dev servers", () =>
338 withDescriptorServer((origin) =>
339 Effect.gen(function* () {
340 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-dev-test-"));
341 const port = Number(new URL(origin).port);
342 const statePath = NodePath.join(baseDir, "dev", "server-runtime.json");
343 yield* persistServerRuntimeState({
344 path: statePath,
345 state: yield* makePersistedServerRuntimeState({
346 config: { host: undefined, devUrl: new URL("http://localhost:5733") },
347 port,
348 }),
349 });
350​
351 const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir]));
352​
353 assert.include(output, "Pairing URL: http://localhost:5733/pair#token=");
354 }),
355 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
356 );
357​
358 it.effect("refuses --tailscale before turning on Serve when the policy keeps clients local", () =>
359 withDescriptorServer((origin) =>
360 Effect.gen(function* () {
361 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-policy-test-"));
362 const policyPath = NodePath.join(baseDir, "policy.json");
363 NodeFS.writeFileSync(
364 policyPath,
365 JSON.stringify({
366 schemaVersion: 1,
367 organization: "Example Corp",
368 limits: { clients: ["desktop", "local-browser"] },
369 }),
370 );
371 yield* persistServerRuntimeState({
372 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
373 state: yield* makePersistedServerRuntimeState({
374 config: { host: "127.0.0.1", devUrl: undefined },
375 port: Number(new URL(origin).port),
376 }),
377 });
378​
379 const error = yield* provideCliTestLayers(
380 runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
381 Effect.provideService(
382 ChildProcessSpawner.ChildProcessSpawner,
383 ChildProcessSpawner.make(() => Effect.die("tailscale must not run")),
384 ),
385 Effect.provide(
386 ConfigProvider.layer(
387 ConfigProvider.fromEnv({ env: { T3CODE_DEV_MANAGED_POLICY_PATH: policyPath } }),
388 ),
389 ),
390 Effect.flip,
391 ),
392 );
393​
394 assert.include(
395 String(error),
396 "Example Corp's policy does not allow remote clients, so Tailscale Serve cannot be turned on.",
397 );
398 }),
399 ).pipe(Effect.provide(NodeServices.layer)),
400 );
401​
402 it.effect("ignores runtime state whose recorded pid is no longer alive", () =>
403 withDescriptorServer((origin) =>
404 Effect.gen(function* () {
405 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3 pair pid test-"));
406 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
407 const state = yield* makePersistedServerRuntimeState({
408 config: { host: "127.0.0.1", devUrl: undefined },
409 port: Number(new URL(origin).port),
410 });
411 yield* persistServerRuntimeState({
412 path: statePath,
413 state: { ...state, pid: 4_194_305 },
414 });
415​
416 const error = yield* provideCliTestLayers(
417 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
418 );
419​
420 const rendered = String(
421 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
422 );
423 assert.include(rendered, "No running T3 Code server found.");
424 for (const subcommand of ["serve", "connect"]) {
425 const { command } = yield* resolveHostCliCommand(subcommand, ["--base-dir", baseDir]);
426 assert.include(rendered, `\`${command}\``);
427 }
428 }),
429 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
430 );
431​
432 // regression: #10516 minting opens the target database with this checkout's
433 // migrations, so a worktree must never fall back to the live install.
434 it.effect("does not fall back to the shared home from a linked worktree", () =>
435 withDescriptorServer((origin) =>
436 Effect.gen(function* () {
437 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-worktree-test-"));
438 const worktree = NodePath.join(root, "worktree");
439 NodeFS.mkdirSync(worktree);
440 NodeFS.writeFileSync(
441 NodePath.join(worktree, ".git"),
442 `gitdir: ${NodePath.join(root, "repo", ".git", "worktrees", "worktree")}\n`,
443 );
444 const sharedHome = NodePath.join(root, "shared");
445 yield* persistServerRuntimeState({
446 path: NodePath.join(sharedHome, "userdata", "server-runtime.json"),
447 state: yield* makePersistedServerRuntimeState({
448 config: { host: "127.0.0.1", devUrl: undefined },
449 port: Number(new URL(origin).port),
450 }),
451 });
452​
453 const error = yield* provideCliTestLayers(
454 runCli(["pair"]).pipe(
455 Effect.provideService(HostProcess.WorkingDirectory, worktree),
456 Effect.provide(
457 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: sharedHome } })),
458 ),
459 Effect.flip,
460 ),
461 );
462​
463 assert.include(String(error), "No running T3 Code server found.");
464 assert.notInclude(String(error), sharedHome);
465 assert.deepEqual(NodeFS.readdirSync(NodePath.join(sharedHome, "userdata")), [
466 "server-runtime.json",
467 ]);
468 }),
469 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
470 );
471​
472 // regression: #15492 an older CLI minted into a V1 database the protocol 2
473 // server never reads, so pairing must refuse before opening the database.
474 it.effect("rejects a server on another protocol before opening its database", () =>
475 withDescriptorServer(
476 (origin) =>
477 Effect.gen(function* () {
478 const baseDir = NodeFS.mkdtempSync(
479 NodePath.join(NodeOS.tmpdir(), "t3-pair-protocol-test-"),
480 );
481 yield* persistServerRuntimeState({
482 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
483 state: yield* makePersistedServerRuntimeState({
484 config: { host: "127.0.0.1", devUrl: undefined },
485 port: Number(new URL(origin).port),
486 }),
487 });
488​
489 const exit = yield* provideCliTestLayers(
490 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.exit),
491 );
492​
493 assert.deepEqual(NodeFS.readdirSync(NodePath.join(baseDir, "userdata")), [
494 "server-runtime.json",
495 ]);
496 assert.isTrue(Exit.isFailure(exit));
497 if (Exit.isFailure(exit)) {
498 assert.include(
499 String(Cause.squash(exit.cause)),
500 `t3@0.0.45 pair --base-dir ${baseDir}`,
501 );
502 }
503 }),
504 "127.0.0.1",
505 { ...testDescriptor, serverVersion: "0.0.45", orchestrationProtocolVersion: 1 },
506 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
507 );
508​
509 // Without the capability `tailscale serve` blocks on an enablement URL, so
510 // pairing must stop at status instead of spawning it.
511 it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () =>
512 withDescriptorServer((origin) =>
513 Effect.gen(function* () {
514 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ts-test-"));
515 yield* persistServerRuntimeState({
516 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
517 state: yield* makePersistedServerRuntimeState({
518 config: { host: "127.0.0.1", devUrl: undefined },
519 port: Number(new URL(origin).port),
520 }),
521 });
522 const tailscaleCalls: Array<ReadonlyArray<string>> = [];
523 const spawner = ChildProcessSpawner.make((command) => {
524 const { args } = command as unknown as { readonly args: ReadonlyArray<string> };
525 tailscaleCalls.push(args);
526 const stdout =
527 args[0] === "status"
528 ? `{"Self":{"DNSName":"pair-test.invalid.","CapMap":{"funnel":null}}}`
529 : "";
530 return Effect.succeed(
531 ChildProcessSpawner.makeHandle({
532 pid: ChildProcessSpawner.ProcessId(1),
533 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(args[0] === "status" ? 0 : 1)),
534 isRunning: Effect.succeed(false),
535 kill: () => Effect.void,
536 unref: Effect.succeed(Effect.void),
537 stdin: Sink.drain,
538 stdout: Stream.make(new TextEncoder().encode(stdout)),
539 stderr: Stream.empty,
540 all: Stream.empty,
541 getInputFd: () => Sink.drain,
542 getOutputFd: () => Stream.empty,
543 }),
544 );
545 });
546​
547 const error = yield* provideCliTestLayers(
548 runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
549 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
550 Effect.flip,
551 ),
552 );
553​
554 assert.include(String(error), "Tailscale Serve is not enabled on your tailnet.");
555 assert.include(String(error), "https://login.tailscale.com/admin/dns");
556 assert.deepEqual(tailscaleCalls, [["status", "--json"]]);
557 }),
558 ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
559 );
560​
561 // regression: #35517 - the macOS Tailscale apps refuse Serve targets other than
562 // localhost, 127.0.0.1 and ::1, so a specific bind needs an explanation.
563 it.effect("names the bind address when Tailscale refuses it as a Serve target", () =>
564 Effect.gen(function* () {
565 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-remote-"));
566 yield* persistServerRuntimeState({
567 path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
568 state: yield* makePersistedServerRuntimeState({
569 config: { host: "127.0.0.2", devUrl: undefined },
570 port: 3773,
571 }),
572 });
573 const spawner = ChildProcessSpawner.make((command) => {
574 const { args } = command as unknown as { readonly args: ReadonlyArray<string> };
575 const serve = args[0] === "serve" && args[1] !== "status";
576 return Effect.succeed(
577 ChildProcessSpawner.makeHandle({
578 pid: ChildProcessSpawner.ProcessId(1),
579 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(serve ? 1 : 0)),
580 isRunning: Effect.succeed(false),
581 kill: () => Effect.void,
582 unref: Effect.succeed(Effect.void),
583 stdin: Sink.drain,
584 stdout: Stream.make(
585 new TextEncoder().encode(
586 serve
587 ? ""
588 : args[0] === "status"
589 ? '{"Self":{"DNSName":"pair-test.tail.ts.net."}}'
590 : "{}",
591 ),
592 ),
593 stderr: Stream.make(
594 new TextEncoder().encode(
595 serve
596 ? "Warning: the MacOS extension doesn't support connecting to remote destinations from non-default route\n"
597 : "",
598 ),
599 ),
600 all: Stream.empty,
601 getInputFd: () => Sink.drain,
602 getOutputFd: () => Stream.empty,
603 }),
604 );
605 });
606​
607 const error = yield* provideCliTestLayers(
608 runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
609 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
610 Effect.provideService(HostProcess.Platform, "darwin"),
611 Effect.provideService(FetchHttpClient.Fetch, async (input) => {
612 if (String(input) === "http://127.0.0.2:3773/.well-known/t3/environment") {
613 return Response.json(testDescriptor);
614 }
615 assert.equal(String(input), "https://pair-test.tail.ts.net/.well-known/t3/environment");
616 return new Response(null, { status: 503 });
617 }),
618 Effect.flip,
619 ),
620 );
621​
622 assert.instanceOf(error, TailscaleServeTargetRefusedError);
623 assert.include(String(error), "127.0.0.2");
624 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
625 );
626});
627​
628describe("auth scope options", () => {
629 it.effect.each([
630 { group: "pairing", action: "create" },
631 { group: "session", action: "issue" },
632 ] as const)(
633 "issues and persists only the selected scopes for auth $group $action",
634 ({ group, action }) =>
635 Effect.gen(function* () {
636 const baseDir = NodeFS.mkdtempSync(
637 NodePath.join(NodeOS.tmpdir(), "t3-cli-auth-scopes-test-"),
638 );
639 const output = yield* captureStdout(
640 runCli([
641 "auth",
642 group,
643 action,
644 "--base-dir",
645 baseDir,
646 "--json",
647 "--scope",
648 "orchestration:read",
649 "--scope",
650 "access:read",
651 "--scope",
652 "orchestration:read",
653 ]),
654 );
655 const issued = JSON.parse(output) as { readonly scopes: ReadonlyArray<string> };
656 const listOutput = yield* captureStdout(
657 runCli(["auth", group, "list", "--base-dir", baseDir, "--json"]),
658 );
659 const listed = JSON.parse(listOutput) as ReadonlyArray<{
660 readonly scopes: ReadonlyArray<string>;
661 }>;
662​
663 assert.deepEqual(issued.scopes, ["orchestration:read", "access:read"]);
664 assert.lengthOf(listed, 1);
665 assert.deepEqual(listed[0]?.scopes, issued.scopes);
666 }),
667 );
668​
669 it.effect.each(
670 [["pair"], ["auth", "pairing", "create"], ["auth", "session", "issue"]].map((command) => ({
671 command,
672 label: command.join(" "),
673 })),
674 )("rejects invalid scopes before running $label", ({ command }) =>
675 Effect.gen(function* () {
676 const error = yield* runCli([
677 ...command,
678 "--scope",
679 "orchestration:read",
680 "--scope",
681 "admin",
682 ]).pipe(Effect.provide(layerCliRuntime), Effect.flip);
683​
684 if (!CliError.isCliError(error) || error._tag !== "ShowHelp") {
685 assert.fail(`Expected ShowHelp, got ${String(error)}`);
686 }
687 assert.deepEqual(error.commandPath, ["t3", ...command]);
688 const scopeError = error.errors[0];
689 if (scopeError?._tag !== "InvalidValue") {
690 assert.fail(`Expected InvalidValue, got ${String(scopeError?._tag)}`);
691 }
692 assert.equal(scopeError.option, "scope");
693 assert.equal(scopeError.value, "admin");
694 }),
695 );
696});
697​
698describe("auth pairing create --base-url", () => {
699 it.effect.each(["192.168.1.5:3773", "localhost:3773", "ftp://localhost:3773"])(
700 "rejects %s before issuing a token",
701 (baseUrl) =>
702 Effect.gen(function* () {
703 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-"));
704 const error = yield* runCli([
705 "auth",
706 "pairing",
707 "create",
708 "--base-dir",
709 baseDir,
710 "--base-url",
711 baseUrl,
712 ]).pipe(Effect.provide(layerCliRuntime), Effect.flip);
713​
714 if (!CliError.isCliError(error) || error._tag !== "ShowHelp") {
715 assert.fail(`Expected ShowHelp, got ${String(error)}`);
716 }
717 const flagError = error.errors[0];
718 if (flagError?._tag !== "InvalidValue") {
719 assert.fail(`Expected InvalidValue, got ${String(flagError?._tag)}`);
720 }
721 assert.equal(flagError.option, "base-url");
722 assert.equal(flagError.value, baseUrl);
723​
724 const listOutput = yield* captureStdout(
725 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
726 );
727 assert.deepEqual(JSON.parse(listOutput), []);
728 }),
729 );
730​
731 it.effect.each(["http://192.168.1.5:3773", "https://example.com", "http:localhost:3773"])(
732 "prints a pair URL for %s",
733 (baseUrl) =>
734 Effect.gen(function* () {
735 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-base-url-test-"));
736 const output = yield* captureStdout(
737 runCli([
738 "auth",
739 "pairing",
740 "create",
741 "--base-dir",
742 baseDir,
743 "--base-url",
744 baseUrl,
745 "--json",
746 ]),
747 );
748 const issued = JSON.parse(output) as {
749 readonly credential: string;
750 readonly pairUrl: string;
751 };
752​
753 assert.equal(issued.pairUrl, `${new URL(baseUrl).origin}/pair#token=${issued.credential}`);
754 }),
755 );
756});
757​
758describe("scripted auth output", () => {
759 it.effect.each(
760 [
761 ["auth", "session", "issue", "--token-only"],
762 ["auth", "session", "list", "--json"],
763 ["auth", "pairing", "list", "--json"],
764 ].map((command) => ({ command, label: command.join(" ") })),
765 )("prints only the result for $label after removing an abandoned upload", ({ command }) =>
766 Effect.gen(function* () {
767 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-quiet-test-"));
768 const attachmentsDir = NodePath.join(baseDir, "userdata", "attachments");
769 NodeFS.mkdirSync(attachmentsDir, { recursive: true });
770 // An abandoned upload makes config resolution log that it removed it.
771 const abandoned = NodePath.join(attachmentsDir, "abandoned.part");
772 NodeFS.writeFileSync(abandoned, "");
773 const jan2020Seconds = 1_577_836_800;
774 NodeFS.utimesSync(abandoned, jan2020Seconds, jan2020Seconds);
775 // The sweep reads Clock, so a week past the upload's mtime makes it expired.
776 yield* TestClock.setTime((jan2020Seconds + 7 * 24 * 60 * 60) * 1000);
777​
778 const lines = yield* provideCliTestLayers(
779 Effect.gen(function* () {
780 yield* runCli([...command, "--base-dir", baseDir]);
781 return yield* TestConsole.logLines;
782 }),
783 );
784​
785 assert.isFalse(NodeFS.existsSync(abandoned));
786 assert.lengthOf(lines, 1);
787 const output = String(lines[0]);
788 if (command.includes("--json")) {
789 assert.deepEqual(JSON.parse(output), []);
790 } else {
791 assert.match(output.trim(), /^[\w-]+\.[\w-]+/);
792 }
793 }),
794 );
795});