Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
merge_group:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build
run: make build

verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Verify
run: make verify

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Test
run: make test

image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Build Image
run: make image IMAGE_REPOSITORY=token-refresher-ci VERSION=${{ github.sha }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.cache/
/bin/
24 changes: 24 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM golang:1.26.0-bookworm AS builder

WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
COPY internal ./internal

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/token-refresher ./cmd/token-refresher

FROM node:22-bookworm-slim

ARG CODEX_VERSION=0.118.0

RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& npm install -g @openai/codex@${CODEX_VERSION} \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /out/token-refresher /usr/local/bin/token-refresher

USER node

ENTRYPOINT ["/usr/local/bin/token-refresher"]
56 changes: 56 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
REGISTRY ?= ghcr.io/kelos-dev
IMAGE_NAME ?= token-refresher
IMAGE_REPOSITORY ?= $(REGISTRY)/$(IMAGE_NAME)
VERSION ?= latest

BIN_DIR ?= bin
BIN ?= $(BIN_DIR)/token-refresher
GOCACHE ?= $(CURDIR)/.cache/go-build
GOMODCACHE ?= $(CURDIR)/.cache/go-mod
GO_SOURCES := $(shell find cmd internal -name '*.go' -type f 2>/dev/null)
BUILD_DIRS := $(BIN_DIR) $(GOCACHE) $(GOMODCACHE)

SHELL = /usr/bin/env bash -o pipefail
.SHELLFLAGS = -ec

.PHONY: all
all: build

.PHONY: help
help: ## Display available targets.
@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)

$(BUILD_DIRS):
mkdir -p $@

.PHONY: build
build: | $(BUILD_DIRS) ## Build the token-refresher binary.
CGO_ENABLED=0 GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go build -o $(BIN) ./cmd/token-refresher

.PHONY: test
test: | $(BUILD_DIRS) ## Run unit tests.
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./...

.PHONY: verify
verify: | $(BUILD_DIRS) ## Verify formatting, tests, and vet checks.
@unformatted="$$(gofmt -l $(GO_SOURCES))"; \
if [[ -n "$$unformatted" ]]; then \
echo "unformatted Go files:"; \
echo "$$unformatted"; \
exit 1; \
fi
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go test ./...
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go vet ./...

.PHONY: update
update: | $(BUILD_DIRS) ## Format Go code and tidy modules.
gofmt -w $(GO_SOURCES)
GOCACHE=$(GOCACHE) GOMODCACHE=$(GOMODCACHE) go mod tidy

.PHONY: image
image: ## Build the container image.
docker build -t $(IMAGE_REPOSITORY):$(VERSION) .

.PHONY: clean
clean: ## Remove local build artifacts.
rm -rf $(BIN_DIR) .cache
90 changes: 89 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,89 @@
# token-refresher
# token-refresher

`token-refresher` is a small Go job that keeps a coding agent auth blob in sync inside Kubernetes.

The first provider is `codex`:

- reads `auth.json` from a Kubernetes `Secret`
- copies it into an isolated temporary `HOME`
- runs a lightweight `codex exec` to trigger validation or refresh
- reads the resulting `auth.json`
- updates the Kubernetes `Secret` only if the file changed

The runtime path is isolated on purpose. The refresher never mutates any existing host-side `~/.codex/auth.json` directly.

## Why this shape

Codex stores auth state in `~/.codex/auth.json`. For a Kubernetes `CronJob`, the cleanest flow is:

1. fetch the current `auth.json` from a secret
2. materialize it in a temp home directory
3. let `codex` operate against that copied state
4. write the updated file back to the secret if it changed

That makes the implementation safe for CronJobs and leaves room to add other agent providers later.

## Current defaults

- `CronJob` schedule: every 12 hours
- refresh behavior: every run performs a lightweight `codex exec`
- Kubernetes secret key: `auth.json`

## Configuration

Runtime configuration is intentionally small and uses flags instead of environment variables:

- `--agent-provider`: provider name. Currently `codex` only.
- `--secret-name`: name of the Kubernetes secret to read and update.

Everything else is fixed in code:

- namespace: inferred from the in-cluster service account namespace
- secret key: `auth.json`
- Codex command: `codex`
- refresh behavior: run on every scheduled execution
- timeout: `10m`

