Skip to content

Commit 645fb29

Browse files
committed
fix(shell): sync github auth env and improve docker error handling
1 parent 5c19076 commit 645fb29

File tree

6 files changed

+175
-1
lines changed

6 files changed

+175
-1
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,47 @@ MCP errors in `codex` UI:
101101
- `handshaking ... initialize response`:
102102
- The configured MCP command is not a real MCP server (example: `command="echo"`).
103103

104+
Docker permission error (`/var/run/docker.sock`):
105+
- Symptom:
106+
- `permission denied while trying to connect to the docker API at unix:///var/run/docker.sock`
107+
- Check:
108+
```bash
109+
id
110+
ls -l /var/run/docker.sock
111+
docker version
112+
```
113+
- Fix (works in `fish` and `bash`):
114+
```bash
115+
sudo chgrp docker /var/run/docker.sock
116+
sudo chmod 660 /var/run/docker.sock
117+
sudo mkdir -p /etc/systemd/system/docker.socket.d
118+
printf '[Socket]\nSocketGroup=docker\nSocketMode=0660\n' | sudo tee /etc/systemd/system/docker.socket.d/override.conf >/dev/null
119+
sudo systemctl daemon-reload
120+
sudo systemctl restart docker.socket docker
121+
```
122+
- Verify:
123+
```bash
124+
ls -l /var/run/docker.sock
125+
docker version
126+
```
127+
- Note:
128+
- Do not run `pnpm run docker-git ...` with `sudo`.
129+
130+
Clone auth error (`Invalid username or token`):
131+
- Symptom:
132+
- `remote: Invalid username or token. Password authentication is not supported for Git operations.`
133+
- Check and fix token:
134+
```bash
135+
pnpm run docker-git auth github status
136+
pnpm run docker-git auth github logout
137+
pnpm run docker-git auth github login --token '<GITHUB_TOKEN>'
138+
pnpm run docker-git auth github status
139+
```
140+
- Token requirements:
141+
- Token must have access to the target repository.
142+
- For org repositories with SSO/SAML, authorize the token for that organization.
143+
- Recommended scopes: `repo,workflow,read:org`.
144+
104145
## Security Notes
105146

