Files
hermes-control-panel/test/status-identities.test.cjs
T

313 lines
11 KiB
JavaScript

"use strict"
const assert = require("assert")
const fs = require("fs")
const http = require("http")
const os = require("os")
const path = require("path")
const { spawn } = require("child_process")
const root = path.join(__dirname, "..")
const server = path.join(root, "server.cjs")
function jwt(claims) {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url")
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
return `${header}.${payload}.`
}
function requestJson(url) {
return new Promise((resolve, reject) => {
http.get(url, (res) => {
let body = ""
res.setEncoding("utf8")
res.on("data", (chunk) => { body += chunk })
res.on("end", () => {
try {
resolve(JSON.parse(body))
} catch (err) {
reject(err)
}
})
}).on("error", reject)
})
}
function postJson(url, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const payload = JSON.stringify(body)
const req = http.request({
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
}, (res) => {
let raw = ""
res.setEncoding("utf8")
res.on("data", (chunk) => { raw += chunk })
res.on("end", () => {
try {
resolve(JSON.parse(raw))
} catch (err) {
reject(err)
}
})
})
req.on("error", reject)
req.write(payload)
req.end()
})
}
function startStatusServer(t, options) {
const {
hermesHome,
codexHome,
claudeHome,
geminiHome,
fakeHermes,
port,
} = options
const env = {
...process.env,
HOME: path.dirname(hermesHome),
HERMES_HOME: hermesHome,
CODEX_HOME: codexHome,
CLAUDE_CONFIG_DIR: claudeHome,
GEMINI_CONFIG_DIR: geminiHome,
HERMES_EXE: fakeHermes,
HERMES_SETUP_UI_HOST: "127.0.0.1",
HERMES_SETUP_UI_PORT: String(port)
}
const proc = spawn(process.execPath, [server], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] })
t.after(() => proc.kill())
return proc
}
async function waitForServer(url, proc) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
if (proc.exitCode !== null) break
try {
return await requestJson(url)
} catch {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
throw new Error("server did not become ready")
}
async function main() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-control-plane-test-"))
const hermesHome = path.join(tmp, ".hermes")
const codexHome = path.join(tmp, ".codex")
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(codexHome, { recursive: true })
fs.writeFileSync(path.join(hermesHome, "config.yaml"), "model:\n provider: openai-codex\n default: gpt-5\nfallback_providers: []\n")
fs.writeFileSync(path.join(codexHome, "auth.json"), JSON.stringify({ tokens: { id_token: jwt({ email: "zach@example.com", name: "Zach" }) } }))
fs.writeFileSync(path.join(codexHome, "authEmma.json"), JSON.stringify({ tokens: { id_token: jwt({ email: "emma@example.com", name: "Emma" }) } }))
fs.writeFileSync(path.join(codexHome, "authMom.json"), JSON.stringify({ tokens: { id_token: jwt({ email: "mom@example.com", name: "Mom" }) } }))
fs.writeFileSync(path.join(hermesHome, "auth.json"), JSON.stringify({
credential_pool: {
"openai-codex": [
{ id: "codex-main", label: "default", access_token: jwt({ sub: "codex-main" }) },
{ id: "codex-emma", label: "emma", access_token: jwt({ sub: "codex-emma" }) },
{ id: "codex-mom", label: "mom", access_token: jwt({ sub: "codex-mom" }) }
],
anthropic: [
{ id: "claude-1", label: "claude", access_token: jwt({ email: "claude@example.com", name: "Claude User" }) }
],
"google-gemini-cli": [
{ id: "gemini-1", label: "gemini", id_token: jwt({ email: "gemini@example.com", name: "Gemini User" }) }
]
}
}))
const fakeHermes = path.join(tmp, "hermes")
fs.writeFileSync(fakeHermes, `#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "list" ]; then
cat <<'EOF'
openai-codex (3 credentials):
#1 default ←
#2 emma
#3 mom
anthropic (1 credential):
#1 claude ←
google-gemini-cli (1 credential):
#1 gemini ←
EOF
elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then
echo "Primary: gpt-5 (via openai-codex)"
elif [ "$1" = "version" ]; then
echo "test-hermes"
else
echo "unsupported command: $*" >&2
exit 1
fi
`)
fs.chmodSync(fakeHermes, 0o755)
const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, {
hermesHome,
codexHome,
claudeHome: path.join(tmp, ".claude"),
geminiHome: path.join(tmp, ".gemini"),
fakeHermes,
port: 19743,
})
try {
const status = await waitForServer("http://127.0.0.1:19743/api/status", proc)
const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool]))
assert.strictEqual(byProvider["openai-codex"].authState.state, "authenticated")
assert.strictEqual(byProvider["openai-codex"].authState.label, "Authenticated")
assert.deepStrictEqual(byProvider["openai-codex"].entries.map((entry) => entry.email), [
"zach@example.com",
"emma@example.com",
"mom@example.com"
])
assert.strictEqual(byProvider.anthropic.entries[0].email, "claude@example.com")
assert.strictEqual(byProvider["google-gemini-cli"].entries[0].email, "gemini@example.com")
} finally {
proc.kill()
fs.rmSync(tmp, { recursive: true, force: true })
}
}
async function directMountedAuthMain() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-direct-auth-test-"))
const hermesHome = path.join(tmp, ".hermes")
const codexHome = path.join(tmp, ".codex")
const claudeHome = path.join(tmp, ".claude")
const geminiHome = path.join(tmp, ".gemini")
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(codexHome, { recursive: true })
fs.mkdirSync(claudeHome, { recursive: true })
fs.mkdirSync(geminiHome, { recursive: true })
fs.writeFileSync(path.join(hermesHome, "config.yaml"), "fallback_providers: []\n")
fs.writeFileSync(path.join(codexHome, "auth.json"), JSON.stringify({ tokens: { id_token: jwt({ email: "zach@example.com", name: "Zach" }) } }))
fs.writeFileSync(path.join(codexHome, "authZach.json"), JSON.stringify({
account: { email: "alt@example.com", name: "Alt Zach" },
tokens: {
id_token: jwt({ email: "alt@example.com", name: "Alt Zach" }),
access_token: jwt({ email: "alt-token@example.com", name: "Alt Token" }),
refresh_token: jwt({ email: "alt-refresh@example.com", name: "Alt Refresh" }),
},
}))
fs.writeFileSync(path.join(claudeHome, "auth.json"), JSON.stringify({ access_token: jwt({ email: "claude@example.com", name: "Claude User" }) }))
fs.writeFileSync(path.join(geminiHome, "auth.json"), JSON.stringify({ id_token: jwt({ email: "gemini@example.com", name: "Gemini User" }) }))
const fakeHermes = path.join(tmp, "hermes")
fs.writeFileSync(fakeHermes, `#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "list" ]; then
exit 0
elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then
echo "No fallback configured"
elif [ "$1" = "version" ]; then
echo "test-hermes"
else
exit 0
fi
`)
fs.chmodSync(fakeHermes, 0o755)
const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, {
hermesHome,
codexHome,
claudeHome,
geminiHome,
fakeHermes,
port: 19744,
})
try {
const status = await waitForServer("http://127.0.0.1:19744/api/status", proc)
const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool]))
assert.deepStrictEqual(byProvider["openai-codex"].entries.map((entry) => entry.identity), [
"zach@example.com",
"alt@example.com",
])
assert.deepStrictEqual(byProvider["openai-codex"].entries.map((entry) => entry.file), [
"auth.json",
"authZach.json",
])
assert.strictEqual(byProvider.anthropic.entries[0].identity, "claude@example.com")
assert.strictEqual(byProvider["google-gemini-cli"].entries[0].identity, "gemini@example.com")
assert.strictEqual(byProvider["openai-codex"].authState.state, "authenticated")
assert.strictEqual(byProvider.anthropic.authState.state, "authenticated")
assert.strictEqual(byProvider["google-gemini-cli"].authState.state, "authenticated")
const remove = await postJson("http://127.0.0.1:19744/api/auth/remove", {
provider: "openai-codex",
index: 2,
source: "mounted-auth",
file: "authZach.json",
})
assert.strictEqual(remove.exitCode, 0)
assert.strictEqual(fs.existsSync(path.join(codexHome, "authZach.json")), false)
const backups = fs.readdirSync(path.join(codexHome, ".hermes-control-plane-deleted-auth"))
assert.strictEqual(backups.some((file) => file.endsWith("-authZach.json")), true)
} finally {
proc.kill()
fs.rmSync(tmp, { recursive: true, force: true })
}
}
async function authStateMain() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-auth-state-test-"))
const hermesHome = path.join(tmp, ".hermes")
const codexHome = path.join(tmp, ".codex")
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(codexHome, { recursive: true })
fs.writeFileSync(path.join(hermesHome, "config.yaml"), "fallback_providers: []\n")
const fakeHermes = path.join(tmp, "hermes")
fs.writeFileSync(fakeHermes, `#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "list" ]; then
cat <<'EOF'
openai-codex (1 credential):
#1 default exhausted: monthly usage limit reached ←
anthropic (0 credentials):
EOF
elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then
echo "No fallback configured"
elif [ "$1" = "version" ]; then
echo "test-hermes"
else
exit 0
fi
`)
fs.chmodSync(fakeHermes, 0o755)
const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, {
hermesHome,
codexHome,
claudeHome: path.join(tmp, ".claude"),
geminiHome: path.join(tmp, ".gemini"),
fakeHermes,
port: 19745,
})
try {
const status = await waitForServer("http://127.0.0.1:19745/api/status", proc)
const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool]))
assert.strictEqual(byProvider["openai-codex"].authState.state, "usage_limited")
assert.match(byProvider["openai-codex"].authState.label, /Usage limit/i)
assert.strictEqual(byProvider.anthropic.authState.state, "unauthenticated")
assert.strictEqual(byProvider.anthropic.authState.label, "Unauthenticated")
} finally {
proc.kill()
fs.rmSync(tmp, { recursive: true, force: true })
}
}
main().then(directMountedAuthMain).then(authStateMain).catch((err) => {
console.error(err)
process.exit(1)
})