diff --git a/apps/discord-bot/src/t3/PersistedBearer.test.ts b/apps/discord-bot/src/t3/PersistedBearer.test.ts new file mode 100644 index 000000000000..5533b9332681 --- /dev/null +++ b/apps/discord-bot/src/t3/PersistedBearer.test.ts @@ -0,0 +1,114 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +import { + clearPersistedBearerSession, + parsePersistedBearerSession, + persistedBearerExpiresAtIso, + readPersistedBearerSession, + shouldReusePersistedBearer, + writePersistedBearerSession, +} from "./PersistedBearer.ts"; + +describe("shouldReusePersistedBearer", () => { + const nowMs = Date.parse("2026-08-20T06:00:00.000Z"); + const record = { + accessToken: "tok", + expiresAt: "2026-09-19T06:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + }; + + it("reuses a same-host token with more than an hour remaining", () => { + expect( + shouldReusePersistedBearer({ + record, + nowMs, + httpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe(true); + }); + + it("rejects a token for a different T3 host", () => { + expect( + shouldReusePersistedBearer({ + record, + nowMs, + httpBaseUrl: "http://127.0.0.1:9999", + }), + ).toBe(false); + }); + + it("rejects a token inside the remaining-time floor", () => { + expect( + shouldReusePersistedBearer({ + record: { ...record, expiresAt: "2026-08-20T06:30:00.000Z" }, + nowMs, + httpBaseUrl: record.httpBaseUrl, + }), + ).toBe(false); + }); + + it("rejects an empty token", () => { + expect( + shouldReusePersistedBearer({ + record: { ...record, accessToken: " " }, + nowMs, + httpBaseUrl: record.httpBaseUrl, + }), + ).toBe(false); + }); +}); + +describe("parsePersistedBearerSession", () => { + it("accepts a well-formed document", () => { + expect( + parsePersistedBearerSession({ + accessToken: "tok", + expiresAt: "2026-09-19T06:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + }), + ).toEqual({ + accessToken: "tok", + expiresAt: "2026-09-19T06:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + }); + }); + + it("rejects missing fields and bad expiry", () => { + expect(parsePersistedBearerSession({ accessToken: "tok" })).toBeNull(); + expect( + parsePersistedBearerSession({ + accessToken: "tok", + expiresAt: "not-a-date", + httpBaseUrl: "http://127.0.0.1:3773/", + }), + ).toBeNull(); + expect(parsePersistedBearerSession("tok")).toBeNull(); + }); +}); + +describe("persisted bearer file", () => { + it("round-trips and clears a session on disk", () => { + const dataDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-discord-bearer-")); + try { + expect(readPersistedBearerSession(dataDir)).toBeNull(); + writePersistedBearerSession(dataDir, { + accessToken: "tok-1", + expiresAt: persistedBearerExpiresAtIso(Date.parse("2026-08-20T06:00:00.000Z"), 3600), + httpBaseUrl: "http://127.0.0.1:3773/", + }); + expect(readPersistedBearerSession(dataDir)).toEqual({ + accessToken: "tok-1", + expiresAt: "2026-08-20T07:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + }); + clearPersistedBearerSession(dataDir); + expect(readPersistedBearerSession(dataDir)).toBeNull(); + } finally { + NodeFS.rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/discord-bot/src/t3/PersistedBearer.ts b/apps/discord-bot/src/t3/PersistedBearer.ts new file mode 100644 index 000000000000..d5ccabfa2ab8 --- /dev/null +++ b/apps/discord-bot/src/t3/PersistedBearer.ts @@ -0,0 +1,97 @@ +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off globalDate:off +/** + * Durable T3 bearer for the Discord bot. + * + * Bootstrap (`local-bootstrap-credential`) is exchanged for a 30-day session + * token. Persist that token so a bot restart does not need the in-memory + * bootstrap grant still to be valid. + */ +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as Schema from "effect/Schema"; + +import { expandHomePath } from "../projectAliases.ts"; + +export const PERSISTED_BEARER_FILENAME = "t3-bearer.json"; + +/** Re-bootstrap when less than this remains on the stored session. */ +export const PERSISTED_BEARER_MIN_REMAINING_MS = 60 * 60 * 1000; + +export const PersistedBearerSession = Schema.Struct({ + accessToken: Schema.String, + expiresAt: Schema.String, + httpBaseUrl: Schema.String, +}); +export type PersistedBearerSession = typeof PersistedBearerSession.Type; + +const decodePersistedBearerSession = Schema.decodeUnknownSync(PersistedBearerSession); + +export function persistedBearerFilePath(dataDir: string): string { + return NodePath.join(expandHomePath(dataDir), PERSISTED_BEARER_FILENAME); +} + +export function normalizeHttpBaseUrl(value: string): string { + try { + return new URL(value).toString(); + } catch { + return value; + } +} + +export function persistedBearerExpiresAtIso(nowMs: number, expiresInSeconds: number): string { + const ttlMs = Math.max(0, expiresInSeconds) * 1000; + return new Date(nowMs + ttlMs).toISOString(); +} + +export function parsePersistedBearerSession(raw: unknown): PersistedBearerSession | null { + try { + const decoded = decodePersistedBearerSession(raw); + if (decoded.accessToken.trim() === "" || decoded.httpBaseUrl.trim() === "") return null; + if (!Number.isFinite(Date.parse(decoded.expiresAt))) return null; + return decoded; + } catch { + return null; + } +} + +export function shouldReusePersistedBearer(input: { + readonly record: Pick; + readonly nowMs: number; + readonly httpBaseUrl: string; + readonly minRemainingMs?: number; +}): boolean { + if (input.record.accessToken.trim() === "") return false; + if (normalizeHttpBaseUrl(input.record.httpBaseUrl) !== normalizeHttpBaseUrl(input.httpBaseUrl)) { + return false; + } + const expiresAtMs = Date.parse(input.record.expiresAt); + if (!Number.isFinite(expiresAtMs)) return false; + const minRemaining = input.minRemainingMs ?? PERSISTED_BEARER_MIN_REMAINING_MS; + return expiresAtMs - input.nowMs >= minRemaining; +} + +export function readPersistedBearerSession(dataDir: string): PersistedBearerSession | null { + try { + const raw = NodeFS.readFileSync(persistedBearerFilePath(dataDir), "utf8"); + return parsePersistedBearerSession(JSON.parse(raw) as unknown); + } catch { + return null; + } +} + +export function writePersistedBearerSession(dataDir: string, record: PersistedBearerSession): void { + const filePath = persistedBearerFilePath(dataDir); + const dir = NodePath.dirname(filePath); + NodeFS.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tempPath = `${filePath}.${process.pid}.tmp`; + NodeFS.writeFileSync(tempPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); + NodeFS.renameSync(tempPath, filePath); +} + +export function clearPersistedBearerSession(dataDir: string): void { + try { + NodeFS.unlinkSync(persistedBearerFilePath(dataDir)); + } catch { + // missing is the desired end state + } +} diff --git a/apps/discord-bot/src/t3/T3Session.test.ts b/apps/discord-bot/src/t3/T3Session.test.ts index 237b99264ef4..f7d6be54a150 100644 --- a/apps/discord-bot/src/t3/T3Session.test.ts +++ b/apps/discord-bot/src/t3/T3Session.test.ts @@ -1,13 +1,18 @@ +// @effect-diagnostics nodeBuiltinImport:off - existence contract reads T3Session source on disk. +import * as NodeFS from "node:fs"; import { ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + isT3InvalidCredentialError, isT3TransportError, shouldContinueWaitingForT3Ready, shouldPersistThreadModelSelectionForNextTurn, T3_STILL_CONNECTING_MESSAGE, } from "./T3Session.ts"; +const t3SessionSource = NodeFS.readFileSync(new URL("./T3Session.ts", import.meta.url), "utf8"); + describe("isT3TransportError", () => { it("matches SocketCloseError and ConnectionTransientError", () => { expect(isT3TransportError(new Error("SocketCloseError: 1005"))).toBe(true); @@ -26,6 +31,34 @@ describe("isT3TransportError", () => { }); }); +describe("isT3InvalidCredentialError", () => { + it("matches bootstrap/bearer rejection copy", () => { + expect( + isT3InvalidCredentialError( + new Error( + "Bootstrap failed: The environment rejected this client's credentials (invalid_credential).", + ), + ), + ).toBe(true); + expect(isT3InvalidCredentialError(new Error('reason: "invalid_credential"'))).toBe(true); + }); + + it("does not match transport or timeout copy", () => { + expect(isT3InvalidCredentialError(new Error("SocketCloseError: 1005"))).toBe(false); + expect(isT3InvalidCredentialError(new Error(T3_STILL_CONNECTING_MESSAGE))).toBe(false); + }); +}); + +describe("persisted T3 bearer", () => { + it("reuses a stored bearer on connect and falls back to bootstrap when rejected", () => { + expect(t3SessionSource).toContain("readPersistedBearerSession(botConfig.dataDir)"); + expect(t3SessionSource).toContain("shouldReusePersistedBearer({"); + expect(t3SessionSource).toContain("writePersistedBearerSession(botConfig.dataDir"); + expect(t3SessionSource).toContain("Persisted T3 bearer rejected; falling back to bootstrap"); + expect(t3SessionSource).toContain("clearPersistedBearerSession(botConfig.dataDir)"); + }); +}); + describe("shouldContinueWaitingForT3Ready", () => { it("returns ready as soon as the shell is live", () => { expect(shouldContinueWaitingForT3Ready({ ready: true, elapsedMs: 0, timeoutMs: 1_000 })).toBe( diff --git a/apps/discord-bot/src/t3/T3Session.ts b/apps/discord-bot/src/t3/T3Session.ts index cc6bdd61f5f3..7922ca2b0cac 100644 --- a/apps/discord-bot/src/t3/T3Session.ts +++ b/apps/discord-bot/src/t3/T3Session.ts @@ -65,6 +65,13 @@ import { formatThreadTitle } from "../presentation/messages.ts"; import { normalizeWorkspacePath } from "../presentation/mentions.ts"; import { followOrchestrationThread } from "./DiscordThreadFollower.ts"; import { newCommandId, newMessageId, newThreadId, shortId } from "./ids.ts"; +import { + clearPersistedBearerSession, + persistedBearerExpiresAtIso, + readPersistedBearerSession, + shouldReusePersistedBearer, + writePersistedBearerSession, +} from "./PersistedBearer.ts"; function wsBaseUrl(httpBaseUrl: string): string { const url = new URL(httpBaseUrl); @@ -98,6 +105,12 @@ export function isT3TransportError(cause: unknown): boolean { return false; } +/** True when T3 rejected a bearer/bootstrap credential (re-bootstrap, don't loop). */ +export function isT3InvalidCredentialError(cause: unknown): boolean { + const msg = messageFromCause(cause); + return /invalid_credential|rejected this client's credentials/i.test(msg); +} + /** * User-facing copy when the bot waited for T3 to become ready and the deadline * elapsed (boot/reconnect still incomplete). Mentions normally **wait** instead @@ -685,29 +698,73 @@ export const makeT3Session = (botConfig: DiscordBotConfig) => : new T3SessionError(messageFromCause(cause), { cause }), }); + const persistBootstrappedBearer = (accessToken: string, expiresInSeconds: number) => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + try { + writePersistedBearerSession(botConfig.dataDir, { + accessToken, + expiresAt: persistedBearerExpiresAtIso(nowMs, expiresInSeconds), + httpBaseUrl: botConfig.t3HttpBaseUrl, + }); + } catch (cause) { + yield* Effect.logWarning( + `Could not persist T3 bearer session: ${messageFromCause(cause)}`, + ); + } + }); + + const connectWithBootstrapCredential = () => + Effect.gen(function* () { + const tokenSession = yield* Effect.tryPromise({ + try: () => + runtime.runPromise( + bootstrapRemoteBearerSession({ + httpBaseUrl: botConfig.t3HttpBaseUrl, + credential: botConfig.t3BootstrapCredential!, + clientMetadata: { label: "T3 Discord Bot", deviceType: "bot" }, + }), + ), + catch: (cause) => + new T3SessionError(`Bootstrap failed: ${messageFromCause(cause)}`, { cause }), + }); + yield* persistBootstrappedBearer(tokenSession.access_token, tokenSession.expires_in); + yield* connectPrepared(botConfig.t3HttpBaseUrl, tokenSession.access_token); + }); + const connect = () => Effect.gen(function* () { if (botConfig.t3BearerToken !== undefined && botConfig.t3BearerToken !== "") { yield* connectPrepared(botConfig.t3HttpBaseUrl, botConfig.t3BearerToken); return; } + + const nowMs = yield* Clock.currentTimeMillis; + const persisted = readPersistedBearerSession(botConfig.dataDir); + if ( + persisted !== null && + shouldReusePersistedBearer({ + record: persisted, + nowMs, + httpBaseUrl: botConfig.t3HttpBaseUrl, + }) + ) { + const persistedConnect = yield* Effect.result( + connectPrepared(botConfig.t3HttpBaseUrl, persisted.accessToken), + ); + if (Result.isSuccess(persistedConnect)) return; + if (!isT3InvalidCredentialError(persistedConnect.failure)) { + return yield* Effect.fail(persistedConnect.failure); + } + yield* Effect.logWarning("Persisted T3 bearer rejected; falling back to bootstrap"); + clearPersistedBearerSession(botConfig.dataDir); + } + if ( botConfig.t3BootstrapCredential !== undefined && botConfig.t3BootstrapCredential !== "" ) { - const tokenSession = yield* Effect.tryPromise({ - try: () => - runtime.runPromise( - bootstrapRemoteBearerSession({ - httpBaseUrl: botConfig.t3HttpBaseUrl, - credential: botConfig.t3BootstrapCredential!, - clientMetadata: { label: "T3 Discord Bot", deviceType: "bot" }, - }), - ), - catch: (cause) => - new T3SessionError(`Bootstrap failed: ${messageFromCause(cause)}`, { cause }), - }); - yield* connectPrepared(botConfig.t3HttpBaseUrl, tokenSession.access_token); + yield* connectWithBootstrapCredential(); return; } yield* connectPrepared(botConfig.t3HttpBaseUrl); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5242dd738b89..6880124da409 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -1,4 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; +import { LOCAL_BOOTSTRAP_CREDENTIAL_FILE } from "@t3tools/shared/serverRuntime"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -36,6 +41,29 @@ const makePairingGrantStoreLayer = ( Layer.provide(makeServerConfigLayer(overrides)), ); +const makeLocalBootstrapGrantStoreLayer = (credential: string) => + PairingGrantStore.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + Layer.effect( + ServerConfig.ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + NodeFS.writeFileSync( + NodePath.join(config.stateDir, LOCAL_BOOTSTRAP_CREDENTIAL_FILE), + `${credential}\n`, + { mode: 0o600 }, + ); + return config; + }), + ).pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-local-bootstrap-test-" }), + ), + ), + ), + ); + const makePairingGrantStoreTestLayer = ( overrides: Partial, ) => @@ -194,6 +222,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { ), ); + it.effect("seeds the local bootstrap file as a long-lived reusable grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + + yield* TestClock.adjust(Duration.hours(25)); + const afterADay = yield* bootstrapCredentials.consume("local-file-bootstrap-token"); + expect(afterADay.method).toBe("desktop-bootstrap"); + expect(afterADay.subject).toBe("local-bootstrap"); + + yield* TestClock.adjust(Duration.days(400)); + const monthsLater = yield* bootstrapCredentials.consume("local-file-bootstrap-token"); + expect(monthsLater.subject).toBe("local-bootstrap"); + }).pipe( + Effect.provide( + Layer.merge( + makeLocalBootstrapGrantStoreLayer("local-file-bootstrap-token"), + TestClock.layer(), + ), + ), + ), + ); + it.effect("lists and revokes active pairing links", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 2e8584368c03..61c0b958b995 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -265,6 +265,12 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // window can still recover by re-bootstrapping rather than locking // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +// File-backed local bootstrap (`local-bootstrap-credential` in stateDir) is for +// colocated trusted clients — the Discord bot on t3vm re-exchanges it on every +// process start. It must outlive a long-running server: a 24h TTL meant any bot +// restart more than a day after `t3code-server` started failed with +// `invalid_credential` until the server itself was restarted. +const LOCAL_BOOTSTRAP_TTL = Duration.days(3650); // A dev server's startup token is read off a log by whoever (or whatever) is // driving the session, often minutes later — after a `node --watch` restart, a // detour into another task, or a hand-off to the person actually doing the @@ -353,7 +359,7 @@ export const make = Effect.gen(function* () { scopes: AuthAdministrativeScopes, subject: "local-bootstrap", expiresAt: DateTime.add(now, { - milliseconds: Duration.toMillis(DESKTOP_BOOTSTRAP_TTL_HOURS), + milliseconds: Duration.toMillis(LOCAL_BOOTSTRAP_TTL), }), remainingUses: "unbounded", }); diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 4aa4fafaa52d..7e457eb443fe 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -490,7 +490,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { // and .bin shims never ship. assert.equal( WINDOWS_SERVER_ASAR_UNPACK_GLOB, - "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}", + "{*.node,**/*.node,*.dll,**/*.dll,*.exe,**/*.exe,*.so,**/*.so,*.so.*,**/*.so.*,*.dylib,**/*.dylib}", ); assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [ "**/node_modules/@anthropic-ai/claude-agent-sdk-*", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index e16640dbb609..4974089905a3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -827,8 +827,12 @@ export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; // dlopen/spawn need real files, so native modules, shared libraries, and // helper executables live in the server.asar.unpacked sibling (the standard // asar redirect convention). Everything else stays packed. +// Include both `*.ext` and `**/*.ext`. `@electron/asar` 3.4's unpack matcher +// treats `*.node` as a basename match at any depth on some hosts, and +// `**/*.node` as the nested match on others; either pattern alone can leave +// `server.asar.unpacked` missing and fail the pack. export const WINDOWS_SERVER_ASAR_UNPACK_GLOB = - "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; + "{*.node,**/*.node,*.dll,**/*.dll,*.exe,**/*.exe,*.so,**/*.so,*.so.*,**/*.so.*,*.dylib,**/*.dylib}"; // Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK // platform packages are dead weight (see above), and node_modules/.bin shims // are never spawned at runtime (and are symlinks on POSIX build hosts, which