sandcastle-codex-auth
Configure Sandcastle Docker runs to use a user's existing Codex CLI ChatGPT subscription login instead of an OpenAI API key. Use when Sandcastle with Codex fails auth, cannot find codex in Docker, can
SKILL.mdskills/sandcastle-codex-auth/SKILL.md
GitHubSandcastle Codex Auth
Goal
Make Sandcastle's Docker sandbox run codex using the host's existing ~/.codex/auth.json login, while keeping Sandcastle session capture working.
Reference article for more detail: https://www.eddyvinck.com/blog/how-to-set-up-sandcastle-with-codex/
Process
- Verify host Codex login:
which codex
codex --version
test -f ~/.codex/auth.json
codex login status
Expected paid ChatGPT auth signal:
Logged in using ChatGPT
- Ensure
.sandcastle/Dockerfileinstalls Codex inside the image:
RUN npm install -g @openai/codex
Place it before USER agent / USER ${AGENT_UID}:${AGENT_GID}.
- In
.sandcastle/main.mts, import Node path helpers:
import os from "node:os";
import path from "node:path";
- Add shared Codex paths:
const hostCodexHome = path.join(os.homedir(), ".codex");
const sandboxCodexMount = "/mnt/host-codex";
const sandboxCodexHome = "/home/agent/.codex";
- Use one shared Docker provider for all
run()andcreateSandbox()calls:
const dockerSandbox = docker({
env: {
CODEX_HOME: sandboxCodexHome,
},
mounts: [
{
hostPath: hostCodexHome,
sandboxPath: sandboxCodexMount,
readonly: true,
},
],
});
Do not set GH_TOKEN: process.env.GH_TOKEN ?? "" in provider env; it overrides .sandcastle/.env with blank. Let Sandcastle resolve .sandcastle/.env and process env.
- Copy auth into Codex home during
onSandboxReady:
const hooks = {
sandbox: {
onSandboxReady: [
{
command: [
`mkdir -p "${sandboxCodexHome}"`,
`test -f "${sandboxCodexMount}/auth.json"`,
`cp "${sandboxCodexMount}/auth.json" "${sandboxCodexHome}/auth.json"`,
`if [ -f "${sandboxCodexMount}/config.toml" ]; then cp "${sandboxCodexMount}/config.toml" "${sandboxCodexHome}/config.toml"; fi`,
].join(" && "),
},
{ command: "npm install" },
],
},
};
Use /home/agent/.codex, not /tmp/codex-home; Sandcastle looks there for Codex session capture.
- Replace every
sandbox: docker()with:
sandbox: dockerSandbox
For createSandbox(), use:
const issueSandbox = await sandcastle.createSandbox({
branch: issue.branch,
sandbox: dockerSandbox,
hooks,
});
Avoid naming the local sandbox handle sandbox; it can shadow dockerSandbox and cause type/name bugs.
- On macOS Docker, remove
copyToWorktree: ["node_modules"].
Linux containers cannot use macOS native modules reliably. Let npm install run inside Docker.
- Rebuild image:
bunx sandcastle docker build-image
- Verify auth inside image without spending a model call:
docker run --rm --entrypoint sh \
-v "$HOME/.codex:/mnt/host-codex:ro" \
-e CODEX_HOME=/home/agent/.codex \
sandcastle:$(basename "$PWD") \
-lc 'mkdir -p "$CODEX_HOME" && test -f /mnt/host-codex/auth.json && cp /mnt/host-codex/auth.json "$CODEX_HOME/auth.json" && if [ -f /mnt/host-codex/config.toml ]; then cp /mnt/host-codex/config.toml "$CODEX_HOME/config.toml"; fi && codex --version && codex login status'
Expected:
Logged in using ChatGPT
- Configure GitHub CLI auth for sandbox prompt expansion.
Sandcastle expands prompt shell blocks such as !`gh issue list ...` inside the sandbox. Host gh auth status can pass while sandbox gh fails because the container cannot read the host GitHub CLI keyring.
Verify the host account can access the repo:
gh repo view OWNER/REPO --json nameWithOwner,viewerPermission
gh issue list -R OWNER/REPO --state open --limit 1 --json number,title
Use a fine-grained personal access token (github_pat_...) with access to only this repo; this is the default, best option for Sandcastle GitHub auth. Do not use the broad GitHub CLI OAuth token (gho_...) unless GitHub blocks fine-grained PAT access for the repo.
Tell the user to create the fine-grained token with these GitHub UI settings:
- Resource owner: repo owner, e.g.
OWNER - Repository access: Only select repositories ->
OWNER/REPO - Repository permissions:
- Metadata: Read-only (required/automatic)
- Issues: Read and write
- Contents: No access, unless this same token will also be used for git push/API commits; then use Read and write
- Leave Pull requests, Actions, Workflows, Administration, Secrets, and all other permissions as No access.
- Expiration: set one, preferably 30-90 days.
Tell the user this token name and create a new token in the GitHub UI:
!`open https://github.com/settings/personal-access-tokens >/dev/null 2>&1; u="${GH_REPO:-$(git config --get remote.origin.url 2>/dev/null || basename "$PWD")}"; u="${u%.git}"; u="${u##*:}"; u="${u#*github.com/}"; p="${u##*/}"; p="${p:-$(basename "$PWD")}"; p="$(printf '%s' "$p" | tr '[:upper:]_' '[:lower:]-' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-|-$//g' | cut -c1-40)"; printf 'sandcastle-%s-%s\n' "$p" "$(date +%Y-%m)"`
Create .sandcastle/.env with an empty GH_TOKEN= slot and explicit repo:
umask 077
{
printf 'GH_TOKEN=\n'
printf 'GH_REPO=OWNER/REPO\n'
} > .sandcastle/.env
chmod 600 .sandcastle/.env
Tell the user to paste the token locally in .sandcastle/.env after GH_TOKEN=. Never ask the user to paste GitHub tokens into chat.
Ensure .sandcastle/.gitignore contains:
.env
logs/
worktrees/
Use GH_REPO when the git remote is a fork, renamed repo, or SSH host alias such as github-personal; otherwise gh inside Docker may not infer the GitHub repo. If a collaborator-only repo does not appear in the fine-grained PAT repo picker, fallback to gh auth token, a classic PAT with repo access, or a repo-installed GitHub App, assuming org policy allows it.
Verify GitHub auth inside Docker without spending a model call:
docker run --rm --entrypoint sh \
--env-file .sandcastle/.env \
-v "$PWD:/home/agent/workspace" \
-w /home/agent/workspace \
sandcastle:$(basename "$PWD") \
-lc 'gh issue list --state open --limit 1 --json number,title'
If .sandcastle/main.mts sets a custom Docker imageName, use that image instead of sandcastle:$(basename "$PWD").
- If
.sandcastle/main.mtshas red TypeScript squiggles fornode:*,process,@ai-hero/sandcastle, orzod, make the root workspace a Node TypeScript project.
Install Node types at the repo root:
bun add -d @types/node@^20
Add root tsconfig.json if absent:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ESNext"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": [".sandcastle/**/*.mts"]
}
Workspace/app tsconfigs usually do not cover root .sandcastle files, so VS Code can otherwise infer the wrong project.
Validate the editor-facing TypeScript project:
bunx --bun tsc --noEmit -p tsconfig.json --pretty false
If VS Code still shows old errors after this passes, restart the TypeScript server.
- Validate script syntax:
bun build .sandcastle/main.mts --target=node --outfile=/tmp/sandcastle-main-check.js
If running TypeScript directly, missing @types/node may make tsc fail even when runtime is fine.
- Add convenience script if absent:
{
"scripts": {
"sandcastle": "npx tsx .sandcastle/main.mts"
}
}
Run with:
bun run sandcastle
Common Errors
sh: codex: command not found: install Codex in.sandcastle/Dockerfile, then rebuild image.Session capture failed: setCODEX_HOME=/home/agent/.codex.invalid ELF header: stop copying hostnode_modules; runnpm installin Docker.GH_TOKENmissing despite.sandcastle/.env: check provider env is not overriding it with"".gh issue listasks forgh auth logininside Docker: writeGH_TOKENto.sandcastle/.env.none of the git remotes configured for this repository point to a known GitHub host: addGH_REPO=OWNER/REPOto.sandcastle/.env.- Collaborator repo missing from fine-grained PAT picker: use
gh auth tokenor a classic PAT with repo access, if org policy allows it. - Red squiggles on
node:*imports /process: add root@types/nodeand a roottsconfig.jsonthat includes.sandcastle/**/*.mts.