106147
The generated Codex config uses:

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export const program = pipe(
117117
Effect.logWarning(renderError(error)),
118118
Effect.asVoid
119119
)),
120+
Effect.catchTag("DockerCommandError", logWarningAndExit),
120121
Effect.catchTag("AuthError", logWarningAndExit),
121122
Effect.catchTag("CommandFailedError", logWarningAndExit),
122123
Effect.matchEffect({

packages/lib/src/usecases/auth-sync.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type * as FileSystem from "@effect/platform/FileSystem"
33
import type * as Path from "@effect/platform/Path"
44
import { Effect } from "effect"
55

6+
import { parseEnvEntries, removeEnvKey, upsertEnvKey } from "./env-file.js"
67
import { withFsPathContext } from "./runtime.js"
78

89
type CopyDecision = "skip" | "copy"
@@ -69,6 +70,69 @@ const shouldCopyEnv = (sourceText: string, targetText: string): CopyDecision =>
6970
return "skip"
7071
}
7172

73+
const isGithubTokenKey = (key: string): boolean =>
74+
key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__")
75+
76+
// CHANGE: synchronize GitHub auth keys between env files
77+
// WHY: avoid stale per-project tokens that cause clone auth failures after token rotation
78+
// QUOTE(ТЗ): n/a
79+
// REF: user-request-2026-02-11-clone-invalid-token
80+
// SOURCE: n/a
81+
// FORMAT THEOREM: ∀k ∈ github_token_keys: source(k)=v → merged(k)=v
82+
// PURITY: CORE
83+
// INVARIANT: non-auth keys in target are preserved
84+
// COMPLEXITY: O(n) where n = |env entries|
85+
export const syncGithubAuthKeys = (sourceText: string, targetText: string): string => {
86+
const sourceTokenEntries = parseEnvEntries(sourceText).filter((entry) => isGithubTokenKey(entry.key))
87+
if (sourceTokenEntries.length === 0) {
88+
return targetText
89+
}
90+
91+
const targetTokenKeys = parseEnvEntries(targetText)
92+
.filter((entry) => isGithubTokenKey(entry.key))
93+
.map((entry) => entry.key)
94+
95+
let next = targetText
96+
for (const key of targetTokenKeys) {
97+
next = removeEnvKey(next, key)
98+
}
99+
for (const entry of sourceTokenEntries) {
100+
next = upsertEnvKey(next, entry.key, entry.value)
101+
}
102+
103+
return next
104+
}
105+
106+
const syncGithubTokenKeysInFile = (
107+
sourcePath: string,
108+
targetPath: string
109+
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
110+
withFsPathContext(({ fs }) =>
111+
Effect.gen(function*(_) {
112+
const sourceExists = yield* _(fs.exists(sourcePath))
113+
if (!sourceExists) {
114+
return
115+
}
116+
const targetExists = yield* _(fs.exists(targetPath))
117+
if (!targetExists) {
118+
return
119+
}
120+
const sourceInfo = yield* _(fs.stat(sourcePath))
121+
const targetInfo = yield* _(fs.stat(targetPath))
122+
if (sourceInfo.type !== "File" || targetInfo.type !== "File") {
123+
return
124+
}
125+
126+
const sourceText = yield* _(fs.readFileString(sourcePath))
127+
const targetText = yield* _(fs.readFileString(targetPath))
128+
const mergedText = syncGithubAuthKeys(sourceText, targetText)
129+
if (mergedText !== targetText) {
130+
yield* _(fs.writeFileString(targetPath, mergedText))
131+
yield* _(Effect.log(`Synced GitHub auth keys from ${sourcePath} to ${targetPath}`))
132+
}
133+
})
134+
)
135+
72136
const copyFileIfNeeded = (
73137
sourcePath: string,
74138
targetPath: string
@@ -249,6 +313,7 @@ export const syncAuthArtifacts = (
249313
const targetCodex = resolvePathFromBase(path, spec.targetBase, spec.target.codexAuthPath)
250314

251315
yield* _(copyFileIfNeeded(sourceGlobal, targetGlobal))
316+
yield* _(syncGithubTokenKeysInFile(sourceGlobal, targetGlobal))
252317
yield* _(copyFileIfNeeded(sourceProject, targetProject))
253318
yield* _(fs.makeDirectory(targetCodex, { recursive: true }))
254319
if (sourceCodex !== targetCodex) {

packages/lib/src/usecases/errors.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ const renderPrimaryError = (error: NonParseError): string | null => {
4444
}
4545

4646
if (error._tag === "DockerCommandError") {
47-
return `docker compose failed with exit code ${error.exitCode}`
47+
return [
48+
`docker compose failed with exit code ${error.exitCode}`,
49+
"Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group)."
50+
].join("\n")
4851
}
4952

5053
if (error._tag === "CloneFailedError") {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { syncGithubAuthKeys } from "../../src/usecases/auth-sync.js"
4+
5+
describe("syncGithubAuthKeys", () => {
6+
it("updates github token keys from source and preserves non-auth target keys", () => {
7+
const source = [
8+
"# docker-git env",
9+
"# KEY=value",
10+
"GITHUB_TOKEN=token_new",
11+
"GITHUB_TOKEN__WORK=token_work",
12+
"SOME_SOURCE_ONLY=value",
13+
""
14+
].join("\n")
15+
const target = [
16+
"# docker-git env",
17+
"# KEY=value",
18+
"GITHUB_TOKEN=token_old",
19+
"GH_TOKEN=legacy_old",
20+
"CUSTOM_FLAG=1",
21+
""
22+
].join("\n")
23+
24+
const next = syncGithubAuthKeys(source, target)
25+
26+
expect(next).toContain("GITHUB_TOKEN=token_new")
27+
expect(next).toContain("GITHUB_TOKEN__WORK=token_work")
28+
expect(next).not.toContain("GH_TOKEN=legacy_old")
29+
expect(next).toContain("CUSTOM_FLAG=1")
30+
})
31+
32+
it("keeps target unchanged when source has no github token keys", () => {
33+
const source = [
34+
"# docker-git env",
35+
"# KEY=value",
36+
"UNRELATED=1",
37+
""
38+
].join("\n")
39+
const target = [
40+
"# docker-git env",
41+
"# KEY=value",
42+
"GITHUB_TOKEN=token_old",
43+
"CUSTOM_FLAG=1",
44+
""
45+
].join("\n")
46+
47+
const next = syncGithubAuthKeys(source, target)
48+
49+
expect(next).toBe(target)
50+
})
51+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { DockerCommandError } from "../../src/shell/errors.js"
4+
import { renderError } from "../../src/usecases/errors.js"
5+
6+
describe("renderError", () => {
7+
it("includes docker daemon access hint for DockerCommandError", () => {
8+
const message = renderError(new DockerCommandError({ exitCode: 1 }))
9+
10+
expect(message).toContain("docker compose failed with exit code 1")
11+
expect(message).toContain("/var/run/docker.sock")
12+
})
13+
})

0 commit comments

Comments
 (0)