Skip to content

Commit 8ac62fa

Browse files
committed
fix(ci): restore api-only auth and e2e flows
1 parent 9379d52 commit 8ac62fa

31 files changed

+743
-235
lines changed

packages/api/src/api/contracts.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ export type GithubAuthLogoutRequest = {
5050
readonly label?: string | null | undefined
5151
}
5252

53+
export type CodexAuthImportRequest = {
54+
readonly label?: string | null | undefined
55+
readonly authText: string
56+
}
57+
58+
export type CodexAuthStatus = {
59+
readonly label: string
60+
readonly message: string
61+
readonly present: boolean
62+
readonly authPath: string
63+
}
64+
65+
export type CodexAuthLogoutRequest = {
66+
readonly label?: string | null | undefined
67+
}
68+
5369
export type ApplyAllRequest = {
5470
readonly activeOnly?: boolean | undefined
5571
}

packages/api/src/api/schema.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export const GithubAuthLogoutRequestSchema = Schema.Struct({
4848
label: OptionalNullableString
4949
})
5050

51+
export const CodexAuthImportRequestSchema = Schema.Struct({
52+
label: OptionalNullableString,
53+
authText: Schema.String
54+
})
55+
56+
export const CodexAuthLogoutRequestSchema = Schema.Struct({
57+
label: OptionalNullableString
58+
})
59+
5160
export const ApplyAllRequestSchema = Schema.Struct({
5261
activeOnly: OptionalBoolean
5362
})
@@ -108,6 +117,8 @@ export const AgentLogLineSchema = Schema.Struct({
108117
export type CreateProjectRequestInput = Schema.Schema.Type<typeof CreateProjectRequestSchema>
109118
export type GithubAuthLoginRequestInput = Schema.Schema.Type<typeof GithubAuthLoginRequestSchema>
110119
export type GithubAuthLogoutRequestInput = Schema.Schema.Type<typeof GithubAuthLogoutRequestSchema>
120+
export type CodexAuthImportRequestInput = Schema.Schema.Type<typeof CodexAuthImportRequestSchema>
121+
export type CodexAuthLogoutRequestInput = Schema.Schema.Type<typeof CodexAuthLogoutRequestSchema>
111122
export type ApplyAllRequestInput = Schema.Schema.Type<typeof ApplyAllRequestSchema>
112123
export type UpProjectRequestInput = Schema.Schema.Type<typeof UpProjectRequestSchema>
113124
export type CreateAgentRequestInput = Schema.Schema.Type<typeof CreateAgentRequestSchema>

packages/api/src/http.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import * as Schema from "effect/Schema"
1212
import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js"
1313
import {
1414
ApplyAllRequestSchema,
15+
CodexAuthImportRequestSchema,
16+
CodexAuthLogoutRequestSchema,
1517
CreateAgentRequestSchema,
1618
CreateFollowRequestSchema,
1719
CreateProjectRequestSchema,
@@ -20,7 +22,14 @@ import {
2022
UpProjectRequestSchema
2123
} from "./api/schema.js"
2224
import { uiHtml, uiScript, uiStyles } from "./ui.js"
23-
import { loginGithubAuth, logoutGithubAuth, readGithubAuthStatus } from "./services/auth.js"
25+
import {
26+
importCodexAuth,
27+
loginGithubAuth,
28+
logoutCodexAuth,
29+
logoutGithubAuth,
30+
readCodexAuthStatus,
31+
readGithubAuthStatus
32+
} from "./services/auth.js"
2433
import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js"
2534
import { latestProjectCursor, listProjectEventsSince } from "./services/events.js"
2635
import {
@@ -149,6 +158,8 @@ const readCreateProjectRequest = () => HttpServerRequest.schemaBodyJson(CreatePr
149158
const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFollowRequestSchema)
150159
const readGithubAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLoginRequestSchema)
151160
const readGithubAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLogoutRequestSchema)
161+
const readCodexAuthImportRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthImportRequestSchema)
162+
const readCodexAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(CodexAuthLogoutRequestSchema)
152163
const readApplyAllRequest = () => HttpServerRequest.schemaBodyJson(ApplyAllRequestSchema)
153164
const readUpProjectRequest = () =>
154165
HttpServerRequest.schemaBodyJson(UpProjectRequestSchema).pipe(
@@ -240,6 +251,31 @@ export const makeRouter = () => {
240251
return yield* _(jsonResponse({ ok: true, status }, 200))
241252
}).pipe(Effect.catchAll(errorResponse))
242253
),
254+
HttpRouter.get(
255+
"/auth/codex/status",
256+
Effect.gen(function*(_) {
257+
const request = yield* _(HttpServerRequest.HttpServerRequest)
258+
const label = new URL(request.url, "http://localhost").searchParams.get("label")
259+
const status = yield* _(readCodexAuthStatus(label))
260+
return yield* _(jsonResponse({ status }, 200))
261+
}).pipe(Effect.catchAll(errorResponse))
262+
),
263+
HttpRouter.post(
264+
"/auth/codex/import",
265+
Effect.gen(function*(_) {
266+
const request = yield* _(readCodexAuthImportRequest())
267+
const status = yield* _(importCodexAuth(request))
268+
return yield* _(jsonResponse({ ok: true, status }, 201))
269+
}).pipe(Effect.catchAll(errorResponse))
270+
),
271+
HttpRouter.post(
272+
"/auth/codex/logout",
273+
Effect.gen(function*(_) {
274+
const request = yield* _(readCodexAuthLogoutRequest())
275+
const status = yield* _(logoutCodexAuth(request))
276+
return yield* _(jsonResponse({ ok: true, status }, 200))
277+
}).pipe(Effect.catchAll(errorResponse))
278+
),
243279
HttpRouter.get(
244280
"/federation/issues",
245281
Effect.sync(() => ({ issues: listFederationIssues() })).pipe(

packages/api/src/services/auth.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,25 @@ import {
1010
resolveGithubCloneAuthToken
1111
} from "@effect-template/lib/usecases/github-token-preflight"
1212
import { validateGithubToken, type GithubTokenValidationResult } from "@effect-template/lib/usecases/github-token-validation"
13+
import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers"
1314
import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
1415
import { Effect, Match } from "effect"
1516

1617
import type {
18+
CodexAuthImportRequest,
19+
CodexAuthLogoutRequest,
20+
CodexAuthStatus,
1721
GithubAuthLoginRequest,
1822
GithubAuthLogoutRequest,
1923
GithubAuthStatus,
2024
GithubAuthTokenStatus
2125
} from "../api/contracts.js"
22-
import { ApiAuthRequiredError } from "../api/errors.js"
26+
import { ApiAuthRequiredError, ApiBadRequestError } from "../api/errors.js"
2327

2428
export const githubAuthRequiredCommand = "docker-git auth github login --web"
2529
export const githubAuthRequiredMessage = "GitHub authentication is required. Run: docker-git auth github login --web"
2630
export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath
31+
export const codexAuthPath = defaultTemplateConfig.codexAuthPath
2732

2833
const githubTokenKey = "GITHUB_TOKEN"
2934
const githubTokenPrefix = "GITHUB_TOKEN__"
@@ -90,6 +95,29 @@ const resolveControllerEnvPath = (
9095
): string =>
9196
resolvePathFromCwd(path, process.cwd(), envGlobalPath)
9297

98+
const resolveControllerCodexPath = (path: Path.Path, authPath: string): string =>
99+
resolvePathFromCwd(path, process.cwd(), authPath)
100+
101+
const resolveCodexLabel = (label: string | null | undefined): string =>
102+
normalizeAccountLabel(label ?? null, "default")
103+
104+
const resolveCodexAccountPath = (
105+
path: Path.Path,
106+
authPath: string,
107+
label: string | null | undefined
108+
): string => {
109+
const basePath = resolveControllerCodexPath(path, authPath)
110+
const normalizedLabel = resolveCodexLabel(label)
111+
return normalizedLabel === "default" ? basePath : path.join(basePath, normalizedLabel)
112+
}
113+
114+
const resolveCodexAuthFilePath = (
115+
path: Path.Path,
116+
authPath: string,
117+
label: string | null | undefined
118+
): string =>
119+
path.join(resolveCodexAccountPath(path, authPath, label), "auth.json")
120+
93121
const readGithubAuthTokens = (
94122
envGlobalPath: string
95123
): Effect.Effect<GithubAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
@@ -144,6 +172,89 @@ export const logoutGithubAuth = (request: GithubAuthLogoutRequest) =>
144172
return yield* _(readGithubAuthTokens(githubAuthEnvGlobalPath))
145173
})
146174