## Build

```bash
make build
make verify
make test
make image IMAGE_REPOSITORY=ghcr.io/kelos-dev/token-refresher VERSION=latest
```

The container image includes:

- the Go refresher binary
- Node.js
- `@openai/codex`

`make update` formats Go files and runs `go mod tidy`.

## Kubernetes setup

Create the starting secret from an existing local Codex auth file:

```bash
kubectl -n your-namespace create secret generic codex-auth \
--from-file=auth.json=$HOME/.codex/auth.json
```

Apply RBAC and the `CronJob`:

```bash
kubectl -n your-namespace apply -f deploy/kubernetes/rbac.yaml
kubectl -n your-namespace apply -f deploy/kubernetes/cronjob.yaml
```

Before applying the `CronJob`, edit `deploy/kubernetes/cronjob.yaml` and set:

- `image`
- `--secret-name` if you use a different secret name

## Notes

- The job uses the in-cluster service account token and Kubernetes API directly. No `kubectl` dependency is required.
- The process updates the secret only when `auth.json` changes.
- The current implementation is deliberately provider-oriented so other agent auth formats can be added behind the same interface.
100 changes: 100 additions & 0 deletions cmd/token-refresher/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"

"github.com/kelos-dev/token-refresher/internal/config"
"github.com/kelos-dev/token-refresher/internal/kube"
"github.com/kelos-dev/token-refresher/internal/providers"
)

func main() {
log.SetFlags(0)

if err := run(); err != nil {
log.Fatalf("token-refresher: %v", err)
}
}

func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

cfg, err := config.Load(os.Args[1:])
if err != nil {
return fmt.Errorf("load config: %w", err)
}

client, err := kube.NewInClusterClient()
if err != nil {
return fmt.Errorf("create kubernetes client: %w", err)
}

provider, err := providers.New(cfg)
if err != nil {
return fmt.Errorf("load provider: %w", err)
}

secret, authJSON, err := client.GetSecretKey(ctx, cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey)
if err != nil {
return fmt.Errorf("get secret %s/%s[%s]: %w", cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey, err)
}

log.Printf(
"provider=%s loaded secret=%s/%s key=%s bytes=%d",
provider.Name(),
cfg.SecretNamespace,
cfg.SecretName,
cfg.SecretKey,
len(authJSON),
)

result, err := provider.Refresh(ctx, authJSON)
if err != nil {
return fmt.Errorf("refresh auth data: %w", err)
}

if result.ExpiresAt != nil {
log.Printf(
"provider=%s attempted_refresh=%t changed=%t expires_at=%s reason=%s",
provider.Name(),
result.AttemptedRefresh,
result.Changed,
result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z"),
result.Reason,
)
} else {
log.Printf(
"provider=%s attempted_refresh=%t changed=%t reason=%s",
provider.Name(),
result.AttemptedRefresh,
result.Changed,
result.Reason,
)
}

if !result.Changed {
log.Printf("provider=%s no secret update required", provider.Name())
return nil
}

if err := client.UpdateSecretKey(ctx, &secret, cfg.SecretKey, result.UpdatedAuth); err != nil {
return fmt.Errorf("update secret %s/%s[%s]: %w", cfg.SecretNamespace, cfg.SecretName, cfg.SecretKey, err)
}

log.Printf(
"provider=%s updated secret=%s/%s key=%s bytes=%d",
provider.Name(),
cfg.SecretNamespace,
cfg.SecretName,
cfg.SecretKey,
len(result.UpdatedAuth),
)

return nil
}
29 changes: 29 additions & 0 deletions deploy/kubernetes/cronjob.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: token-refresher
spec:
schedule: "0 */12 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
serviceAccountName: token-refresher
restartPolicy: Never
containers:
- name: token-refresher
image: ghcr.io/kelos-dev/token-refresher:latest
imagePullPolicy: IfNotPresent
args:
- --agent-provider=codex
- --secret-name=codex-auth
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
25 changes: 25 additions & 0 deletions deploy/kubernetes/rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: token-refresher
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: token-refresher
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: token-refresher
subjects:
- kind: ServiceAccount
name: token-refresher
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: token-refresher
Loading
Loading