Skip to content

Commit 554aebc

Browse files
author
Dataflow Dev
committed
fix: persist session binding to disk to survive restarts
Addresses bot feedback on PR #1171: the in-memory session binding was bypassed by simply restarting the CLI process. Now the binding is persisted to ~/.config/manicode/session-binding.json alongside the session lifecycle: - Persisted when session becomes active - Loaded on startup and restored to in-memory state - Cleared when session ends or on force-logout - Checked on startup against current credentials This closes the restart-bypass abuse path: a user who Ctrl-C's and restarts with different credentials will hit the startup guard, which reads the persisted binding and ends the session if the user changed.
1 parent 792fcc1 commit 554aebc

5 files changed

Lines changed: 178 additions & 3 deletions

File tree

cli/src/commands/command-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
getSessionBoundUserId,
1919
} from '../hooks/use-freebuff-session'
2020
import { releaseFreebuffSlot } from '../utils/freebuff-session-api'
21+
import { clearSessionBinding } from '../utils/session-binding'
2122
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
2223
import { useThemeStore } from '../hooks/use-theme'
2324
import { LOGIN_WEBSITE_URL, WEBSITE_URL } from '../login/constants'
@@ -322,6 +323,7 @@ const ALL_COMMANDS: CommandDefinition[] = [
322323
if (boundUserId && force) {
323324
releaseFreebuffSlot().catch(() => {})
324325
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
326+
clearSessionBinding()
325327
}
326328

327329
const { resetLoginState } = useLoginStore.getState()

cli/src/hooks/use-auth-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { clearUserCredentials, getUserCredentials } from '../utils/auth'
99
import { resetCodebuffClient } from '../utils/codebuff-client'
1010
import { IS_FREEBUFF } from '../utils/constants'
1111
import { logger, loggerContext } from '../utils/logger'
12+
import { clearSessionBinding } from '../utils/session-binding'
1213

1314
import type { MultilineInputHandle } from '../components/multiline-input'
1415
import type { User } from '../utils/auth'

cli/src/hooks/use-freebuff-session.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ import {
2727
recordFreebuffInstanceOwner,
2828
} from '../utils/freebuff-instance-owner'
2929
import { logger } from '../utils/logger'
30+
import {
31+
clearSessionBinding,
32+
persistSessionBinding,
33+
readSessionBinding,
34+
} from '../utils/session-binding'
3035
import { getSystemMessage } from '../utils/message-history'
3136
import {
3237
clearReferralCache,
@@ -425,17 +430,21 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
425430

426431
// Startup guard: if a session is bound to a different user, end it
427432
// immediately to prevent multi-account abuse on the same machine.
428-
const { sessionBoundUserId } = useFreebuffSessionStore.getState()
433+
// Check both in-memory state and persisted binding (survives restarts).
434+
const inMemoryBoundUserId = useFreebuffSessionStore.getState().sessionBoundUserId
435+
const persistedBoundUserId = readSessionBinding()
436+
const boundUserId = inMemoryBoundUserId ?? persistedBoundUserId
429437
const currentUser = getUserCredentials()
430-
if (sessionBoundUserId && currentUser?.id !== sessionBoundUserId) {
438+
if (boundUserId && currentUser?.id !== boundUserId) {
431439
logger.warn(
432440
{
433-
sessionBoundUserId,
441+
sessionBoundUserId: boundUserId,
434442
currentUserId: currentUser?.id,
435443
},
436444
'[freebuff-session] Session bound to different user; ending session',
437445
)
438446
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
447+
clearSessionBinding()
439448
releaseFreebuffSlot().catch(() => {})
440449
setSession({
441450
status: 'ended',
@@ -446,6 +455,14 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
446455
return
447456
}
448457

458+
// If we have a persisted binding but no in-memory state (e.g. after restart),
459+
// restore the binding so the guard works on the next startup too.
460+
if (persistedBoundUserId && !inMemoryBoundUserId) {
461+
useFreebuffSessionStore
462+
.getState()
463+
.setSessionBoundUserId(persistedBoundUserId)
464+
}
465+
449466
let cancelled = false
450467
let abortController = new AbortController()
451468
let timer: ReturnType<typeof setTimeout> | null = null
@@ -474,11 +491,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
474491
if (next.status === 'active') {
475492
recordFreebuffInstanceOwner(next.instanceId)
476493
// Bind the session to the current user to prevent multi-account abuse.
494+
// Persist to disk so the binding survives process restarts.
477495
const credentials = getUserCredentials()
478496
if (credentials?.id) {
479497
useFreebuffSessionStore
480498
.getState()
481499
.setSessionBoundUserId(credentials.id)
500+
persistSessionBinding(credentials.id)
482501
}
483502
} else if (
484503
next.status === 'ended' ||
@@ -487,6 +506,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
487506
) {
488507
// Clear the binding when the session is no longer active.
489508
useFreebuffSessionStore.getState().setSessionBoundUserId(null)
509+
clearSessionBinding()
490510
}
491511
setSession(next)
492512
setFailure(null)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2+
import fs from 'fs'
3+
4+
import { getConfigDir } from '../auth'
5+
import {
6+
persistSessionBinding,
7+
readSessionBinding,
8+
clearSessionBinding,
9+
} from '../session-binding'
10+
11+
const getBindingPath = () =>
12+
require('path').join(getConfigDir(), 'session-binding.json')
13+
14+
describe('session-binding persistence', () => {
15+
const bindingPath = getBindingPath()
16+
17+
beforeEach(() => {
18+
try {
19+
fs.mkdirSync(getConfigDir(), { recursive: true })
20+
} catch {
21+
// ignore
22+
}
23+
try {
24+
fs.unlinkSync(bindingPath)
25+
} catch {
26+
// ignore
27+
}
28+
})
29+
30+
afterEach(() => {
31+
try {
32+
fs.unlinkSync(bindingPath)
33+
} catch {
34+
// ignore
35+
}
36+
})
37+
38+
it('readSessionBinding returns null when no file exists', () => {
39+
expect(readSessionBinding()).toBeNull()
40+
})
41+
42+
it('persistSessionBinding writes a JSON file with userId', () => {
43+
persistSessionBinding('user-abc')
44+
45+
const raw = fs.readFileSync(bindingPath, 'utf8')
46+
const parsed = JSON.parse(raw)
47+
expect(parsed.userId).toBe('user-abc')
48+
})
49+
50+
it('readSessionBinding reads back the persisted userId', () => {
51+
persistSessionBinding('user-abc')
52+
expect(readSessionBinding()).toBe('user-abc')
53+
})
54+
55+
it('persistSessionBinding overwrites previous binding', () => {
56+
persistSessionBinding('user-abc')
57+
persistSessionBinding('user-xyz')
58+
59+
expect(readSessionBinding()).toBe('user-xyz')
60+
})
61+
62+
it('clearSessionBinding removes the file', () => {
63+
persistSessionBinding('user-abc')
64+
clearSessionBinding()
65+
66+
expect(fs.existsSync(bindingPath)).toBe(false)
67+
expect(readSessionBinding()).toBeNull()
68+
})
69+
70+
it('clearSessionBinding is idempotent', () => {
71+
persistSessionBinding('user-abc')
72+
clearSessionBinding()
73+
clearSessionBinding() // second call should not throw
74+
75+
expect(readSessionBinding()).toBeNull()
76+
})
77+
78+
it('readSessionBinding returns null for malformed JSON', () => {
79+
fs.writeFileSync(bindingPath, 'not-json')
80+
expect(readSessionBinding()).toBeNull()
81+
})
82+
83+
it('readSessionBinding returns null when userId is missing', () => {
84+
fs.writeFileSync(bindingPath, JSON.stringify({ other: 'data' }))
85+
expect(readSessionBinding()).toBeNull()
86+
})
87+
})

cli/src/utils/session-binding.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
4+
import { getConfigDir } from './auth'
5+
import { logger } from './logger'
6+
7+
const SESSION_BINDING_FILE = 'session-binding.json'
8+
9+
interface SessionBinding {
10+
userId: string
11+
}
12+
13+
const getBindingPath = (): string =>
14+
path.join(getConfigDir(), SESSION_BINDING_FILE)
15+
16+
/**
17+
* Persist the session-bound user id to disk so it survives process restarts.
18+
* Without this, a user could Ctrl-C and restart the CLI to clear the in-memory
19+
* binding and switch accounts.
20+
*/
21+
export function persistSessionBinding(userId: string): void {
22+
try {
23+
fs.mkdirSync(getConfigDir(), { recursive: true })
24+
fs.writeFileSync(
25+
getBindingPath(),
26+
JSON.stringify({ userId } satisfies SessionBinding, null, 2),
27+
)
28+
} catch (error) {
29+
logger.debug(
30+
{ error: error instanceof Error ? error.message : String(error) },
31+
'[session-binding] Failed to persist binding',
32+
)
33+
}
34+
}
35+
36+
/**
37+
* Read the persisted session-bound user id from disk. Returns null if no
38+
* binding exists or the file is malformed.
39+
*/
40+
export function readSessionBinding(): string | null {
41+
try {
42+
const raw = fs.readFileSync(getBindingPath(), 'utf8')
43+
const parsed = JSON.parse(raw) as Partial<SessionBinding>
44+
if (typeof parsed.userId !== 'string') return null
45+
return parsed.userId
46+
} catch {
47+
return null
48+
}
49+
}
50+
51+
/**
52+
* Clear the persisted session binding from disk.
53+
*/
54+
export function clearSessionBinding(): void {
55+
try {
56+
if (fs.existsSync(getBindingPath())) {
57+
fs.unlinkSync(getBindingPath())
58+
}
59+
} catch (error) {
60+
logger.debug(
61+
{ error: error instanceof Error ? error.message : String(error) },
62+
'[session-binding] Failed to clear binding',
63+
)
64+
}
65+
}

0 commit comments

Comments
 (0)