feat(tui): enforce the inactivity timeout instead of only storing it

inactivity_timeout was stored, displayed as Settings item 12, and enforced
nowhere: an unattended terminal stayed unlocked indefinitely. Python's legacy
TUI checks it at the top of its main loop and locks; this ports that.

The main menu now measures how long the user took to answer. If that exceeds
the configured timeout, the vault is locked -- the seed is dropped and
re-derived through the normal relock path (password, or the session agent) --
and the keystroke that arrived after the timeout is discarded rather than
acted on against a stale unlocked screen. 0 disables it, matching Python.

Scope, stated plainly: like Python's, this fires when the user returns to the
main menu, not via a background timer, so idling mid-submenu does not lock
until they surface. A true idle-anywhere auto-lock would mean interrupting a
readline mid-line and is a larger change; this matches the reference.

The millisecond clock is injected through the Session (defaults to Date.now),
so two tests drive it deterministically: idling past the timeout locks and
demands the password a second time, and answering within it does not. Verified
as a negative control -- forcing the timeout to 0 turns the locking test red.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-17 22:28:49 -04:00
1 parent 933cad31c5
commit a698255db6
3 files changed
+108

No files matched your search

+3
View File
@@ -24,6 +24,8 @@ import { mainMenu, type Session } from "./menus.js";
export interface TuiOptions {
appDir?: string;
fingerprint?: string;
/** Millisecond clock, injectable for tests; defaults to Date.now. */
clock?: () => number;
}
function isInteractive(): boolean {
@@ -97,6 +99,7 @@ export async function runTui(opts: TuiOptions, injectedUi?: Ui): Promise<number>
vault: await openVault(join(app.profileDir(fingerprint), INDEX_FILENAME), mnemonic),
config: await loadConfig(app.profileDir(fingerprint), mnemonic),
relock,
clock: opts.clock ?? (() => Date.now()),
};
} catch (e) {
process.stderr.write(`seedpass-js: ${(e as Error).message}\n`);
+29
View File
@@ -79,6 +79,8 @@ export interface Session {
config: Record<string, unknown>;
/** Re-prompt for the master password; used by Lock Vault and profile switch. */
relock: (fingerprint: string) => Promise<string>;
/** Milliseconds since epoch. Injectable so inactivity locking is testable. */
clock: () => number;
}
// ---------------------------------------------------------------- utilities
@@ -247,7 +249,18 @@ async function askInt(
// ------------------------------------------------------------- main menu
/**
* Inactivity timeout in milliseconds, from config (seconds). 0 disables it.
* Parity with Python's inactivity_timeout, which the legacy TUI also checks
* at the top of its main loop.
*/
function inactivityTimeoutMs(s: Session): number {
const raw = Number(s.config["inactivity_timeout"] ?? 0);
return Number.isFinite(raw) && raw > 0 ? raw * 1000 : 0;
}
export async function mainMenu(s: Session): Promise<number> {
const timeoutMs = inactivityTimeoutMs(s);
for (;;) {
title(s, "Main Menu");
menu(s.ui, [
@@ -260,7 +273,23 @@ export async function mainMenu(s: Session): Promise<number> {
{ key: "7", label: "Settings" },
{ key: "8", label: "List Archived" },
]);
const promptedAt = s.clock();
const choice = await s.ui.ask("Enter your choice (1-8) or press Enter to exit: ");
// If the user was away from the main menu longer than the timeout, lock
// the vault and require the password again before acting on their input.
// The seed is dropped and re-derived; the keystroke that arrived after the
// timeout is discarded rather than obeyed on a stale, unlocked screen.
if (timeoutMs > 0 && s.clock() - promptedAt > timeoutMs) {
warn(s.ui, "Session timed out. Vault locked.");
try {
const mnemonic = await s.relock(s.fingerprint);
s.vault = await openVault(s.vault.path, mnemonic);
} catch (e) {
fail(s.ui, `Vault remains locked: ${(e as Error).message}`);
return 1;
}
continue;
}
if (!choice) return 0;
try {
switch (choice) {