From 70feed563b5402c0c49433f79a45b4ec6a1892db Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:43:31 -0400 Subject: [PATCH 1/2] Run provider sign-in inside settings with auto-captured setup token Provider auth no longer jumps to a chat-thread terminal. A new providerAuth.* RPC family drives ephemeral server-side PTY sessions (ProviderAuthSessions): the command is derived server-side from the instance config, nothing is persisted to disk, and the settings page hosts the whole flow in an inline panel with a collapsible terminal. For claude setup-token, the server captures the printed token, saves it as the sensitive CLAUDE_CODE_OAUTH_TOKEN instance variable, and masks it before fanout so the browser never receives it. The setup-token PTY is spawned 512 columns wide and never resized: the CLI hard-wraps at PTY width, and a wrapped token was previously captured truncated with its tail leaking past the mask. Capture is decoupled from output flushing and only accepts a terminated match. Auth PTYs strip CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_* so stale credentials cannot no-op an interactive sign-in. Command builders move to @threadlines/shared/providerAuthCommands, shared by server and web. The old jump-to-chat runner and its terminal-store plumbing are removed, and the account section renders status chips with an inline action instead of a detached button. --- .../auth/ProviderAuthSessions.test.ts | 315 ++++++++ .../src/provider/auth/ProviderAuthSessions.ts | 681 ++++++++++++++++++ apps/server/src/server.test.ts | 16 +- apps/server/src/server.ts | 5 + apps/server/src/ws.ts | 32 + .../src/components/ThreadTerminalDrawer.tsx | 106 +-- .../settings/ProviderConnectFlow.tsx | 396 ++++++++++ .../settings/ProviderInstanceCard.test.ts | 153 +--- .../settings/ProviderInstanceCard.tsx | 396 ++-------- .../settings/SettingsPanels.browser.tsx | 123 ++++ .../components/settings/SettingsPanels.tsx | 138 +--- .../providerConnectFlow.logic.test.ts | 95 +++ .../settings/providerConnectFlow.logic.ts | 122 ++++ .../src/components/terminal/xtermSurface.ts | 134 ++++ apps/web/src/rpc/wsRpcClient.ts | 20 + apps/web/src/terminalStateStore.test.ts | 80 -- apps/web/src/terminalStateStore.ts | 74 -- packages/contracts/src/index.ts | 1 + packages/contracts/src/providerAuth.ts | 138 ++++ packages/contracts/src/rpc.ts | 51 ++ packages/shared/package.json | 4 + .../shared/src/providerAuthCommands.test.ts | 220 ++++++ packages/shared/src/providerAuthCommands.ts | 220 ++++++ 23 files changed, 2629 insertions(+), 891 deletions(-) create mode 100644 apps/server/src/provider/auth/ProviderAuthSessions.test.ts create mode 100644 apps/server/src/provider/auth/ProviderAuthSessions.ts create mode 100644 apps/web/src/components/settings/ProviderConnectFlow.tsx create mode 100644 apps/web/src/components/settings/providerConnectFlow.logic.test.ts create mode 100644 apps/web/src/components/settings/providerConnectFlow.logic.ts create mode 100644 apps/web/src/components/terminal/xtermSurface.ts create mode 100644 packages/contracts/src/providerAuth.ts create mode 100644 packages/shared/src/providerAuthCommands.test.ts create mode 100644 packages/shared/src/providerAuthCommands.ts diff --git a/apps/server/src/provider/auth/ProviderAuthSessions.test.ts b/apps/server/src/provider/auth/ProviderAuthSessions.test.ts new file mode 100644 index 000000000..bac454fb7 --- /dev/null +++ b/apps/server/src/provider/auth/ProviderAuthSessions.test.ts @@ -0,0 +1,315 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@threadlines/contracts"; +import type { ProviderAuthEvent } from "@threadlines/contracts"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import * as Scope from "effect/Scope"; +import { expect } from "vite-plus/test"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + PtySpawnError, + type PtyAdapterShape, + type PtyExitEvent, + type PtyProcess, + type PtySpawnInput, +} from "../../terminal/Services/PTY.ts"; +import { makeProviderAuthSessions } from "./ProviderAuthSessions.ts"; + +class WaitForConditionError extends Data.TaggedError("WaitForConditionError")<{ + readonly message: string; +}> {} + +class FakePtyProcess implements PtyProcess { + readonly pid = 4242; + killed = false; + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + + write(): void {} + resize(): void {} + kill(): void { + this.killed = true; + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback); + return () => { + this.dataListeners.delete(callback); + }; + } + + onExit(callback: (event: PtyExitEvent) => void): () => void { + this.exitListeners.add(callback); + return () => { + this.exitListeners.delete(callback); + }; + } + + emitData(data: string): void { + for (const listener of this.dataListeners) { + listener(data); + } + } + + emitExit(exitCode: number): void { + for (const listener of this.exitListeners) { + listener({ exitCode, signal: null }); + } + } +} + +class FakePtyAdapter implements PtyAdapterShape { + readonly spawnInputs: PtySpawnInput[] = []; + readonly processes: FakePtyProcess[] = []; + failNextSpawn = false; + + spawn(input: PtySpawnInput): Effect.Effect { + this.spawnInputs.push(input); + if (this.failNextSpawn) { + this.failNextSpawn = false; + return Effect.fail( + new PtySpawnError({ adapter: "fake", message: "Failed to spawn PTY process" }), + ); + } + const process = new FakePtyProcess(); + this.processes.push(process); + return Effect.succeed(process); + } +} + +const waitFor = ( + predicate: Effect.Effect, + timeout: Duration.Input = 1_000, +): Effect.Effect => + predicate.pipe( + Effect.filterOrFail( + (done) => done, + () => new WaitForConditionError({ message: "Condition not met" }), + ), + Effect.retry(Schedule.spaced("10 millis")), + Effect.timeoutOption(timeout), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail(new WaitForConditionError({ message: "Timed out waiting for condition" })), + onSome: () => Effect.void, + }), + ), + ); + +const CLAUDE_INSTANCE = ProviderInstanceId.make("claude-work"); +const CODEX_INSTANCE = ProviderInstanceId.make("codex-work"); + +const settingsLayer = ServerSettingsService.layerTest({ + providerInstances: { + [CLAUDE_INSTANCE]: { + driver: "claudeAgent", + config: { binaryPath: "claude", homePath: "/tmp/claude-home" }, + }, + [CODEX_INSTANCE]: { + driver: "codex", + config: { binaryPath: "codex", shadowHomePath: "/tmp/codex-home" }, + }, + }, +}); + +const createSessions = Effect.fn("createSessions")(function* () { + const settings = yield* ServerSettingsService; + const ptyAdapter = new FakePtyAdapter(); + const refreshedRef = yield* Ref.make>([]); + const sessions = yield* makeProviderAuthSessions({ + ptyAdapter, + settings, + refreshInstance: (instanceId) => + Ref.update(refreshedRef, (refreshed) => [...refreshed, String(instanceId)]), + homeDir: "/tmp", + env: { PATH: "/usr/bin", CLAUDE_CODE_OAUTH_TOKEN: "stale-token" }, + }); + + const eventsRef = yield* Ref.make>([]); + const scope = yield* Effect.scope; + const subscribeTo = (instanceId: ProviderInstanceId) => + sessions + .subscribe(instanceId, (event) => Ref.update(eventsRef, (events) => [...events, event])) + .pipe(Effect.tap((unsubscribe) => Scope.addFinalizer(scope, Effect.sync(unsubscribe)))); + + return { + sessions, + ptyAdapter, + settings, + subscribeTo, + getEvents: Ref.get(eventsRef), + getRefreshed: Ref.get(refreshedRef), + }; +}); + +const statusesOf = (events: ReadonlyArray) => + events.flatMap((event) => (event.type === "status" ? [event.status] : [])); + +const outputOf = (events: ReadonlyArray) => + events + .flatMap((event) => (event.type === "output" ? [event.data] : [])) + .join("") + .trimEnd(); + +it.layer(NodeServices.layer, { excludeTestServices: true })("ProviderAuthSessions", (it) => { + it.effect("captures a setup token split across chunks, saves it, and masks the broadcast", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + yield* harness.subscribeTo(CLAUDE_INSTANCE); + yield* harness.sessions.start({ + instanceId: CLAUDE_INSTANCE, + flow: "claude-setup-token", + }); + + const process = harness.ptyAdapter.processes[0]!; + process.emitData("Your token: sk-ant-oat01-ABCD"); + process.emitData("EFGH1234\nDone.\n"); + + yield* waitFor(harness.getRefreshed.pipe(Effect.map((refreshed) => refreshed.length === 1))); + + const events = yield* harness.getEvents; + const output = outputOf(events); + expect(output).not.toContain("sk-ant-oat01-"); + expect(output).toContain("••• captured"); + expect(statusesOf(events)).toEqual(["idle", "starting", "running", "succeeded"]); + + const settings = yield* harness.settings.getSettings; + const environment = settings.providerInstances[CLAUDE_INSTANCE]?.environment ?? []; + expect(environment).toEqual([ + { + name: "CLAUDE_CODE_OAUTH_TOKEN", + value: "sk-ant-oat01-ABCDEFGH1234", + sensitive: true, + valueRedacted: false, + }, + ]); + + // A second capture must not re-save or double-refresh. + process.emitExit(0); + const refreshed = yield* harness.getRefreshed; + assert.deepStrictEqual(refreshed, [String(CLAUDE_INSTANCE)]); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("spawns the setup-token pty too wide for the CLI to wrap the token", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + yield* harness.subscribeTo(CLAUDE_INSTANCE); + yield* harness.sessions.start({ + instanceId: CLAUDE_INSTANCE, + flow: "claude-setup-token", + cols: 100, + rows: 20, + }); + + // The CLI hard-wraps output at the PTY width. A token wider than the + // requested viewport must still arrive on one line, or capture would + // save a truncated (dead) credential and leak the tail past the mask. + const spawnInput = harness.ptyAdapter.spawnInputs[0]!; + expect(spawnInput.cols).toBeGreaterThanOrEqual(256); + + // Resizing (e.g. the browser terminal fitting itself) must not shrink + // it back down mid-run. + yield* harness.sessions.resize({ instanceId: CLAUDE_INSTANCE, cols: 80, rows: 20 }); + const token = `sk-ant-oat01-${"A".repeat(90)}zbk`; + const process = harness.ptyAdapter.processes[0]!; + process.emitData(`Your OAuth token (valid for 1 year):\n\n${token}\n\nStore it.\n`); + + yield* waitFor(harness.getRefreshed.pipe(Effect.map((refreshed) => refreshed.length === 1))); + const settings = yield* harness.settings.getSettings; + const environment = settings.providerInstances[CLAUDE_INSTANCE]?.environment ?? []; + expect(environment[0]?.value).toBe(token); + expect(outputOf(yield* harness.getEvents)).not.toContain("zbk"); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("fails a setup-token run that never prints a token", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + yield* harness.subscribeTo(CLAUDE_INSTANCE); + yield* harness.sessions.start({ + instanceId: CLAUDE_INSTANCE, + flow: "claude-setup-token", + }); + + harness.ptyAdapter.processes[0]!.emitExit(1); + + yield* waitFor( + harness.getEvents.pipe(Effect.map((events) => statusesOf(events).includes("failed"))), + ); + const refreshed = yield* harness.getRefreshed; + assert.deepStrictEqual(refreshed, []); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("re-probes the provider when a login flow exits cleanly", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + yield* harness.subscribeTo(CODEX_INSTANCE); + yield* harness.sessions.start({ instanceId: CODEX_INSTANCE, flow: "login" }); + + const spawnInput = harness.ptyAdapter.spawnInputs[0]!; + assert.equal(spawnInput.shell, "codex"); + assert.deepStrictEqual(spawnInput.args, ["login"]); + assert.equal(spawnInput.env.CODEX_HOME, "/tmp/codex-home"); + // Credential overrides never reach an auth process. + assert.equal(spawnInput.env.CLAUDE_CODE_OAUTH_TOKEN, undefined); + + harness.ptyAdapter.processes[0]!.emitData("Signed in.\n"); + harness.ptyAdapter.processes[0]!.emitExit(0); + + yield* waitFor(harness.getRefreshed.pipe(Effect.map((refreshed) => refreshed.length === 1))); + const events = yield* harness.getEvents; + expect(statusesOf(events)).toEqual(["idle", "starting", "running", "succeeded"]); + expect(outputOf(events)).toBe("Signed in."); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("reports the exit code when a login flow fails", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + yield* harness.subscribeTo(CODEX_INSTANCE); + yield* harness.sessions.start({ instanceId: CODEX_INSTANCE, flow: "login" }); + + harness.ptyAdapter.processes[0]!.emitExit(7); + + yield* waitFor( + harness.getEvents.pipe(Effect.map((events) => statusesOf(events).includes("failed"))), + ); + const events = yield* harness.getEvents; + const failure = events.findLast((event) => event.type === "status"); + assert.equal(failure?.type === "status" ? failure.exitCode : null, 7); + const refreshed = yield* harness.getRefreshed; + assert.deepStrictEqual(refreshed, []); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("rejects a setup-token flow on a driver that has no such command", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + const error = yield* Effect.flip( + harness.sessions.start({ instanceId: CODEX_INSTANCE, flow: "claude-setup-token" }), + ); + expect(error).toMatchObject({ _tag: "ProviderAuthError", reason: "unsupportedFlow" }); + }).pipe(Effect.provide(settingsLayer)), + ); + + it.effect("stops a running flow when a new one starts for the same instance", () => + Effect.gen(function* () { + const harness = yield* createSessions(); + yield* harness.sessions.start({ instanceId: CODEX_INSTANCE, flow: "login" }); + yield* harness.sessions.start({ instanceId: CODEX_INSTANCE, flow: "login" }); + + expect(harness.ptyAdapter.processes).toHaveLength(2); + assert.isTrue(harness.ptyAdapter.processes[0]!.killed); + assert.isFalse(harness.ptyAdapter.processes[1]!.killed); + }).pipe(Effect.provide(settingsLayer)), + ); +}); diff --git a/apps/server/src/provider/auth/ProviderAuthSessions.ts b/apps/server/src/provider/auth/ProviderAuthSessions.ts new file mode 100644 index 000000000..9b62477fb --- /dev/null +++ b/apps/server/src/provider/auth/ProviderAuthSessions.ts @@ -0,0 +1,681 @@ +/** + * ProviderAuthSessions - ephemeral PTY sessions for provider sign-in. + * + * Deliberately separate from `TerminalManager`: thread terminals are + * thread-scoped and persist their scrollback to disk, which must never + * happen here because an auth flow prints credentials. These sessions keep + * a small in-memory buffer, are keyed by provider instance id, and are + * disposed as soon as the flow ends. + * + * Behaviour worth knowing: + * - The command is derived server-side from the instance's configuration + * (binary path, shadow HOME / CODEX_HOME) via + * `@threadlines/shared/providerAuthCommands`, and spawned directly in + * the PTY — no intermediate shell — so process exit means "the flow + * finished" and the exit code is meaningful. + * - Starting a flow for an instance stops any flow already running for it. + * - `claude setup-token` output is scanned for the long-lived OAuth token. + * On a match the token is stored as a sensitive instance environment + * variable and *masked before fanout*, so no client ever receives it. + * + * @module provider/auth/ProviderAuthSessions + */ +import { + ProviderAuthError, + type ProviderAuthEvent, + type ProviderAuthFlow, + type ProviderAuthResizeInput, + type ProviderAuthStartInput, + type ProviderAuthStatus, + type ProviderAuthStopInput, + type ProviderAuthWriteInput, + type ProviderInstanceConfig, + ProviderInstanceId, + type ProviderInstanceEnvironmentVariable, + type ServerSettings, +} from "@threadlines/contracts"; +import { + buildProviderAuthCommand, + CLAUDE_CREDENTIAL_OVERRIDE_ENV_NAMES, + CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV, + upsertClaudeLongLivedOAuthTokenEnvironment, +} from "@threadlines/shared/providerAuthCommands"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; + +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; +import { PtyAdapter, type PtyAdapterShape, type PtyProcess } from "../../terminal/Services/PTY.ts"; +import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import { deriveProviderInstanceConfigMap } from "../Layers/ProviderInstanceRegistryHydration.ts"; + +const DEFAULT_COLS = 100; +const DEFAULT_ROWS = 26; +const DEFAULT_PARTIAL_FLUSH_DELAY_MS = 75; +const DEFAULT_SCROLLBACK_CHARS = 64_000; +const CAPTURE_BUFFER_CHARS = 8_192; + +/** + * The setup-token PTY is spawned much wider than any real token and never + * resized: the CLI hard-wraps its output at the PTY width, and a token + * wrapped onto a second line would be captured truncated and leak its tail + * past the mask. At this width the token always arrives on one line; the + * client's xterm still soft-wraps long lines for display. + */ +const SETUP_TOKEN_PTY_COLS = 512; + +/** Placeholder swapped in for a captured token before any fanout. */ +export const CAPTURED_TOKEN_MASK = "••• captured"; + +const TOKEN_PREFIX = "sk-ant-oat01-"; +const TOKEN_BODY_CHAR = /[A-Za-z0-9_-]/; +const TOKEN_PATTERN = /sk-ant-oat01-[A-Za-z0-9_-]+/; +const TOKEN_PATTERN_GLOBAL = /sk-ant-oat01-[A-Za-z0-9_-]+/g; + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +/** + * Index at which a trailing "this could still grow into a token" run starts, + * or `value.length` when the tail is safe to flush. + * + * PTY data arrives in arbitrary chunks, so a token can straddle two events. + * Holding the candidate tail back until it is terminated is what makes the + * masking reliable — we never emit half a token and then the other half. + */ +export function trailingTokenCandidateIndex(value: string): number { + const scanStart = Math.max(0, value.length - (TOKEN_PREFIX.length + 512)); + for (let index = scanStart; index < value.length; index += 1) { + const tail = value.slice(index); + if (tail.length <= TOKEN_PREFIX.length) { + if (TOKEN_PREFIX.startsWith(tail)) { + return index; + } + continue; + } + if (!tail.startsWith(TOKEN_PREFIX)) { + continue; + } + const body = tail.slice(TOKEN_PREFIX.length); + if ([...body].every((character) => TOKEN_BODY_CHAR.test(character))) { + return index; + } + } + return value.length; +} + +/** + * Split buffered PTY output into the part that is safe to broadcast and the + * part that must stay buffered. + * + * `mode`: + * - `"line"` — a data chunk just arrived: flush whole lines only. + * - `"partial"` — the stream went quiet: flush the partial line too, minus + * any trailing token candidate (interactive prompts have no trailing + * newline, so without this they would never appear). + * - `"final"` — the process exited: nothing more can arrive, flush it all. + */ +export function splitProviderAuthOutput( + buffer: string, + mode: "line" | "partial" | "final", +): { readonly flush: string; readonly pending: string } { + if (mode === "final") { + return { flush: buffer, pending: "" }; + } + const lineEnd = buffer.lastIndexOf("\n") + 1; + if (mode === "line") { + return { flush: buffer.slice(0, lineEnd), pending: buffer.slice(lineEnd) }; + } + const rest = buffer.slice(lineEnd); + const candidateStart = trailingTokenCandidateIndex(rest); + return { + flush: buffer.slice(0, lineEnd + candidateStart), + pending: rest.slice(candidateStart), + }; +} + +/** + * Find a complete token in the raw capture stream. + * + * The match must be *terminated* — followed by at least one more character, + * or the process exited — because a match at the very end of the stream may + * still grow in the next chunk, and saving a truncated token means saving a + * dead credential. Newlines are valid terminators only because the + * setup-token PTY is spawned too wide (`SETUP_TOKEN_PTY_COLS`) for the CLI + * to ever wrap the token across lines. + */ +export function findClaudeOAuthToken(value: string, options?: { final?: boolean }): string | null { + const match = value.match(TOKEN_PATTERN); + if (!match) return null; + const endIndex = (match.index ?? 0) + match[0].length; + if (endIndex >= value.length && options?.final !== true) return null; + return match[0]; +} + +export function maskClaudeOAuthTokens(value: string): string { + return value.replace(TOKEN_PATTERN_GLOBAL, CAPTURED_TOKEN_MASK); +} + +export interface ProviderAuthSessionsShape { + /** + * Start (or restart) the auth flow for an instance. Any flow already + * running for that instance is stopped first. + */ + readonly start: (input: ProviderAuthStartInput) => Effect.Effect; + readonly write: (input: ProviderAuthWriteInput) => Effect.Effect; + readonly resize: (input: ProviderAuthResizeInput) => Effect.Effect; + readonly stop: (input: ProviderAuthStopInput) => Effect.Effect; + /** + * Attach to one instance's event stream. Replays the current command and + * status (plus buffered scrollback for a live run) before streaming, so a + * reconnecting client lands on the right panel state. + */ + readonly subscribe: ( + instanceId: ProviderInstanceId, + listener: (event: ProviderAuthEvent) => Effect.Effect, + ) => Effect.Effect<() => void>; +} + +export class ProviderAuthSessions extends Context.Service< + ProviderAuthSessions, + ProviderAuthSessionsShape +>()("threadlines/provider/auth/ProviderAuthSessions") {} + +export interface ProviderAuthSessionsOptions { + readonly ptyAdapter: PtyAdapterShape; + readonly settings: ServerSettingsShape; + /** The existing provider re-probe, run after a flow succeeds. */ + readonly refreshInstance: (instanceId: ProviderInstanceId) => Effect.Effect; + readonly homeDir?: string; + readonly env?: NodeJS.ProcessEnv; + readonly partialFlushDelayMs?: number; + readonly scrollbackChars?: number; +} + +interface SessionState { + readonly flow: ProviderAuthFlow; + readonly command: string; + status: ProviderAuthStatus; + exitCode: number | null; + detail: string | null; + process: PtyProcess | null; + unsubscribeData: (() => void) | null; + unsubscribeExit: (() => void) | null; + buffer: string; + scrollback: string; + /** Raw output kept solely for token capture; independent of flush timing. */ + captureBuffer: string; + flushFiber: Fiber.Fiber | null; + tokenCaptured: boolean; +} + +function readConfigString(config: unknown, key: string): string { + if (config === null || typeof config !== "object") return ""; + const value = (config as Record)[key]; + return typeof value === "string" ? value : ""; +} + +/** + * Environment for the auth process: the server's environment plus the + * instance's own variables, minus every credential override. A stale + * `CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` in scope makes the + * interactive sign-in a no-op, which is exactly the state the user is + * trying to fix. + */ +function buildAuthSpawnEnv(input: { + readonly baseEnv: NodeJS.ProcessEnv; + readonly instanceEnvironment: ReadonlyArray; + readonly commandEnv: Readonly>; +}): NodeJS.ProcessEnv { + const suppressed = new Set([ + CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV, + ...CLAUDE_CREDENTIAL_OVERRIDE_ENV_NAMES, + ]); + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(input.baseEnv)) { + if (value === undefined || suppressed.has(key)) continue; + env[key] = value; + } + for (const variable of input.instanceEnvironment) { + if (suppressed.has(variable.name)) continue; + env[variable.name] = variable.value; + } + for (const [key, value] of Object.entries(input.commandEnv)) { + env[key] = value; + } + return env; +} + +export const makeProviderAuthSessions = Effect.fn("makeProviderAuthSessions")(function* ( + options: ProviderAuthSessionsOptions, +) { + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const baseEnv = options.env ?? process.env; + const homeDir = options.homeDir ?? baseEnv.HOME ?? baseEnv.USERPROFILE ?? process.cwd(); + const partialFlushDelayMs = options.partialFlushDelayMs ?? DEFAULT_PARTIAL_FLUSH_DELAY_MS; + const scrollbackChars = options.scrollbackChars ?? DEFAULT_SCROLLBACK_CHARS; + + const sessions = new Map(); + const listeners = new Map Effect.Effect>>(); + const startLock = yield* Semaphore.make(1); + + const publish = (instanceId: ProviderInstanceId, event: ProviderAuthEvent) => + Effect.gen(function* () { + for (const listener of listeners.get(String(instanceId)) ?? []) { + yield* listener(event).pipe(Effect.ignoreCause({ log: true })); + } + }); + + const publishStatus = ( + instanceId: ProviderInstanceId, + session: SessionState, + status: ProviderAuthStatus, + ) => + Effect.gen(function* () { + session.status = status; + const createdAt = yield* nowIso; + yield* publish(instanceId, { + type: "status", + instanceId, + createdAt, + status, + exitCode: session.exitCode, + detail: session.detail, + }); + }); + + const emitOutput = (instanceId: ProviderInstanceId, session: SessionState, data: string) => + Effect.gen(function* () { + if (data.length === 0) return; + session.scrollback = `${session.scrollback}${data}`.slice(-scrollbackChars); + const createdAt = yield* nowIso; + yield* publish(instanceId, { type: "output", instanceId, createdAt, data }); + }); + + /** + * Persist a captured token into the instance's environment through the + * same settings path the manual paste flow uses, so the value lands in the + * secret store as a sensitive variable rather than in settings.json. + */ + const persistCapturedToken = (instanceId: ProviderInstanceId, token: string) => + Effect.gen(function* () { + const settings = yield* options.settings.getSettings; + const instance = deriveProviderInstanceConfigMap(settings)[instanceId]; + if (!instance) { + return yield* Effect.fail( + new ProviderAuthError({ instanceId: String(instanceId), reason: "unknownInstance" }), + ); + } + const nextInstance: ProviderInstanceConfig = { + ...instance, + environment: upsertClaudeLongLivedOAuthTokenEnvironment(instance.environment ?? [], token), + }; + yield* options.settings.updateSettings({ + providerInstances: { + ...settings.providerInstances, + [instanceId]: nextInstance, + } as ServerSettings["providerInstances"], + }); + }).pipe( + Effect.catchTag("ServerSettingsError", (cause) => + Effect.fail( + new ProviderAuthError({ + instanceId: String(instanceId), + reason: "settingsFailed", + detail: cause.message, + }), + ), + ), + ); + + const finishSuccess = (instanceId: ProviderInstanceId, session: SessionState) => + Effect.gen(function* () { + yield* options.refreshInstance(instanceId); + yield* publishStatus(instanceId, session, "succeeded"); + }); + + const captureToken = (instanceId: ProviderInstanceId, session: SessionState, token: string) => + Effect.gen(function* () { + session.tokenCaptured = true; + const persisted = yield* persistCapturedToken(instanceId, token).pipe(Effect.result); + if (persisted._tag === "Failure") { + session.detail = persisted.failure.message; + yield* publishStatus(instanceId, session, "failed"); + return; + } + yield* finishSuccess(instanceId, session); + }); + + const flushOutput = ( + instanceId: ProviderInstanceId, + session: SessionState, + mode: "line" | "partial" | "final", + ) => + Effect.gen(function* () { + if (session.flow !== "claude-setup-token") { + const data = session.buffer; + session.buffer = ""; + yield* emitOutput(instanceId, session, data); + return; + } + + const { flush, pending } = splitProviderAuthOutput(session.buffer, mode); + session.buffer = pending; + if (flush.length === 0) return; + yield* emitOutput(instanceId, session, maskClaudeOAuthTokens(flush)); + }); + + /** Scan the raw capture stream; runs on every chunk and once on exit. */ + const tryCaptureToken = ( + instanceId: ProviderInstanceId, + session: SessionState, + options: { readonly final: boolean }, + ) => + Effect.gen(function* () { + if (session.flow !== "claude-setup-token" || session.tokenCaptured) return; + const token = findClaudeOAuthToken(session.captureBuffer, options); + if (token) { + yield* captureToken(instanceId, session, token); + } + }); + + const schedulePartialFlush = (instanceId: ProviderInstanceId, session: SessionState) => + Effect.gen(function* () { + if (session.flushFiber) { + yield* Fiber.interrupt(session.flushFiber).pipe(Effect.ignore); + session.flushFiber = null; + } + const fiber = runFork( + Effect.sleep(partialFlushDelayMs).pipe( + Effect.andThen(flushOutput(instanceId, session, "partial")), + ), + ); + session.flushFiber = fiber; + }); + + const disposeProcess = (session: SessionState) => + Effect.sync(() => { + session.unsubscribeData?.(); + session.unsubscribeData = null; + session.unsubscribeExit?.(); + session.unsubscribeExit = null; + session.process = null; + }); + + const handleExit = (instanceId: ProviderInstanceId, session: SessionState, exitCode: number) => + Effect.gen(function* () { + if (session.flushFiber) { + yield* Fiber.interrupt(session.flushFiber).pipe(Effect.ignore); + session.flushFiber = null; + } + session.exitCode = exitCode; + yield* flushOutput(instanceId, session, "final"); + yield* tryCaptureToken(instanceId, session, { final: true }); + yield* disposeProcess(session); + session.scrollback = ""; + session.captureBuffer = ""; + + if (session.status === "succeeded" || session.status === "failed") { + return; + } + if (session.flow === "claude-setup-token") { + session.detail = session.tokenCaptured + ? null + : "The command finished without printing a token."; + yield* publishStatus(instanceId, session, session.tokenCaptured ? "succeeded" : "failed"); + return; + } + if (exitCode === 0) { + yield* finishSuccess(instanceId, session); + return; + } + session.detail = `The sign-in command exited with code ${exitCode}.`; + yield* publishStatus(instanceId, session, "failed"); + }); + + const stopSession = (instanceId: ProviderInstanceId) => + Effect.gen(function* () { + const session = sessions.get(String(instanceId)); + if (!session) return; + if (session.flushFiber) { + yield* Fiber.interrupt(session.flushFiber).pipe(Effect.ignore); + session.flushFiber = null; + } + const process = session.process; + yield* disposeProcess(session); + session.buffer = ""; + session.scrollback = ""; + session.captureBuffer = ""; + if (process) { + yield* Effect.sync(() => process.kill()).pipe(Effect.ignore); + } + sessions.delete(String(instanceId)); + }); + + const requireRunning = (instanceId: ProviderInstanceId) => + Effect.gen(function* () { + const session = sessions.get(String(instanceId)); + if (!session?.process) { + return yield* Effect.fail( + new ProviderAuthError({ instanceId: String(instanceId), reason: "notRunning" }), + ); + } + return session.process; + }); + + const start: ProviderAuthSessionsShape["start"] = (input) => + startLock.withPermit( + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make(input.instanceId); + yield* stopSession(instanceId); + + const settings = yield* options.settings.getSettings.pipe( + Effect.mapError( + (cause) => + new ProviderAuthError({ + instanceId: String(instanceId), + reason: "settingsFailed", + detail: cause.message, + }), + ), + ); + const instance = deriveProviderInstanceConfigMap(settings)[instanceId]; + if (!instance) { + return yield* Effect.fail( + new ProviderAuthError({ instanceId: String(instanceId), reason: "unknownInstance" }), + ); + } + + const command = buildProviderAuthCommand({ + driver: String(instance.driver), + flow: input.flow, + binaryPath: readConfigString(instance.config, "binaryPath"), + homePath: readConfigString(instance.config, "homePath"), + shadowHomePath: readConfigString(instance.config, "shadowHomePath"), + }); + if (!command) { + return yield* Effect.fail( + new ProviderAuthError({ instanceId: String(instanceId), reason: "unsupportedFlow" }), + ); + } + + const session: SessionState = { + flow: input.flow, + command: command.display, + status: "starting", + exitCode: null, + detail: null, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + buffer: "", + scrollback: "", + captureBuffer: "", + flushFiber: null, + tokenCaptured: false, + }; + sessions.set(String(instanceId), session); + + const createdAt = yield* nowIso; + yield* publish(instanceId, { + type: "command", + instanceId, + createdAt, + flow: input.flow, + command: command.display, + }); + yield* publishStatus(instanceId, session, "starting"); + + const spawned = yield* options.ptyAdapter + .spawn({ + shell: command.file, + args: [...command.args], + cwd: homeDir, + cols: + input.flow === "claude-setup-token" + ? SETUP_TOKEN_PTY_COLS + : (input.cols ?? DEFAULT_COLS), + rows: input.rows ?? DEFAULT_ROWS, + env: buildAuthSpawnEnv({ + baseEnv, + instanceEnvironment: instance.environment ?? [], + commandEnv: command.env, + }), + }) + .pipe(Effect.result); + + if (spawned._tag === "Failure") { + sessions.delete(String(instanceId)); + session.detail = spawned.failure.message; + yield* publishStatus(instanceId, session, "failed"); + return yield* Effect.fail( + new ProviderAuthError({ + instanceId: String(instanceId), + reason: "spawnFailed", + detail: spawned.failure.message, + }), + ); + } + + const process = spawned.success; + session.process = process; + session.unsubscribeData = process.onData((data) => { + runFork( + Effect.gen(function* () { + if (sessions.get(String(instanceId)) !== session) return; + session.buffer += data; + session.captureBuffer = `${session.captureBuffer}${data}`.slice( + -CAPTURE_BUFFER_CHARS, + ); + yield* flushOutput(instanceId, session, "line"); + yield* tryCaptureToken(instanceId, session, { final: false }); + if (session.buffer.length > 0) { + yield* schedulePartialFlush(instanceId, session); + } + }), + ); + }); + session.unsubscribeExit = process.onExit((event) => { + runFork( + Effect.gen(function* () { + if (sessions.get(String(instanceId)) !== session) return; + yield* handleExit(instanceId, session, event.exitCode); + }), + ); + }); + + yield* publishStatus(instanceId, session, "running"); + }), + ); + + const shape: ProviderAuthSessionsShape = { + start, + write: (input) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make(input.instanceId); + const process = yield* requireRunning(instanceId); + yield* Effect.sync(() => process.write(input.data)); + }), + resize: (input) => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make(input.instanceId); + const process = yield* requireRunning(instanceId); + const session = sessions.get(String(instanceId)); + // The setup-token PTY stays at its extra-wide spawn size: resizing it + // down would make the CLI re-wrap output and could split the token. + if (session?.flow === "claude-setup-token") return; + yield* Effect.sync(() => process.resize(input.cols, input.rows)); + }), + stop: (input) => stopSession(ProviderInstanceId.make(input.instanceId)), + subscribe: (instanceId, listener) => + Effect.gen(function* () { + const key = String(instanceId); + const existing = listeners.get(key) ?? new Set(); + existing.add(listener); + listeners.set(key, existing); + + const session = sessions.get(key); + const createdAt = yield* nowIso; + if (session) { + yield* listener({ + type: "command", + instanceId, + createdAt, + flow: session.flow, + command: session.command, + }).pipe(Effect.ignoreCause({ log: true })); + if (session.scrollback.length > 0) { + yield* listener({ + type: "output", + instanceId, + createdAt, + data: session.scrollback, + }).pipe(Effect.ignoreCause({ log: true })); + } + } + yield* listener({ + type: "status", + instanceId, + createdAt, + status: session?.status ?? "idle", + exitCode: session?.exitCode ?? null, + detail: session?.detail ?? null, + }).pipe(Effect.ignoreCause({ log: true })); + + return () => { + const current = listeners.get(key); + if (!current) return; + current.delete(listener); + if (current.size === 0) { + listeners.delete(key); + } + }; + }), + }; + + yield* Effect.addFinalizer(() => + Effect.forEach([...sessions.keys()], (key) => stopSession(ProviderInstanceId.make(key)), { + discard: true, + }).pipe(Effect.ignore), + ); + + return shape; +}); + +export const ProviderAuthSessionsLive = Layer.effect( + ProviderAuthSessions, + Effect.gen(function* () { + const ptyAdapter = yield* PtyAdapter; + const settings = yield* ServerSettingsService; + const providerRegistry = yield* ProviderRegistry; + return yield* makeProviderAuthSessions({ + ptyAdapter, + settings, + refreshInstance: (instanceId) => + providerRegistry.refreshInstance(instanceId).pipe(Effect.asVoid), + }); + }), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8dbc10f1a..fd79598b3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -56,6 +56,10 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; +import { + ProviderAuthSessions, + type ProviderAuthSessionsShape, +} from "./provider/auth/ProviderAuthSessions.ts"; import { makeRoutesLayer } from "./server.ts"; import { resolveAttachmentRelativePath } from "./attachmentPaths.ts"; import { @@ -354,6 +358,7 @@ const buildAppUnderTest = (options?: { vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; + providerAuthSessions?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; threadSearch?: Partial; @@ -722,9 +727,14 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.mergeAll( + Layer.mock(TerminalManager)({ + ...options?.layers?.terminalManager, + }), + Layer.mock(ProviderAuthSessions)({ + ...options?.layers?.providerAuthSessions, + }), + ), ), Layer.provide( Layer.mock(OrchestrationEngineService)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7eea4f03d..9f918c492 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,6 +52,7 @@ import { SleepInhibitorLive } from "./power/Layers/SleepInhibitor.ts"; import { StorageMaintenanceDaemonLive } from "./persistence/Layers/StorageMaintenance.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as PreviewAutomationBroker from "./preview/PreviewAutomationBroker.ts"; +import { ProviderAuthSessionsLive } from "./provider/auth/ProviderAuthSessions.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; @@ -295,6 +296,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // satisfies the builder's dependency; consumed by `ProviderCommandReactorLive`. Layer.provideMerge(ThreadContextSeedBuilderLive), Layer.provideMerge(ProviderRuntimeLayerLive), + // Settings-owned provider sign-in. Listed before the terminal layer so it + // receives that layer's PTY adapter, but it keeps its own ephemeral, + // never-persisted sessions rather than going through TerminalManager. + Layer.provideMerge(ProviderAuthSessionsLive), Layer.provideMerge(TerminalLayerLive), // The browser side of the agent's tools. Holds no resources of its own -- // it is a rendezvous between a provider turn and whichever client is showing diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a4d7cb790..b4836f483 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -41,6 +41,7 @@ import { ProjectWriteFileError, OrchestrationReplayEventsError, FilesystemBrowseError, + type ProviderAuthEvent, ProviderExtensionsError, ProviderExternalThreadError, ProviderRealtimeError, @@ -105,6 +106,7 @@ import { import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; +import { ProviderAuthSessions } from "./provider/auth/ProviderAuthSessions.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { realtimeAudioHub } from "./realtime/RealtimeAudioHub.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; @@ -240,6 +242,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const previewAutomationBroker = yield* PreviewAutomationBroker; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; + const providerAuthSessions = yield* ProviderAuthSessions; const providerRegistry = yield* ProviderRegistry; const providerService = yield* ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; @@ -1915,6 +1918,35 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.providerAuthStart]: (input) => + observeRpcEffect(WS_METHODS.providerAuthStart, providerAuthSessions.start(input), { + "rpc.aggregate": "providerAuth", + }), + [WS_METHODS.providerAuthWrite]: (input) => + observeRpcEffect(WS_METHODS.providerAuthWrite, providerAuthSessions.write(input), { + "rpc.aggregate": "providerAuth", + }), + [WS_METHODS.providerAuthResize]: (input) => + observeRpcEffect(WS_METHODS.providerAuthResize, providerAuthSessions.resize(input), { + "rpc.aggregate": "providerAuth", + }), + [WS_METHODS.providerAuthStop]: (input) => + observeRpcEffect(WS_METHODS.providerAuthStop, providerAuthSessions.stop(input), { + "rpc.aggregate": "providerAuth", + }), + [WS_METHODS.providerAuthSubscribe]: (input) => + observeRpcStream( + WS_METHODS.providerAuthSubscribe, + Stream.callback((queue) => + Effect.acquireRelease( + providerAuthSessions.subscribe(input.instanceId, (event) => + Queue.offer(queue, event), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "providerAuth" }, + ), [WS_METHODS.realtimeAppendAudio]: (input) => observeRpcEffect( WS_METHODS.realtimeAppendAudio, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c5083ba39..9ccd6d97a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,4 +1,3 @@ -import { FitAddon } from "@xterm/addon-fit"; import { CheckIcon, ChevronDown, @@ -16,7 +15,8 @@ import { type TerminalSessionSnapshot, type ThreadId, } from "@threadlines/contracts"; -import { Terminal, type ITheme } from "@xterm/xterm"; +import type { FitAddon } from "@xterm/addon-fit"; +import type { Terminal } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -35,6 +35,7 @@ import { type TerminalContextSelection } from "~/lib/terminalContext"; import { isElectron } from "../env"; import { openInPreferredEditor } from "../editorPreferences"; import { openUrlInBrowserPanel } from "./browser/openInBrowserPanel"; +import { createXtermSurface, terminalThemeFromApp } from "./terminal/xtermSurface"; import { copyTextWithToast } from "./chat/copyTextWithToast"; import { applyTerminalInputData, createTerminalCommandInputState } from "../terminalCommandTracker"; import { @@ -162,93 +163,6 @@ export function selectPendingTerminalEventEntries( return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); } -function normalizeComputedColor(value: string | null | undefined, fallback: string): string { - const normalizedValue = value?.trim().toLowerCase(); - if ( - !normalizedValue || - normalizedValue === "transparent" || - normalizedValue === "rgba(0, 0, 0, 0)" || - normalizedValue === "rgba(0 0 0 / 0)" - ) { - return fallback; - } - return value ?? fallback; -} - -function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; - const drawerSurface = - mountElement?.closest(".thread-terminal-drawer") ?? - document.querySelector(".thread-terminal-drawer") ?? - document.body; - const drawerStyles = getComputedStyle(drawerSurface); - const bodyStyles = getComputedStyle(document.body); - const background = normalizeComputedColor( - drawerStyles.backgroundColor, - normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), - ); - const foreground = normalizeComputedColor( - drawerStyles.color, - normalizeComputedColor(bodyStyles.color, fallbackForeground), - ); - - if (isDark) { - return { - background, - foreground, - cursor: "rgb(180, 203, 255)", - selectionBackground: "rgba(180, 203, 255, 0.25)", - scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", - scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", - scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", - black: "rgb(24, 30, 38)", - red: "rgb(255, 122, 142)", - green: "rgb(134, 231, 149)", - yellow: "rgb(244, 205, 114)", - blue: "rgb(137, 190, 255)", - magenta: "rgb(208, 176, 255)", - cyan: "rgb(124, 232, 237)", - white: "rgb(210, 218, 230)", - brightBlack: "rgb(110, 120, 136)", - brightRed: "rgb(255, 168, 180)", - brightGreen: "rgb(176, 245, 186)", - brightYellow: "rgb(255, 224, 149)", - brightBlue: "rgb(174, 210, 255)", - brightMagenta: "rgb(229, 203, 255)", - brightCyan: "rgb(167, 244, 247)", - brightWhite: "rgb(244, 247, 252)", - }; - } - - return { - background, - foreground, - cursor: "rgb(38, 56, 78)", - selectionBackground: "rgba(37, 63, 99, 0.2)", - scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", - scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", - scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", - black: "rgb(44, 53, 66)", - red: "rgb(191, 70, 87)", - green: "rgb(60, 126, 86)", - yellow: "rgb(146, 112, 35)", - blue: "rgb(72, 102, 163)", - magenta: "rgb(132, 86, 149)", - cyan: "rgb(53, 127, 141)", - white: "rgb(210, 215, 223)", - brightBlack: "rgb(112, 123, 140)", - brightRed: "rgb(212, 95, 112)", - brightGreen: "rgb(85, 148, 111)", - brightYellow: "rgb(173, 133, 45)", - brightBlue: "rgb(91, 124, 194)", - brightMagenta: "rgb(153, 107, 172)", - brightCyan: "rgb(70, 149, 164)", - brightWhite: "rgb(236, 240, 246)", - }; -} - function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { @@ -529,19 +443,7 @@ export function TerminalViewport({ const localApi = readLocalApi(); if (!api || !localApi) return; - const fitAddon = new FitAddon(); - const terminal = new Terminal({ - cursorBlink: true, - lineHeight: 1.2, - fontSize: 12, - scrollback: 5_000, - fontFamily: - '"Cascadia Mono Variable", "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', - theme: terminalThemeFromApp(container), - }); - terminal.loadAddon(fitAddon); - terminal.open(mount); - fitAddon.fit(); + const { terminal, fitAddon } = createXtermSurface({ mount, themeSource: container }); terminalRef.current = terminal; fitAddonRef.current = fitAddon; diff --git a/apps/web/src/components/settings/ProviderConnectFlow.tsx b/apps/web/src/components/settings/ProviderConnectFlow.tsx new file mode 100644 index 000000000..943daa0b4 --- /dev/null +++ b/apps/web/src/components/settings/ProviderConnectFlow.tsx @@ -0,0 +1,396 @@ +"use client"; + +import type { + ProviderAuthEvent, + ProviderAuthFlow, + ProviderInstanceId, +} from "@threadlines/contracts"; +import type { Terminal } from "@xterm/xterm"; +import { CheckIcon, ChevronDownIcon, CopyIcon, LoaderIcon } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; + +import { getPrimaryEnvironmentConnection } from "../../environments/runtime"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { createXtermSurface } from "../terminal/xtermSurface"; +import { + applyProviderAuthEvent, + initialProviderConnectFlowState, + isProviderConnectFlowActive, + providerConnectStatusLine, + shouldAutoExpandTerminal, + type ProviderConnectFlowState, +} from "./providerConnectFlow.logic"; + +const TERMINAL_COLS = 100; +const TERMINAL_ROWS = 20; +const AUTO_EXPAND_TICK_MS = 1_000; + +interface ProviderConnectTerminalProps { + readonly instanceId: ProviderInstanceId; + readonly bufferRef: { current: string }; + readonly writeRef: { current: ((data: string) => void) | null }; +} + +/** + * The real interactive terminal behind "Show details". Mounted only while + * expanded; it replays the buffered output the panel has seen so far, then + * receives live chunks through `writeRef`. + */ +function ProviderConnectTerminal({ + instanceId, + bufferRef, + writeRef, +}: ProviderConnectTerminalProps) { + const mountRef = useRef(null); + + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + + const client = getPrimaryEnvironmentConnection().client; + const { terminal, fitAddon } = createXtermSurface({ mount, fontSize: 11, scrollback: 2_000 }); + let disposed = false; + + if (bufferRef.current.length > 0) { + terminal.write(bufferRef.current); + } + writeRef.current = (data: string) => { + if (!disposed) terminal.write(data); + }; + + const dataSubscription = terminal.onData((data) => { + void client.providerAuth.write({ instanceId, data }).catch(() => { + // The panel's status line already reports a dead session. + }); + }); + + const pushSize = (activeTerminal: Terminal) => { + void client.providerAuth + .resize({ instanceId, cols: activeTerminal.cols, rows: activeTerminal.rows }) + .catch(() => {}); + }; + + const resizeObserver = new ResizeObserver(() => { + if (disposed) return; + fitAddon.fit(); + pushSize(terminal); + }); + resizeObserver.observe(mount); + pushSize(terminal); + terminal.focus(); + + return () => { + disposed = true; + writeRef.current = null; + resizeObserver.disconnect(); + dataSubscription.dispose(); + terminal.dispose(); + }; + }, [bufferRef, instanceId, writeRef]); + + return ( +
+ ); +} + +export interface ProviderConnectFlowProps { + readonly instanceId: ProviderInstanceId; + readonly flow: ProviderAuthFlow; + readonly displayName: string; + /** Label for the idle button, e.g. "Sign in" / "Reconnect" / "Generate token". */ + readonly actionLabel: string; + /** Command shown under the copy fallback before the server reports one. */ + readonly command: string; + readonly description?: string | undefined; + /** + * Status content (badges) rendered inline before the action, so the row + * reads as one statement: state first, then what you can do about it. + * Without it the button leads the row. + */ + readonly statusRow?: React.ReactNode; + /** + * `default` when the action is needed now, `outline` for a secondary + * standalone button, `ghost` for a rare maintenance action sitting next to + * a healthy status ("Sign in again"). + */ + readonly buttonVariant?: "default" | "outline" | "ghost"; +} + +/** + * Settings-owned provider sign-in. + * + * The whole flow stays on this page: the server runs the provider's auth + * command in an ephemeral PTY, the panel shows the last output line, and a + * "Show details" toggle reveals the real terminal for flows that need a + * pasted code or a menu choice. + */ +export function ProviderConnectFlow({ + instanceId, + flow, + displayName, + actionLabel, + command, + description, + statusRow, + buttonVariant = "default", +}: ProviderConnectFlowProps) { + const [state, setState] = useState(initialProviderConnectFlowState); + const [isStarting, setIsStarting] = useState(false); + const [showFallback, setShowFallback] = useState(false); + const [showTerminal, setShowTerminal] = useState(false); + const [runningForMs, setRunningForMs] = useState(0); + const outputBufferRef = useRef(""); + const terminalWriteRef = useRef<((data: string) => void) | null>(null); + // An instance has one auth session but can render two panels (sign-in and + // token setup). Sessions announce their flow in the "command" event; a + // panel ignores sessions that belong to the other flow. + const sessionFlowRef = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard<"provider-auth-command">({ + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy the command", + description: error.message, + }), + ); + }, + }); + + const handleEvent = useEffectEvent((event: ProviderAuthEvent) => { + if (event.type === "command") { + sessionFlowRef.current = event.flow; + if (event.flow !== flow) { + outputBufferRef.current = ""; + setShowTerminal(false); + setState(initialProviderConnectFlowState); + return; + } + } else if (sessionFlowRef.current !== flow) { + // The server announces a session's command (and flow) before any output + // or status, so anything arriving unclaimed belongs to the other panel. + return; + } + if (event.type === "output") { + outputBufferRef.current = `${outputBufferRef.current}${event.data}`.slice(-64_000); + terminalWriteRef.current?.(event.data); + } + setState((previous) => applyProviderAuthEvent(previous, event)); + }); + + // Subscribed for the component's whole lifetime, not just after a click: + // the server replays the command, buffered output, and status on attach, so + // a flow started before a tab switch or remount lands back on the panel. + useEffect(() => { + let cancelled = false; + const client = getPrimaryEnvironmentConnection().client; + const unsubscribe = client.providerAuth.subscribe({ instanceId }, (event) => { + if (cancelled) return; + handleEvent(event); + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [instanceId]); + + const isActive = isProviderConnectFlowActive(state.status); + + useEffect(() => { + if (!isActive) { + setRunningForMs(0); + return; + } + const startedAt = Date.now(); + const timer = window.setInterval( + () => setRunningForMs(Date.now() - startedAt), + AUTO_EXPAND_TICK_MS, + ); + return () => window.clearInterval(timer); + }, [isActive]); + + useEffect(() => { + if (state.status === "succeeded") { + // The job is done and, for token flows, the transcript is no longer + // interesting — the panel collapses to its success line. + setShowTerminal(false); + return; + } + if (shouldAutoExpandTerminal({ status: state.status, runningForMs })) { + setShowTerminal(true); + } + }, [runningForMs, state.status]); + + const startFlow = () => { + outputBufferRef.current = ""; + setState(initialProviderConnectFlowState); + setShowTerminal(false); + setIsStarting(true); + void getPrimaryEnvironmentConnection() + .client.providerAuth.start({ instanceId, flow, cols: TERMINAL_COLS, rows: TERMINAL_ROWS }) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not start ${displayName} sign-in`, + description: + error instanceof Error ? error.message : "The command could not be started.", + }), + ); + }) + .finally(() => { + setIsStarting(false); + }); + }; + + // Cancel and Dismiss both clear the server-side session too, so a finished + // run doesn't replay a stale success/failure panel on the next visit. + const dismissFlow = () => { + void getPrimaryEnvironmentConnection() + .client.providerAuth.stop({ instanceId }) + .catch(() => {}); + outputBufferRef.current = ""; + setState(initialProviderConnectFlowState); + setShowTerminal(false); + }; + + const statusLine = providerConnectStatusLine({ flow, state, displayName }); + const displayCommand = state.command ?? command; + const panelOpen = state.status !== "idle" || isStarting; + + const actionButton = ( + + ); + + return ( +
+ {statusRow ? ( +
+ {statusRow} + {actionButton} +
+ ) : ( +
+ {actionButton} + {description && !panelOpen ? ( + {description} + ) : null} +
+ )} + {statusRow && description && !panelOpen ? ( +

{description}

+ ) : null} + + {panelOpen ? ( +
+
+ + {state.status === "succeeded" ? ( + + + {statusLine} + + ) : ( + statusLine + )} + +
+ + {state.status === "succeeded" ? null : ( + + )} +
+
+ + {!showTerminal && state.lastLine.length > 0 && state.status !== "succeeded" ? ( +

{state.lastLine}

+ ) : null} + + {showTerminal ? ( + + ) : null} +
+ ) : null} + +
setShowFallback(event.currentTarget.open)} + > + + + Prefer your own terminal? + +
+ + {displayCommand} + + +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index aea4a68f0..be8d4083c 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -2,25 +2,17 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind, ProviderInstanceId, - type ProviderInstanceEnvironmentVariable, type ServerProvider, type ServerProviderModel, } from "@threadlines/contracts"; import { - buildClaudeAuthLoginCommand, - buildClaudeSetupTokenCommand, - buildCodexLoginCommand, claudeAuthCapabilityBadge, - deriveClaudeLongLivedOAuthTokenState, deriveProviderModelsForDisplay, hasClaudeCredentialOverrideEnvironment, preferClaudeNormalSignInEnvironment, preferClaudeLongLivedOAuthTokenEnvironment, providerAuthBadge, - removeClaudeLongLivedOAuthTokenEnvironment, - sanitizeClaudeLongLivedOAuthTokenInput, - upsertClaudeLongLivedOAuthTokenEnvironment, } from "./ProviderInstanceCard"; import { getProviderSummary } from "./providerStatus"; @@ -56,125 +48,7 @@ describe("deriveProviderModelsForDisplay", () => { }); }); -describe("Claude long-lived OAuth token environment helpers", () => { - it("builds Claude terminal login commands for default and custom homes", () => { - expect(buildClaudeAuthLoginCommand({ binaryPath: "", homePath: "" })).toBe("claude auth login"); - expect( - buildClaudeAuthLoginCommand({ - binaryPath: "/Applications/Claude Code/claude", - homePath: "/Users/example/Claude Home", - }), - ).toBe("HOME='/Users/example/Claude Home' '/Applications/Claude Code/claude' auth login"); - }); - - it("builds the default setup-token command", () => { - expect(buildClaudeSetupTokenCommand({ binaryPath: "", homePath: "" })).toBe( - "claude setup-token", - ); - }); - - it("builds a setup-token command for custom Claude homes and binary paths", () => { - expect( - buildClaudeSetupTokenCommand({ - binaryPath: "/Applications/Claude Code/claude", - homePath: "/Users/example/Claude Home", - }), - ).toBe("HOME='/Users/example/Claude Home' '/Applications/Claude Code/claude' setup-token"); - }); - - it("keeps tilde homes unquoted so the shell can expand them", () => { - expect(buildClaudeSetupTokenCommand({ binaryPath: "claude", homePath: "~/.claude_work" })).toBe( - "HOME=~/.claude_work claude setup-token", - ); - }); - - it("detects redacted stored tokens without exposing a value", () => { - expect( - deriveClaudeLongLivedOAuthTokenState([ - { - name: "CLAUDE_CODE_OAUTH_TOKEN", - value: "", - sensitive: true, - valueRedacted: true, - }, - ]), - ).toEqual({ - configured: true, - redacted: true, - value: "", - }); - }); - - it("stores the token as a sensitive provider environment variable", () => { - const environment: ReadonlyArray = [ - { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, - ]; - - expect(upsertClaudeLongLivedOAuthTokenEnvironment(environment, " token-123 \n")).toEqual([ - { name: "ANTHROPIC_API_KEY", value: "", sensitive: false }, - { - name: "CLAUDE_CODE_OAUTH_TOKEN", - value: "token-123", - sensitive: true, - valueRedacted: false, - }, - ]); - }); - - it("removes paste artifacts from Claude setup-token output before storing", () => { - expect( - sanitizeClaudeLongLivedOAuthTokenInput( - " export CLAUDE_CODE_OAUTH_TOKEN='sk-ant-oat01-part one\\npart two' ", - ), - ).toBe("sk-ant-oat01-partoneparttwo"); - - expect( - upsertClaudeLongLivedOAuthTokenEnvironment([], "sk-ant-oat01-part one\npart two"), - ).toEqual([ - { - name: "CLAUDE_CODE_OAUTH_TOKEN", - value: "sk-ant-oat01-partoneparttwo", - sensitive: true, - valueRedacted: false, - }, - ]); - }); - - it("replaces duplicate token variables with one sensitive value", () => { - expect( - upsertClaudeLongLivedOAuthTokenEnvironment( - [ - { - name: "CLAUDE_CODE_OAUTH_TOKEN", - value: "", - sensitive: true, - valueRedacted: true, - }, - { name: "OTHER_VAR", value: "kept", sensitive: false }, - { name: "CLAUDE_CODE_OAUTH_TOKEN", value: "old", sensitive: false }, - ], - "new-token", - ), - ).toEqual([ - { - name: "CLAUDE_CODE_OAUTH_TOKEN", - value: "new-token", - sensitive: true, - valueRedacted: false, - }, - { name: "OTHER_VAR", value: "kept", sensitive: false }, - ]); - }); - - it("removes the token variable without touching unrelated environment", () => { - expect( - removeClaudeLongLivedOAuthTokenEnvironment([ - { name: "CLAUDE_CODE_OAUTH_TOKEN", value: "token", sensitive: true }, - { name: "OTHER_VAR", value: "kept", sensitive: false }, - ]), - ).toEqual([{ name: "OTHER_VAR", value: "kept", sensitive: false }]); - }); - +describe("Claude credential preference helpers", () => { it("switches to normal Claude sign-in while masking inherited credential overrides", () => { expect( preferClaudeNormalSignInEnvironment([ @@ -305,28 +179,3 @@ describe("Claude authentication presentation", () => { }); }); }); - -describe("Codex login command helpers", () => { - it("builds default and custom Codex login commands", () => { - expect(buildCodexLoginCommand({ binaryPath: "", homePath: "", shadowHomePath: "" })).toBe( - "codex login", - ); - expect( - buildCodexLoginCommand({ - binaryPath: "codex", - homePath: "~/.codex_work", - shadowHomePath: "", - }), - ).toBe("CODEX_HOME=~/.codex_work codex login"); - }); - - it("uses the Codex shadow home for account-specific login", () => { - expect( - buildCodexLoginCommand({ - binaryPath: "/opt/Code Agent/codex", - homePath: "~/.codex", - shadowHomePath: "/Users/example/Codex Personal", - }), - ).toBe("CODEX_HOME='/Users/example/Codex Personal' '/opt/Code Agent/codex' login"); - }); -}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 9798b0ced..dd1e2bb98 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -11,7 +11,6 @@ import { PipetteIcon, PlusIcon, RotateCcwIcon, - TerminalIcon, Trash2Icon, XIcon, } from "lucide-react"; @@ -27,6 +26,17 @@ import { type ServerProviderModel, } from "@threadlines/contracts"; +import { + buildClaudeAuthLoginCommand, + buildClaudeSetupTokenCommand, + buildCodexLoginCommand, + CLAUDE_CREDENTIAL_OVERRIDE_ENV_NAMES, + CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV, + deriveClaudeLongLivedOAuthTokenState, + sanitizeClaudeLongLivedOAuthTokenInput, + upsertClaudeLongLivedOAuthTokenEnvironment, +} from "@threadlines/shared/providerAuthCommands"; + import { cn } from "../../lib/utils"; import { deriveProviderAccountUsagePresentationForProvider, @@ -44,6 +54,7 @@ import { ScrollArea } from "../ui/scroll-area"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ProviderConnectFlow } from "./ProviderConnectFlow"; import type { DriverOption } from "./providerDriverMeta"; import { deriveProviderSettingsFields, @@ -69,8 +80,6 @@ const PROVIDER_UPDATE_OUTPUT_PREVIEW_CHARS = 700; const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); -const CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV = "CLAUDE_CODE_OAUTH_TOKEN"; -const CLAUDE_CREDENTIAL_OVERRIDE_ENV_NAMES = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const; const RUNTIME_PROVIDER_CONFIG_FIELD_KEYS = new Set([ "binaryPath", "launchArgs", @@ -82,17 +91,6 @@ const RUNTIME_PROVIDER_CONFIG_FIELD_KEYS = new Set([ let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; -export interface ProviderAccountTerminalCommandRequest { - readonly title: string; - readonly command: string; - readonly terminalId: string; -} - -function providerAccountTerminalId(driverKind: ProviderDriverKind | null): string { - const driverKey = driverKind ? String(driverKind).replace(/[^A-Za-z0-9_-]/g, "-") : "provider"; - return `auth-${driverKey}`; -} - function truncateProviderUpdateOutput(value: string): string { const trimmed = value.trim(); if (trimmed.length <= PROVIDER_UPDATE_OUTPUT_PREVIEW_CHARS) { @@ -201,119 +199,6 @@ export function deriveProviderModelsForDisplay(input: { return [...serverModels, ...customModels]; } -function shellWord(value: string): string { - if (/^[A-Za-z0-9_./~:@%+=,-]+$/u.test(value)) { - return value; - } - return `'${value.replace(/'/g, "'\\''")}'`; -} - -export function buildClaudeSetupTokenCommand(input: { - readonly binaryPath: string; - readonly homePath: string; -}): string { - const binaryPath = input.binaryPath.trim() || "claude"; - const homePath = input.homePath.trim(); - const command = `${shellWord(binaryPath)} setup-token`; - return homePath ? `HOME=${shellWord(homePath)} ${command}` : command; -} - -export function buildClaudeAuthLoginCommand(input: { - readonly binaryPath: string; - readonly homePath: string; -}): string { - const binaryPath = input.binaryPath.trim() || "claude"; - const homePath = input.homePath.trim(); - const command = `${shellWord(binaryPath)} auth login`; - return homePath ? `HOME=${shellWord(homePath)} ${command}` : command; -} - -export function buildCodexLoginCommand(input: { - readonly binaryPath: string; - readonly homePath: string; - readonly shadowHomePath: string; -}): string { - const binaryPath = input.binaryPath.trim() || "codex"; - const authHomePath = input.shadowHomePath.trim() || input.homePath.trim(); - const command = `${shellWord(binaryPath)} login`; - return authHomePath ? `CODEX_HOME=${shellWord(authHomePath)} ${command}` : command; -} - -export interface ClaudeLongLivedOAuthTokenState { - readonly configured: boolean; - readonly redacted: boolean; - readonly value: string; -} - -export function deriveClaudeLongLivedOAuthTokenState( - environment: ReadonlyArray, -): ClaudeLongLivedOAuthTokenState { - const variable = environment.find((entry) => entry.name === CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV); - if (!variable) { - return { configured: false, redacted: false, value: "" }; - } - const redacted = variable.valueRedacted === true; - const value = redacted ? "" : variable.value; - return { - configured: redacted || value.trim().length > 0, - redacted, - value, - }; -} - -export function sanitizeClaudeLongLivedOAuthTokenInput(value: string): string { - const trimmed = value.trim(); - const assignmentMatch = trimmed.match(/(?:^|\s)CLAUDE_CODE_OAUTH_TOKEN\s*=\s*(.+)$/u); - const token = assignmentMatch?.[1]?.trim() ?? trimmed; - return token - .replace(/^['"]|['"]$/g, "") - .replace(/\\[nr]/g, "") - .replace(/\s+/g, ""); -} - -export function upsertClaudeLongLivedOAuthTokenEnvironment( - environment: ReadonlyArray, - token: string, -): ReadonlyArray { - const trimmed = sanitizeClaudeLongLivedOAuthTokenInput(token); - const nextEnvironment: ProviderInstanceEnvironmentVariable[] = []; - let inserted = false; - - for (const variable of environment) { - if (variable.name !== CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV) { - nextEnvironment.push(variable); - continue; - } - if (trimmed.length === 0 || inserted) { - continue; - } - nextEnvironment.push({ - name: CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV, - value: trimmed, - sensitive: true, - valueRedacted: false, - }); - inserted = true; - } - - if (trimmed.length > 0 && !inserted) { - nextEnvironment.push({ - name: CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV, - value: trimmed, - sensitive: true, - valueRedacted: false, - }); - } - - return nextEnvironment; -} - -export function removeClaudeLongLivedOAuthTokenEnvironment( - environment: ReadonlyArray, -): ReadonlyArray { - return environment.filter((variable) => variable.name !== CLAUDE_LONG_LIVED_OAUTH_TOKEN_ENV); -} - export function preferClaudeNormalSignInEnvironment( environment: ReadonlyArray, ): ReadonlyArray { @@ -656,53 +541,18 @@ function ProviderEnvironmentEditor(props: { function ClaudeLongLivedAuthSection(props: { readonly idPrefix: string; + readonly instanceId: ProviderInstanceId; readonly setupCommand: string; readonly environment: ReadonlyArray; readonly onChange: (environment: ReadonlyArray) => void; - readonly onRunTerminalCommand?: - | ((request: ProviderAccountTerminalCommandRequest) => Promise | void) - | undefined; - readonly terminalCommandRequest?: ProviderAccountTerminalCommandRequest | undefined; }) { const tokenState = deriveClaudeLongLivedOAuthTokenState(props.environment); const tokenInputId = `${props.idPrefix}-claude-oauth-token`; const [tokenDraft, setTokenDraft] = useState(""); - const [isRunningSetupCommand, setIsRunningSetupCommand] = useState(false); const [isExpanded, setIsExpanded] = useState(tokenState.configured); const sanitizedTokenDraft = sanitizeClaudeLongLivedOAuthTokenInput(tokenDraft); const tokenDraftHasValue = tokenDraft.trim().length > 0; const tokenDraftWillBeSanitized = tokenDraftHasValue && sanitizedTokenDraft !== tokenDraft.trim(); - const { copyToClipboard, isCopied } = useCopyToClipboard<"setup-token-command">({ - onCopy: () => { - toastManager.add({ - type: "success", - title: "Claude token setup command copied", - description: "Run it in a terminal, then paste the generated token here.", - }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not copy Claude token setup command", - description: error.message, - }), - ); - }, - }); - const canRunSetupCommand = - props.onRunTerminalCommand !== undefined && props.terminalCommandRequest !== undefined; - const runSetupCommand = async () => { - if (!props.onRunTerminalCommand || !props.terminalCommandRequest) { - return; - } - setIsRunningSetupCommand(true); - try { - await props.onRunTerminalCommand(props.terminalCommandRequest); - } finally { - setIsRunningSetupCommand(false); - } - }; const saveToken = () => { if (sanitizedTokenDraft.length === 0) { toastManager.add({ @@ -746,48 +596,17 @@ function ClaudeLongLivedAuthSection(props: {

Optional for remote or headless chat. Usage still requires normal Claude sign-in.

-
-
- Setup command -
- {canRunSetupCommand ? ( - - ) : null} - -
-
- - {props.setupCommand} - -

- Finish browser authorization, then paste the token below. -

-
+