| author | |
| committer | |
| log | 99e3b721c5255ded20b00ba1798f848bfc0f1f65 |
| tree | ac689cba47b84e4fe480eb9248655c9639d2fa0d |
| parent | c3b8825bf476cbce5e061c0f99570cf1f6723b89 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 614 insertions(+), 42 deletions(-)
apps/server/src/cli/connect.ts+9-6| ... | @@ -35,6 +35,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ... | @@ -35,6 +35,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 35 | import * as BootService from "../cloud/bootService.ts"; | 35 | import * as BootService from "../cloud/bootService.ts"; |
| 36 | import * as CliState from "../cloud/CliState.ts"; | 36 | import * as CliState from "../cloud/CliState.ts"; |
| 37 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; | 37 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 38 | import { filterRelayResponse } from "../cloud/relayResponse.ts"; | ||
| 38 | import { | 39 | import { |
| 39 | CLOUD_LINKED_USER_ID, | 40 | CLOUD_LINKED_USER_ID, |
| 40 | isAgentActivityPublishingEnabledValue, | 41 | isAgentActivityPublishingEnabledValue, |
| ... | @@ -208,6 +209,8 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b | ... | @@ -208,6 +209,8 @@ function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: b |
| 208 | ` Relay: ${status.relayUrl ?? "not provisioned"}`, | 209 | ` Relay: ${status.relayUrl ?? "not provisioned"}`, |
| 209 | ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, | 210 | ` Publish agent activity: ${status.publishAgentActivity ? "enabled" : "disabled"}`, |
| 210 | ...formatRelayClientStatus(status.relayClient), | 211 | ...formatRelayClientStatus(status.relayClient), |
| 212 | "", | ||
| 213 | "This is saved setup, not a live connection check. Check the background service with `t3 service status`.", | ||
| 211 | ...(nextStep ? ["", `Next: ${nextStep}`] : []), | 214 | ...(nextStep ? ["", `Next: ${nextStep}`] : []), |
| 212 | ].join("\n"); | 215 | ].join("\n"); |
| 213 | } | 216 | } |
| ... | @@ -346,7 +349,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f | ... | @@ -346,7 +349,7 @@ const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(f |
| 346 | ).pipe( | 349 | ).pipe( |
| 347 | HttpClientRequest.bearerToken(token.value.accessToken), | 350 | HttpClientRequest.bearerToken(token.value.accessToken), |
| 348 | httpClient.execute, | 351 | httpClient.execute, |
| 349 | Effect.flatMap(HttpClientResponse.filterStatusOk), | 352 | Effect.flatMap(filterRelayResponse), |
| 350 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), | 353 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), |
| 351 | withRelayClientTracing, | 354 | withRelayClientTracing, |
| 352 | ); | 355 | ); |
| ... | @@ -689,17 +692,17 @@ export const connectCommand = Command.make("connect", { | ... | @@ -689,17 +692,17 @@ export const connectCommand = Command.make("connect", { |
| 689 | // Show which account was linked so an unexpected identity (an | 692 | // Show which account was linked so an unexpected identity (an |
| 690 | // authorization code for a different account) is visible before the | 693 | // authorization code for a different account) is visible before the |
| 691 | // machine is brought online. | 694 | // machine is brought online. |
| 692 | yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`); | 695 | yield* Console.log(`✓ Authorized${connectedAs(linked.identity)}`); |
| 693 | 696 | ||
| 694 | // Connect itself already succeeded; a boot-service failure must not | 697 | // Authorization is stored. If service setup fails, preserve it and |
| 695 | // fail the command, just tell the user what happened and move on. | 698 | // show how to run the server manually. |
| 696 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); | 699 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); |
| 697 | if (background) { | 700 | if (background) { |
| 698 | const platform = yield* HostProcessPlatform; | 701 | const platform = yield* HostProcessPlatform; |
| 699 | yield* Console.log( | 702 | yield* Console.log( |
| 700 | platform === "darwin" | 703 | platform === "darwin" |
| 701 | ? "\n✓ Background service ready\n\nT3 Code will stay reachable while you are logged in to this Mac." | 704 | ? "\n✓ Background service ready\n\nT3 Code is set to run while you are logged in to this Mac. The server establishes the T3 Connect link on startup." |
| 702 | : "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.", | 705 | : "\n✓ Background service ready\n\nT3 Code is set to keep running after you log out. The server establishes the T3 Connect link on startup.", |
| 703 | ); | 706 | ); |
| 704 | return; | 707 | return; |
| 705 | } | 708 | } |
apps/server/src/cli/service.test.ts+47-1| ... | @@ -45,10 +45,38 @@ it("reports the installed service version and host paths", () => { | ... | @@ -45,10 +45,38 @@ it("reports the installed service version and host paths", () => { |
| 45 | it("gives a direct repair command for a stale service", () => { | 45 | it("gives a direct repair command for a stale service", () => { |
| 46 | assert.include( | 46 | assert.include( |
| 47 | formatServiceStatus({ ...status, current: false }, "0.0.29"), | 47 | formatServiceStatus({ ...status, current: false }, "0.0.29"), |
| 48 | "Next: Run `npx t3@latest service update`.", | 48 | "Next: Run `npx t3@0.0.29 service update`.", |
| 49 | ); | 49 | ); |
| 50 | }); | 50 | }); |
| 51 | 51 | ||
| 52 | it("explains an incomplete nightly installation and keeps repair on its installed version", () => { | ||
| 53 | const output = formatServiceStatus( | ||
| 54 | { | ||
| 55 | ...status, | ||
| 56 | current: false, | ||
| 57 | installedVersion: "0.0.32-nightly.1", | ||
| 58 | problems: ["linger-disabled", "service-stopped"], | ||
| 59 | }, | ||
| 60 | "0.0.32-nightly.1", | ||
| 61 | ); | ||
| 62 | |||
| 63 | expect(output).toContain("[linger-disabled]"); | ||
| 64 | expect(output).toContain("last login session ends"); | ||
| 65 | expect(output).toContain('sudo loginctl enable-linger "$(id -un)"'); | ||
| 66 | expect(output).toContain("[service-stopped]"); | ||
| 67 | expect(output).toContain("npx t3@0.0.32-nightly.1 service update"); | ||
| 68 | expect(output).not.toContain("t3@latest"); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("suggests the newer CLI version when the installed service needs an update", () => { | ||
| 72 | const output = formatServiceStatus( | ||
| 73 | { ...status, current: false, installedVersion: "0.0.28" }, | ||
| 74 | "0.0.29", | ||
| 75 | ); | ||
| 76 | expect(output).toContain("npx t3@0.0.29 service update"); | ||
| 77 | expect(output).not.toContain("npx t3@0.0.28 service update"); | ||
| 78 | }); | ||
| 79 | |||
| 52 | it("explains where the service is supported", () => { | 80 | it("explains where the service is supported", () => { |
| 53 | assert.include( | 81 | assert.include( |
| 54 | formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), | 82 | formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"), |
| ... | @@ -151,6 +179,15 @@ it.effect.each([ | ... | @@ -151,6 +179,15 @@ it.effect.each([ |
| 151 | name: "the same version", | 179 | name: "the same version", |
| 152 | state: { ...status, current: false, installedVersion: packageJson.version }, | 180 | state: { ...status, current: false, installedVersion: packageJson.version }, |
| 153 | }, | 181 | }, |
| 182 | { | ||
| 183 | name: "an incomplete install of the same version", | ||
| 184 | state: { | ||
| 185 | ...status, | ||
| 186 | current: false, | ||
| 187 | installedVersion: packageJson.version, | ||
| 188 | problems: ["linger-disabled"] as const, | ||
| 189 | }, | ||
| 190 | }, | ||
| 154 | { name: "an unknown version", state: { ...status, current: false } }, | 191 | { name: "an unknown version", state: { ...status, current: false } }, |
| 155 | ])("installs or repairs $name without an override", ({ state }) => | 192 | ])("installs or repairs $name without an override", ({ state }) => |
| 156 | Effect.gen(function* () { | 193 | Effect.gen(function* () { |
| ... | @@ -201,3 +238,12 @@ it.effect("keeps onboarding successful when a newer version appears before insta | ... | @@ -201,3 +238,12 @@ it.effect("keeps onboarding successful when a newer version appears before insta |
| 201 | expect(ready).toBe(false); | 238 | expect(ready).toBe(false); |
| 202 | }), | 239 | }), |
| 203 | ); | 240 | ); |
| 241 | |||
| 242 | it.effect("keeps the manual-server fallback when background prerequisites fail", () => | ||
| 243 | Effect.gen(function* () { | ||
| 244 | const ready = yield* recoverServiceOnboardingOffer( | ||
| 245 | Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })), | ||
| 246 | ); | ||
| 247 | expect(ready).toBe(false); | ||
| 248 | }), | ||
| 249 | ); |
apps/server/src/cli/service.ts+11-1| ... | @@ -68,6 +68,9 @@ export function formatServiceStatus( | ... | @@ -68,6 +68,9 @@ export function formatServiceStatus( |
| 68 | return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; | 68 | return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; |
| 69 | } | 69 | } |
| 70 | const installedVersion = status.installedVersion ?? cliVersion; | 70 | const installedVersion = status.installedVersion ?? cliVersion; |
| 71 | const problems = (status.problems ?? []).map( | ||
| 72 | (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`, | ||
| 73 | ); | ||
| 71 | if ( | 74 | if ( |
| 72 | !status.current && | 75 | !status.current && |
| 73 | status.installedVersion !== undefined && | 76 | status.installedVersion !== undefined && |
| ... | @@ -78,6 +81,7 @@ export function formatServiceStatus( | ... | @@ -78,6 +81,7 @@ export function formatServiceStatus( |
| 78 | ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, | 81 | ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, |
| 79 | ` Unit: ${status.unitPath}`, | 82 | ` Unit: ${status.unitPath}`, |
| 80 | ` Logs: ${status.logPath}`, | 83 | ` Logs: ${status.logPath}`, |
| 84 | ...problems, | ||
| 81 | ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, | 85 | ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, |
| 82 | ].join("\n"); | 86 | ].join("\n"); |
| 83 | } | 87 | } |
| ... | @@ -86,7 +90,8 @@ export function formatServiceStatus( | ... | @@ -86,7 +90,8 @@ export function formatServiceStatus( |
| 86 | ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, | 90 | ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, |
| 87 | ` Unit: ${status.unitPath}`, | 91 | ` Unit: ${status.unitPath}`, |
| 88 | ` Logs: ${status.logPath}`, | 92 | ` Logs: ${status.logPath}`, |
| 89 | ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), | 93 | ...problems, |
| 94 | ...(status.current ? [] : [` Next: Run \`npx t3@${cliVersion} service update\`.`]), | ||
| 90 | ].join("\n"); | 95 | ].join("\n"); |
| 91 | } | 96 | } |
| 92 | 97 | ||
| ... | @@ -189,6 +194,9 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { | ... | @@ -189,6 +194,9 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { |
| 189 | yield* Console.log("T3 Code is already set up to run in the background on this machine."); | 194 | yield* Console.log("T3 Code is already set up to run in the background on this machine."); |
| 190 | return true; | 195 | return true; |
| 191 | } | 196 | } |
| 197 | for (const problem of status.problems ?? []) { | ||
| 198 | yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); | ||
| 199 | } | ||
| 192 | if ( | 200 | if ( |
| 193 | installed && | 201 | installed && |
| 194 | status.installedVersion !== undefined && | 202 | status.installedVersion !== undefined && |
| ... | @@ -239,6 +247,8 @@ export const recoverServiceOnboardingOffer = <R>( | ... | @@ -239,6 +247,8 @@ export const recoverServiceOnboardingOffer = <R>( |
| 239 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), | 247 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 240 | BootServiceInstallError: (error) => | 248 | BootServiceInstallError: (error) => |
| 241 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), | 249 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 250 | BootServicePrerequisiteError: (error) => | ||
| 251 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), | ||
| 242 | BootServiceUpdatePendingError: (error) => | 252 | BootServiceUpdatePendingError: (error) => |
| 243 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), | 253 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 244 | BootServiceDowngradeRefusedError: (error) => | 254 | BootServiceDowngradeRefusedError: (error) => |
apps/server/src/cloud/bootService.test.ts+136-8| ... | @@ -127,8 +127,17 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -127,8 +127,17 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 127 | 127 | ||
| 128 | const commands: string[] = []; | 128 | const commands: string[] = []; |
| 129 | const timeouts = new Map<string, unknown>(); | 129 | const timeouts = new Map<string, unknown>(); |
| 130 | const control: { failCommand: string | undefined; stateAfterStop?: string } = { | 130 | const control: { |
| 131 | failCommand: string | undefined; | ||
| 132 | stateAfterStop?: string; | ||
| 133 | linger: string; | ||
| 134 | enabled: boolean; | ||
| 135 | active: boolean; | ||
| 136 | } = { | ||
| 131 | failCommand: undefined, | 137 | failCommand: undefined, |
| 138 | linger: "yes", | ||
| 139 | enabled: true, | ||
| 140 | active: true, | ||
| 132 | }; | 141 | }; |
| 133 | const runner = ProcessRunner.ProcessRunner.of({ | 142 | const runner = ProcessRunner.ProcessRunner.of({ |
| 134 | run: Effect.fn("test.run_boot_service_command")(function* ( | 143 | run: Effect.fn("test.run_boot_service_command")(function* ( |
| ... | @@ -137,6 +146,11 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -137,6 +146,11 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 137 | const command = `${input.command} ${input.args.join(" ")}`; | 146 | const command = `${input.command} ${input.args.join(" ")}`; |
| 138 | commands.push(command); | 147 | commands.push(command); |
| 139 | timeouts.set(command, input.timeout); | 148 | timeouts.set(command, input.timeout); |
| 149 | const failed = command === control.failCommand; | ||
| 150 | if (!failed && command === "loginctl enable-linger --no-ask-password 501") | ||
| 151 | control.linger = "yes"; | ||
| 152 | if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true; | ||
| 153 | if (!failed && command === "systemctl --user restart t3code.service") control.active = true; | ||
| 140 | if ( | 154 | if ( |
| 141 | control.stateAfterStop !== undefined && | 155 | control.stateAfterStop !== undefined && |
| 142 | (command === "systemctl --user stop t3code.service" || | 156 | (command === "systemctl --user stop t3code.service" || |
| ... | @@ -145,9 +159,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -145,9 +159,20 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 145 | yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); | 159 | yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); |
| 146 | } | 160 | } |
| 147 | return { | 161 | return { |
| 148 | stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", | 162 | stdout: |
| 163 | input.args[1] === "--version" | ||
| 164 | ? "t3 v1.2.3\n" | ||
| 165 | : input.command === "loginctl" && input.args[0] === "show-user" | ||
| 166 | ? `${control.linger}\n` | ||
| 167 | : input.args[1] === "is-enabled" | ||
| 168 | ? control.enabled | ||
| 169 | ? "enabled\n" | ||
| 170 | : "disabled\n" | ||
| 171 | : "", | ||
| 149 | stderr: "", | 172 | stderr: "", |
| 150 | code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), | 173 | code: ChildProcessSpawner.ExitCode( |
| 174 | failed || (input.args[1] === "is-active" && !control.active) ? 1 : 0, | ||
| 175 | ), | ||
| 151 | timedOut: false, | 176 | timedOut: false, |
| 152 | stdoutTruncated: false, | 177 | stdoutTruncated: false, |
| 153 | stderrTruncated: false, | 178 | stderrTruncated: false, |
| ... | @@ -182,10 +207,103 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -182,10 +207,103 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 182 | ), | 207 | ), |
| 183 | ); | 208 | ); |
| 184 | const service = yield* makeService(); | 209 | const service = yield* makeService(); |
| 185 | return { service, makeService, fs, statePath, commands, timeouts, control }; | 210 | return { service, makeService, fs, statePath, commands, timeouts, control, runtime }; |
| 186 | }); | 211 | }); |
| 187 | 212 | ||
| 188 | it.layer(NodeServices.layer)("boot service install", (it) => { | 213 | it.layer(NodeServices.layer)("boot service install", (it) => { |
| 214 | it.effect( | ||
| 215 | "fails before installing files or validating a runtime when lingering needs an administrator", | ||
| 216 | () => | ||
| 217 | Effect.gen(function* () { | ||
| 218 | const { service, fs, statePath, commands, control, runtime } = yield* makeHarness(); | ||
| 219 | const before = yield* service.status; | ||
| 220 | control.linger = "no"; | ||
| 221 | control.failCommand = "loginctl enable-linger --no-ask-password 501"; | ||
| 222 | yield* fs.remove(runtime.sentinelPath); | ||
| 223 | |||
| 224 | const error = yield* service.install().pipe(Effect.flip); | ||
| 225 | |||
| 226 | expect(error).toMatchObject({ | ||
| 227 | _tag: "BootServicePrerequisiteError", | ||
| 228 | problem: "linger-disabled", | ||
| 229 | }); | ||
| 230 | expect(error.message).toContain('sudo loginctl enable-linger "$(id -un)"'); | ||
| 231 | expect(error.message).toContain("last login session ends"); | ||
| 232 | expect(yield* fs.exists(before.unitPath)).toBe(false); | ||
| 233 | expect(yield* fs.exists(statePath)).toBe(false); | ||
| 234 | expect( | ||
| 235 | commands.some((command) => command.startsWith("npm ") || command.includes("--version")), | ||
| 236 | ).toBe(false); | ||
| 237 | expect( | ||
| 238 | commands.some( | ||
| 239 | (command) => command.includes("daemon-reload") || command.includes("restart"), | ||
| 240 | ), | ||
| 241 | ).toBe(false); | ||
| 242 | expect(yield* fs.readFileString(before.logPath)).toContain("[linger-disabled]"); | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | |||
| 246 | it.effect( | ||
| 247 | "detects a partial install and preserves the running service when repair lacks permission", | ||
| 248 | () => | ||
| 249 | Effect.gen(function* () { | ||
| 250 | const { service, fs, statePath, commands, control } = yield* makeHarness(); | ||
| 251 | const plan = yield* service.install(); | ||
| 252 | const before = yield* fs.readFileString(statePath); | ||
| 253 | const unit = yield* fs.readFileString(plan.unitPath); | ||
| 254 | control.linger = "no"; | ||
| 255 | control.failCommand = "loginctl enable-linger --no-ask-password 501"; | ||
| 256 | |||
| 257 | expect(yield* service.status).toMatchObject({ | ||
| 258 | current: false, | ||
| 259 | problems: ["linger-disabled"], | ||
| 260 | }); | ||
| 261 | commands.length = 0; | ||
| 262 | expect((yield* service.install().pipe(Effect.flip))._tag).toBe( | ||
| 263 | "BootServicePrerequisiteError", | ||
| 264 | ); | ||
| 265 | expect(yield* fs.readFileString(statePath)).toBe(before); | ||
| 266 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); | ||
| 267 | expect(commands).not.toContain("systemctl --user stop t3code.service"); | ||
| 268 | }), | ||
| 269 | ); | ||
| 270 | |||
| 271 | it.effect("enables lingering before installing and repairs stopped or disabled services", () => | ||
| 272 | Effect.gen(function* () { | ||
| 273 | const { service, commands, control } = yield* makeHarness(); | ||
| 274 | control.linger = "no"; | ||
| 275 | yield* service.install(); | ||
| 276 | expect(control.linger).toBe("yes"); | ||
| 277 | expect(commands.indexOf("loginctl enable-linger --no-ask-password 501")).toBeLessThan( | ||
| 278 | commands.indexOf("systemctl --user daemon-reload"), | ||
| 279 | ); | ||
| 280 | |||
| 281 | control.enabled = false; | ||
| 282 | control.active = false; | ||
| 283 | expect(yield* service.status).toMatchObject({ | ||
| 284 | current: false, | ||
| 285 | problems: ["service-disabled", "service-stopped"], | ||
| 286 | }); | ||
| 287 | yield* service.install(); | ||
| 288 | expect((yield* service.status).current).toBe(true); | ||
| 289 | }), | ||
| 290 | ); | ||
| 291 | |||
| 292 | it.effect.each([ | ||
| 293 | { command: "systemctl --user show-environment", problem: "user-manager-unavailable" }, | ||
| 294 | { command: "loginctl show-user 501 --property=Linger --value", problem: "linger-unavailable" }, | ||
| 295 | ])("reports failed prerequisite probes without installing: $command", ({ command, problem }) => | ||
| 296 | Effect.gen(function* () { | ||
| 297 | const { service, fs, statePath, control } = yield* makeHarness(); | ||
| 298 | control.failCommand = command; | ||
| 299 | expect(yield* service.install().pipe(Effect.flip)).toMatchObject({ | ||
| 300 | _tag: "BootServicePrerequisiteError", | ||
| 301 | problem, | ||
| 302 | }); | ||
| 303 | expect(yield* fs.exists(statePath)).toBe(false); | ||
| 304 | }), | ||
| 305 | ); | ||
| 306 | |||
| 189 | it.effect("installs, reports current state, and uninstalls", () => | 307 | it.effect("installs, reports current state, and uninstalls", () => |
| 190 | Effect.gen(function* () { | 308 | Effect.gen(function* () { |
| 191 | const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); | 309 | const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); |
| ... | @@ -286,8 +404,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -286,8 +404,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 286 | expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); | 404 | expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); |
| 287 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); | 405 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); |
| 288 | expect( | 406 | expect( |
| 289 | commands.filter((command) => | 407 | commands.filter( |
| 290 | command.startsWith(platform === "linux" ? "systemctl " : "launchctl "), | 408 | (command) => |
| 409 | command.startsWith(platform === "linux" ? "systemctl " : "launchctl ") && | ||
| 410 | !command.includes("show-environment"), | ||
| 291 | ), | 411 | ), |
| 292 | ).toEqual( | 412 | ).toEqual( |
| 293 | platform === "linux" | 413 | platform === "linux" |
| ... | @@ -351,7 +471,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -351,7 +471,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 351 | 471 | ||
| 352 | const error = yield* service.install().pipe(Effect.flip); | 472 | const error = yield* service.install().pipe(Effect.flip); |
| 353 | expect(error._tag).toBe("BootServiceCommandError"); | 473 | expect(error._tag).toBe("BootServiceCommandError"); |
| 354 | expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ | 474 | expect( |
| 475 | commands.filter( | ||
| 476 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), | ||
| 477 | ), | ||
| 478 | ).toEqual([ | ||
| 355 | "systemctl --user stop t3code.service", | 479 | "systemctl --user stop t3code.service", |
| 356 | "systemctl --user daemon-reload", | 480 | "systemctl --user daemon-reload", |
| 357 | "systemctl --user restart t3code.service", | 481 | "systemctl --user restart t3code.service", |
| ... | @@ -382,7 +506,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -382,7 +506,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 382 | "BootServiceUpdatePendingError", | 506 | "BootServiceUpdatePendingError", |
| 383 | ); | 507 | ); |
| 384 | expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); | 508 | expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); |
| 385 | expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ | 509 | expect( |
| 510 | commands.filter( | ||
| 511 | (command) => command.startsWith("systemctl ") && !command.includes("show-environment"), | ||
| 512 | ), | ||
| 513 | ).toEqual([ | ||
| 386 | "systemctl --user stop t3code.service", | 514 | "systemctl --user stop t3code.service", |
| 387 | "systemctl --user restart t3code.service", | 515 | "systemctl --user restart t3code.service", |
| 388 | ]); | 516 | ]); |
apps/server/src/cloud/bootService.ts+113-11| ... | @@ -235,7 +235,6 @@ function systemdManager(input: { | ... | @@ -235,7 +235,6 @@ function systemdManager(input: { |
| 235 | command: "systemctl", | 235 | command: "systemctl", |
| 236 | args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], | 236 | args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], |
| 237 | }, | 237 | }, |
| 238 | { step: "enabling lingering for this user", command: "loginctl", args: ["enable-linger"] }, | ||
| 239 | // Start last. No administrative state write occurs after this succeeds. | 238 | // Start last. No administrative state write occurs after this succeeds. |
| 240 | { | 239 | { |
| 241 | step: "starting the service", | 240 | step: "starting the service", |
| ... | @@ -409,6 +408,40 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass<BootService | ... | @@ -409,6 +408,40 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass<BootService |
| 409 | } | 408 | } |
| 410 | } | 409 | } |
| 411 | 410 | ||
| 411 | const BootServiceProblem = Schema.Literals([ | ||
| 412 | "user-manager-unavailable", | ||
| 413 | "linger-unavailable", | ||
| 414 | "linger-disabled", | ||
| 415 | "service-disabled", | ||
| 416 | "service-stopped", | ||
| 417 | ]); | ||
| 418 | type BootServiceProblem = typeof BootServiceProblem.Type; | ||
| 419 | |||
| 420 | /** These codes and recovery steps are documented in docs/user/background-service.md. */ | ||
| 421 | export function formatBootServiceProblem(problem: BootServiceProblem): string { | ||
| 422 | switch (problem) { | ||
| 423 | case "user-manager-unavailable": | ||
| 424 | return "Cannot reach the systemd user manager. Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if it is missing; do not run T3 with sudo."; | ||
| 425 | case "linger-unavailable": | ||
| 426 | return 'Cannot check whether this user can run services after logout. Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available.'; | ||
| 427 | case "linger-disabled": | ||
| 428 | return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.'; | ||
| 429 | case "service-disabled": | ||
| 430 | return "The service is not enabled to start automatically. Run `t3 service update` to repair it."; | ||
| 431 | case "service-stopped": | ||
| 432 | return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`."; | ||
| 433 | } | ||
| 434 | } | ||
| 435 | |||
| 436 | export class BootServicePrerequisiteError extends Schema.TaggedErrorClass<BootServicePrerequisiteError>()( | ||
| 437 | "BootServicePrerequisiteError", | ||
| 438 | { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) }, | ||
| 439 | ) { | ||
| 440 | override get message(): string { | ||
| 441 | return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`; | ||
| 442 | } | ||
| 443 | } | ||
| 444 | |||
| 412 | export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()( | 445 | export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass<BootServiceUpdatePendingError>()( |
| 413 | "BootServiceUpdatePendingError", | 446 | "BootServiceUpdatePendingError", |
| 414 | {}, | 447 | {}, |
| ... | @@ -434,6 +467,7 @@ export type BootServiceError = | ... | @@ -434,6 +467,7 @@ export type BootServiceError = |
| 434 | | BootServiceUnsupportedError | 467 | | BootServiceUnsupportedError |
| 435 | | BootServiceCommandError | 468 | | BootServiceCommandError |
| 436 | | BootServiceInstallError | 469 | | BootServiceInstallError |
| 470 | | BootServicePrerequisiteError | ||
| 437 | | BootServiceUpdatePendingError | 471 | | BootServiceUpdatePendingError |
| 438 | | BootServiceDowngradeRefusedError; | 472 | | BootServiceDowngradeRefusedError; |
| 439 | 473 | ||
| ... | @@ -442,6 +476,7 @@ export interface BootServiceStatus { | ... | @@ -442,6 +476,7 @@ export interface BootServiceStatus { |
| 442 | readonly installed: boolean; | 476 | readonly installed: boolean; |
| 443 | readonly current: boolean; | 477 | readonly current: boolean; |
| 444 | readonly installedVersion?: string; | 478 | readonly installedVersion?: string; |
| 479 | readonly problems?: ReadonlyArray<BootServiceProblem>; | ||
| 445 | readonly unitPath: string; | 480 | readonly unitPath: string; |
| 446 | readonly logPath: string; | 481 | readonly logPath: string; |
| 447 | } | 482 | } |
| ... | @@ -539,6 +574,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -539,6 +574,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 539 | : Effect.succeed(detectedManager), | 574 | : Effect.succeed(detectedManager), |
| 540 | ); | 575 | ); |
| 541 | 576 | ||
| 577 | const logFailure = (error: { readonly message: string }) => | ||
| 578 | DateTime.now.pipe( | ||
| 579 | Effect.flatMap((now) => | ||
| 580 | fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { flag: "a" }), | ||
| 581 | ), | ||
| 582 | Effect.ignore, | ||
| 583 | ); | ||
| 584 | |||
| 542 | const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( | 585 | const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( |
| 543 | step: string, | 586 | step: string, |
| 544 | command: string, | 587 | command: string, |
| ... | @@ -557,16 +600,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -557,16 +600,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 557 | stderrLength: result.stderr.length, | 600 | stderrLength: result.stderr.length, |
| 558 | }), | 601 | }), |
| 559 | ), | 602 | ), |
| 560 | Effect.tapError((error) => | 603 | Effect.tapError(logFailure), |
| 561 | DateTime.now.pipe( | ||
| 562 | Effect.flatMap((now) => | ||
| 563 | fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { | ||
| 564 | flag: "a", | ||
| 565 | }), | ||
| 566 | ), | ||
| 567 | Effect.ignore, | ||
| 568 | ), | ||
| 569 | ), | ||
| 570 | ); | 604 | ); |
| 571 | }); | 605 | }); |
| 572 | 606 | ||
| ... | @@ -587,6 +621,66 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -587,6 +621,66 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 587 | { discard: true }, | 621 | { discard: true }, |
| 588 | ); | 622 | ); |
| 589 | 623 | ||
| 624 | const probe = (command: string, args: ReadonlyArray<string>) => | ||
| 625 | runner.run({ command, args, timeout: Duration.seconds(5) }).pipe(Effect.option); | ||
| 626 | const succeeded = (result: Option.Option<ProcessRunner.ProcessRunOutput>) => | ||
| 627 | Option.isSome(result) && result.value.code === 0; | ||
| 628 | const lingerArgs = [ | ||
| 629 | "show-user", | ||
| 630 | ...(uid === undefined ? [] : [String(uid)]), | ||
| 631 | "--property=Linger", | ||
| 632 | "--value", | ||
| 633 | ]; | ||
| 634 | const readSystemdProblems = Effect.fn("cloud.boot_service.read_systemd_problems")(function* ( | ||
| 635 | includeService: boolean, | ||
| 636 | ) { | ||
| 637 | const [manager, linger] = yield* Effect.all( | ||
| 638 | [probe("systemctl", ["--user", "show-environment"]), probe("loginctl", lingerArgs)], | ||
| 639 | { concurrency: "unbounded" }, | ||
| 640 | ); | ||
| 641 | const problems: BootServiceProblem[] = []; | ||
| 642 | if (!succeeded(manager)) problems.push("user-manager-unavailable"); | ||
| 643 | const lingering = succeeded(linger) && Option.isSome(linger) ? linger.value.stdout.trim() : ""; | ||
| 644 | if (lingering !== "yes") { | ||
| 645 | problems.push(lingering === "no" ? "linger-disabled" : "linger-unavailable"); | ||
| 646 | } | ||
| 647 | if (includeService && succeeded(manager)) { | ||
| 648 | const [enabled, active] = yield* Effect.all( | ||
| 649 | [ | ||
| 650 | probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]), | ||
| 651 | probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]), | ||
| 652 | ], | ||
| 653 | { concurrency: "unbounded" }, | ||
| 654 | ); | ||
| 655 | if ( | ||
| 656 | !succeeded(enabled) || | ||
| 657 | (Option.isSome(enabled) && enabled.value.stdout.trim() !== "enabled") | ||
| 658 | ) { | ||
| 659 | problems.push("service-disabled"); | ||
| 660 | } | ||
| 661 | if (!succeeded(active)) problems.push("service-stopped"); | ||
| 662 | } | ||
| 663 | return problems; | ||
| 664 | }); | ||
| 665 | |||
| 666 | const requireSystemdPrerequisites = Effect.gen(function* () { | ||
| 667 | const problems = yield* readSystemdProblems(false); | ||
| 668 | const unavailable = problems.find((problem) => problem !== "linger-disabled"); | ||
| 669 | if (unavailable) return yield* new BootServicePrerequisiteError({ problem: unavailable }); | ||
| 670 | if (!problems.includes("linger-disabled")) return; | ||
| 671 | yield* runStep("enabling lingering for this user", "loginctl", [ | ||
| 672 | "enable-linger", | ||
| 673 | "--no-ask-password", | ||
| 674 | ...(uid === undefined ? [] : [String(uid)]), | ||
| 675 | ]).pipe( | ||
| 676 | Effect.mapError( | ||
| 677 | (cause) => new BootServicePrerequisiteError({ problem: "linger-disabled", cause }), | ||
| 678 | ), | ||
| 679 | ); | ||
| 680 | const remaining = yield* readSystemdProblems(false); | ||
| 681 | if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] }); | ||
| 682 | }); | ||
| 683 | |||
| 590 | const install = Effect.fn("cloud.boot_service.install")(function* (options?: { | 684 | const install = Effect.fn("cloud.boot_service.install")(function* (options?: { |
| 591 | readonly allowDowngrade?: boolean; | 685 | readonly allowDowngrade?: boolean; |
| 592 | }) { | 686 | }) { |
| ... | @@ -595,6 +689,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -595,6 +689,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 595 | .makeDirectory(input.logsDir, { recursive: true }) | 689 | .makeDirectory(input.logsDir, { recursive: true }) |
| 596 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 690 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 597 | 691 | ||
| 692 | // A permissions failure must not leave a partial install or stop a working server. | ||
| 693 | if (manager.kind === "systemd") { | ||
| 694 | yield* requireSystemdPrerequisites.pipe(Effect.tapError(logFailure)); | ||
| 695 | } | ||
| 696 | |||
| 598 | // Prepare every immutable artifact before stopping the installed unit. | 697 | // Prepare every immutable artifact before stopping the installed unit. |
| 599 | yield* ensurePinnedRuntimeInstalled({ | 698 | yield* ensurePinnedRuntimeInstalled({ |
| 600 | baseDir: input.baseDir, | 699 | baseDir: input.baseDir, |
| ... | @@ -743,11 +842,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -743,11 +842,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 743 | detectedManager.kind === "launchd" | 842 | detectedManager.kind === "launchd" |
| 744 | ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2") | 843 | ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2") |
| 745 | : contents; | 844 | : contents; |
| 845 | const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : []; | ||
| 746 | return { | 846 | return { |
| 747 | supported: true, | 847 | supported: true, |
| 748 | installed: true, | 848 | installed: true, |
| 749 | ...(installedVersion === undefined ? {} : { installedVersion }), | 849 | ...(installedVersion === undefined ? {} : { installedVersion }), |
| 850 | problems, | ||
| 750 | current: | 851 | current: |
| 852 | problems.length === 0 && | ||
| 751 | normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && | 853 | normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && |
| 752 | launcherExists && | 854 | launcherExists && |
| 753 | runtimeEntryExists && | 855 | runtimeEntryExists && |
apps/server/src/cloud/http.ts+4-8| ... | @@ -85,6 +85,7 @@ import { | ... | @@ -85,6 +85,7 @@ import { |
| 85 | import * as CliTokenManager from "./CliTokenManager.ts"; | 85 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 86 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 86 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 87 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | 87 | import { traceRelayRequest } from "./traceRelayRequest.ts"; |
| 88 | import { filterRelayResponse, relayRequestError } from "./relayResponse.ts"; | ||
| 88 | 89 | ||
| 89 | const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; | 90 | const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; |
| 90 | const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; | 91 | const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; |
| ... | @@ -526,14 +527,9 @@ const relayClientRequest = <A>( | ... | @@ -526,14 +527,9 @@ const relayClientRequest = <A>( |
| 526 | HttpClientRequest.bearerToken(input.token), | 527 | HttpClientRequest.bearerToken(input.token), |
| 527 | HttpClientRequest.bodyJson(input.payload), | 528 | HttpClientRequest.bodyJson(input.payload), |
| 528 | Effect.flatMap(dependencies.httpClient.execute), | 529 | Effect.flatMap(dependencies.httpClient.execute), |
| 529 | Effect.flatMap(HttpClientResponse.filterStatusOk), | 530 | Effect.flatMap(filterRelayResponse), |
| 530 | Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), | 531 | Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), |
| 531 | Effect.mapError( | 532 | Effect.mapError(relayRequestError), |
| 532 | (cause) => | ||
| 533 | new EnvironmentHttpInternalServerError({ | ||
| 534 | message: `T3 Connect relay request failed: ${String(cause)}`, | ||
| 535 | }), | ||
| 536 | ), | ||
| 537 | withRelayClientTracing, | 533 | withRelayClientTracing, |
| 538 | ); | 534 | ); |
| 539 | 535 | ||
| ... | @@ -720,7 +716,7 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( | ... | @@ -720,7 +716,7 @@ export const releaseManagedTunnelOnShutdown = Effect.fn( |
| 720 | ).pipe( | 716 | ).pipe( |
| 721 | HttpClientRequest.bearerToken(token.value.accessToken), | 717 | HttpClientRequest.bearerToken(token.value.accessToken), |
| 722 | dependencies.httpClient.execute, | 718 | dependencies.httpClient.execute, |
| 723 | Effect.flatMap(HttpClientResponse.filterStatusOk), | 719 | Effect.flatMap(filterRelayResponse), |
| 724 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), | 720 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), |
| 725 | withRelayClientTracing, | 721 | withRelayClientTracing, |
| 726 | ); | 722 | ); |
apps/server/src/cloud/relayResponse.test.ts created+145| ... | @@ -0,0 +1,145 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | import * as HttpClientError from "effect/unstable/http/HttpClientError"; | ||
| 5 | |||
| 6 | import { filterRelayResponse, relayRequestError, shouldRetryCloudLink } from "./relayResponse.ts"; | ||
| 7 | |||
| 8 | const response = ( | ||
| 9 | status: number, | ||
| 10 | body: string | Record<string, unknown>, | ||
| 11 | headers?: Record<string, string>, | ||
| 12 | ) => | ||
| 13 | HttpClientResponse.fromWeb( | ||
| 14 | HttpClientRequest.post("https://relay.example.test/v1/client/environment-links"), | ||
| 15 | typeof body === "string" | ||
| 16 | ? new Response(body, { status, ...(headers ? { headers } : {}) }) | ||
| 17 | : Response.json(body, { status, ...(headers ? { headers } : {}) }), | ||
| 18 | ); | ||
| 19 | |||
| 20 | it("reports a transport failure category without exposing request or cause details", () => { | ||
| 21 | const error = relayRequestError( | ||
| 22 | new HttpClientError.HttpClientError({ | ||
| 23 | reason: new HttpClientError.TransportError({ | ||
| 24 | request: HttpClientRequest.post("https://relay.example.test/link?token=private-token"), | ||
| 25 | description: "private transport details", | ||
| 26 | cause: new Error("private cause details"), | ||
| 27 | }), | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | |||
| 31 | expect(error._tag).toBe("EnvironmentHttpInternalServerError"); | ||
| 32 | expect(error.message).toContain("TransportError"); | ||
| 33 | expect(error.message).toContain("network connection"); | ||
| 34 | expect(error.message).not.toContain("relay.example.test"); | ||
| 35 | expect(error.message).not.toContain("private"); | ||
| 36 | expect(shouldRetryCloudLink(error)).toBe(true); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it.effect("reports the tunnel limit and relay trace instead of a generic 403", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const error = yield* filterRelayResponse( | ||
| 42 | response(403, { | ||
| 43 | _tag: "RelayEnvironmentLinkLimitExceededError", | ||
| 44 | code: "environment_link_limit_exceeded", | ||
| 45 | maxTunnels: 3, | ||
| 46 | traceId: "trace-limit", | ||
| 47 | }), | ||
| 48 | ).pipe(Effect.mapError(relayRequestError), Effect.flip); | ||
| 49 | |||
| 50 | expect(error._tag).toBe("EnvironmentHttpForbiddenError"); | ||
| 51 | expect(error.message).toContain("at most 3 tunnels"); | ||
| 52 | expect(error.message).toContain("Unlink an unused environment"); | ||
| 53 | expect(error.message).toContain("Trace ID: trace-limit"); | ||
| 54 | }), | ||
| 55 | ); | ||
| 56 | |||
| 57 | it.effect("makes revoked authorization actionable and non-retryable", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const error = yield* filterRelayResponse( | ||
| 60 | response(401, { | ||
| 61 | _tag: "RelayAuthInvalidError", | ||
| 62 | code: "auth_invalid", | ||
| 63 | reason: "invalid_bearer", | ||
| 64 | traceId: "trace-auth", | ||
| 65 | }), | ||
| 66 | ).pipe(Effect.mapError(relayRequestError), Effect.flip); | ||
| 67 | |||
| 68 | expect(error._tag).toBe("EnvironmentHttpUnauthorizedError"); | ||
| 69 | expect(error.message).toContain("invalid_bearer"); | ||
| 70 | expect(error.message).toContain("t3 connect login"); | ||
| 71 | expect(error.message).toContain("Trace ID: trace-auth"); | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | |||
| 75 | it.effect("reports an unrecognized access denial without printing its response body", () => | ||
| 76 | Effect.gen(function* () { | ||
| 77 | const error = yield* filterRelayResponse( | ||
| 78 | response(403, "<html>private upstream details</html>", { | ||
| 79 | "content-type": "text/html", | ||
| 80 | "cf-ray": "abcdef1234-IAD", | ||
| 81 | }), | ||
| 82 | ).pipe(Effect.flip); | ||
| 83 | |||
| 84 | expect(error._tag).toBe("EnvironmentHttpForbiddenError"); | ||
| 85 | expect(error.message).toContain("HTTP 403"); | ||
| 86 | expect(error.message).toContain("proxy or firewall"); | ||
| 87 | expect(error.message).toContain("Cloudflare Ray ID: abcdef1234-IAD"); | ||
| 88 | expect(error.message).not.toContain("private upstream details"); | ||
| 89 | }), | ||
| 90 | ); | ||
| 91 | |||
| 92 | it.effect.each([408, 429, 500, 502, 503, 504])( | ||
| 93 | "keeps transient HTTP %s failures retryable", | ||
| 94 | (status) => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const error = yield* filterRelayResponse(response(status, "unavailable")).pipe(Effect.flip); | ||
| 97 | expect(error._tag).toBe("EnvironmentHttpInternalServerError"); | ||
| 98 | expect(error.message).toContain(`HTTP ${status}`); | ||
| 99 | }), | ||
| 100 | ); | ||
| 101 | |||
| 102 | it.effect.each([ | ||
| 103 | { status: 401, attempts: 1 }, | ||
| 104 | { status: 403, attempts: 1 }, | ||
| 105 | { status: 429, attempts: 2 }, | ||
| 106 | { status: 503, attempts: 2 }, | ||
| 107 | ])("stops rejected startup links but retries temporary failures: $status", ({ status, attempts }) => | ||
| 108 | Effect.gen(function* () { | ||
| 109 | let requests = 0; | ||
| 110 | const result = yield* Effect.suspend(() => { | ||
| 111 | requests++; | ||
| 112 | return filterRelayResponse(response(requests === 1 ? status : 200, "{}")); | ||
| 113 | }).pipe( | ||
| 114 | Effect.mapError(relayRequestError), | ||
| 115 | Effect.retry({ while: shouldRetryCloudLink, times: 1 }), | ||
| 116 | Effect.result, | ||
| 117 | ); | ||
| 118 | expect(requests).toBe(attempts); | ||
| 119 | expect(result._tag).toBe(attempts === 1 ? "Failure" : "Success"); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | |||
| 123 | it.effect("keeps the relay failure reason and trace when tunnel cleanup fails", () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const error = yield* filterRelayResponse( | ||
| 126 | response(500, { | ||
| 127 | _tag: "RelayInternalError", | ||
| 128 | code: "internal_error", | ||
| 129 | reason: "upstream_unavailable", | ||
| 130 | traceId: "trace-cleanup", | ||
| 131 | }), | ||
| 132 | ).pipe(Effect.flip); | ||
| 133 | |||
| 134 | expect(error._tag).toBe("EnvironmentHttpInternalServerError"); | ||
| 135 | expect(error.message).toContain("upstream_unavailable"); | ||
| 136 | expect(error.message).toContain("Trace ID: trace-cleanup"); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 140 | it.effect("leaves successful response bodies available to their decoder", () => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const result = yield* filterRelayResponse(response(200, '{"ok":true}')); | ||
| 143 | expect(yield* result.json).toEqual({ ok: true }); | ||
| 144 | }), | ||
| 145 | ); | ||
apps/server/src/cloud/relayResponse.ts created+82| ... | @@ -0,0 +1,82 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentHttpBadRequestError, | ||
| 3 | EnvironmentHttpConflictError, | ||
| 4 | EnvironmentHttpForbiddenError, | ||
| 5 | EnvironmentHttpInternalServerError, | ||
| 6 | EnvironmentHttpUnauthorizedError, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { RelayProtectedError } from "@t3tools/contracts/relay"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; | ||
| 13 | import { isHttpClientError } from "effect/unstable/http/HttpClientError"; | ||
| 14 | |||
| 15 | const isRelayResponseError = Schema.is( | ||
| 16 | Schema.Union([ | ||
| 17 | EnvironmentHttpBadRequestError, | ||
| 18 | EnvironmentHttpForbiddenError, | ||
| 19 | EnvironmentHttpInternalServerError, | ||
| 20 | EnvironmentHttpUnauthorizedError, | ||
| 21 | ]), | ||
| 22 | ); | ||
| 23 | |||
| 24 | export function relayRequestError(cause: unknown) { | ||
| 25 | return isRelayResponseError(cause) | ||
| 26 | ? cause | ||
| 27 | : new EnvironmentHttpInternalServerError({ | ||
| 28 | message: `Could not complete the T3 Connect relay request. ${isHttpClientError(cause) ? `The relay request failed (${cause.reason._tag}).` : "The relay returned an unexpected response."} Check this machine's network connection and relay availability, then retry.`, | ||
| 29 | }); | ||
| 30 | } | ||
| 31 | |||
| 32 | const isPermanentCloudLinkError = Schema.is( | ||
| 33 | Schema.Union([ | ||
| 34 | EnvironmentHttpBadRequestError, | ||
| 35 | EnvironmentHttpForbiddenError, | ||
| 36 | EnvironmentHttpUnauthorizedError, | ||
| 37 | EnvironmentHttpConflictError, | ||
| 38 | ]), | ||
| 39 | ); | ||
| 40 | |||
| 41 | export const shouldRetryCloudLink = (error: unknown): boolean => !isPermanentCloudLinkError(error); | ||
| 42 | |||
| 43 | function recoveryHint(error: RelayProtectedError): string { | ||
| 44 | switch (error._tag) { | ||
| 45 | case "RelayEnvironmentLinkLimitExceededError": | ||
| 46 | return "Unlink an unused environment in T3 Connect, then restart T3 Code on this machine."; | ||
| 47 | case "RelayAuthInvalidError": | ||
| 48 | return "Run `t3 connect login` to check this machine's authorization. If the stored credential was revoked, sign out with `t3 connect logout`, then run `t3 connect` again. Restart T3 Code after signing in."; | ||
| 49 | case "RelayEnvironmentLinkProofExpiredError": | ||
| 50 | case "RelayEnvironmentLinkProofInvalidError": | ||
| 51 | return "Check this machine's date and time, update T3 Code, then restart it."; | ||
| 52 | default: | ||
| 53 | return "Retry when the relay is available. If this continues, include the trace ID when reporting it."; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | /** Preserve relay diagnostics before converting permanent rejections into non-retryable errors. */ | ||
| 58 | export const filterRelayResponse = Effect.fn("cloud.filter_relay_response")(function* ( | ||
| 59 | response: HttpClientResponse.HttpClientResponse, | ||
| 60 | ) { | ||
| 61 | if (response.status >= 200 && response.status < 300) return response; | ||
| 62 | const decoded = yield* HttpClientResponse.schemaBodyJson(RelayProtectedError)(response).pipe( | ||
| 63 | Effect.option, | ||
| 64 | ); | ||
| 65 | const ray = response.headers["cf-ray"]; | ||
| 66 | const requestId = ray && /^[a-zA-Z0-9-]{1,128}$/.test(ray) ? ` Cloudflare Ray ID: ${ray}.` : ""; | ||
| 67 | const message = Option.isSome(decoded) | ||
| 68 | ? `T3 Connect: ${decoded.value.message}. ${recoveryHint(decoded.value)} Trace ID: ${decoded.value.traceId}.` | ||
| 69 | : `T3 Connect relay returned HTTP ${response.status} without a recognized error response. Check relay access and any proxy or firewall restrictions, then restart T3 Code.${requestId}`; | ||
| 70 | |||
| 71 | if (response.status === 401) return yield* new EnvironmentHttpUnauthorizedError({ message }); | ||
| 72 | if (response.status === 403) return yield* new EnvironmentHttpForbiddenError({ message }); | ||
| 73 | if ( | ||
| 74 | response.status >= 400 && | ||
| 75 | response.status < 500 && | ||
| 76 | response.status !== 408 && | ||
| 77 | response.status !== 429 | ||
| 78 | ) { | ||
| 79 | return yield* new EnvironmentHttpBadRequestError({ message }); | ||
| 80 | } | ||
| 81 | return yield* new EnvironmentHttpInternalServerError({ message }); | ||
| 82 | }); | ||
apps/server/src/server.ts+5-6| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | ||
| 2 | import * as Duration from "effect/Duration"; | 3 | import * as Duration from "effect/Duration"; |
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| ... | @@ -106,6 +107,7 @@ import { | ... | @@ -106,6 +107,7 @@ import { |
| 106 | releaseManagedTunnelOnShutdown, | 107 | releaseManagedTunnelOnShutdown, |
| 107 | } from "./cloud/http.ts"; | 108 | } from "./cloud/http.ts"; |
| 108 | import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; | 109 | import { serverRelayBrokerTracingLayer } from "./cloud/relayTracing.ts"; |
| 110 | import { shouldRetryCloudLink } from "./cloud/relayResponse.ts"; | ||
| 109 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; | 111 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; |
| 110 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; | 112 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; |
| 111 | import * as CloudCliState from "./cloud/CliState.ts"; | 113 | import * as CloudCliState from "./cloud/CliState.ts"; |
| ... | @@ -675,7 +677,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -675,7 +677,7 @@ export const makeServerLayer = Layer.unwrap( |
| 675 | Effect.catchCause((cause) => | 677 | Effect.catchCause((cause) => |
| 676 | Effect.logWarning( | 678 | Effect.logWarning( |
| 677 | "Failed to release the managed tunnel on shutdown; the next link reuses it", | 679 | "Failed to release the managed tunnel on shutdown; the next link reuses it", |
| 678 | { cause }, | 680 | { errors: Cause.prettyErrors(cause).map((error) => error.message) }, |
| 679 | ), | 681 | ), |
| 680 | ), | 682 | ), |
| 681 | Effect.asVoid, | 683 | Effect.asVoid, |
| ... | @@ -707,10 +709,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -707,10 +709,7 @@ export const makeServerLayer = Layer.unwrap( |
| 707 | // reachability after a restart. | 709 | // reachability after a restart. |
| 708 | yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( | 710 | yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( |
| 709 | Effect.retry({ | 711 | Effect.retry({ |
| 710 | while: (error) => | 712 | while: shouldRetryCloudLink, |
| 711 | error._tag !== "EnvironmentHttpBadRequestError" && | ||
| 712 | error._tag !== "EnvironmentHttpUnauthorizedError" && | ||
| 713 | error._tag !== "EnvironmentHttpConflictError", | ||
| 714 | schedule: Schedule.exponential("1 second").pipe( | 713 | schedule: Schedule.exponential("1 second").pipe( |
| 715 | Schedule.modifyDelay(({ duration }) => | 714 | Schedule.modifyDelay(({ duration }) => |
| 716 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), | 715 | Effect.succeed(Duration.min(duration, Duration.seconds(30))), |
| ... | @@ -721,7 +720,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -721,7 +720,7 @@ export const makeServerLayer = Layer.unwrap( |
| 721 | Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), | 720 | Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), |
| 722 | Effect.catch((cause) => | 721 | Effect.catch((cause) => |
| 723 | Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { | 722 | Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { |
| 724 | cause, | 723 | message: cause.message, |
| 725 | }), | 724 | }), |
| 726 | ), | 725 | ), |
| 727 | ); | 726 | ); |
docs/user/background-service.md+35-1| ... | @@ -11,7 +11,8 @@ Install it with the latest T3 Code release: | ... | @@ -11,7 +11,8 @@ Install it with the latest T3 Code release: |
| 11 | npx t3@latest service install | 11 | npx t3@latest service install |
| 12 | ``` | 12 | ``` |
| 13 | 13 | ||
| 14 | Check whether it is installed: | 14 | Check whether it is installed. On Linux this also checks whether the service is running, enabled |
| 15 | at startup, and allowed to keep running after logout: | ||
| 15 | 16 | ||
| 16 | ```sh | 17 | ```sh |
| 17 | npx t3@latest service status | 18 | npx t3@latest service status |
| ... | @@ -58,6 +59,8 @@ updates roll back with the server version. An older launcher may require one loc | ... | @@ -58,6 +59,8 @@ updates roll back with the server version. An older launcher may require one loc |
| 58 | 59 | ||
| 59 | **Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts | 60 | **Linux** uses a systemd user unit at `~/.config/systemd/user/t3code.service`. The service starts |
| 60 | when the machine boots and keeps running after you log out (lingering is enabled during install). | 61 | when the machine boots and keeps running after you log out (lingering is enabled during install). |
| 62 | Setup checks the systemd user manager and enables lingering before installing a runtime or stopping | ||
| 63 | an existing service. If that requires administrator permission, setup stops with a recovery command. | ||
| 61 | 64 | ||
| 62 | **macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It | 65 | **macOS** uses a launch agent at `~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. It |
| 63 | starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no | 66 | starts when you log in, not when the Mac boots, and it stops when you log out; macOS has no |
| ... | @@ -87,3 +90,34 @@ background. This is only an onboarding shortcut: the service and T3 Connect are | ... | @@ -87,3 +90,34 @@ background. This is only an onboarding shortcut: the service and T3 Connect are |
| 87 | 90 | ||
| 88 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer | 91 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer |
| 89 | want T3 Code to start in the background. | 92 | want T3 Code to start in the background. |
| 93 | |||
| 94 | ## Troubleshooting | ||
| 95 | |||
| 96 | Run `t3 service status` on the server machine. An installed version alone does not mean the service | ||
| 97 | is running or will survive logout. Linux status reports these problems: | ||
| 98 | |||
| 99 | | Code | What it means | Recovery | | ||
| 100 | | -------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | ||
| 101 | | `linger-disabled` | The service stops after your last login session ends and does not start at boot. | Run `sudo loginctl enable-linger "$(id -un)"`, then retry setup as your normal user. | | ||
| 102 | | `linger-unavailable` | T3 Code could not verify the logout setting. | Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available. | | ||
| 103 | | `user-manager-unavailable` | T3 Code cannot reach your systemd user manager. | Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if needed. | | ||
| 104 | | `service-disabled` | The service is not enabled to start automatically. | Run the repair command shown by `t3 service status`. | | ||
| 105 | | `service-stopped` | The service is installed but is not running. | Read the service log and `systemctl --user status t3code.service`, then run the displayed repair command. | | ||
| 106 | |||
| 107 | For an SSH host, run the administrator command in an interactive terminal so sudo can prompt for | ||
| 108 | your password: | ||
| 109 | |||
| 110 | ```sh | ||
| 111 | ssh -t your-server 'sudo loginctl enable-linger "$(id -un)"' | ||
| 112 | ``` | ||
| 113 | |||
| 114 | Run only the `loginctl` command with sudo. Running `t3` with sudo creates a separate installation and | ||
| 115 | Connect identity for root. If an administrator is unavailable, run `t3 serve` in a terminal and | ||
| 116 | keep that session open. | ||
| 117 | |||
| 118 | The repair command shown by status uses the CLI version, or the installed service version if that | ||
| 119 | is newer. An older stable CLI therefore does not recommend downgrading a nightly installation. | ||
| 120 | Setup leaves an existing service running if the user-manager or lingering check fails. | ||
| 121 | |||
| 122 | `t3 service status` prints the log path. The adjacent `server.trace.ndjson` file contains detailed | ||
| 123 | server traces. For failures after authorization, see [T3 Connect troubleshooting](./remote-access.md#t3-connect-troubleshooting). |
docs/user/remote-access.md+27| ... | @@ -2,6 +2,33 @@ | ... | @@ -2,6 +2,33 @@ |
| 2 | 2 | ||
| 3 | Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. | 3 | Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. |
| 4 | 4 | ||
| 5 | ## T3 Connect troubleshooting | ||
| 6 | |||
| 7 | Run `t3 connect` on the server machine to authorize it and optionally install the background service. | ||
| 8 | The authorization message means your sign-in was saved. The server must then start and establish its | ||
| 9 | relay link before the machine is reachable. | ||
| 10 | |||
| 11 | `t3 connect status` reports saved authorization and link configuration, not a live reachability | ||
| 12 | check. If the machine appears offline, run `t3 service status` on it and read the displayed log. | ||
| 13 | On Linux, a service that works while SSH is open but stops after logout usually has lingering | ||
| 14 | disabled. See [background service troubleshooting](./background-service.md#troubleshooting). | ||
| 15 | |||
| 16 | Relay errors include the returned reason and trace ID when available: | ||
| 17 | |||
| 18 | | Error | Next step | | ||
| 19 | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| 20 | | `environment_link_limit_exceeded` / managed tunnel limit reached | Unlink an unused environment in T3 Connect, then restart T3 Code on this machine. | | ||
| 21 | | `auth_invalid` / `invalid_bearer` | Run `t3 connect login`. If the stored credential was revoked, run `t3 connect logout`, then `t3 connect` and restart the server. | | ||
| 22 | | Expired or invalid link proof | Check the server's date and time, update T3 Code, and restart it. Include the reason and trace ID if it still fails. | | ||
| 23 | | HTTP 403 without a recognized error response | Check relay access and any proxy or firewall restrictions. Include the Cloudflare Ray ID if one was returned; an HTTP status alone does not identify the cause. | | ||
| 24 | | HTTP 408, 429, or 5xx | The server retries temporary failures during startup for up to ten minutes. Check network and relay availability; include the trace ID when reporting a persistent failure. | | ||
| 25 | |||
| 26 | Authorization and other permanent 4xx rejections stop the startup link attempt immediately. | ||
| 27 | After correcting them, restart the server. For the Linux background service, use | ||
| 28 | `systemctl --user restart t3code.service`; for a foreground server, stop it and run `t3 serve` again. | ||
| 29 | Keep the diagnostic message and trace ID when reporting a problem. Do not post authorization codes, | ||
| 30 | pairing URLs, or the contents of the secrets directory. | ||
| 31 | |||
| 5 | ## Quick Pairing for a Running Server | 32 | ## Quick Pairing for a Running Server |
| 6 | 33 | ||
| 7 | If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything: | 34 | If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything: |