Skip to content

Commit b7c949e

Browse files
committed
fix(shell): remove client sudo bootstrap and harden github auth
1 parent da15eae commit b7c949e

19 files changed

+675
-52
lines changed

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ services:
33
build:
44
context: .
55
dockerfile: packages/api/Dockerfile
6+
args:
7+
DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown}
68
container_name: docker-git-api
79
environment:
810
DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334}

packages/api/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
FROM ubuntu:24.04
22

3+
ARG DOCKER_GIT_CONTROLLER_REV=unknown
4+
LABEL io.prover-coder-ai.docker-git.controller-rev=$DOCKER_GIT_CONTROLLER_REV
5+
36
ENV DEBIAN_FRONTEND=noninteractive
7+
ENV DOCKER_GIT_CONTROLLER_REV=$DOCKER_GIT_CONTROLLER_REV
48
WORKDIR /workspace
59

610
RUN apt-get update && apt-get install -y --no-install-recommends \

packages/api/src/services/auth.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { parseGithubRepoUrl } from "@effect-template/lib/core/repo"
66
import { authGithubLogin as runGithubLogin, authGithubLogout as runGithubLogout } from "@effect-template/lib/usecases/auth-github"
77
import { readEnvText } from "@effect-template/lib/usecases/env-file"
88
import {
9+
githubRepoAccessMessage,
10+
githubRepoAccessWarning,
911
githubInvalidTokenMessage,
12+
probeGithubRepoAccess,
1013
resolveGithubCloneAuthToken
1114
} from "@effect-template/lib/usecases/github-token-preflight"
1215
import { validateGithubToken, type GithubTokenValidationResult } from "@effect-template/lib/usecases/github-token-validation"
@@ -26,7 +29,10 @@ import type {
2629
import { ApiAuthRequiredError, ApiBadRequestError } from "../api/errors.js"
2730

2831
export const githubAuthRequiredCommand = "docker-git auth github login --web"
29-
export const githubAuthRequiredMessage = "GitHub authentication is required. Run: docker-git auth github login --web"
32+
export const githubAuthRequiredMessage = [
33+
"GitHub auth is missing: no GitHub token/key was found for this repository.",
34+
"If the repository requires access, run: docker-git auth github login --web"
35+
].join("\n")
3036
export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath
3137
export const codexAuthPath = defaultTemplateConfig.codexAuthPath
3238

@@ -260,7 +266,7 @@ export const ensureGithubAuthForCreate = (config: {
260266
readonly gitTokenLabel?: string | undefined
261267
readonly skipGithubAuth?: boolean | undefined
262268
readonly envGlobalPath: string
263-
}): Effect.Effect<void, ApiAuthRequiredError | PlatformError, FileSystem.FileSystem | Path.Path> =>
269+
}): Effect.Effect<void, ApiAuthRequiredError | ApiBadRequestError | PlatformError, FileSystem.FileSystem | Path.Path> =>
264270
Effect.gen(function*(_) {
265271
if (parseGithubRepoUrl(config.repoUrl) === null) {
266272
return
@@ -284,12 +290,27 @@ export const ensureGithubAuthForCreate = (config: {
284290
}
285291

286292
const validation: GithubTokenValidationResult = yield* _(validateGithubToken(token))
287-
return yield* _(
293+
yield* _(
288294
Match.value(validation.status).pipe(
289295
Match.when("valid", () => Effect.void),
290296
Match.when("invalid", () => Effect.fail(githubAuthError(githubInvalidTokenMessage))),
291297
Match.when("unknown", () => Effect.logWarning("Unable to validate GitHub token before create; continuing.")),
292298
Match.exhaustive
293299
)
294300
)
301+
302+
const access = yield* _(probeGithubRepoAccess(config.repoUrl, token))
303+
return yield* _(
304+
Match.value(access).pipe(
305+
Match.when("accessible", () => Effect.void),
306+
Match.when("notAccessible", () =>
307+
Effect.fail(
308+
new ApiBadRequestError({
309+
message: githubRepoAccessMessage(config.repoUrl, true)
310+
})
311+
)),
312+
Match.when("unknown", () => Effect.logWarning(githubRepoAccessWarning)),
313+
Match.exhaustive
314+
)
315+
)
295316
})

packages/api/tests/auth.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"
55
import { Effect } from "effect"
66
import { vi } from "vitest"
77

8+
import { githubRepoAccessMessage } from "@effect-template/lib/usecases/github-token-preflight"
9+
810
import { ApiAuthRequiredError } from "../src/api/errors.js"
911
import {
1012
ensureGithubAuthForCreate,
@@ -47,6 +49,13 @@ const withWorkingDirectory = <A, E, R>(
4749
})
4850
)
4951

52+
const resolveFetchUrl = (input: Parameters<typeof globalThis.fetch>[0]): string =>
53+
typeof input === "string"
54+
? input
55+
: input instanceof URL
56+
? input.toString()
57+
: input.url
58+
5059
const withProjectsRoot = <A, E, R>(
5160
projectsRoot: string,
5261
effect: Effect.Effect<A, E, R>
@@ -192,6 +201,63 @@ describe("api auth", () => {
192201
})
193202
).pipe(Effect.provide(NodeContext.layer)))
194203

204+
it.effect("returns bad request when the selected GitHub token cannot access the repository", () =>
205+
withTempDir((root) =>
206+
Effect.gen(function*(_) {
207+
const fs = yield* _(FileSystem.FileSystem)
208+
const path = yield* _(Path.Path)
209+
const projectsRoot = path.join(root, ".docker-git")
210+
const envDir = path.join(projectsRoot, ".orch", "env")
211+
const envPath = path.join(envDir, "global.env")
212+
const repoUrl = "https://github.com/TestOrganization123213/openclaw_autodeployer"
213+
const fetchMock = vi.fn<typeof globalThis.fetch>((input) => {
214+
const url = resolveFetchUrl(input)
215+
216+
if (url === "https://api.github.com/user") {
217+
return Effect.runPromise(
218+
Effect.succeed(
219+
new Response(JSON.stringify({ login: "octocat" }), {
220+
status: 200,
221+
headers: {
222+
"content-type": "application/json"
223+
}
224+
})
225+
)
226+
)
227+
}
228+
229+
return Effect.runPromise(Effect.succeed(new Response(null, { status: 404 })))
230+
})
231+
232+
yield* _(fs.makeDirectory(envDir, { recursive: true }))
233+
yield* _(fs.writeFileString(envPath, "GITHUB_TOKEN=live-token\n"))
234+
235+
const failure = yield* _(
236+
withProjectsRoot(
237+
projectsRoot,
238+
withWorkingDirectory(
239+
root,
240+
withPatchedFetch(
241+
fetchMock,
242+
ensureGithubAuthForCreate({
243+
repoUrl,
244+
gitTokenLabel: undefined,
245+
skipGithubAuth: false,
246+
envGlobalPath: ".docker-git/.orch/env/global.env"
247+
}).pipe(Effect.flip)
248+
)
249+
)
250+
)
251+
)
252+
253+
expect(failure._tag).toBe("ApiBadRequestError")
254+
if (failure._tag === "ApiBadRequestError") {
255+
expect(failure.message).toBe(githubRepoAccessMessage(repoUrl, true))
256+
}
257+
expect(fetchMock).toHaveBeenCalledTimes(2)
258+
})
259+
).pipe(Effect.provide(NodeContext.layer)))
260+
195261
it.effect("imports Codex auth into the controller-owned auth directory", () =>
196262
withTempDir((root) =>
197263
Effect.gen(function*(_) {

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

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Effect } from "effect"
77
import { runCommandCapture, runCommandExitCode } from "@lib/shell/command-runner"
88

99
import { type DockerNetworkIps, parseDockerNetworkIps, uniqueStrings } from "./controller-reachability.js"
10+
import { computeLocalControllerRevision, controllerRevisionEnvKey, parseControllerRevisionEnvOutput } from "./controller-revision.js"
1011
import type { ControllerBootstrapError } from "./host-errors.js"
1112

1213
export type ControllerRuntime =
@@ -18,6 +19,7 @@ export const controllerContainerName = "docker-git-api"
1819

1920
const inspectNetworksTemplate = String
2021
.raw`{{range $k,$v := .NetworkSettings.Networks}}{{printf "%s=%s\n" $k $v.IPAddress}}{{end}}`
22+
const inspectEnvTemplate = String.raw`{{range .Config.Env}}{{println .}}{{end}}`
2123

2224
const controllerBootstrapError = (message: string): ControllerBootstrapError => ({
2325
_tag: "ControllerBootstrapError",
@@ -48,6 +50,17 @@ const composeFilePath = (): Effect.Effect<string, PlatformError, FileSystem.File
4850
const mapComposePathError = (error: PlatformError): ControllerBootstrapError =>
4951
controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`)
5052

53+
const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError =>
54+
controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`)
55+
56+
const renderDockerAccessDeniedMessage = (): string =>
57+
[
58+
"docker-git host CLI cannot access Docker from the client process.",
59+
"Client-side sudo fallback is disabled.",
60+
"Keep the docker-git backend container running and reach it via DOCKER_GIT_API_URL or the default local API port, or grant this user direct Docker access (docker group/rootless Docker).",
61+
"Probe command: docker info"
62+
].join("\n")
63+
5164
const runExitCode = (
5265
command: string,
5366
args: ReadonlyArray<string>
@@ -65,18 +78,16 @@ const runExitCode = (
6578

6679
export const resolveDockerCommand = (): Effect.Effect<
6780
ReadonlyArray<string>,
68-
never,
81+
ControllerBootstrapError,
6982
CommandExecutor.CommandExecutor
7083
> =>
71-
Effect.gen(function*(_) {
72-
const dockerInfoExit = yield* _(runExitCode("docker", ["info"]))
73-
if (dockerInfoExit === 0) {
74-
return ["docker"]
75-
}
76-
77-
const sudoDockerInfoExit = yield* _(runExitCode("sudo", ["-n", "docker", "info"]))
78-
return sudoDockerInfoExit === 0 ? ["sudo", "docker"] : ["docker"]
79-
})
84+
runExitCode("docker", ["info"]).pipe(
85+
Effect.flatMap((dockerInfoExit) =>
86+
dockerInfoExit === 0
87+
? Effect.succeed<ReadonlyArray<string>>(["docker"])
88+
: Effect.fail(controllerBootstrapError(renderDockerAccessDeniedMessage()))
89+
)
90+
)
8091

8192
type DockerInvocation = {
8293
readonly command: string
@@ -104,7 +115,7 @@ const formatDockerInvocationFailure = (
104115

105116
const runDockerExitCodeCommand = (
106117
args: ReadonlyArray<string>
107-
): Effect.Effect<number, never, ControllerRuntime> =>
118+
): Effect.Effect<number, ControllerBootstrapError, ControllerRuntime> =>
108119
Effect.gen(function*(_) {
109120
const dockerCommand = yield* _(resolveDockerCommand())
110121
const invocation = buildDockerInvocation(dockerCommand, args)
@@ -166,6 +177,37 @@ export const runCompose = (
166177
)
167178
})
168179

180+
export const controllerExists = (): Effect.Effect<boolean, ControllerBootstrapError, ControllerRuntime> =>
181+
runDockerExitCodeCommand(["inspect", controllerContainerName]).pipe(
182+
Effect.map((exitCode) => exitCode === 0)
183+
)
184+
185+
export const inspectControllerRevision = (): Effect.Effect<string | null, ControllerBootstrapError, ControllerRuntime> =>
186+
controllerExists().pipe(
187+
Effect.flatMap((exists) =>
188+
exists
189+
? runDockerCapture(
190+
["inspect", "-f", inspectEnvTemplate, controllerContainerName],
191+
`Failed to inspect env for ${controllerContainerName}`
192+
).pipe(
193+
Effect.map(parseControllerRevisionEnvOutput),
194+
Effect.orElseSucceed((): string | null => null)
195+
)
196+
: Effect.succeed<string | null>(null))
197+
)
198+
199+
export const prepareLocalControllerRevision = (): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
200+
Effect.gen(function*(_) {
201+
const composePath = yield* _(composeFilePath().pipe(Effect.mapError(mapComposePathError)))
202+
const revision = yield* _(computeLocalControllerRevision(composePath).pipe(Effect.mapError(mapControllerRevisionError)))
203+
yield* _(
204+
Effect.sync(() => {
205+
process.env[controllerRevisionEnvKey] = revision
206+
})
207+
)
208+
return revision
209+
})
210+
169211
export const inspectContainerNetworks = (
170212
containerName: string
171213
): Effect.Effect<DockerNetworkIps, never, ControllerRuntime> =>
@@ -197,7 +239,10 @@ const connectControllerToNetworkBestEffort = (
197239
return Effect.void
198240
}
199241

200-
return runDockerExitCodeCommand(["network", "connect", trimmed, controllerContainerName]).pipe(Effect.asVoid)
242+
return runDockerExitCodeCommand(["network", "connect", trimmed, controllerContainerName]).pipe(
243+
Effect.asVoid,
244+
Effect.orElseSucceed(() => undefined)
245+
)
201246
}
202247

203248
export const ensureControllerReachabilityNetworks = (

0 commit comments

Comments
 (0)