175+
const codexAuthStatus = (
176+
present: boolean,
177+
label: string,
178+
authPath: string
179+
): CodexAuthStatus => ({
180+
label,
181+
message: present
182+
? "Codex auth imported into controller state."
183+
: "Codex auth not found in controller state.",
184+
present,
185+
authPath
186+
})
187+
188+
export const readCodexAuthStatus = (
189+
label?: string | null | undefined
190+
): Effect.Effect<CodexAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
191+
Effect.gen(function*(_) {
192+
const fs = yield* _(FileSystem.FileSystem)
193+
const path = yield* _(Path.Path)
194+
const resolvedLabel = resolveCodexLabel(label)
195+
const resolvedAuthPath = resolveCodexAuthFilePath(path, codexAuthPath, label)
196+
const exists = yield* _(fs.exists(resolvedAuthPath))
197+
if (!exists) {
198+
return codexAuthStatus(false, resolvedLabel, resolvedAuthPath)
199+
}
200+
201+
const info = yield* _(fs.stat(resolvedAuthPath))
202+
if (info.type !== "File") {
203+
return codexAuthStatus(false, resolvedLabel, resolvedAuthPath)
204+
}
205+
206+
return codexAuthStatus(true, resolvedLabel, resolvedAuthPath)
207+
})
208+
209+
export const importCodexAuth = (
210+
request: CodexAuthImportRequest
211+
): Effect.Effect<CodexAuthStatus, ApiBadRequestError | PlatformError, FileSystem.FileSystem | Path.Path> =>
212+
Effect.gen(function*(_) {
213+
const fs = yield* _(FileSystem.FileSystem)
214+
const path = yield* _(Path.Path)
215+
const resolvedAuthPath = resolveCodexAuthFilePath(path, codexAuthPath, request.label)
216+
const parsed = yield* _(
217+
Effect.try({
218+
try: () => JSON.parse(request.authText),
219+
catch: (cause) =>
220+
new ApiBadRequestError({
221+
message: "Invalid Codex auth JSON.",
222+
details: cause
223+
})
224+
})
225+
)
226+
227+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
228+
return yield* _(
229+
Effect.fail(
230+
new ApiBadRequestError({
231+
message: "Codex auth JSON must be an object."
232+
})
233+
)
234+
)
235+
}
236+
237+
yield* _(fs.makeDirectory(path.dirname(resolvedAuthPath), { recursive: true }))
238+
yield* _(fs.writeFileString(resolvedAuthPath, JSON.stringify(parsed, null, 2)))
239+
return yield* _(readCodexAuthStatus(request.label))
240+
})
241+
242+
export const logoutCodexAuth = (
243+
request: CodexAuthLogoutRequest
244+
): Effect.Effect<CodexAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
245+
Effect.gen(function*(_) {
246+
const fs = yield* _(FileSystem.FileSystem)
247+
const path = yield* _(Path.Path)
248+
const resolvedAuthPath = resolveCodexAuthFilePath(path, codexAuthPath, request.label)
249+
const exists = yield* _(fs.exists(resolvedAuthPath))
250+
251+
if (exists) {
252+
yield* _(fs.remove(resolvedAuthPath))
253+
}
254+
255+
return yield* _(readCodexAuthStatus(request.label))
256+
})
257+
147258
export const ensureGithubAuthForCreate = (config: {
148259
readonly repoUrl: string
149260
readonly gitTokenLabel?: string | undefined

packages/api/tests/auth.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { Effect } from "effect"
66
import { vi } from "vitest"
77

88
import { ApiAuthRequiredError } from "../src/api/errors.js"
9-
import { ensureGithubAuthForCreate, readGithubAuthStatus } from "../src/services/auth.js"
9+
import {
10+
ensureGithubAuthForCreate,
11+
importCodexAuth,
12+
logoutCodexAuth,
13+
readCodexAuthStatus,
14+
readGithubAuthStatus
15+
} from "../src/services/auth.js"
1016
import { createProjectFromRequest } from "../src/services/projects.js"
1117

1218
const withTempDir = <A, E, R>(
@@ -185,4 +191,75 @@ describe("api auth", () => {
185191
)
186192
})
187193
).pipe(Effect.provide(NodeContext.layer)))
194+
195+
it.effect("imports Codex auth into the controller-owned auth directory", () =>
196+
withTempDir((root) =>
197+
Effect.gen(function*(_) {
198+
const fs = yield* _(FileSystem.FileSystem)
199+
const path = yield* _(Path.Path)
200+
const projectsRoot = path.join(root, ".docker-git")
201+
const authDir = path.join(projectsRoot, ".orch", "auth", "codex")
202+
const authText = JSON.stringify({ openai: { type: "oauth", refresh: "refresh", access: "access" } }, null, 2)
203+
204+
yield* _(fs.makeDirectory(projectsRoot, { recursive: true }))
205+
206+
const status = yield* _(
207+
withProjectsRoot(
208+
projectsRoot,
209+
withWorkingDirectory(
210+
root,
211+
importCodexAuth({ authText })
212+
)
213+
)
214+
)
215+
216+
expect(status.present).toBe(true)
217+
expect(status.authPath).toBe(path.join(authDir, "auth.json"))
218+
expect(status.message).toBe("Codex auth imported into controller state.")
219+
220+
const fileText = yield* _(fs.readFileString(path.join(authDir, "auth.json")))
221+
expect(fileText).toContain('"refresh": "refresh"')
222+
223+
const readStatus = yield* _(
224+
withProjectsRoot(
225+
projectsRoot,
226+
withWorkingDirectory(root, readCodexAuthStatus())
227+
)
228+
)
229+
230+
expect(readStatus.present).toBe(true)
231+
expect(readStatus.authPath).toBe(path.join(authDir, "auth.json"))
232+
})
233+
).pipe(Effect.provide(NodeContext.layer)))
234+
235+
it.effect("removes labeled Codex auth from controller state", () =>
236+
withTempDir((root) =>
237+
Effect.gen(function*(_) {
238+
const path = yield* _(Path.Path)
239+
const projectsRoot = path.join(root, ".docker-git")
240+
const labeledAuthDir = path.join(projectsRoot, ".orch", "auth", "codex", "team-a")
241+
const authText = JSON.stringify({ tokens: { access_token: "access", refresh_token: "refresh" } }, null, 2)
242+
243+
yield* _(
244+
withProjectsRoot(
245+
projectsRoot,
246+
withWorkingDirectory(
247+
root,
248+
importCodexAuth({ label: "team-a", authText })
249+
)
250+
)
251+
)
252+
253+
const removed = yield* _(
254+
withProjectsRoot(
255+
projectsRoot,
256+
withWorkingDirectory(root, logoutCodexAuth({ label: "team-a" }))
257+
)
258+
)
259+
260+
expect(removed.present).toBe(false)
261+
expect(removed.label).toBe("team-a")
262+
expect(removed.authPath).toBe(path.join(labeledAuthDir, "auth.json"))
263+
})
264+
).pipe(Effect.provide(NodeContext.layer)))
188265
})

0 commit comments

Comments
 (0)