garrytan/gstack · error · Error
token-registry already initialized with a different token; e
Error message
token-registry already initialized with a different token; embedders must call buildFetchHandler before any registry-mutating code path
What it means
Thrown by initRegistry when it is called a second time with a different root token. The registry is meant to be initialized exactly once per process; same-token re-init is a no-op, but a different token signals two embedders fighting over who owns the registry and would silently invalidate every scoped session token already minted.
Source
Thrown at browse/src/token-registry.ts:155
return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 100) };
}
bucket.count++;
return { allowed: true };
}
// ─── Token Registry ─────────────────────────────────────────────
const tokens = new Map<string, TokenInfo>();
let rootToken: string = '';
export function initRegistry(root: string): void {
// Idempotent re-init: same token is a no-op so embedders can call this
// alongside any prior call without fighting. Different token after init
// means a misconfigured caller — throw clearly rather than silently
// invalidate every scoped token already issued.
if (rootToken !== '' && rootToken !== root) {
throw new Error(
'token-registry already initialized with a different token; ' +
'embedders must call buildFetchHandler before any registry-mutating code path'
);
}
rootToken = root;
}
export function getRootToken(): string {
return rootToken;
}
export function isRootToken(token: string): boolean {
// Constant-time compare so a tunnel-reachable caller who can provoke an
// isRootToken() call (e.g., via the 403 "root over tunnel" rejection path)
// can't measure byte-by-byte string-compare timing to recover the token.
// Compare UTF-8 byte lengths (not JS string length) before timingSafeEqual,
// which throws on length-mismatched buffers. A multibyte input whose JS
// string length matches rootToken but whose UTF-8 byte length differs mustView on GitHub (pinned to 94993f7401)
Solutions
- Ensure only one caller invokes buildFetchHandler / initRegistry per process — the one that owns the root token.
- In tests, isolate the token-registry module per test (jest.resetModules()) or factor the root token into a beforeAll fixture.
- If two services legitimately need different roots, run them in separate processes.
- Check for accidental double import (different import paths resolving to different module instances) that each call init.
Example fix
// before
buildFetchHandler({ rootToken: 'tok-a' });
buildFetchHandler({ rootToken: 'tok-b' }); // throws
// after
buildFetchHandler({ rootToken: 'tok-a' }); // single owner; later calls reuse tok-a Defensive patterns
Strategy: validation
Validate before calling
import { getRootToken } from './token-registry';
function safeInitRegistry(root: string): void {
const current = getRootToken();
if (current && current !== root) {
throw new Error(`registry already owns a different root token; refusing to re-init`);
}
initRegistry(root); // no-op when same token
} Try / catch
try {
buildFetchHandler({ rootToken });
} catch (e: any) {
if (/token-registry already initialized/.test(e.message)) {
// another embedder owns the registry; adopt its root instead
rootToken = getRootToken();
} else throw e;
} Prevention
- Centralize buildFetchHandler / initRegistry in a single bootstrap path.
- In tests, call jest.resetModules() between cases that touch the registry.
- Run services that need different root tokens in separate processes.
- Read the existing root with getRootToken() before initializing.
When it happens
Trigger: Two embedders in the same process both calling buildFetchHandler (which internally calls initRegistry) with different root tokens; a test harness that reuses the module across cases without resetting rootToken; hot-reload re-importing the token-registry module.
Common situations: Embedding the browse server inside a larger app that also has its own auth bootstrap; Jest/Vitest module caches that retain rootToken across tests; a worker pool where multiple workers share a single registry module.
Related errors
- rateLimit must be >= 0
- expiresSeconds must be >= 0 or null
- commitSkill: tier "${opts.tier}" has no resolved path.
- commitSkill: a skill named "${opts.name}" already exists at
- skill command requires daemonPort in MetaCommandOpts (server
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/726c2923094918c3.
Report an issue: GitHub.