Skip to content

Commit 025b925

Browse files
committed
fix(auth): harden claude oauth login probe path
1 parent a02936b commit 025b925

21 files changed

Lines changed: 706 additions & 177 deletions

.github/workflows/check.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,9 @@ jobs:
257257
DOCKER_GIT_CONTROLLER_BUILD_SKILLER: "0"
258258
DOCKER_GIT_E2E_REUSE_WORKSPACE_INSTALL: "1"
259259
steps:
260-
- uses: actions/checkout@v6
260+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
261261
with:
262+
persist-credentials: false
262263
submodules: true
263264
- name: Install dependencies
264265
uses: ./.github/actions/setup

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ services:
2727
DOCKER_GIT_EXCHANGE_AGENT_COMMAND: ${DOCKER_GIT_EXCHANGE_AGENT_COMMAND:-}
2828
DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS: ${DOCKER_GIT_EXCHANGE_AGENT_TIMEOUT_MS:-3600000}
2929
DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS: ${DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS:-5000}
30-
DOCKER_GIT_CLAUDE_OAUTH_TOKEN: ${DOCKER_GIT_CLAUDE_OAUTH_TOKEN:-}
3130
ports:
3231
- "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}"
3332
dns:

packages/app/src/docker-git/controller-compose.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import type { ControllerBootstrapError } from "./host-errors.js"
1313

1414
export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU"
1515
export const controllerBuildSkillerEnvKey = "DOCKER_GIT_CONTROLLER_BUILD_SKILLER"
16+
export const controllerComposeExtraFileEnvKey = "DOCKER_GIT_CONTROLLER_COMPOSE_EXTRA_FILE"
1617

1718
export type ControllerGpuMode = "none" | "all"
1819
export type ControllerBuildSkillerMode = "0" | "1"
1920

