Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
# Get this from your relay deployment. `infra/relay` deploys update it automatically.
# T3CODE_RELAY_URL=https://relay.example.com

# Optional: hosted app origin used by the CLI's out-of-band OAuth flow.
# Defaults to https://app.t3.codes; override to test against a staging deployment.
# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes

# Public, ingest-only mobile OpenTelemetry configuration.
# T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces
# T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev
Expand Down
257 changes: 257 additions & 0 deletions .plans/t3-connect-remote-setup.html

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
}),
);

it.effect("logs in to headless connect without enabling access", () =>
it.effect("accepts the --headless login override without enabling access", () =>
Effect.gen(function* () {
const baseDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"),
Expand All @@ -254,7 +254,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
);

const login = yield* captureStdout(
runConnectCli(["connect", "login", "--base-dir", baseDir]),
runConnectCli(["connect", "login", "--base-dir", baseDir, "--headless"]),
);
const status = yield* captureStdout(
runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
Expand Down
244 changes: 207 additions & 37 deletions apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import {
type RelayClientInstallProgressStage,
} from "@t3tools/contracts";
import { RelayOkResponse } from "@t3tools/contracts/relay";
import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
import * as RelayClient from "@t3tools/shared/relayClient";
import * as Terminal from "effect/Terminal";
import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
import * as Cause from "effect/Cause";
import * as Console from "effect/Console";
import * as Crypto from "effect/Crypto";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as References from "effect/References";
import * as Schema from "effect/Schema";
import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
import {
FetchHttpClient,
Expand All @@ -25,8 +29,10 @@ import {
} from "effect/unstable/http";
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";

import packageJson from "../../package.json" with { type: "json" };
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as BootService from "../cloud/bootService.ts";
import * as CliState from "../cloud/CliState.ts";
import * as CliTokenManager from "../cloud/CliTokenManager.ts";
import {
Expand All @@ -38,6 +44,7 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts";
import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ProcessRunner from "../processRunner.ts";
import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";

Expand All @@ -46,6 +53,70 @@ const jsonFlag = Flag.boolean("json").pipe(
Flag.withDefault(false),
);

const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);

const headlessFlag = Flag.boolean("headless").pipe(
Flag.withDescription("Authorize without a local browser using out-of-band OAuth."),
Flag.withDefault(false),
);

/**
* Inside an SSH session there is no local browser to complete the loopback
* OAuth callback, so out-of-band OAuth is the only flow that can work.
*/
const detectHeadlessSession = Effect.map(
HostProcessEnvironment,
(env) => env.SSH_CONNECTION !== undefined || env.SSH_TTY !== undefined,
);

const promptForOutOfBandOAuthCode = ({
authorizeUrl,
validate,
}: CliTokenManager.OutOfBandOAuthPromptInput) =>
Console.log(`To set up T3 Connect, open this URL and sign in:\n ${authorizeUrl}\n`).pipe(
Effect.andThen(
Prompt.run(Prompt.text({ message: "Enter your authentication code", validate })),
),
);

/** Returns the connected account identity, if the flow could determine one. */
const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
readonly headless: boolean;
}) {
const tokens = yield* CliTokenManager.CloudCliTokenManager;
const useOutOfBandOAuth = options.headless || (yield* detectHeadlessSession);
if (!useOutOfBandOAuth) {
yield* tokens.get;
return null;
}
// A stored credential whose refresh fails (revoked, expired grant) must
// fall through to a fresh out-of-band authorization, not dead-end the command.
const existing = yield* tokens.getExisting.pipe(
Effect.catchTags({
CloudCliCredentialRefreshError: () =>
Console.log(
"The stored T3 Connect credential could not be refreshed; signing in again.",
).pipe(Effect.as(Option.none())),
}),
);
if (Option.isSome(existing)) {
return null;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
promptForOutOfBandOAuthCode,
).pipe(
Effect.mapError((cause) =>
// Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI
// cancels quietly instead of dumping an authorization error.
Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
? cause
: new CliTokenManager.CloudCliAuthorizationError({ cause }),
),
);
yield* tokens.store(token);
return identity;
});

function bytesToString(value: Uint8Array): string {
return new TextDecoder().decode(value);
}
Expand Down Expand Up @@ -313,6 +384,23 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: {
if (options.clearAuthorization) {
const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.clear;

// uninstall itself no-ops when nothing is installed (and on non-Linux),
// so no status pre-check that could mask a real removal failure.
const bootService = yield* BootService.BootService;
yield* bootService.uninstall.pipe(
Effect.tap((removed) =>
removed ? Console.log("Removed the T3 Code background service.") : Effect.void,
),
Effect.catchTags({
BootServiceUnsupportedError: () => Effect.succeed(false),
}),
Effect.catch((error) =>
Console.warn(`Could not remove the background service: ${error.message}`).pipe(
Effect.as(false),
),
),
);
}

yield* reportCloudDisconnectResults({
Expand All @@ -335,6 +423,8 @@ const runCloudCommand = <A, E>(
| CliTokenManager.CloudCliTokenManager
| RelayClient.RelayClient
| EnvironmentAuth.EnvironmentAuth
| BootService.BootService
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| Prompt.Environment
Expand All @@ -355,6 +445,11 @@ const runCloudCommand = <A, E>(
RelayClient.layerCloudflared({ baseDir: config.baseDir }),
EnvironmentAuth.runtimeLayer,
ServerEnvironment.layer,
BootService.layer({
baseDir: config.baseDir,
logsDir: config.logsDir,
cliVersion: packageJson.version,
}).pipe(Layer.provide(ProcessRunner.layer)),
headlessRelayClientTracingLayer,
).pipe(
Layer.provideMerge(FetchHttpClient.layer),
Expand All @@ -364,24 +459,57 @@ const runCloudCommand = <A, E>(
return yield* run.pipe(Effect.provide(runtimeLayer));
});

const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");

const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
readonly headless: boolean;
readonly publishOnly?: boolean;
}) {
const publishOnly = options.publishOnly ?? false;
if (!publishOnly) {
const relayClient = yield* RelayClient.RelayClient;
const installed = yield* acquireRelayClientForLink(
relayClient,
confirmRelayClientInstall,
reportRelayClientInstallProgress,
);
if (Option.isNone(installed)) {
yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
return null;
}
yield* Console.log(
`Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
);
}

const identity = yield* authorizeCli(options);
yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
if (publishOnly) {
const secrets = yield* ServerSecretStore.ServerSecretStore;
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
}
return { identity } as const;
});

const connectLoginCommand = Command.make("login", {
...projectLocationFlags,
headless: headlessFlag,
}).pipe(
Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
Command.withHandler((flags) =>
runCloudCommand(
flags,
Effect.gen(function* () {
const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.get;
yield* Console.log("Signed in to T3 Connect.");
const identity = yield* authorizeCli(flags);
yield* Console.log(`Signed in to T3 Connect${connectedAs(identity)}.`);
}),
),
),
);

const connectLinkCommand = Command.make("link", {
...projectLocationFlags,
headless: headlessFlag,
publishOnly: Flag.boolean("publish-only").pipe(
Flag.withDescription(
"Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
Expand All @@ -394,41 +522,14 @@ const connectLinkCommand = Command.make("link", {
runCloudCommand(
flags,
Effect.gen(function* () {
// A publish-only link needs no Cloudflare tunnel, so skip installing the
// relay client entirely.
if (!flags.publishOnly) {
const relayClient = yield* RelayClient.RelayClient;
const installed = yield* acquireRelayClientForLink(
relayClient,
confirmRelayClientInstall,
reportRelayClientInstallProgress,
);
if (Option.isNone(installed)) {
yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
return;
}
const linked = yield* linkEnvironmentForConnect(flags);
if (linked) {
yield* Console.log(
`Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
flags.publishOnly
? `Authorized T3 Connect${connectedAs(linked.identity)}. This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel).`
: `Authorized T3 Connect${connectedAs(linked.identity)}. This environment will be available the next time T3 starts.`,
);
}

const tokens = yield* CliTokenManager.CloudCliTokenManager;
yield* tokens.get;
yield* CliState.setCliDesiredCloudLink(
true,
flags.publishOnly ? "publish_only" : "managed",
);
if (flags.publishOnly) {
// A publish-only link exists solely to publish; without the publish
// flag the link would be inert and the success message a lie.
const secrets = yield* ServerSecretStore.ServerSecretStore;
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
}
yield* Console.log(
flags.publishOnly
? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)."
: "This T3 environment will be available through T3 Connect the next time T3 starts.",
);
}),
),
),
Expand Down Expand Up @@ -566,8 +667,77 @@ const connectLogoutCommand = Command.make("logout", {
),
);

