313 lines
11 KiB
JavaScript
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)
|
|
})
|