Skip to content

Commit 0e81cfb

Browse files
authored
Merge pull request #219 from ProverCoderAI/codex/ci-e2e-ssh-checks
feat(web): require GitHub auth before actions
2 parents fd00336 + a5345a8 commit 0e81cfb

22 files changed

Lines changed: 695 additions & 141 deletions

packages/api/src/services/auth-github-login-stream.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
1212
import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
1313
import { ensureStateDotDockerGitRepo } from "@effect-template/lib/usecases/state-repo-github"
1414
import { migrateLegacyOrchLayout } from "@effect-template/lib/usecases/auth-sync"
15-
import { Effect, Runtime } from "effect"
15+
import { Effect, Logger, Runtime } from "effect"
1616
import * as Stream from "effect/Stream"
1717
import { spawn, type ChildProcess } from "node:child_process"
1818

@@ -188,6 +188,20 @@ const finalizeMessage = (status: string): string =>
188188
? `\nGitHub login completed.\n${githubLoginStreamSuccessMarker}\n`
189189
: `\n${githubLoginStreamErrorMarkerPrefix}${status}\n`
190190

191+
const normalizeCapturedLogLines = (lines: ReadonlyArray<string>): ReadonlyArray<string> =>
192+
lines
193+
.map((line) => line.trim())
194+
.filter((line) => line.length > 0)
195+
196+
export const renderGithubPostLoginOutput = (
197+
lines: ReadonlyArray<string>,
198+
status: string
199+
): string => {
200+
const output = normalizeCapturedLogLines(lines).join("\n")
201+
const logBlock = output.length === 0 ? "" : `\n${output}\n`
202+
return `${logBlock}${finalizeMessage(status)}`
203+
}
204+
191205
const toStreamError = (error: unknown): ApiInternalError | ApiBadRequestError =>
192206
error instanceof ApiBadRequestError || error instanceof ApiInternalError
193207
? error
@@ -252,17 +266,25 @@ export const streamGithubAuthLogin = (
252266
return
253267
}
254268

