|
| 1 | +import * as Command from "@effect/platform/Command" |
| 2 | +import * as CommandExecutor from "@effect/platform/CommandExecutor" |
| 3 | +import * as FileSystem from "@effect/platform/FileSystem" |
| 4 | +import * as Path from "@effect/platform/Path" |
| 5 | +import { NodeContext } from "@effect/platform-node" |
| 6 | +import { describe, expect, it } from "@effect/vitest" |
| 7 | +import { Effect } from "effect" |
| 8 | +import * as Inspectable from "effect/Inspectable" |
| 9 | +import * as Sink from "effect/Sink" |
| 10 | +import * as Stream from "effect/Stream" |
| 11 | + |
| 12 | +import { authClaudeLogin } from "../../src/usecases/auth-claude.js" |
| 13 | + |
| 14 | +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) |
| 15 | + |
| 16 | +const oauthToken = "sk-ant-oat01-EXAMPLE0123456789abcdef" |
| 17 | + |
| 18 | +// Mirrors the real `claude setup-token` output that the OAuth parser scans for. |
| 19 | +const setupTokenOutput = (token: string): string => |
| 20 | + [ |
| 21 | + "Welcome to Claude Code", |
| 22 | + "", |
| 23 | + " ✓ Long-lived authentication token created successfully!", |
| 24 | + "", |
| 25 | + " Your OAuth token (valid for 1 year):", |
| 26 | + "", |
| 27 | + ` ${token}`, |
| 28 | + "", |
| 29 | + " Store this token securely. You won't be able to see it again." |
| 30 | + ].join("\n") |
| 31 | + |
| 32 | +const isSetupToken = (args: ReadonlyArray<string>): boolean => args.includes("setup-token") |
| 33 | +const isPingProbe = (args: ReadonlyArray<string>): boolean => args.includes("-p") && args.includes("ping") |
| 34 | + |
| 35 | +// CHANGE: fake docker executor that captures a setup-token and lets the ping probe fail |
| 36 | +// WHY: reproduce issue-439 where a successful OAuth login was discarded by a failing probe |
| 37 | +// REF: issue-439 |
| 38 | +const makeFakeExecutor = ( |
| 39 | + token: string, |
| 40 | + pingExitCode: number |
| 41 | +): CommandExecutor.CommandExecutor => { |
| 42 | + const start = (command: Command.Command): Effect.Effect<CommandExecutor.Process, never> => |
| 43 | + Effect.sync(() => { |
| 44 | + const flattened = Command.flatten(command) |
| 45 | + const invocation = flattened[flattened.length - 1]! |
| 46 | + const args = invocation.args |
| 47 | + |
| 48 | + const stdoutText = isSetupToken(args) ? setupTokenOutput(token) : "" |
| 49 | + const exitCode = isPingProbe(args) ? pingExitCode : 0 |
| 50 | + const stdout = stdoutText.length === 0 ? Stream.empty : Stream.succeed(encode(stdoutText)) |
| 51 | + |
| 52 | + const process: CommandExecutor.Process = { |
| 53 | + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, |
| 54 | + pid: CommandExecutor.ProcessId(1), |
| 55 | + exitCode: Effect.succeed(CommandExecutor.ExitCode(exitCode)), |
| 56 | + isRunning: Effect.succeed(false), |
| 57 | + kill: (_signal) => Effect.void, |
| 58 | + stderr: Stream.empty, |
| 59 | + stdin: Sink.drain, |
| 60 | + stdout, |
| 61 | + toJSON: () => ({ _tag: "ClaudeLoginTestProcess", command: invocation.command, args }), |
| 62 | + [Inspectable.NodeInspectSymbol]: () => ({ |
| 63 | + _tag: "ClaudeLoginTestProcess", |
| 64 | + command: invocation.command, |
| 65 | + args |
| 66 | + }), |
| 67 | + toString: () => `[ClaudeLoginTestProcess ${invocation.command}]` |
| 68 | + } |
| 69 | + |
| 70 | + return process |
| 71 | + }) |
| 72 | + |
| 73 | + return CommandExecutor.makeExecutor(start) |
| 74 | +} |
| 75 | + |
| 76 | +const withTempDir = <A, E, R>( |
| 77 | + use: (tempDir: string) => Effect.Effect<A, E, R> |
| 78 | +): Effect.Effect<A, E, R | FileSystem.FileSystem> => |
| 79 | + Effect.scoped( |
| 80 | + Effect.gen(function*(_) { |
| 81 | + const fs = yield* _(FileSystem.FileSystem) |
| 82 | + const tempDir = yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-auth-claude-" })) |
| 83 | + return yield* _(use(tempDir)) |
| 84 | + }) |
| 85 | + ) |
| 86 | + |
| 87 | +const withPatchedEnv = <A, E, R>( |
| 88 | + patch: Readonly<Record<string, string | undefined>>, |
| 89 | + effect: Effect.Effect<A, E, R> |
| 90 | +): Effect.Effect<A, E, R> => |
| 91 | + Effect.acquireUseRelease( |
| 92 | + Effect.sync(() => { |
| 93 | + const previous = new Map<string, string | undefined>() |
| 94 | + for (const [key, value] of Object.entries(patch)) { |
| 95 | + previous.set(key, process.env[key]) |
| 96 | + if (value === undefined) { |
| 97 | + delete process.env[key] |
| 98 | + } else { |
| 99 | + process.env[key] = value |
| 100 | + } |
| 101 | + } |
| 102 | + return previous |
| 103 | + }), |
| 104 | + () => effect, |
| 105 | + (previous) => |
| 106 | + Effect.sync(() => { |
| 107 | + for (const [key, value] of previous.entries()) { |
| 108 | + if (value === undefined) { |
| 109 | + delete process.env[key] |
| 110 | + } else { |
| 111 | + process.env[key] = value |
| 112 | + } |
| 113 | + } |
| 114 | + }) |
| 115 | + ) |
| 116 | + |
| 117 | +const runLoginAndReadToken = ( |
| 118 | + root: string, |
| 119 | + pingExitCode: number |
| 120 | +): Effect.Effect<string, unknown, FileSystem.FileSystem | Path.Path> => |
| 121 | + Effect.gen(function*(_) { |
| 122 | + const fs = yield* _(FileSystem.FileSystem) |
| 123 | + const path = yield* _(Path.Path) |
| 124 | + const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude") |
| 125 | + |
| 126 | + yield* _( |
| 127 | + authClaudeLogin({ |
| 128 | + _tag: "AuthClaudeLogin", |
| 129 | + label: null, |
| 130 | + claudeAuthPath |
| 131 | + }).pipe( |
| 132 | + Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, pingExitCode)) |
| 133 | + ) |
| 134 | + ) |
| 135 | + |
| 136 | + return yield* _(fs.readFileString(path.join(claudeAuthPath, "default", ".oauth-token"))) |
| 137 | + }) |
| 138 | + |
| 139 | +describe("authClaudeLogin", () => { |
| 140 | + // Regression for issue-439: a non-zero probe exit must not discard a created token. |
| 141 | + it.effect("persists the OAuth token even when the post-login API probe fails", () => |
| 142 | + withTempDir((root) => |
| 143 | + withPatchedEnv( |
| 144 | + { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, |
| 145 | + Effect.gen(function*(_) { |
| 146 | + const persisted = yield* _(runLoginAndReadToken(root, 7)) |
| 147 | + expect(persisted.trim()).toBe(oauthToken) |
| 148 | + }) |
| 149 | + ) |
| 150 | + ).pipe(Effect.provide(NodeContext.layer))) |
| 151 | + |
| 152 | + it.effect("persists the OAuth token when the post-login API probe succeeds", () => |
| 153 | + withTempDir((root) => |
| 154 | + withPatchedEnv( |
| 155 | + { HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined }, |
| 156 | + Effect.gen(function*(_) { |
| 157 | + const persisted = yield* _(runLoginAndReadToken(root, 0)) |
| 158 | + expect(persisted.trim()).toBe(oauthToken) |
| 159 | + }) |
| 160 | + ) |
| 161 | + ).pipe(Effect.provide(NodeContext.layer))) |
| 162 | +}) |
0 commit comments