export const connectCommand = Command.make("connect").pipe(
Command.withDescription("Manage headless T3 Connect access."),
const offerBootService = Effect.gen(function* () {
const bootService = yield* BootService.BootService;
const { supported, installed, current } = yield* bootService.status;
if (!supported) {
// Don't prompt for something that can only fail; background setup is
// Linux/systemd-only for now.
return false;
}
if (installed && current) {
yield* Console.log("T3 Code is already set up to run in the background on this machine.");
return true;
}
const wanted = yield* Prompt.run(
Prompt.confirm({
message: installed
? "The installed T3 Code background service is from an older setup. Update it now?"
: "Run T3 Code in the background whenever this machine boots? " +
"It stays reachable through T3 Connect even after you log out.",
initial: true,
}),
);
if (!wanted) {
return false;
}
const plan = yield* bootService.install;
yield* Console.log(`Background service installed. Logs: ${plan.logPath}`);
return true;
});

export const connectCommand = Command.make("connect", {
...projectLocationFlags,
headless: headlessFlag,
}).pipe(
Command.withDescription("Set up T3 Connect for this machine."),
Command.withHandler((flags) =>
runCloudCommand(
flags,
Effect.gen(function* () {
const linked = yield* linkEnvironmentForConnect(flags);
if (!linked) {
return;
}
// Show which account was linked so an unexpected identity (an
// authorization code for a different account) is visible before the
// machine is brought online.
yield* Console.log(`\nConnected${connectedAs(linked.identity)}!`);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

// Connect itself already succeeded; a boot-service failure must not
// fail the command, just tell the user what happened and move on.
const background = yield* offerBootService.pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Effect.catchTags({
BootServiceUnsupportedError: (error) =>
Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
BootServiceCommandError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(
Effect.as(false),
),
BootServiceInstallError: (error) =>
Console.warn(`Background setup did not finish: ${error.message}`).pipe(
Effect.as(false),
),
}),
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
yield* Console.log(
background
? "\nGreat, T3 Code is now set up and ready to go."
: "\nT3 Connect is set up. Start the server with `t3 serve` to make this machine reachable.",
);
}),
),
),
Command.withSubcommands([
connectLoginCommand,
connectLinkCommand,
Expand Down
Loading
Loading