Skip to content

Commit 7f4cb2c

Browse files
committed
fix(shell): harden auth snapshots and isolate compose projects
1 parent 40d0b1e commit 7f4cb2c

22 files changed

Lines changed: 582 additions & 211 deletions

packages/app/src/lib/core/domain.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,19 @@ export const resolveComposeNetworkName = (
268268
export const resolveProjectBootstrapVolumeName = (
269269
config: Pick<TemplateConfig, "volumeName">
270270
): string => `${config.volumeName}-bootstrap`
271+
272+
// CHANGE: derive a stable docker compose project name from typed template config
273+
// WHY: managed project lifecycle must not depend on the output directory basename, otherwise "docker compose down -v"
274+
// can target an unrelated stack that happens to share the same folder name (for example the controller repo itself).
275+
// QUOTE(ТЗ): "Весь процесс должен быть высроен так что основной бекенд крутится в docker"
276+
// REF: user-request-2026-04-03-compose-project-isolation
277+
// SOURCE: n/a
278+
// FORMAT THEOREM: ∀cfg: resolveComposeProjectName(cfg) = p -> deterministic(p) ∧ isolated_compose_project(p)
279+
// PURITY: CORE
280+
// EFFECT: n/a
281+
// INVARIANT: compose project identity is derived solely from serviceName, never cwd basename
282+
// COMPLEXITY: O(1)
283+
export const resolveComposeProjectName = (
284+
config: Pick<TemplateConfig, "serviceName">
285+
): string => config.serviceName
271286
/* jscpd:ignore-end */

packages/app/src/lib/core/templates/docker-compose.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* jscpd:ignore-start */
22
import {
3+
resolveComposeProjectName,
34
dockerGitSharedCacheVolumeName,
45
dockerGitSharedCodexVolumeName,
56
resolveComposeNetworkName,
@@ -213,6 +214,7 @@ export const renderDockerCompose = (
213214
): string => {
214215
const fragments = buildComposeFragments(config, resourceLimits)
215216
return [
217+
`name: ${resolveComposeProjectName(config)}`,
216218
renderComposeServices(config, fragments, resourceLimits),
217219
renderComposeNetworks(fragments.networkMode, fragments.networkName),
218220
renderComposeVolumes(config, fragments.maybeBrowserVolume)

packages/app/src/lib/usecases/actions/prepare-files.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
resolveAuthorizedKeysPath
2222
} from "../path-helpers.js"
2323
import { withFsPathContext } from "../runtime.js"
24+
import { writeFileStringEnsuringParent } from "../volatile-files.js"
2425
import { resolvePathFromBase } from "./paths.js"
2526

2627
type ExistingFileState = "exists" | "missing"
@@ -49,6 +50,7 @@ const ensureFileReady = (
4950

5051
const appendKeyIfMissing = (
5152
fs: FileSystem.FileSystem,
53+
path: Path.Path,
5254
resolved: string,
5355
source: string,
5456
desiredContents: string
@@ -69,7 +71,7 @@ const appendKeyIfMissing = (
6971
? `${desiredContents}\n`
7072
: `${normalizedCurrent}\n${desiredContents}\n`
7173

72-
yield* _(fs.writeFileString(resolved, nextContents))
74+
yield* _(writeFileStringEnsuringParent(fs, path, resolved, nextContents))
7375
yield* _(Effect.log(`Authorized keys appended from ${source} to ${resolved}`))
7476
})
7577

@@ -111,8 +113,7 @@ const ensureMissingAuthorizedKeysPlaceholder = (
111113
): Effect.Effect<void, PlatformError> =>
112114
Effect.gen(function*(_) {
113115
if (state === "missing") {
114-
yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true }))
115-
yield* _(fs.writeFileString(resolved, ""))
116+
yield* _(writeFileStringEnsuringParent(fs, path, resolved, ""))
116117
}
117118

118119
yield* _(
@@ -160,13 +161,12 @@ const syncAuthorizedKeysTarget = ({
160161
Effect.gen(function*(_) {
161162
if (state === "exists") {
162163
if (overwriteExisting || resolved === managedDefaultAuthorizedKeys) {
163-
yield* _(appendKeyIfMissing(fs, resolved, source, desiredContents))
164+
yield* _(appendKeyIfMissing(fs, path, resolved, source, desiredContents))
164165
}
165166
return
166167
}
167168

168-
yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true }))
169-
yield* _(fs.copyFile(source, resolved))
169+
yield* _(writeFileStringEnsuringParent(fs, path, resolved, `${desiredContents}\n`))
170170
yield* _(Effect.log(`Authorized keys copied from ${source} to ${resolved}`))
171171
})
172172

@@ -254,8 +254,7 @@ const ensureEnvFile = (
254254
return
255255
}
256256

257-
yield* _(fs.makeDirectory(path.dirname(resolved), { recursive: true }))
258-
yield* _(fs.writeFileString(resolved, defaultContents))
257+
yield* _(writeFileStringEnsuringParent(fs, path, resolved, defaultContents))
259258
})
260259
)
261260