269+
const postLoginLogs: Array<string> = []
270+
const logger = Logger.make(({ message }) => {
271+
postLoginLogs.push(String(message))
272+
})
273+
255274
void runPromiseExit(
256275
finalizeGithubLogin(prepared).pipe(
276+
Effect.provide(Logger.replace(Logger.defaultLogger, logger)),
257277
Effect.matchEffect({
258278
onFailure: (error) =>
259279
Effect.sync(() => {
260-
enqueue(`\nGitHub login finished in browser, but post-login sync failed: ${error.message}\n`)
261-
enqueue(finalizeMessage("post-login"))
280+
enqueue(renderGithubPostLoginOutput([
281+
...postLoginLogs,
282+
`GitHub login finished in browser, but post-login sync failed: ${error.message}`
283+
], "post-login"))
262284
}),
263285
onSuccess: () =>
264286
Effect.sync(() => {
265-
enqueue(finalizeMessage("ok"))
287+
enqueue(renderGithubPostLoginOutput(postLoginLogs, "ok"))
266288
})
267289
})
268290
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { renderGithubPostLoginOutput } from "../src/services/auth-github-login-stream.js"
4+
5+
describe("GitHub auth login stream", () => {
6+
it("renders post-login state logs before the success marker", () => {
7+
const output = renderGithubPostLoginOutput([
8+
"Initializing state repository: https://github.com/octocat/.docker-git.git",
9+
"State dir ready: /home/dev/.docker-git"
10+
], "ok")
11+
12+
expect(output).toContain("Initializing state repository")
13+
expect(output).toContain("State dir ready")
14+
expect(output).toContain("GitHub login completed.")
15+
expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok")
16+
expect(output.indexOf("State dir ready")).toBeLessThan(output.indexOf("GitHub login completed."))
17+
})
18+
19+
it("renders post-login failure details before the failure marker", () => {
20+
const output = renderGithubPostLoginOutput([
21+
"GitHub login finished in browser, but post-login sync failed: git fetch failed"
22+
], "post-login")
23+
24+
expect(output).toContain("post-login sync failed")
25+
expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:post-login")
26+
expect(output.indexOf("post-login sync failed")).toBeLessThan(output.indexOf("__DOCKER_GIT_GITHUB_LOGIN_STATUS__"))
27+
})
28+
})

packages/app/src/docker-git/api-client-auth.ts

Lines changed: 21 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import * as FsPlatform from "@effect/platform/FileSystem"
22
import * as PathPlatform from "@effect/platform/Path"
33
import { Effect } from "effect"
44

5+
import {
6+
authStreamMarkerExitCode,
7+
type AuthStreamMarkers,
8+
authStreamSucceeded,
9+
authStreamVisibleLines,
10+
codexLoginStreamMarkers,
11+
githubLoginFailureMessage,
12+
githubLoginStreamMarkers,
13+
makeVisibleAuthStreamWriter
14+
} from "../shared/auth-stream-markers.js"
515
import { request, requestTextStream, requestVoid } from "./api-http.js"
616
import { asObject, type JsonRequest, type JsonValue } from "./api-json.js"
717
import type { ControllerRuntime } from "./controller.js"
@@ -17,75 +27,18 @@ import type {
1727
import { resolvePathFromCwd } from "./frontend-lib/usecases/path-helpers.js"
1828
import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js"
1929

20-
type StreamMarkers = {
21-
readonly success: string
22-
readonly errorPrefix: string
23-
}
24-
25-
const codexLoginMarkers: StreamMarkers = {
26-
success: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok",
27-
errorPrefix: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:error:"
28-
}
29-
30-
const githubLoginMarkers: StreamMarkers = {
31-
success: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok",
32-
errorPrefix: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:"
33-
}
34-
35-
const isMarkerLine = (line: string, markers: StreamMarkers): boolean =>
36-
line.startsWith(markers.success) || line.startsWith(markers.errorPrefix)
37-
38-
const visibleLines = (output: string, markers: StreamMarkers): ReadonlyArray<string> =>
39-
output
40-
.split(/\r?\n/u)
41-
.map((line) => line.trim())
42-
.filter((line) => line.length > 0 && !isMarkerLine(line, markers))
43-
44-
const markerExitCode = (output: string, markers: StreamMarkers): string | null => {
45-
const failureLine = output
46-
.split(/\r?\n/u)
47-
.find((line) => line.startsWith(markers.errorPrefix))
48-
49-
return failureLine === undefined
50-
? null
51-
: failureLine.slice(markers.errorPrefix.length)
52-
}
53-
54-
const makeVisibleChunkWriter = (markers: StreamMarkers) => {
55-
let pending = ""
56-
const flushVisiblePending = () => {
57-
if (pending.length > 0 && !isMarkerLine(pending, markers)) {
58-
process.stdout.write(pending)
59-
}
60-
}
61-
62-
const writeVisibleChunk = (chunk: string) => {
63-
pending += chunk
64-
const lines = pending.split("\n")
65-
pending = lines.pop() ?? ""
66-
67-
for (const line of lines) {
68-
if (!isMarkerLine(line, markers)) {
69-
process.stdout.write(`${line}\n`)
70-
}
71-
}
72-
}
73-
74-
return { flushVisiblePending, writeVisibleChunk }
75-
}
76-
7730
const codexLoginFailureMessage = (output: string, exitCode: string | null): string => {
7831
if (output.includes("429 Too Many Requests")) {
7932
return "Codex device auth is rate-limited by OpenAI (429 Too Many Requests). Wait a few minutes and retry."
8033
}
8134

82-
const detailedLine = visibleLines(output, codexLoginMarkers)
35+
const detailedLine = authStreamVisibleLines(output, codexLoginStreamMarkers)
8336
.findLast((line) => line.toLowerCase().includes("error"))
8437
if (detailedLine !== undefined) {
8538
return detailedLine
8639
}
8740

88-
const lastLine = visibleLines(output, codexLoginMarkers).at(-1)
41+
const lastLine = authStreamVisibleLines(output, codexLoginStreamMarkers).at(-1)
8942
if (lastLine !== undefined) {
9043
return lastLine
9144
}
@@ -95,23 +48,6 @@ const codexLoginFailureMessage = (output: string, exitCode: string | null): stri
9548
: `Codex login failed (${exitCode}).`
9649
}
9750

98-
const githubLoginFailureMessage = (output: string, exitCode: string | null): string => {
99-
const detailedLine = visibleLines(output, githubLoginMarkers)
100-
.findLast((line) => line.toLowerCase().includes("failed") || line.toLowerCase().includes("error"))
101-
if (detailedLine !== undefined) {
102-
return detailedLine
103-
}
104-
105-
const lastLine = visibleLines(output, githubLoginMarkers).at(-1)
106-
if (lastLine !== undefined) {
107-
return lastLine
108-
}
109-
110-
return exitCode === null
111-
? "GitHub login stream ended without a completion marker."
112-
: `GitHub login failed (${exitCode}).`
113-
}
114-
11551
const streamFailure = (
11652
method: "POST",
11753
path: string,
@@ -127,21 +63,23 @@ const streamFailure = (
12763
const requestMarkedAuthStream = (
12864
path: string,
12965
body: JsonRequest,
130-
markers: StreamMarkers,
66+
markers: AuthStreamMarkers,
13167
failureMessage: (output: string, exitCode: string | null) => string
13268
) =>
13369
Effect.gen(function*(_) {
134-
const writer = makeVisibleChunkWriter(markers)
135-
const output = yield* _(requestTextStream("POST", path, body, writer.writeVisibleChunk))
70+
const writer = makeVisibleAuthStreamWriter(markers, (chunk) => {
71+
process.stdout.write(chunk)
72+
})
73+
const output = yield* _(requestTextStream("POST", path, body, writer.writeChunk))
13674
writer.flushVisiblePending()
13775

138-
if (output.includes(markers.success)) {
76+
if (authStreamSucceeded(output, markers)) {
13977
return output
14078
}
14179

14280
return yield* _(
14381
Effect.fail<ApiRequestError>(
144-
streamFailure("POST", path, failureMessage(output, markerExitCode(output, markers)))
82+
streamFailure("POST", path, failureMessage(output, authStreamMarkerExitCode(output, markers)))
14583
)
14684
)
14785
})
@@ -164,7 +102,7 @@ const githubWebLogin = (
164102
token: null,
165103
scopes: command.scopes
166104
},
167-
githubLoginMarkers,
105+
githubLoginStreamMarkers,
168106
githubLoginFailureMessage
169107
).pipe(
170108
Effect.flatMap(() => request("GET", "/auth/github/status")),
@@ -193,7 +131,7 @@ export const codexLogin = (command: AuthCodexLoginCommand) =>
193131
requestMarkedAuthStream(
194132
"/auth/codex/login",
195133
{ label: command.label },
196-
codexLoginMarkers,
134+
codexLoginStreamMarkers,
197135
codexLoginFailureMessage
198136
).pipe(Effect.asVoid)
199137

packages/app/src/docker-git/api-http.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { FetchHttpClient, HttpBody, HttpClient, HttpClientResponse } from "@effect/platform"
1+
import type { HttpClientResponse } from "@effect/platform"
2+
import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"
23
import type * as HttpClientError from "@effect/platform/HttpClientError"
34
import { Effect } from "effect"
4-
import * as Stream from "effect/Stream"
55

6+
import { readHttpResponseTextStream } from "../shared/http-response-stream.js"
67
import { asObject, asString, type JsonRequest, type JsonValue, parseResponseBody } from "./api-json.js"
78
import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js"
89
import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js"
@@ -202,19 +203,6 @@ export const request = (
202203
export const requestVoid = (method: ApiHttpMethod, path: string, body?: JsonRequest) =>
203204
request(method, path, body).pipe(Effect.asVoid)
204205

205-
const readResponseTextStream = (
206-
response: HttpClientResponse.HttpClientResponse,
207-
onChunk: (chunk: string) => void
208-
) =>
209-
HttpClientResponse.stream(Effect.succeed(response)).pipe(
210-
Stream.decodeText(),
211-
Stream.runFoldEffect("", (output, chunk) =>
212-
Effect.sync(() => {
213-
onChunk(chunk)
214-
return output + chunk
215-
}))
216-
)
217-
218206
export const requestTextStream = (
219207
method: ApiHttpMethod,
220208
path: string,
@@ -230,5 +218,5 @@ export const requestTextStream = (
230218
return yield* _(Effect.fail(toRequestError(method, path, response.status, parsed)))
231219
}
232220

233-
return yield* _(readResponseTextStream(response, onChunk))
221+
return yield* _(readHttpResponseTextStream(response, onChunk))
234222
}).pipe(Effect.provide(FetchHttpClient.layer), mapTransportError(method, path))
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
export type AuthStreamMarkers = {
2+
readonly success: string
3+
readonly errorPrefix: string
4+
}
5+
6+
export const codexLoginStreamMarkers: AuthStreamMarkers = {
7+
success: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok",
8+
errorPrefix: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:error:"
9+
}
10+
11+
export const githubLoginStreamMarkers: AuthStreamMarkers = {
12+
success: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok",
13+
errorPrefix: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:"
14+
}
15+
16+
export const isAuthStreamMarkerLine = (line: string, markers: AuthStreamMarkers): boolean =>
17+
line.startsWith(markers.success) || line.startsWith(markers.errorPrefix)
18+
19+
export const authStreamVisibleLines = (
20+
output: string,
21+
markers: AuthStreamMarkers
22+
): ReadonlyArray<string> =>
23+
output
24+
.split(/\r?\n/u)
25+
.map((line) => line.trim())
26+
.filter((line) => line.length > 0 && !isAuthStreamMarkerLine(line, markers))
27+
28+
export const authStreamMarkerExitCode = (output: string, markers: AuthStreamMarkers): string | null => {
29+
const failureLine = output
30+
.split(/\r?\n/u)
31+
.find((line) => line.startsWith(markers.errorPrefix))
32+
33+
return failureLine === undefined
34+
? null
35+
: failureLine.slice(markers.errorPrefix.length)
36+
}
37+
38+
export const authStreamSucceeded = (output: string, markers: AuthStreamMarkers): boolean =>
39+
output.includes(markers.success)
40+
41+
export const githubLoginFailureMessage = (output: string, exitCode: string | null): string => {
42+
const detailedLine = authStreamVisibleLines(output, githubLoginStreamMarkers)
43+
.findLast((line) => line.toLowerCase().includes("failed") || line.toLowerCase().includes("error"))
44+
if (detailedLine !== undefined) {
45+
return detailedLine
46+
}
47+
48+
const lastLine = authStreamVisibleLines(output, githubLoginStreamMarkers).at(-1)
49+
if (lastLine !== undefined) {
50+
return lastLine
51+
}
52+
53+
return exitCode === null
54+
? "GitHub login stream ended without a completion marker."
55+
: `GitHub login failed (${exitCode}).`
56+
}
57+
58+
export const makeVisibleAuthStreamWriter = (
59+
markers: AuthStreamMarkers,
60+
writeVisibleChunk: (chunk: string) => void
61+
) => {
62+
let pending = ""
63+
const flushVisiblePending = () => {
64+
if (pending.length > 0 && !isAuthStreamMarkerLine(pending, markers)) {
65+
writeVisibleChunk(pending)
66+
}
67+
pending = ""
68+
}
69+
70+
const writeChunk = (chunk: string) => {
71+
pending += chunk
72+
const lines = pending.split("\n")
73+
pending = lines.pop() ?? ""
74+
75+
for (const line of lines) {
76+
if (!isAuthStreamMarkerLine(line, markers)) {
77+
writeVisibleChunk(`${line}\n`)
78+
}
79+
}
80+
}
81+
82+
return { flushVisiblePending, writeChunk }
83+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { HttpClientResponse } from "@effect/platform"
2+
import { Effect } from "effect"
3+
import * as Stream from "effect/Stream"
4+
5+
export const readHttpResponseTextStream = (
6+
response: HttpClientResponse.HttpClientResponse,
7+
onChunk: (chunk: string) => void
8+
) =>
9+
HttpClientResponse.stream(Effect.succeed(response)).pipe(
10+
Stream.decodeText(),
11+
Stream.runFoldEffect("", (output, chunk) =>
12+
Effect.sync(() => {
13+
onChunk(chunk)
14+
return output + chunk
15+
}))
16+
)

0 commit comments

Comments
 (0)