Skip to content

Commit 7920be2

Browse files
committed
Add Codex token refresher CronJob
1 parent 34069e7 commit 7920be2

16 files changed

Lines changed: 1086 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
types: [opened, synchronize, reopened]
9+
merge_group:
10+
workflow_dispatch:
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
build:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-go@v5
23+
with:
24+
go-version-file: go.mod
25+
26+
- name: Build
27+
run: make build
28+
29+
verify:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- uses: actions/setup-go@v5
35+
with:
36+
go-version-file: go.mod
37+
38+
- name: Verify
39+
run: make verify
40+
41+
test:
42+
runs-on: ubuntu-latest
43+
steps:
44+
- uses: actions/checkout@v4
45+
46+
- uses: actions/setup-go@v5
47+
with:
48+
go-version-file: go.mod
49+
50+
- name: Test
51+
run: make test
52+
53+
image:
54+
runs-on: ubuntu-latest
55+
steps:
56+
- uses: actions/checkout@v4
57+
58+
- name: Build Image
59+
run: make image IMAGE_REPOSITORY=token-refresher-ci VERSION=${{ github.sha }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/.cache/
2+
/bin/

Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
FROM golang:1.26.0-bookworm AS builder
2+
3+
WORKDIR /src
4+
COPY go.mod ./
5+
COPY cmd ./cmd
6+
COPY internal ./internal
7+
8+
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/token-refresher ./cmd/token-refresher
9+
10+
FROM node:22-bookworm-slim
11+
12+
ARG CODEX_VERSION=0.118.0
13+
14+
RUN apt-get update \
15+
&& apt-get install -y --no-install-recommends ca-certificates \
16+
&& npm install -g @openai/codex@${CODEX_VERSION} \
17+
&& apt-get clean \
18+
&& rm -rf /var/lib/apt/lists/*
19+
20+
COPY --from=builder /out/token-refresher /usr/local/bin/token-refresher
21+
22+
USER node
23+
24+
ENTRYPOINT ["/usr/local/bin/token-refresher"]

Makefile

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
REGISTRY ?= ghcr.io/gjkim
2+
IMAGE_NAME ?= token-refresher
3+
IMAGE_REPOSITORY ?= $(REGISTRY)/$(IMAGE_NAME)
4+
VERSION ?= latest
5+
6+
BIN_DIR ?= bin
7+
BIN ?= $(BIN_DIR)/token-refresher
8+
GOCACHE ?= $(CURDIR)/.cache/go-build
9+
GOMODCACHE ?= $(CURDIR)/.cache/go-mod
10+
GO_SOURCES := $(shell find cmd internal -name '*.go' -type f 2>/dev/null)
11+
12+
SHELL = /usr/bin/env bash -o pipefail
13+
.SHELLFLAGS = -ec
14+
15+
.PHONY: all
16+
all: build
17+
18+
.PHONY: help
19+
help: ## Display available targets.
20+
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-12s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
21+
22+
.PHONY: prepare
23+
prepare:
24+
mkdir -p $(BIN_DIR) $(GOCACHE) $(GOMODCACHE)
25+
26+
.PHONY: build
27+
build: prepare ## Build the token-refresher binary.
28+
CGO_ENABLED=0 GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go build -o $(BIN) ./cmd/token-refresher
29+
30+
.PHONY: test
31+
test: prepare ## Run unit tests.
32+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./...
33+
34+
.PHONY: verify
35+
verify: prepare ## Verify formatting, tests, and vet checks.
36+
@unformatted="$$(gofmt -l $(GO_SOURCES))"; \
37+
if [[ -n "$$unformatted" ]]; then \
38+
echo "unformatted Go files:"; \
39+
echo "$$unformatted"; \
40+
exit 1; \
41+
fi
42+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./...
43+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go vet ./...
44+
45+
.PHONY: update
46+
update: prepare ## Format Go code and tidy modules.
47+
gofmt -w $(GO_SOURCES)
48+
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go mod tidy
49+
50+
.PHONY: image
51+
image: ## Build the container image.
52+
docker build -t $(IMAGE_REPOSITORY):$(VERSION) .
53+
54+
.PHONY: clean
55+
clean: ## Remove local build artifacts.
56+
rm -rf $(BIN_DIR) .cache

README.md

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,96 @@
1-
# token-refresher
1+
# token-refresher
2+
3+
`token-refresher` is a small Go job that keeps a coding agent auth blob in sync inside Kubernetes.
4+
5+
The first provider is `codex`:
6+
7+
- reads `auth.json` from a Kubernetes `Secret`
8+
- copies it into an isolated temporary `HOME`
9+
- runs a lightweight `codex exec` to trigger validation or refresh
10+
- reads the resulting `auth.json`
11+
- updates the Kubernetes `Secret` only if the file changed
12+
13+
The runtime path is isolated on purpose. The refresher never mutates any existing host-side `~/.codex/auth.json` directly.
14+
15+
## Why this shape
16+
17+
Codex stores auth state in `~/.codex/auth.json`. For a Kubernetes `CronJob`, the cleanest flow is:
18+
19+
1. fetch the current `auth.json` from a secret
20+
2. materialize it in a temp home directory
21+
3. let `codex` operate against that copied state
22+
4. write the updated file back to the secret if it changed
23+
24+
That makes the implementation safe for CronJobs and leaves room to add other agent providers later.
25+
26+
## Current defaults
27+
28+
- `CronJob` schedule: every 12 hours
29+
- example refresh policy: `always`
30+
- optional lower-churn mode: `threshold`
31+
- threshold window default: `72h`
32+
33+
`always` means every scheduled run performs a lightweight `codex exec`.
34+
35+
`threshold` means the job parses the current `access_token.exp` from `auth.json` and only runs `codex exec` when the access token is close to expiry.
36+
37+
## Configuration
38+
39+
Environment variables:
40+
41+
- `AGENT_PROVIDER`: provider name. Currently `codex` only.
42+
- `SECRET_NAMESPACE`: namespace containing the secret. Falls back to `POD_NAMESPACE`.
43+
- `SECRET_NAME`: name of the Kubernetes secret to read and update.
44+
- `SECRET_KEY`: key inside the secret. Defaults to `auth.json`.
45+
- `CODEX_COMMAND`: Codex CLI binary name. Defaults to `codex`.
46+
- `CODEX_PROMPT`: prompt used for the refresh probe. Defaults to `Reply with the single word ok.`
47+
- `CODEX_REFRESH_POLICY`: `always` or `threshold`. Defaults to `threshold`.
48+
- `CODEX_REFRESH_WINDOW`: only used in `threshold` mode. Defaults to `72h`.
49+
- `CODEX_REFRESH_TIMEOUT`: timeout for `codex exec`. Defaults to `10m`.
50+
- `CODEX_AUTH_SUBDIR`: auth directory under the temporary home. Defaults to `.codex`.
51+
- `CODEX_AUTH_FILE`: auth file name. Defaults to `auth.json`.
52+
53+
## Build
54+
55+
```bash
56+
make build
57+
make verify
58+
make test
59+
make image IMAGE_REPOSITORY=ghcr.io/your-org/token-refresher VERSION=latest
60+
```
61+
62+
The container image includes:
63+
64+
- the Go refresher binary
65+
- Node.js
66+
- `@openai/codex`
67+
68+
`make update` formats Go files and runs `go mod tidy`.
69+
70+
## Kubernetes setup
71+
72+
Create the starting secret from an existing local Codex auth file:
73+
74+
```bash
75+
kubectl -n your-namespace create secret generic codex-auth \
76+
--from-file=auth.json=$HOME/.codex/auth.json
77+
```
78+
79+
Apply RBAC and the `CronJob`:
80+
81+
```bash
82+
kubectl -n your-namespace apply -f deploy/kubernetes/rbac.yaml
83+
kubectl -n your-namespace apply -f deploy/kubernetes/cronjob.yaml
84+
```
85+
86+
Before applying the `CronJob`, edit [deploy/kubernetes/cronjob.yaml](/Users/gjkim/workspace/token-refresher/deploy/kubernetes/cronjob.yaml) and set:
87+
88+
- `image`
89+
- `SECRET_NAME` if you use a different secret name
90+
- `CODEX_REFRESH_POLICY` if you want `threshold` instead of `always`
91+
92+
## Notes
93+
94+
- The job uses the in-cluster service account token and Kubernetes API directly. No `kubectl` dependency is required.
95+
- The process updates the secret only when `auth.json` changes.
96+
- The current implementation is deliberately provider-oriented so other agent auth formats can be added behind the same interface.

cmd/token-refresher/main.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
"os/signal"
9+
"syscall"
10+
11+
"github.com/gjkim/token-refresher/internal/config"
12+
"github.com/gjkim/token-refresher/internal/kube"
13+
"github.com/gjkim/token-refresher/internal/providers"
14+
)
15+
16+
func main() {
17+
log.SetFlags(0)
18+
19+
if err := run(); err != nil {
20+
log.Fatalf("token-refresher: %v", err)
21+
}
22+
}
23+
24+
func run() error {
25+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
26+
defer stop()
27+
28+
cfg, err := config.Load()
29+
if err != nil {
30+
return fmt.Errorf("load config: %w", err)
31+
}
32+
33+
client, err := kube.NewInClusterClient()
34+
if err != nil {
35+
return fmt.Errorf("create kubernetes client: %w", err)
36+
}
37+
38+
provider, err := providers.New(cfg)
39+
if err != nil {
40+
return fmt.Errorf("load provider: %w", err)
41+
}
42+
43+
secret, authJSON, err := client.GetSecretKey(ctx, cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey)
44+
if err != nil {
45+
return fmt.Errorf("get secret %s/%s[%s]: %w", cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey, err)
46+
}
47+
48+
log.Printf(
49+
"provider=%s loaded secret=%s/%s key=%s bytes=%d",
50+
provider.Name(),
51+
cfg.SecretNamespace,
52+
cfg.SecretName,
53+
cfg.SecretKey,
54+
len(authJSON),
55+
)
56+
57+
result, err := provider.Refresh(ctx, authJSON)
58+
if err != nil {
59+
return fmt.Errorf("refresh auth data: %w", err)
60+
}
61+
62+
if result.ExpiresAt != nil {
63+
log.Printf(
64+
"provider=%s attempted_refresh=%t changed=%t expires_at=%s reason=%s",
65+
provider.Name(),
66+
result.AttemptedRefresh,
67+
result.Changed,
68+
result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"),
69+
result.Reason,
70+
)
71+
} else {
72+
log.Printf(
73+
"provider=%s attempted_refresh=%t changed=%t reason=%s",
74+
provider.Name(),
75+
result.AttemptedRefresh,
76+
result.Changed,
77+
result.Reason,
78+
)
79+
}
80+
81+
if !result.Changed {
82+
log.Printf("provider=%s no secret update required", provider.Name())
83+
return nil
84+
}
85+
86+
if err := client.UpdateSecretKey(ctx, &secret, cfg.SecretKey, result.UpdatedAuth); err != nil {
87+
return fmt.Errorf("update secret %s/%s[%s]: %w", cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey, err)
88+
}
89+
90+
log.Printf(
91+
"provider=%s updated secret=%s/%s key=%s bytes=%d",
92+
provider.Name(),
93+
cfg.SecretNamespace,
94+
cfg.SecretName,
95+
cfg.SecretKey,
96+
len(result.UpdatedAuth),
97+
)
98+
99+
return nil
100+
}

deploy/kubernetes/cronjob.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
apiVersion: batch/v1
2+
kind: CronJob
3+
metadata:
4+
name: token-refresher
5+
spec:
6+
schedule: "0 */12 * * *"
7+
concurrencyPolicy: Forbid
8+
successfulJobsHistoryLimit: 1
9+
failedJobsHistoryLimit: 3
10+
jobTemplate:
11+
spec:
12+
template:
13+
spec:
14+
serviceAccountName: token-refresher
15+
restartPolicy: Never
16+
containers:
17+
- name: token-refresher
18+
image: ghcr.io/your-org/token-refresher:latest
19+
imagePullPolicy: IfNotPresent
20+
env:
21+
- name: AGENT_PROVIDER
22+
value: codex
23+
- name: POD_NAMESPACE
24+
valueFrom:
25+
fieldRef:
26+
fieldPath: metadata.namespace
27+
- name: SECRET_NAMESPACE
28+
valueFrom:
29+
fieldRef:
30+
fieldPath: metadata.namespace
31+
- name: SECRET_NAME
32+
value: codex-auth
33+
- name: SECRET_KEY
34+
value: auth.json
35+
- name: CODEX_COMMAND
36+
value: codex
37+
- name: CODEX_REFRESH_POLICY
38+
value: always
39+
- name: CODEX_REFRESH_WINDOW
40+
value: 72h
41+
- name: CODEX_REFRESH_TIMEOUT
42+
value: 10m
43+
resources:
44+
requests:
45+
cpu: 100m
46+
memory: 128Mi
47+
limits:
48+
cpu: 500m
49+
memory: 512Mi

0 commit comments

Comments
 (0)