fix(auth): persist internal token across restarts#418
Conversation
Read the existing token from ~/.mitzo/internal-token on startup instead of generating a new one every time. Only generates a fresh token when the file is missing or corrupt. Fixes 401 errors for agents and hooks after every Mitzo restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 2 issue(s) (1 warning).
server/internal-token.ts
Solid, low-risk fix that correctly persists the internal token across restarts. Main gap is missing unit tests for the new loadOrCreateToken function.
- 🟡 missing_tests (L11): The new
loadOrCreateToken()function has no unit tests. The existingauth.test.tstests coverisValidInternalTokenand the auth middleware, but there are no tests for the load-or-create logic: reading an existing valid token from disk, rejecting a corrupted/truncated file and regenerating, creating a new token when the file is missing, or verifying file permissions (0o600). Since the CLAUDE.md mandates TDD, this function should have been test-first.[fixable] - 🔵 unsafe_assumptions (L14): The regex
/^[0-9a-f]+$/only matches lowercase hex.randomBytes().toString('hex')always produces lowercase, but if a user or external tool manually wrote an uppercase hex token to the file (e.g.A1B2...), it would be silently rejected and overwritten. Consider using/^[0-9a-fA-F]+$/ior documenting that only lowercase is accepted.[fixable]
| // Non-fatal — hooks will fall back gracefully | ||
| /** Load existing token from disk or generate a new one. | ||
| * Persists across restarts so external clients (agents, hooks) stay authenticated. */ | ||
| function loadOrCreateToken(): string { |
There was a problem hiding this comment.
🟡 missing_tests: The new loadOrCreateToken() function has no unit tests. The existing auth.test.ts tests cover isValidInternalToken and the auth middleware, but there are no tests for the load-or-create logic: reading an existing valid token from disk, rejecting a corrupted/truncated file and regenerating, creating a new token when the file is missing, or verifying file permissions (0o600). Since the CLAUDE.md mandates TDD, this function should have been test-first. [fixable]
| function loadOrCreateToken(): string { | ||
| try { | ||
| const existing = readFileSync(TOKEN_PATH, 'utf-8').trim(); | ||
| if (existing.length === TOKEN_LENGTH && /^[0-9a-f]+$/.test(existing)) { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: The regex /^[0-9a-f]+$/ only matches lowercase hex. randomBytes().toString('hex') always produces lowercase, but if a user or external tool manually wrote an uppercase hex token to the file (e.g. A1B2...), it would be silently rejected and overwritten. Consider using /^[0-9a-fA-F]+$/i or documenting that only lowercase is accepted. [fixable]
Address Centaur review: 7 tests covering read existing, reject corrupt, regenerate on missing, trim whitespace, consistency. Also accept uppercase hex tokens in validation regex. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 4 issue(s) (1 warning).
server/__tests__/internal-token.test.ts
Core logic is correct and the persistence fix is sound. Main concern is that the test duplicates the function rather than testing the real one, introducing subtle divergences (missing try/catch around writes, platform-specific path splitting).
- 🟡 bugs (L22): The test-local copy of
loadOrCreateTokendiverges from the production version inserver/internal-token.ts. The production code wrapsmkdirSync/writeFileSyncin a try/catch (non-fatal), while the test copy does not. This means the test is validating a slightly different function than what ships. If a future fix touches the error handling path, the test won't catch regressions. Consider extracting the function with a configurable path parameter in the production module (or usingvi.stubEnv/dependency injection) rather than duplicating the logic.[fixable] - 🔵 bugs (L22):
tokenPath.substring(0, tokenPath.lastIndexOf('/'))uses a hardcoded/separator. The production code usesjoin(homedir(), '.mitzo')which is platform-aware viapath.join. While macOS is the only target today, this is a subtle divergence from the production implementation. Preferpath.dirname(tokenPath)for consistency.[fixable] - 🔵 missing_tests: No test covers the non-fatal write failure path (production lines 23-27). The production code silently swallows write errors and still returns the generated token. The test copy doesn't have this try/catch, so this behavior is unverified. A test with a read-only directory would cover this.
[fixable]
server/internal-token.ts
Core logic is correct and the persistence fix is sound. Main concern is that the test duplicates the function rather than testing the real one, introducing subtle divergences (missing try/catch around writes, platform-specific path splitting).
- 🔵 unsafe_assumptions (L14): The regex
/^[0-9a-fA-F]+$/accepts uppercase hex on read, butrandomBytes(32).toString('hex')always generates lowercase. This is fine for accepting externally-edited files, butisValidInternalTokenat line 37 usestimingSafeEqualon the raw string — a token written as uppercase would work on load but an external client sending the same uppercase value would match. No bug, but worth noting the case-sensitivity contract is intentionally loose on read.
| } | ||
|
|
||
| const token = randomBytes(32).toString('hex'); | ||
| const dir = tokenPath.substring(0, tokenPath.lastIndexOf('/')); |
There was a problem hiding this comment.
🟡 bugs: The test-local copy of loadOrCreateToken diverges from the production version in server/internal-token.ts. The production code wraps mkdirSync/writeFileSync in a try/catch (non-fatal), while the test copy does not. This means the test is validating a slightly different function than what ships. If a future fix touches the error handling path, the test won't catch regressions. Consider extracting the function with a configurable path parameter in the production module (or using vi.stubEnv/dependency injection) rather than duplicating the logic. [fixable]
| } | ||
|
|
||
| const token = randomBytes(32).toString('hex'); | ||
| const dir = tokenPath.substring(0, tokenPath.lastIndexOf('/')); |
There was a problem hiding this comment.
🔵 bugs: tokenPath.substring(0, tokenPath.lastIndexOf('/')) uses a hardcoded / separator. The production code uses join(homedir(), '.mitzo') which is platform-aware via path.join. While macOS is the only target today, this is a subtle divergence from the production implementation. Prefer path.dirname(tokenPath) for consistency. [fixable]
| function loadOrCreateToken(): string { | ||
| try { | ||
| const existing = readFileSync(TOKEN_PATH, 'utf-8').trim(); | ||
| if (existing.length === TOKEN_LENGTH && /^[0-9a-fA-F]+$/.test(existing)) { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: The regex /^[0-9a-fA-F]+$/ accepts uppercase hex on read, but randomBytes(32).toString('hex') always generates lowercase. This is fine for accepting externally-edited files, but isValidInternalToken at line 37 uses timingSafeEqual on the raw string — a token written as uppercase would work on load but an external client sending the same uppercase value would match. No bug, but worth noting the case-sensitivity contract is intentionally loose on read.
Address second Centaur review: extract function with configurable path, test the real implementation instead of a copy, add write failure test, use path.dirname instead of string splitting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 4 issue(s) (1 warning).
server/internal-token.ts
Clean, well-tested change that correctly persists the internal token across restarts. No bugs — just minor hardening suggestions around permission testing, corruption logging, and tightening the hex regex to match actual output.
- 🟡 bugs (L15): loadOrCreateToken accepts uppercase hex tokens (regex allows a-fA-F), but isValidInternalToken uses timingSafeEqual which is case-sensitive. If someone manually writes an uppercase token to ~/.mitzo/internal-token, the loaded INTERNAL_TOKEN will be uppercase, but callers (chat.ts line 576) pass this value as MITZO_INTERNAL_TOKEN env var — the task-mcp-server then sends it back in headers — so it matches itself. This is internally consistent because the same INTERNAL_TOKEN is used on both sides. Not a bug in practice, but the uppercase acceptance is dead code since randomBytes().toString('hex') always produces lowercase — consider tightening the regex to /^[0-9a-f]+$/ to avoid a latent mismatch if a future caller normalizes case.
[fixable] - 🔵 unsafe_assumptions (L15): Corrupt file validation catches wrong length and non-hex, but doesn't log when it regenerates a token. A corrupted token file silently invalidates all existing hook/agent credentials with no audit trail. Consider logging at warn level when regenerating due to corruption (the logger module is already available in the project).
[fixable]
server/__tests__/internal-token.test.ts
Clean, well-tested change that correctly persists the internal token across restarts. No bugs — just minor hardening suggestions around permission testing, corruption logging, and tightening the hex regex to match actual output.
- 🔵 missing_tests: No test verifies that the file is written with restrictive permissions (mode 0o600) or that the parent directory is created with mode 0o700. Since this is a security-sensitive token persisted to disk, a test asserting fs.statSync(tokenPath).mode & 0o777 === 0o600 would catch permission regressions.
[fixable] - 🔵 style (L12): Using Date.now() for temp directory uniqueness can collide if tests run in parallel within the same millisecond. crypto.randomUUID() or the node:fs mkdtempSync would be more robust, though this is unlikely to cause issues in practice.
[fixable]
| export function loadOrCreateToken(tokenPath: string = TOKEN_PATH): string { | ||
| try { | ||
| const existing = readFileSync(tokenPath, 'utf-8').trim(); | ||
| if (existing.length === TOKEN_LENGTH && /^[0-9a-fA-F]+$/.test(existing)) { |
There was a problem hiding this comment.
🟡 bugs: loadOrCreateToken accepts uppercase hex tokens (regex allows a-fA-F), but isValidInternalToken uses timingSafeEqual which is case-sensitive. If someone manually writes an uppercase token to ~/.mitzo/internal-token, the loaded INTERNAL_TOKEN will be uppercase, but callers (chat.ts line 576) pass this value as MITZO_INTERNAL_TOKEN env var — the task-mcp-server then sends it back in headers — so it matches itself. This is internally consistent because the same INTERNAL_TOKEN is used on both sides. Not a bug in practice, but the uppercase acceptance is dead code since randomBytes().toString('hex') always produces lowercase — consider tightening the regex to /^[0-9a-f]+$/ to avoid a latent mismatch if a future caller normalizes case. [fixable]
| export function loadOrCreateToken(tokenPath: string = TOKEN_PATH): string { | ||
| try { | ||
| const existing = readFileSync(tokenPath, 'utf-8').trim(); | ||
| if (existing.length === TOKEN_LENGTH && /^[0-9a-fA-F]+$/.test(existing)) { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: Corrupt file validation catches wrong length and non-hex, but doesn't log when it regenerates a token. A corrupted token file silently invalidates all existing hook/agent credentials with no audit trail. Consider logging at warn level when regenerating due to corruption (the logger module is already available in the project). [fixable]
| let tokenPath: string; | ||
|
|
||
| beforeEach(() => { | ||
| testDir = join(tmpdir(), `mitzo-token-test-${Date.now()}`); |
There was a problem hiding this comment.
🔵 style: Using Date.now() for temp directory uniqueness can collide if tests run in parallel within the same millisecond. crypto.randomUUID() or the node:fs mkdtempSync would be more robust, though this is unlikely to cause issues in practice. [fixable]
Summary
~/.mitzo/internal-tokenon startup instead of generating a new one every timemitzo_clientafter every Mitzo restart or crash recoveryRoot cause
INTERNAL_TOKEN = randomBytes(32).toString('hex')ran on every import, overwriting the persisted token. Any external client (PR Shepherd, session hooks, CLI) holding the old token got 401'd after a restart. Tonight this broke the Task Board API entirely after a crash + launchd recovery + dev server token collision.Test plan
mitzo_clientAPI calls work after restart🤖 Generated with Claude Code