Skip to content
Merged
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
42 changes: 40 additions & 2 deletions apps/server/src/cli/pair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { AuthStandardClientScopes } from "@t3tools/contracts";
import { AuthStandardClientScopes, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
import * as NetService from "@t3tools/shared/Net";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { assert, describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
Expand Down Expand Up @@ -89,19 +91,21 @@ const testDescriptor = {
label: "pair-test",
platform: { os: "linux", arch: "x64" },
serverVersion: "0.0.1",
orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
capabilities: { repositoryIdentity: true },
};

const withDescriptorServer = <A, E, R>(
run: (origin: string) => Effect.Effect<A, E, R>,
host = "127.0.0.1",
descriptor: object = testDescriptor,
) =>
Effect.acquireUseRelease(
Effect.callback<NodeHttp.Server>((resume) => {
const server = NodeHttp.createServer((request, response) => {
if (request.url === "/.well-known/t3/environment") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(testDescriptor));
response.end(JSON.stringify(descriptor));
return;
}
response.writeHead(404);
Expand Down Expand Up @@ -328,6 +332,40 @@ describe("t3 pair", () => {
).pipe(Effect.provide(NodeServices.layer)),
);

// regression: #15492 an older CLI minted into a V1 database the protocol 2
// server never reads, so pairing must refuse before opening the database.
it.effect("rejects a server on another protocol before opening its database", () =>
withDescriptorServer(
(origin) =>
Effect.gen(function* () {
const baseDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3-pair-protocol-test-"),
);
yield* persistServerRuntimeState({
path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
state: yield* makePersistedServerRuntimeState({
config: { host: "127.0.0.1", devUrl: undefined },
port: Number(new URL(origin).port),
}),
});

const exit = yield* provideCliTestLayers(
runCli(["pair", "--base-dir", baseDir]).pipe(Effect.exit),
);

assert.deepEqual(NodeFS.readdirSync(NodePath.join(baseDir, "userdata")), [
"server-runtime.json",
]);
assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
assert.include(String(Cause.squash(exit.cause)), "t3@0.0.45 pair");
}
}),
"127.0.0.1",
{ ...testDescriptor, serverVersion: "0.0.45", orchestrationProtocolVersion: 1 },
).pipe(Effect.provide(NodeServices.layer)),
);

// Without the capability `tailscale serve` blocks on an enablement URL, so
// pairing must stop at status instead of spawning it.
it.effect("explains a tailnet without HTTPS serve instead of running tailscale serve", () =>
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type AuthEnvironmentScope,
AuthStandardClientScopes,
ExecutionEnvironmentDescriptor,
ORCHESTRATION_PROTOCOL_VERSION,
PortSchema,
} from "@t3tools/contracts";
import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
Expand Down Expand Up @@ -77,6 +78,18 @@ export class NoRunningServerError extends Schema.TaggedError<NoRunningServerErro
}
}

export class IncompatibleServerProtocolError extends Schema.TaggedError<IncompatibleServerProtocolError>()(
"IncompatibleServerProtocolError",
{ serverVersion: Schema.String, serverProtocolVersion: Schema.Number },
) {
override get message(): string {
return [
`The running T3 Code server (${this.serverVersion}) speaks orchestration protocol ${String(this.serverProtocolVersion)}, but this CLI speaks protocol ${String(ORCHESTRATION_PROTOCOL_VERSION)}.`,
`Pair with the matching CLI instead: \`npx t3@${this.serverVersion} pair\`.`,
].join("\n");
}
}

export class TailscaleUnavailableError extends Schema.TaggedError<TailscaleUnavailableError>()(
"TailscaleUnavailableError",
{ cause: Schema.Defect() },
Expand Down Expand Up @@ -493,6 +506,16 @@ export const pairCommand = Command.make("pair", {
const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const);

const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir));
// Each protocol keeps auth in its own database, so a token minted here
// for another protocol's server would never be accepted by it. Refuse
// before any local layer opens (and migrates) that database.
const serverProtocolVersion = target.descriptor.orchestrationProtocolVersion ?? 1;
if (serverProtocolVersion !== ORCHESTRATION_PROTOCOL_VERSION) {
return yield* new IncompatibleServerProtocolError({
serverVersion: target.descriptor.serverVersion,
serverProtocolVersion,
});
}

const notes: Array<string> = [];
let pairingBaseUrl: string;
Expand Down
Loading