packages/app/src/lib/usecases/auth-claude.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ensureDockerImage } from "./docker-image.js"
1717
import { resolvePathFromCwd } from "./path-helpers.js"
1818
import { withFsPathContext } from "./runtime.js"
1919
import { autoSyncState } from "./state-repo.js"
20+
import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js"
2021

2122
type ClaudeRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
2223
type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session"
@@ -26,6 +27,7 @@ type ClaudeAccountContext = {
2627
readonly accountPath: string
2728
readonly cwd: string
2829
readonly fs: FileSystem.FileSystem
30+
readonly path: Path.Path
2931
}
3032

3133
export const claudeAuthRoot = ".docker-git/.orch/auth/claude"
@@ -46,23 +48,29 @@ const claudeNestedCredentialsPath = (accountPath: string): string =>
4648

4749
const syncClaudeCredentialsFile = (
4850
fs: FileSystem.FileSystem,
51+
path: Path.Path,
4952
accountPath: string
5053
): Effect.Effect<void, PlatformError> =>
5154
Effect.gen(function*(_) {
5255
const nestedPath = claudeNestedCredentialsPath(accountPath)
5356
const rootPath = claudeCredentialsPath(accountPath)
5457
const nestedExists = yield* _(isRegularFile(fs, nestedPath))
5558
if (nestedExists) {
56-
yield* _(fs.copyFile(nestedPath, rootPath))
57-
yield* _(fs.chmod(rootPath, 0o600), Effect.orElseSucceed(() => void 0))
59+
const nestedText = yield* _(readFileStringIfPresent(fs, nestedPath))
60+
if (nestedText !== null) {
61+
yield* _(writeFileStringEnsuringParent(fs, path, rootPath, nestedText))
62+
yield* _(fs.chmod(rootPath, 0o600), Effect.orElseSucceed(() => void 0))
63+
}
5864
return
5965
}
6066

6167
const rootExists = yield* _(isRegularFile(fs, rootPath))
6268
if (rootExists) {
63-
const nestedDirPath = `${accountPath}/${claudeCredentialsDirName}`
64-
yield* _(fs.makeDirectory(nestedDirPath, { recursive: true }))
65-
yield* _(fs.copyFile(rootPath, nestedPath))
69+
const rootText = yield* _(readFileStringIfPresent(fs, rootPath))
70+
if (rootText === null) {
71+
return
72+
}
73+
yield* _(writeFileStringEnsuringParent(fs, path, nestedPath, rootText))
6674
yield* _(fs.chmod(nestedPath, 0o600), Effect.orElseSucceed(() => void 0))
6775
}
6876
})
@@ -108,6 +116,7 @@ const readOauthToken = (
108116

109117
const resolveClaudeAuthMethod = (
110118
fs: FileSystem.FileSystem,
119+
path: Path.Path,
111120
accountPath: string
112121
): Effect.Effect<ClaudeAuthMethod, PlatformError> =>
113122
Effect.gen(function*(_) {
@@ -117,7 +126,7 @@ const resolveClaudeAuthMethod = (
117126
return "oauth-token"
118127
}
119128

120-
yield* _(syncClaudeCredentialsFile(fs, accountPath))
129+
yield* _(syncClaudeCredentialsFile(fs, path, accountPath))
121130
const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath)))
122131
return hasCredentials ? "claude-ai-session" : "none"
123132
})
@@ -187,7 +196,7 @@ const withClaudeAuth = <A, E>(
187196
buildLabel: "claude auth"
188197
})
189198
)
190-
return yield* _(run({ accountLabel, accountPath, cwd, fs }))
199+
return yield* _(run({ accountLabel, accountPath, cwd, fs, path }))
191200
})
192201
)
193202