2021
export type ControllerComposeFiles = {
2122
readonly composePath: string
23+
readonly extraOverlayPath: string | null
2224
readonly gpuOverlayPath: string | null
2325
readonly runtimeOverlayPath: string | null
2426
}
@@ -114,6 +116,42 @@ const mapSkillerPathError = (error: PlatformError): ControllerBootstrapError =>
114116
const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError =>
115117
controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`)
116118

119+
// CHANGE: add a verified controller compose overlay boundary for E2E/runtime callers
120+
// WHY: temporary compose overrides must be part of the explicit docker compose argument vector
121+
// QUOTE(ТЗ): n/a
122+
// REF: issue-440-review-compose-overlay
123+
// SOURCE: n/a
124+
// FORMAT THEOREM: forall p: env(extra)=p and exists(resolve(p)) -> resolve(extra)=Some(resolve(p))
125+
// PURITY: SHELL
126+
// EFFECT: Effect<string | null, ControllerBootstrapError, FileSystem | Path>
127+
// INVARIANT: non-empty extra compose env values either resolve to an existing file or fail before docker compose
128+
// COMPLEXITY: O(1)
129+
const loadControllerComposeExtraPath = (): Effect.Effect<
130+
string | null,
131+
ControllerBootstrapError,
132+
FileSystem.FileSystem | Path.Path
133+
> =>
134+
Effect.gen(function*(_) {
135+
const raw = process.env[controllerComposeExtraFileEnvKey]?.trim() ?? ""
136+
if (raw.length === 0) {
137+
return null
138+
}
139+
140+
const fs = yield* _(FileSystem.FileSystem)
141+
const path = yield* _(Path.Path)
142+
const extraOverlayPath = path.resolve(raw)
143+
const isExists = yield* _(fs.exists(extraOverlayPath).pipe(Effect.mapError(mapComposePathError)))
144+
return isExists
145+
? extraOverlayPath
146+
: yield* _(
147+
Effect.fail(
148+
controllerBootstrapError(
149+
`${controllerComposeExtraFileEnvKey} points to ${extraOverlayPath}, but it was not found.`
150+
)
151+
)
152+
)
153+
})
154+
117155
const skillerSubmoduleCommand = [
118156
"submodule",
119157
"update",
@@ -209,19 +247,22 @@ export const ensureSkillerSubmoduleInitialized = (
209247
export const composeFilesForMode = (
210248
composePath: string,
211249
gpuOverlayPath: string | null,
212-
runtimeOverlayPath: string | null = null
250+
runtimeOverlayPath: string | null = null,
251+
extraOverlayPath: string | null = null
213252
): ReadonlyArray<string> => [
214253
"-f",
215254
composePath,
216255
...(runtimeOverlayPath === null ? [] : ["-f", runtimeOverlayPath]),
217-
...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath])
256+
...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath]),
257+
...(extraOverlayPath === null ? [] : ["-f", extraOverlayPath])
218258
]
219259

220260
export const composeFilesToArgs = (composeFiles: ControllerComposeFiles): ReadonlyArray<string> =>
221261
composeFilesForMode(
222262
composeFiles.composePath,
223263
composeFiles.gpuOverlayPath,
224-
composeFiles.runtimeOverlayPath
264+
composeFiles.runtimeOverlayPath,
265+
composeFiles.extraOverlayPath
225266
)
226267

227268
const requireGpuOverlayPath = (
@@ -246,9 +287,9 @@ const composeFilesForGpuMode = (
246287
gpuMode: ControllerGpuMode
247288
): Effect.Effect<ControllerComposeFiles, ControllerBootstrapError, FileSystem.FileSystem | Path.Path> =>
248289
gpuMode === "none"
249-
? Effect.succeed({ composePath, gpuOverlayPath: null, runtimeOverlayPath: null })
290+
? Effect.succeed({ composePath, extraOverlayPath: null, gpuOverlayPath: null, runtimeOverlayPath: null })
250291
: requireGpuOverlayPath(composePath).pipe(
251-
Effect.map((gpuOverlayPath) => ({ composePath, gpuOverlayPath, runtimeOverlayPath: null }))
292+
Effect.map((gpuOverlayPath) => ({ composePath, extraOverlayPath: null, gpuOverlayPath, runtimeOverlayPath: null }))
252293
)
253294

254295
type ComposePathAndGpuMode = {
@@ -286,8 +327,9 @@ export const resolveControllerComposeFiles = (): Effect.Effect<
286327
withComposePathAndGpuMode(({ composePath, dockerRuntime, gpuMode }) =>
287328
Effect.gen(function*(_) {
288329
const composeFiles = yield* _(composeFilesForGpuMode(composePath, gpuMode))
330+
const extraOverlayPath = yield* _(loadControllerComposeExtraPath())
289331
const runtimeOverlayPath = yield* _(resolveControllerRuntimeOverlayPath(composePath, dockerRuntime))
290-
return { ...composeFiles, runtimeOverlayPath }
332+
return { ...composeFiles, extraOverlayPath, runtimeOverlayPath }
291333
})
292334
)
293335

packages/app/tests/docker-git/controller-compose.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as fc from "fast-check"
88
import { resolveControllerRuntimeOverlayPath } from "../../src/docker-git/controller-compose-runtime.js"
99
import {
1010
controllerBuildSkillerEnvKey,
11+
controllerComposeExtraFileEnvKey,
1112
controllerComposeProjectName,
1213
controllerGpuModeEnvKey,
1314
ensureSkillerSubmoduleInitialized,
@@ -61,6 +62,9 @@ const writeMinimalCompose = (rootDir: string) =>
6162
const writeMinimalIsolatedCompose = (rootDir: string) =>
6263
writeRootFile(rootDir, "docker-compose.isolated.yml", "services:\n api:\n volumes: !override []\n")
6364

65+
const writeMinimalExtraCompose = (rootDir: string) =>
66+
writeRootFile(rootDir, "docker-compose.auth-claude-login.yml", "services:\n api:\n environment: {}\n")
67+
6468
const writeSkillerPackage = (rootDir: string) =>
6569
writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n")
6670

@@ -159,6 +163,7 @@ const prepareRevisionInTemporaryRoot = ({
159163
yield* _(
160164
withControllerEnv([
161165
[controllerBuildSkillerEnvKey, buildSkillerMode],
166+
[controllerComposeExtraFileEnvKey, undefined],
162167
[controllerDockerRuntimeEnvKey, undefined],
163168
[controllerGpuModeEnvKey, undefined],
164169
[controllerRevisionEnvKey, undefined]
@@ -192,6 +197,7 @@ const resolveComposeFilesInTemporaryRoot = (
192197
yield* _(
193198
withControllerEnv([
194199
[controllerBuildSkillerEnvKey, "0"],
200+
[controllerComposeExtraFileEnvKey, undefined],
195201
[controllerDockerRuntimeEnvKey, dockerRuntimeMode],
196202
[controllerGpuModeEnvKey, undefined]
197203
])
@@ -215,6 +221,7 @@ describe("controller compose preparation", () => {
215221
yield* _(
216222
withControllerEnv([
217223
[controllerBuildSkillerEnvKey, "0"],
224+
[controllerComposeExtraFileEnvKey, undefined],
218225
[controllerDockerRuntimeEnvKey, undefined],
219226
[controllerGpuModeEnvKey, undefined]
220227
])
@@ -235,6 +242,42 @@ describe("controller compose preparation", () => {
235242
).pipe(Effect.provide(NodeContext.layer))
236243
})
237244

245+
it.effect("passes the verified extra compose overlay into controller compose commands", () => {
246+
const startedCommands: Array<string> = []
247+
248+
return withMinimalControllerRoot((rootDir) =>
249+
Effect.gen(function*(_) {
250+
const path = yield* _(Path.Path)
251+
yield* _(writeMinimalExtraCompose(rootDir))
252+
const extraComposePath = path.join(rootDir, "docker-compose.auth-claude-login.yml")
253+
yield* _(
254+
withControllerEnv([
255+
[controllerBuildSkillerEnvKey, "0"],
256+
[controllerComposeExtraFileEnvKey, extraComposePath],
257+
[controllerDockerRuntimeEnvKey, undefined],
258+
[controllerGpuModeEnvKey, undefined]
259+
])
260+
)
261+
262+
const composeFiles = yield* _(resolveControllerComposeFiles())
263+
expect(composeFiles.extraOverlayPath).toBe(extraComposePath)
264+
265+
const recordedExecutorLayer = recordedCommandExecutorLayer(startedCommands, emptyCommandResult)
266+
yield* _(
267+
runCompose(["up", "-d"]).pipe(
268+
Effect.provide(recordedExecutorLayer)
269+
)
270+
)
271+
272+
const composeCommand = startedCommands.find((command) =>
273+
command.startsWith(`docker compose --project-name ${controllerComposeProjectName} -f `)
274+
)
275+
expect(composeCommand).toBeDefined()
276+
expect(composeCommand).toContain(` -f ${extraComposePath} up -d`)
277+
})
278+
).pipe(Effect.provide(NodeContext.layer))
279+
})
280+
238281
it.effect("does not initialize the Skiller submodule when package metadata already exists", () => {
239282
const startedCommands: Array<string> = []
240283

packages/auth-oauth/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@
3232
},
3333
"homepage": "https://github.com/ProverCoderAI/docker-git#readme",
3434
"devDependencies": {
35+
"@effect/vitest": "^0.29.0",
3536
"@types/node": "^25.9.3",
37+
"effect": "^3.21.3",
38+
"fast-check": "^4.8.0",
3639
"typescript": "^6.0.3",
3740
"vitest": "^4.1.9"
3841
},

packages/auth-oauth/src/claude-docker-oauth.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@ import { fileURLToPath } from "node:url"
55
import { spawn } from "node:child_process"
66

77
import {
8+
claudeOauthTokenFileMode,
89
claudeOauthTokenPath,
910
classifyClaudeSetupTokenResult,
1011
extractClaudeOauthToken,
11-
formatClaudeOauthTokenFile
12+
flushClaudeOauthTokenRedactionState,
13+
formatClaudeOauthTokenFile,
14+
initialClaudeOauthTokenRedactionState,
15+
redactClaudeOauthTokenChunk,
16+
type ClaudeOauthTokenRedactionState
1217
} from "./claude-oauth-token.js"
1318

1419
export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest"
1520
export const defaultClaudeDockerOauthContainerHome = "/claude-home"
21+
export const claudeDockerOauthBaseImage =
22+
"node:24-bookworm-slim@sha256:b31e7a42fdf8b8aa5f5ed477c72d694301273f1069c5a2f71d53c6482e99a2fc"
23+
export const claudeDockerOauthClaudeCodeVersion = "2.1.195"
1624

1725
export type ClaudeDockerOauthOptions = {
1826
readonly cwd?: string
@@ -81,23 +89,19 @@ export type ClaudeDockerProbeStatus =
8189

8290
const outputWindowSize = 262_144
8391

84-
const claudeDockerfile = String.raw`FROM ubuntu:24.04
92+
export const renderClaudeDockerOauthDockerfile = (): string =>
93+
String.raw`FROM ${claudeDockerOauthBaseImage}
8594
ENV DEBIAN_FRONTEND=noninteractive
8695
RUN apt-get update \
87-
&& apt-get install -y --no-install-recommends ca-certificates curl bsdutils \
96+
&& apt-get install -y --no-install-recommends ca-certificates bsdutils \
8897
&& rm -rf /var/lib/apt/lists/*
89-
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
90-
&& apt-get install -y --no-install-recommends nodejs \
91-
&& node -v \
98+
RUN node -v \
9299
&& npm -v \
93-
&& rm -rf /var/lib/apt/lists/*
94-
RUN npm install -g @anthropic-ai/claude-code@latest
100+
&& npm install -g --no-audit --no-fund @anthropic-ai/claude-code@${claudeDockerOauthClaudeCodeVersion} \
101+
&& claude --version
95102
ENTRYPOINT ["claude"]
96103
`
97104

