Skip to content
Merged
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
58 changes: 40 additions & 18 deletions scripts/codex-auth-mirror.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,38 +101,52 @@ function credentialFromProfiles(agentDir) {
if (!profile || !profile.access) return null;
return {
accessToken: profile.access,
refreshToken: profile.refresh,
idToken: profile.id || profile.access,
accountId: accountIdFromAccessToken(profile.access),
};
}

/**
* Build the file contents. Deliberately no refresh_token — see the rule above.
* An existing OPENAI_API_KEY is carried over: that is the API-key path, which
* core reads from this same file and which has no rotation problem.
* Build the file contents.
*
* refresh_token IS included, and it has to be: core's readCodexCliCredentials()
* hard-rejects a credential without one --
*
* if (typeof refreshToken !== "string" || !refreshToken) return null;
*
* -- and a null credential means the codex plugin attaches no auth at all
* (`profile=-` in the gateway log) and every turn dies on 401. An earlier
* attempt at this fix stripped the field and broke Codex exactly that way.
*
* Safety comes from WHERE it is written, not from omitting it: only
* ~/.codex/auth.json gets a credential, and nothing rotates that file. The
* codex plugin reads it and never writes it, and no process runs with
* CODEX_HOME=~/.codex. The file the Codex app-server *does* rotate is
* <agentDir>/codex-home/auth.json -- see the destination list in main().
*/
function buildAuthFile(credential, existing) {
return {
OPENAI_API_KEY: (existing && existing.OPENAI_API_KEY) || null,
tokens: {
id_token: credential.idToken,
access_token: credential.accessToken,
refresh_token: credential.refreshToken,
account_id: credential.accountId,
},
last_refresh: new Date().toISOString(),
};
}

/**
* Rewrite when the access token changed or a refresh_token is present (a
* poisoned 3.1.11 mirror, or a stale copy). Returns a short reason for the log,
* or null when the file was already correct.
* Rewrite whenever the file drifts from core's profile. Core is the only
* rotator, so "different from core" always means "stale copy", never "newer".
*/
function syncReason(existing, credential) {
if (!existing) return "created";
const tokens = existing.tokens || {};
if (tokens.refresh_token) return "stripped refresh_token";
if (tokens.access_token !== credential.accessToken) return "refreshed";
if (tokens.refresh_token !== credential.refreshToken) return "realigned";
return null;
}

Expand Down Expand Up @@ -175,19 +189,27 @@ function main() {
return;
}