@@ -249,7 +258,7 @@ export const authClaudeLogin = (
249258
command: AuthClaudeLoginCommand
250259
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, ClaudeRuntime> => {
251260
const accountLabel = normalizeAccountLabel(command.label, "default")
252-
return withClaudeAuth(command, ({ accountPath, cwd, fs }) =>
261+
return withClaudeAuth(command, ({ accountPath, cwd, fs, path }) =>
253262
Effect.gen(function*(_) {
254263
const token = yield* _(
255264
runClaudeOauthLoginWithPrompt(cwd, accountPath, {
@@ -259,7 +268,7 @@ export const authClaudeLogin = (
259268
)
260269
yield* _(fs.writeFileString(claudeOauthTokenPath(accountPath), `${token}\n`))
261270
yield* _(fs.chmod(claudeOauthTokenPath(accountPath), 0o600), Effect.orElseSucceed(() => void 0))
262-
yield* _(resolveClaudeAuthMethod(fs, accountPath))
271+
yield* _(resolveClaudeAuthMethod(fs, path, accountPath))
263272
const probeExitCode = yield* _(runClaudePingProbeExitCode(cwd, accountPath, token))
264273
if (probeExitCode !== 0) {
265274
yield* _(
@@ -289,9 +298,9 @@ export const authClaudeLogin = (
289298
export const authClaudeStatus = (
290299
command: AuthClaudeStatusCommand
291300
): Effect.Effect<void, CommandFailedError | PlatformError, ClaudeRuntime> =>
292-
withClaudeAuth(command, ({ accountLabel, accountPath, cwd, fs }) =>
301+
withClaudeAuth(command, ({ accountLabel, accountPath, cwd, fs, path }) =>
293302
Effect.gen(function*(_) {
294-
const method = yield* _(resolveClaudeAuthMethod(fs, accountPath))
303+
const method = yield* _(resolveClaudeAuthMethod(fs, path, accountPath))
295304
if (method === "none") {
296305
yield* _(Effect.log(`Claude not connected (${accountLabel}).`))
297306
return

packages/app/src/lib/usecases/auth-copy.ts

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,9 @@ import type * as FileSystem from "@effect/platform/FileSystem"
44
import type * as Path from "@effect/platform/Path"
55
import { Effect } from "effect"
66

7-
const shouldSkipCopiedDir = (entry: string): boolean => entry === "tmp"
8-
9-
const isNotFoundSystemError = (error: PlatformError): boolean =>
10-
error._tag === "SystemError" && error.reason === "NotFound"
7+
import { readFileStringIfPresent, statIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js"
118

12-
const statIfPresent = (
13-
fs: FileSystem.FileSystem,
14-
targetPath: string
15-
) =>
16-
fs.stat(targetPath).pipe(
17-
Effect.catchTag("SystemError", (error) =>
18-
isNotFoundSystemError(error)
19-
? Effect.succeed(null)
20-
: Effect.fail(error))
21-
)
9+
const shouldSkipCopiedDir = (entry: string): boolean => entry === "tmp"
2210

2311
const copyDirRecursive = (
2412
fs: FileSystem.FileSystem,
@@ -49,7 +37,11 @@ const copyDirRecursive = (
4937
if (entryInfo.type === "Directory") {
5038
yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry))
5139
} else if (entryInfo.type === "File") {
52-
yield* _(fs.copyFile(sourceEntry, targetEntry))
40+
const sourceText = yield* _(readFileStringIfPresent(fs, sourceEntry))
41+
if (sourceText === null) {
42+
continue
43+
}
44+
yield* _(writeFileStringEnsuringParent(fs, path, targetEntry, sourceText))
5345
}
5446
}
5547
})
@@ -89,23 +81,21 @@ export const copyCodexFile = (
8981
Effect.gen(function*(_) {
9082
const sourceFile = path.join(spec.sourceDir, spec.fileName)
9183
const targetFile = path.join(spec.targetDir, spec.fileName)
92-
const sourceExists = yield* _(fs.exists(sourceFile))
93-
if (!sourceExists) {
84+
const sourceText = yield* _(readFileStringIfPresent(fs, sourceFile))
85+
if (sourceText === null) {
9486
return
9587
}
96-
const sourceText = yield* _(fs.readFileString(sourceFile))
97-
const targetExists = yield* _(fs.exists(targetFile))
98-
if (targetExists) {
99-
const targetText = yield* _(fs.readFileString(targetFile))
100-
if (targetText === sourceText) {
101-
return
102-
}
103-
yield* _(fs.writeFileString(targetFile, sourceText))
104-
yield* _(Effect.log(`Synced Codex ${spec.label} from ${sourceFile} to ${targetFile}`))
88+
yield* _(fs.makeDirectory(spec.targetDir, { recursive: true }))
89+
const targetText = yield* _(readFileStringIfPresent(fs, targetFile))
90+
if (targetText === sourceText) {
10591
return
10692
}
107-
yield* _(fs.copyFile(sourceFile, targetFile))
108-
yield* _(Effect.log(`Copied Codex ${spec.label} from ${sourceFile} to ${targetFile}`))
93+
yield* _(writeFileStringEnsuringParent(fs, path, targetFile, sourceText))
94+
yield* _(
95+
Effect.log(
96+
`${targetText === null ? "Copied" : "Synced"} Codex ${spec.label} from ${sourceFile} to ${targetFile}`
97+
)
98+
)
10999
})
110100

111101
export const copyDirIfEmpty = (
@@ -153,13 +143,16 @@ const copyMissingRecursive = (
153143
return
154144
}
155145

156-
const targetExists = yield* _(fs.exists(targetPath))
157-
if (targetExists) {
146+
const targetInfo = yield* _(statIfPresent(fs, targetPath))
147+
if (targetInfo !== null) {
158148
return
159149
}
160150

161-
yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true }))
162-
yield* _(fs.copyFile(sourcePath, targetPath))
151+
const sourceText = yield* _(readFileStringIfPresent(fs, sourcePath))
152+
if (sourceText === null) {
153+
return
154+
}
155+
yield* _(writeFileStringEnsuringParent(fs, path, targetPath, sourceText))
163156
})
164157

165158
export const copyDirMissingEntries = (

packages/app/src/lib/usecases/auth-sync-claude-seed.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
resolvePathFromBase
1313
} from "./auth-sync-helpers.js"
1414
import { withFsPathContext } from "./runtime.js"
15+
import { readFileStringIfPresent, statIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js"
1516

1617
type ClaudeJsonSyncSpec = {
1718
readonly sourcePath: string
@@ -28,40 +29,36 @@ const syncClaudeJsonFile = (
2829
spec: ClaudeJsonSyncSpec
2930
): Effect.Effect<void, PlatformError> =>
3031
Effect.gen(function*(_) {
31-
const sourceExists = yield* _(fs.exists(spec.sourcePath))
32-
if (!sourceExists) {
32+
const sourceInfo = yield* _(statIfPresent(fs, spec.sourcePath))
33+
if (sourceInfo === null || sourceInfo.type !== "File") {
3334
return
3435
}
3536

36-
const sourceInfo = yield* _(fs.stat(spec.sourcePath))
37-
if (sourceInfo.type !== "File") {
37+
const sourceText = yield* _(readFileStringIfPresent(fs, spec.sourcePath))
38+
if (sourceText === null) {
3839
return
3940
}
40-
41-
const sourceText = yield* _(fs.readFileString(spec.sourcePath))
4241
const sourceJson = yield* _(parseJsonRecord(sourceText))
4342
if (!spec.hasRequiredData(sourceJson)) {
4443
return
4544
}
4645

47-
const targetExists = yield* _(fs.exists(spec.targetPath))
48-
if (!targetExists) {
49-
yield* _(fs.makeDirectory(path.dirname(spec.targetPath), { recursive: true }))
50-
yield* _(fs.copyFile(spec.sourcePath, spec.targetPath))
46+
const targetInfo = yield* _(statIfPresent(fs, spec.targetPath))
47+
if (targetInfo === null) {
48+
yield* _(writeFileStringEnsuringParent(fs, path, spec.targetPath, sourceText))
5149
yield* _(spec.onWrite(spec.targetPath))
5250
yield* _(Effect.log(`Seeded ${spec.seedLabel} from ${spec.sourcePath} to ${spec.targetPath}`))
5351
return
5452
}
5553

56-
const targetInfo = yield* _(fs.stat(spec.targetPath))
5754
if (targetInfo.type !== "File") {
5855
return
5956
}
6057

6158
const targetText = yield* _(fs.readFileString(spec.targetPath), Effect.orElseSucceed(() => ""))
6259
const targetJson = yield* _(parseJsonRecord(targetText))
6360
if (!spec.hasRequiredData(targetJson)) {
64-
yield* _(fs.writeFileString(spec.targetPath, sourceText))
61+
yield* _(writeFileStringEnsuringParent(fs, path, spec.targetPath, sourceText))
6562
yield* _(spec.onWrite(spec.targetPath))
6663
yield* _(Effect.log(`Updated ${spec.updateLabel} from ${spec.sourcePath} to ${spec.targetPath}`))
6764
}

0 commit comments

Comments
 (0)