Skip to content

Commit 9379d52

Browse files
committed
feat(app): route host docker-git through controller api
1 parent 3a01322 commit 9379d52

File tree

82 files changed

+2776
-551
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+2776
-551
lines changed

packages/api/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
1414

1515
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json tsconfig.json ./
1616
COPY patches ./patches
17+
COPY scripts ./scripts
1718
COPY packages ./packages
1819

1920
RUN pnpm install --frozen-lockfile

packages/api/src/api/contracts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export type ApplyAllRequest = {
5454
readonly activeOnly?: boolean | undefined
5555
}
5656

57+
export type UpProjectRequest = {
58+
readonly authorizedKeysContents?: string | undefined
59+
}
60+
5761
export type ApiAuthRequired = {
5862
readonly provider: "github"
5963
readonly message: string
@@ -71,6 +75,7 @@ export type CreateProjectRequest = {
7175
readonly volumeName?: string | undefined
7276
readonly secretsRoot?: string | undefined
7377
readonly authorizedKeysPath?: string | undefined
78+
readonly authorizedKeysContents?: string | undefined
7479
readonly envGlobalPath?: string | undefined
7580
readonly envProjectPath?: string | undefined
7681
readonly codexAuthPath?: string | undefined
@@ -82,6 +87,7 @@ export type CreateProjectRequest = {
8287
readonly enableMcpPlaywright?: boolean | undefined
8388
readonly outDir?: string | undefined
8489
readonly gitTokenLabel?: string | undefined
90+
readonly skipGithubAuth?: boolean | undefined
8591
readonly codexTokenLabel?: string | undefined
8692
readonly claudeTokenLabel?: string | undefined
8793
readonly agentAutoMode?: string | undefined

packages/api/src/api/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const CreateProjectRequestSchema = Schema.Struct({
1515
volumeName: OptionalString,
1616
secretsRoot: OptionalString,
1717
authorizedKeysPath: OptionalString,
18+
authorizedKeysContents: OptionalString,
1819
envGlobalPath: OptionalString,
1920
envProjectPath: OptionalString,
2021
codexAuthPath: OptionalString,
@@ -26,6 +27,7 @@ export const CreateProjectRequestSchema = Schema.Struct({
2627
enableMcpPlaywright: OptionalBoolean,
2728
outDir: OptionalString,
2829
gitTokenLabel: OptionalString,
30+
skipGithubAuth: OptionalBoolean,
2931
codexTokenLabel: OptionalString,
3032
claudeTokenLabel: OptionalString,
3133
agentAutoMode: OptionalString,
@@ -50,6 +52,10 @@ export const ApplyAllRequestSchema = Schema.Struct({
5052
activeOnly: OptionalBoolean
5153
})
5254

55+
export const UpProjectRequestSchema = Schema.Struct({
56+
authorizedKeysContents: OptionalString
57+
})
58+
5359
export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom")
5460

5561
export const AgentEnvVarSchema = Schema.Struct({
@@ -103,5 +109,6 @@ export type CreateProjectRequestInput = Schema.Schema.Type<typeof CreateProjectR
103109
export type GithubAuthLoginRequestInput = Schema.Schema.Type<typeof GithubAuthLoginRequestSchema>
104110
export type GithubAuthLogoutRequestInput = Schema.Schema.Type<typeof GithubAuthLogoutRequestSchema>
105111
export type ApplyAllRequestInput = Schema.Schema.Type<typeof ApplyAllRequestSchema>
112+
export type UpProjectRequestInput = Schema.Schema.Type<typeof UpProjectRequestSchema>
106113
export type CreateAgentRequestInput = Schema.Schema.Type<typeof CreateAgentRequestSchema>
107114
export type CreateFollowRequestInput = Schema.Schema.Type<typeof CreateFollowRequestSchema>

packages/api/src/http.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@ import * as ParseResult from "effect/ParseResult"
1010
import * as Schema from "effect/Schema"
1111

1212
import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js"
13-
import { ApplyAllRequestSchema, CreateAgentRequestSchema, CreateFollowRequestSchema, CreateProjectRequestSchema, GithubAuthLoginRequestSchema, GithubAuthLogoutRequestSchema } from "./api/schema.js"
13+
import {
14+
ApplyAllRequestSchema,
15+
CreateAgentRequestSchema,
16+
CreateFollowRequestSchema,
17+
CreateProjectRequestSchema,
18+
GithubAuthLoginRequestSchema,
19+
GithubAuthLogoutRequestSchema,
20+
UpProjectRequestSchema
21+
} from "./api/schema.js"
1422
import { uiHtml, uiScript, uiStyles } from "./ui.js"
1523
import { loginGithubAuth, logoutGithubAuth, readGithubAuthStatus } from "./services/auth.js"
1624
import { getAgent, getAgentAttachInfo, listAgents, readAgentLogs, startAgent, stopAgent } from "./services/agents.js"
@@ -142,6 +150,10 @@ const readCreateFollowRequest = () => HttpServerRequest.schemaBodyJson(CreateFol
142150
const readGithubAuthLoginRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLoginRequestSchema)
143151
const readGithubAuthLogoutRequest = () => HttpServerRequest.schemaBodyJson(GithubAuthLogoutRequestSchema)
144152
const readApplyAllRequest = () => HttpServerRequest.schemaBodyJson(ApplyAllRequestSchema)
153+
const readUpProjectRequest = () =>
154+
HttpServerRequest.schemaBodyJson(UpProjectRequestSchema).pipe(
155+
Effect.catchAll(() => Effect.succeed({ authorizedKeysContents: undefined }))
156+
)
145157
const readInboxPayload = () => HttpServerRequest.schemaBodyJson(Schema.Unknown)
146158

147159
const configuredFederationPublicOrigin =
@@ -351,9 +363,12 @@ export const makeRouter = () => {
351363
),
352364
HttpRouter.post(
353365
"/projects/:projectId/up",
354-
projectParams.pipe(
355-
Effect.flatMap(({ projectId }) => upProject(projectId)),
356-
Effect.flatMap(() => jsonResponse({ ok: true }, 200)),
366+
Effect.gen(function*(_) {
367+
const { projectId } = yield* _(projectParams)
368+
const request = yield* _(readUpProjectRequest())
369+
yield* _(upProject(projectId, request.authorizedKeysContents))
370+
return yield* _(jsonResponse({ ok: true }, 200))
371+
}).pipe(
357372
Effect.catchAll(errorResponse)
358373
)
359374
),

packages/api/src/services/auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,18 @@ export const logoutGithubAuth = (request: GithubAuthLogoutRequest) =>
147147
export const ensureGithubAuthForCreate = (config: {
148148
readonly repoUrl: string
149149
readonly gitTokenLabel?: string | undefined
150+
readonly skipGithubAuth?: boolean | undefined
150151
readonly envGlobalPath: string
151152
}): Effect.Effect<void, ApiAuthRequiredError | PlatformError, FileSystem.FileSystem | Path.Path> =>
152153
Effect.gen(function*(_) {
153154
if (parseGithubRepoUrl(config.repoUrl) === null) {
154155
return
155156
}
156157

158+
if (config.skipGithubAuth === true) {
159+
return
160+
}
161+
157162
const fs = yield* _(FileSystem.FileSystem)
158163
const path = yield* _(Path.Path)
159164
const resolvedEnvPath = resolveControllerEnvPath(path, config.envGlobalPath)

packages/api/src/services/projects.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import {
88
readProjectConfig,
99
runDockerComposeUpWithPortCheck
1010
} from "@effect-template/lib"
11+
import * as FileSystem from "@effect/platform/FileSystem"
12+
import * as Path from "@effect/platform/Path"
1113
import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
1214
import { CommandFailedError } from "@effect-template/lib/shell/errors"
15+
import { defaultProjectsRoot, resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
1316
import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects"
1417
import type { RawOptions } from "@effect-template/lib/core/command-options"
1518
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
@@ -159,6 +162,52 @@ const resolveCreatedProject = (
159162
})
160163
)
161164

165+
const normalizeAuthorizedKeys = (value: string): ReadonlyArray<string> =>
166+
value
167+
.split(/\r?\n/u)
168+
.map((line) => line.trim())
169+
.filter((line) => line.length > 0)
170+
171+
const mergeAuthorizedKeys = (
172+
current: ReadonlyArray<string>,
173+
next: ReadonlyArray<string>
174+
): string => {
175+
const merged = [...current]
176+
for (const line of next) {
177+
if (!merged.includes(line)) {
178+
merged.push(line)
179+
}
180+
}
181+
return merged.length === 0 ? "" : `${merged.join("\n")}\n`
182+
}
183+
184+
export const seedAuthorizedKeysForCreate = (
185+
outDir: string,
186+
authorizedKeysContents: string | undefined
187+
) =>
188+
Effect.gen(function*(_) {
189+
const normalized = normalizeAuthorizedKeys(authorizedKeysContents ?? "")
190+
if (normalized.length === 0) {
191+
return
192+
}
193+
194+
const fs = yield* _(FileSystem.FileSystem)
195+
const path = yield* _(Path.Path)
196+
const defaultAuthorizedKeysPath = path.join(defaultProjectsRoot(process.cwd()), "authorized_keys")
197+
const resolvedOutDir = resolvePathFromCwd(path, process.cwd(), outDir)
198+
const projectAuthorizedKeysPath = path.join(resolvedOutDir, "authorized_keys")
199+
const targets = Array.from(new Set([defaultAuthorizedKeysPath, projectAuthorizedKeysPath]))
200+
201+
for (const target of targets) {
202+
const exists = yield* _(fs.exists(target))
203+
const current = exists ? yield* _(fs.readFileString(target)) : ""
204+
const merged = mergeAuthorizedKeys(normalizeAuthorizedKeys(current), normalized)
205+
206+
yield* _(fs.makeDirectory(path.dirname(target), { recursive: true }))
207+
yield* _(fs.writeFileString(target, merged))
208+
}
209+
})
210+
162211
export const listProjects = () =>
163212
listProjectItems.pipe(
164213
Effect.flatMap((projects) => Effect.forEach(projects, withProjectRuntime, { concurrency: "unbounded" })),
@@ -218,6 +267,7 @@ export const createProjectFromRequest = (
218267
...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }),
219268
...(request.outDir === undefined ? {} : { outDir: request.outDir }),
220269
...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }),
270+
...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }),
221271
...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }),
222272
...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }),
223273
...(request.agentAutoMode === undefined ? {} : { agentAutoMode: request.agentAutoMode }),
@@ -245,6 +295,8 @@ export const createProjectFromRequest = (
245295
waitForClone: request.waitForClone ?? parsed.right.waitForClone
246296
}
247297

298+
yield* _(seedAuthorizedKeysForCreate(command.outDir, request.authorizedKeysContents))
299+
248300
yield* _(ensureGithubAuthForCreate(command.config))
249301

250302
yield* _(
@@ -297,13 +349,89 @@ const markDeployment = (projectId: string, phase: string, message: string) =>
297349
emitProjectEvent(projectId, "project.deployment.status", { phase, message })
298350
})
299351

352+
const syncContainerAuthorizedKeys = (
353+
project: ProjectItem
354+
) =>
355+
Effect.gen(function*(_) {
356+
const path = yield* _(Path.Path)
357+
const sourcePath = path.join(project.projectDir, "authorized_keys")
358+
359+
yield* _(
360+
runCommandCapture(
361+
{
362+
cwd: project.projectDir,
363+
command: "docker",
364+
args: [
365+
"exec",
366+
project.containerName,
367+
"sh",
368+
"-c",
369+
[
370+
"set -eu",
371+
`mkdir -p /home/${project.sshUser}/.docker-git`,
372+
`mkdir -p /home/${project.sshUser}/.ssh`
373+
].join("; ")
374+
]
375+
},
376+
[0],
377+
(exitCode) => new CommandFailedError({ command: "docker exec prepare authorized_keys sync", exitCode })
378+
).pipe(Effect.asVoid)
379+
)
380+
381+
yield* _(
382+
runCommandCapture(
383+
{
384+
cwd: project.projectDir,
385+
command: "docker",
386+
args: [
387+
"cp",
388+
sourcePath,
389+
`${project.containerName}:/home/${project.sshUser}/.docker-git/authorized_keys`
390+
]
391+
},
392+
[0],
393+
(exitCode) => new CommandFailedError({ command: "docker cp authorized_keys", exitCode })
394+
).pipe(Effect.asVoid)
395+
)
396+
397+
yield* _(
398+
runCommandCapture(
399+
{
400+
cwd: project.projectDir,
401+
command: "docker",
402+
args: [
403+
"exec",
404+
project.containerName,
405+
"sh",
406+
"-c",
407+
[
408+
"set -eu",
409+
`cp /home/${project.sshUser}/.docker-git/authorized_keys /home/${project.sshUser}/.ssh/authorized_keys`,
410+
`chown ${project.sshUser}:${project.sshUser} /home/${project.sshUser}/.docker-git/authorized_keys`,
411+
`chmod 600 /home/${project.sshUser}/.docker-git/authorized_keys`,
412+
`chown ${project.sshUser}:${project.sshUser} /home/${project.sshUser}/.ssh/authorized_keys`,
413+
`chmod 600 /home/${project.sshUser}/.ssh/authorized_keys`
414+
].join("; ")
415+
]
416+
},
417+
[0],
418+
(exitCode) => new CommandFailedError({ command: "docker exec sync authorized_keys", exitCode })
419+
).pipe(Effect.asVoid)
420+
)
421+
})
422+
300423
export const upProject = (
301-
projectId: string
424+
projectId: string,
425+
authorizedKeysContents?: string
302426
) =>
303427
Effect.gen(function*(_) {
304428
const project = yield* _(findProjectById(projectId))
429+
yield* _(seedAuthorizedKeysForCreate(project.projectDir, authorizedKeysContents))
305430
yield* _(markDeployment(projectId, "build", "docker compose up -d --build"))
306431
yield* _(runDockerComposeUpWithPortCheck(project.projectDir))
432+
if ((authorizedKeysContents ?? "").trim().length > 0) {
433+
yield* _(syncContainerAuthorizedKeys(project))
434+
}
307435
yield* _(markDeployment(projectId, "running", "Container running"))
308436
})
309437

packages/api/tests/auth.test.ts

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

88
import { ApiAuthRequiredError } from "../src/api/errors.js"
9-
import { readGithubAuthStatus } from "../src/services/auth.js"
9+
import { ensureGithubAuthForCreate, readGithubAuthStatus } from "../src/services/auth.js"
1010
import { createProjectFromRequest } from "../src/services/projects.js"
1111

1212
const withTempDir = <A, E, R>(
@@ -156,4 +156,33 @@ describe("api auth", () => {
156156
expect(status.tokens[0]?.login).toBe("octocat")
157157
})
158158
).pipe(Effect.provide(NodeContext.layer)))
159+
160+
it.effect("skips API GitHub auth gate when anonymous clone override is enabled", () =>
161+
withTempDir((root) =>
162+
Effect.gen(function*(_) {
163+
const fs = yield* _(FileSystem.FileSystem)
164+
const path = yield* _(Path.Path)
165+
const projectsRoot = path.join(root, ".docker-git")
166+
const envDir = path.join(projectsRoot, ".orch", "env")
167+
const envPath = path.join(envDir, "global.env")
168+
169+
yield* _(fs.makeDirectory(envDir, { recursive: true }))
170+
yield* _(fs.writeFileString(envPath, "# docker-git env\n"))
171+
172+
yield* _(
173+
withProjectsRoot(
174+
projectsRoot,
175+
withWorkingDirectory(
176+
root,
177+
ensureGithubAuthForCreate({
178+
repoUrl: "https://github.com/ProverCoderAI/docker-git",
179+
gitTokenLabel: undefined,
180+
skipGithubAuth: true,
181+
envGlobalPath: ".docker-git/.orch/env/global.env"
182+
})
183+
)
184+
)
185+
)
186+
})
187+
).pipe(Effect.provide(NodeContext.layer)))
159188
})

0 commit comments

Comments
 (0)