98-
const redactedOauthTokenText = (text: string): string =>
99-
text.replaceAll(/sk-ant-[A-Za-z0-9._-]+/gu, "<redacted-oauth-token>")
100-
101105
const appendOutputWindow = (outputWindow: string, chunk: string): string => {
102106
const next = `${outputWindow}${chunk}`
103107
return next.length > outputWindowSize ? next.slice(-outputWindowSize) : next
@@ -138,7 +142,7 @@ const ensureClaudeDockerImage = async (
138142
}
139143
const contextPath = await mkdtemp(join(tmpdir(), "docker-git-auth-oauth-image-"))
140144
try {
141-
await writeFile(join(contextPath, "Dockerfile"), claudeDockerfile, "utf8")
145+
await writeFile(join(contextPath, "Dockerfile"), renderClaudeDockerOauthDockerfile(), "utf8")
142146
const exitCode = await runBuild({
143147
dockerCommand,
144148
args: ["build", "-t", image, contextPath],
@@ -219,17 +223,36 @@ const runDockerSetupToken = (spec: ClaudeDockerSetupTokenSpec): Promise<ClaudeDo
219223
const decoder = new TextDecoder("utf-8")
220224
let outputWindow = ""
221225
let token: string | null = null
226+
let stdoutRedactionState: ClaudeOauthTokenRedactionState = initialClaudeOauthTokenRedactionState
227+
let stderrRedactionState: ClaudeOauthTokenRedactionState = initialClaudeOauthTokenRedactionState
228+
229+
const writeOutput = (fd: 1 | 2, output: string): void => {
230+
if (output.length === 0) {
231+
return
232+
}
233+
if (fd === 2) {
234+
process.stderr.write(output)
235+
return
236+
}
237+
process.stdout.write(output)
238+
}
222239

223240
const capture = (chunk: Uint8Array, fd: 1 | 2): void => {
224241
const text = decoder.decode(chunk)
225242
outputWindow = appendOutputWindow(outputWindow, text)
226243
token = token ?? extractClaudeOauthToken(outputWindow)
227-
const output = spec.redactLiveOutput ? redactedOauthTokenText(text) : text
228-
if (fd === 2) {
229-
process.stderr.write(output)
244+
if (!spec.redactLiveOutput) {
245+
writeOutput(fd, text)
230246
return
231247
}
232-
process.stdout.write(output)
248+
const state = fd === 2 ? stderrRedactionState : stdoutRedactionState
249+
const redacted = redactClaudeOauthTokenChunk(state, text)
250+
if (fd === 2) {
251+
stderrRedactionState = redacted.state
252+
} else {
253+
stdoutRedactionState = redacted.state
254+
}
255+
writeOutput(fd, redacted.output)
233256
}
234257

235258
child.stdout?.on("data", (chunk: Uint8Array) => {
@@ -240,6 +263,10 @@ const runDockerSetupToken = (spec: ClaudeDockerSetupTokenSpec): Promise<ClaudeDo
240263
})
241264
child.on("error", reject)
242265
child.on("close", (code) => {
266+
if (spec.redactLiveOutput) {
267+
writeOutput(1, flushClaudeOauthTokenRedactionState(stdoutRedactionState))
268+
writeOutput(2, flushClaudeOauthTokenRedactionState(stderrRedactionState))
269+
}
243270
resolveResult({ exitCode: code ?? 1, token })
244271
})
245272
})
@@ -259,7 +286,7 @@ const runDockerProbe = (spec: ClaudeDockerProbeSpec): Promise<number> =>
259286
const writeCapturedToken = async (accountPath: string, token: string): Promise<void> => {
260287
const tokenPath = claudeOauthTokenPath(accountPath)
261288
await writeFile(tokenPath, formatClaudeOauthTokenFile(token), "utf8")
262-
await chmod(tokenPath, 0o600).catch(() => undefined)
289+
await chmod(tokenPath, claudeOauthTokenFileMode)
263290
}
264291

265292
const dockerProbeStatusFromExitCode = (exitCode: number): ClaudeDockerProbeStatus =>
@@ -361,7 +388,7 @@ const isDirectExecution = (): boolean => {
361388
}
362389

363390
if (isDirectExecution()) {
364-
const printToken = !process.argv.includes("--no-print-token")
391+
const printToken = process.argv.includes("--print-token")
365392
const accountPath = readFlagValue(process.argv, "--account-path")
366393
const dockerHostPath = readFlagValue(process.argv, "--docker-host-path")
367394
const image = readFlagValue(process.argv, "--image")

0 commit comments

Comments
 (0)