daemon: fixa TDZ-dispatch + hook-port-per-instans (flera klienter/host)
All checks were successful
build-and-push / build (push) Successful in 9s
release-client / build-release (push) Successful in 46s

Två buggar som hittades vid test mot riktiga servern:

1. Dispatchen (`void runConnect()`) låg överst i filen, ovanför module-level
   const CONFIG_DIR/CRED_FILE. runConnect:s synkrona del (fram till första
   await) kördes då under modul-init medan de const:erna var i TDZ → loadCreds()
   fick undefined path → returnerade null → klienten körde alltid nytt
   device-flöde istället för att återuppta. Flyttad sist i filen.

2. Hook-porten var hårdkodad (8788) → flera daemon-instanser på samma host
   krockade (EADDRINUSE) och kraschade. Nu: bind hook-porten FÖRST på en
   ephemeral port (om AGENT_HELM_HOOK_PORT ej satt) och skicka faktiska porten
   till agenten; bind-fel är icke-fatalt (kör vidare utan hook).

Verifierat mot rcai.brasse-pc.eu: resume återupptar rätt session, --new ger en
parallell session, två instanser kör samtidigt med var sin hook-port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
This commit is contained in:
2026-06-29 01:42:39 +02:00
parent 94259b3456
commit b3204de62e

View File

@@ -29,12 +29,6 @@ type Decision = { decision: "allow" | "deny"; reason?: string };
const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788);
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
const cmd = process.argv[2];
if (cmd === "hook") void runHookMode();
else if (cmd === "logout") logout();
else if (cmd === "whoami") whoami();
else void runConnect();
/* ------------------------------ creds-hantering ---------------------------- */
interface Creds {
@@ -184,21 +178,17 @@ function runAgent(wsUrl: string, token: string, sessionId: string, clientName: s
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow";
const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000);
// Explicit hook-port om satt; annars en ledig (ephemeral) port så flera
// instanser på samma host inte krockar. Faktiska porten skickas till agenten.
const explicitHookPort = process.env.AGENT_HELM_HOOK_PORT ? Number(process.env.AGENT_HELM_HOOK_PORT) : 0;
const COLS = 100;
const ROWS = 30;
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid})`);
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let authFailed = false;
let pty: IPty | null = null;
let started = false;
const pending = new Map<string, (d: Decision) => void>();
function send(msg: DaemonMessage): void {
@@ -215,7 +205,7 @@ function runAgent(wsUrl: string, token: string, sessionId: string, clientName: s
const msg = parseMessage<{ type: string; [k: string]: any }>(raw.toString());
if (!msg) return;
if (msg.type === "server:input") {
pty.write(msg.data);
pty?.write(msg.data);
} else if (msg.type === "server:approval-decision") {
const resolve = pending.get(msg.approvalId);
if (resolve) {
@@ -232,7 +222,7 @@ function runAgent(wsUrl: string, token: string, sessionId: string, clientName: s
});
ws.on("close", () => {
if (authFailed) {
pty.kill();
pty?.kill();
process.exit(1);
}
console.log("[daemon] anslutning stängd — återansluter om 2s");
@@ -249,14 +239,34 @@ function runAgent(wsUrl: string, token: string, sessionId: string, clientName: s
}, 2000);
}
pty.onData((data) => send({ type: "daemon:output", data }));
pty.onExit(({ exitCode }) => {
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
send({ type: "daemon:exit", code: exitCode });
setTimeout(() => process.exit(exitCode), 200);
});
function startPty(hookPort: number): void {
if (started) return;
started = true;
pty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(hookPort) } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid})`);
pty.onData((data) => send({ type: "daemon:output", data }));
pty.onExit(({ exitCode }) => {
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
send({ type: "daemon:exit", code: exitCode });
setTimeout(() => process.exit(exitCode), 200);
});
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
connect();
}
// Lokal hook-endpoint (endast 127.0.0.1).
function shutdown(): void {
pty?.kill();
process.exit(0);
}
// Lokal hook-endpoint (endast 127.0.0.1). Binds FÖRST så agenten får porten.
const hookServer = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/approve") {
res.writeHead(404);
@@ -293,18 +303,16 @@ function runAgent(wsUrl: string, token: string, sessionId: string, clientName: s
send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput });
});
});
hookServer.listen(HOOK_PORT, "127.0.0.1", () => {
console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`);
hookServer.on("error", (e: NodeJS.ErrnoException) => {
console.error(`[daemon] kunde inte binda hook-porten (${e.code ?? e.message}) — kör utan hook (default: ${HOOK_DEFAULT}).`);
startPty(explicitHookPort || HOOK_PORT);
});
hookServer.listen(explicitHookPort, "127.0.0.1", () => {
const addr = hookServer.address();
const port = typeof addr === "object" && addr ? addr.port : explicitHookPort;
console.log(`[daemon] hook-endpoint på 127.0.0.1:${port} (default vid ingen UI: ${HOOK_DEFAULT})`);
startPty(port);
});
function shutdown(): void {
pty.kill();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
connect();
}
/* --------------------------------- hook-läge ------------------------------- */
@@ -372,3 +380,14 @@ function askDaemon(toolName: string, toolInput: unknown): Promise<Decision> {
req.end();
});
}
/* --------------------------------- dispatch -------------------------------- */
// VIKTIGT: körs sist, efter att alla module-level const (CONFIG_DIR/CRED_FILE …)
// och funktioner initierats. Annars skulle runConnect:s synkrona del (fram till
// första await) köras under modul-init och läsa dessa const i TDZ → loadCreds()
// får en undefined path → returnerar null → onödigt nytt device-flöde.
const cmd = process.argv[2];
if (cmd === "hook") void runHookMode();
else if (cmd === "logout") logout();
else if (cmd === "whoami") whoami();
else void runConnect();