const destinations = [
homeAuthPath,
...agentDirs.map((dir) => path.join(dir, "codex-home", "auth.json")),
];
let synced = 0;
for (const dest of destinations) {
const reason = writeMirror(dest, credential);
if (reason) {
synced += 1;
log(`Codex auth.json ${reason}: ${dest}`);
// ONLY ~/.codex/auth.json. The codex plugin reads it and never writes it,
// and nothing runs with CODEX_HOME=~/.codex, so this copy is never rotated.
const reason = writeMirror(homeAuthPath, credential);
if (reason) log(`Codex auth.json ${reason}: ${homeAuthPath}`);
else log("Codex auth.json: credential already current");

// <agentDir>/codex-home/auth.json is the file the Codex app-server rotates.
// A credential there is a second rotator against core's single-use refresh
// token and burns the whole family (401 refresh_token_reused). Core pushes
// the app-server its tokens over account/login/start, so the file is not
// needed -- remove any that 3.1.11 left behind.
for (const dir of agentDirs) {
const rotated = path.join(dir, "codex-home", "auth.json");
if (!fs.existsSync(rotated)) continue;
try {
fs.rmSync(rotated);
log(`Codex auth.json removed rotating copy: ${rotated}`);
} catch (error) {
log(`Codex auth.json: could not remove ${rotated}: ${error.message}`);
}
}
if (synced === 0) log("Codex auth.json: mirrors already current");
}

try {
Expand Down
11 changes: 11 additions & 0 deletions scripts/gateway-pre-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,17 @@ fi
# the API-key path, which core reads from this file and which has no rotation
# problem.
if [ "$NEEDS_CODEX_PLUGIN" = "1" ]; then
# Credentials written by the setup wizard can land only in the legacy
# <agentDir>/auth-profiles.json, while core 2026.7.x resolves auth from the
# auth_profile_store table of openclaw-agent.sqlite. When that happens core
# attaches no profile (`profile=-` in the log), sends no bearer, and every
# turn 401s while the UI still shows the provider as connected. Migrate
# first, so the mirror below reads a populated store.
AUTH_PROFILE_MIGRATION="${CLAWBOX_ROOT:-/home/clawbox/clawbox}/scripts/migrate-auth-profiles.js"
if [ -f "$AUTH_PROFILE_MIGRATION" ]; then
node "$AUTH_PROFILE_MIGRATION" "$OPENCLAW_HOME_DIR" || true
fi

CODEX_AUTH_MIRROR="${CLAWBOX_ROOT:-/home/clawbox/clawbox}/scripts/codex-auth-mirror.js"
if [ -f "$CODEX_AUTH_MIRROR" ]; then
node "$CODEX_AUTH_MIRROR" "$OPENCLAW_HOME_DIR" "$HOME/.codex/auth.json" || true
Expand Down
121 changes: 121 additions & 0 deletions scripts/migrate-auth-profiles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Migrate legacy auth-profiles.json credentials into the sqlite auth profile
* store that OpenClaw core reads at runtime.
*
* WHY THIS EXISTS
*
* Auth profiles used to live in <agentDir>/auth-profiles.json. On core
* 2026.7.x they live in the auth_profile_store table of
* openclaw-agent.sqlite, and the JSON file is treated as legacy — core's own
* doctor offers to "Repair legacy auth-profiles.json files".
*
* A ClawBox that signs in through the setup wizard can still end up with the
* credential only in the JSON file. Core then resolves no auth profile for the
* model (`profile=-` in the gateway log), sends the request with no bearer, and
* every turn fails with 401 — while the UI cheerfully shows the provider as
* connected, because the JSON file is there.
*
* Seen on a factory-fresh box on 2026-07-28: auth-profiles.json held
* codex:default, llamacpp:default and deepseek:default; auth_profile_store had
* ZERO rows. Migrating the three across moved codex from 401 to a real API
* response.
*
* Copy-don't-move: the JSON file is left untouched so a core downgrade still
* finds it, and existing sqlite entries always win (they are the live ones).
*
* Exit code is always 0 — this must never block the gateway from starting.
*/

const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");

const openclawHome =
process.argv[2] || process.env.OPENCLAW_HOME_DIR || path.join(os.homedir(), ".openclaw");
const quiet = process.env.MIGRATE_AUTH_PROFILES_QUIET === "1";

function log(message) {
if (!quiet) console.log(" " + message);
}

function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
return null;
}
}

function migrateAgent(agentDir) {
const legacy = readJson(path.join(agentDir, "auth-profiles.json"));
const legacyProfiles = (legacy && legacy.profiles) || null;
if (!legacyProfiles || Object.keys(legacyProfiles).length === 0) return null;

const dbPath = path.join(agentDir, "openclaw-agent.sqlite");
if (!fs.existsSync(dbPath)) return null;

const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(dbPath);
try {
const row = db
.prepare("SELECT store_json FROM auth_profile_store WHERE store_key = ?")
.get("primary");
const store = row && row.store_json ? JSON.parse(row.store_json) : {};
store.profiles = store.profiles || {};

const migrated = [];
for (const [id, profile] of Object.entries(legacyProfiles)) {
// Never clobber: whatever core already has is the live credential.
if (store.profiles[id]) continue;
store.profiles[id] = profile;
migrated.push(id);
}
if (migrated.length === 0) return null;

// updated_at is NOT NULL in this table.
const now = Date.now();
if (row) {
db.prepare(
"UPDATE auth_profile_store SET store_json = ?, updated_at = ? WHERE store_key = ?",
).run(JSON.stringify(store), now, "primary");
} else {
db.prepare(
"INSERT INTO auth_profile_store (store_key, store_json, updated_at) VALUES (?, ?, ?)",
).run("primary", JSON.stringify(store), now);
}
return migrated;
} finally {
db.close();
}
}

function main() {
const agentsRoot = path.join(openclawHome, "agents");
if (!fs.existsSync(agentsRoot)) return;

let total = 0;
for (const id of fs.readdirSync(agentsRoot)) {
const agentDir = path.join(agentsRoot, id, "agent");
if (!fs.existsSync(agentDir)) continue;
let migrated = null;
try {
migrated = migrateAgent(agentDir);
} catch (error) {
// DB locked mid-write, missing node:sqlite, table absent — all non-fatal.
log(`auth profiles: ${id}: ${error.message}`);
continue;
}
if (migrated) {
total += migrated.length;
log(`auth profiles migrated to sqlite (${id}): ${migrated.join(", ")}`);
}
}
if (total === 0) log("auth profiles: sqlite store already current");
}

try {
main();
} catch (error) {
log("auth profiles: " + error.message);
}
Loading
Loading