unsnooze 1.16.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/package.json +1 -1
- package/src/agents/kimi.js +8 -0
- package/src/askpass.js +3 -2
- package/src/config.js +170 -1
- package/src/dashboard/run.js +46 -6
- package/src/doctor.js +60 -2
- package/src/fleet.js +13 -10
- package/src/hook.js +3 -2
- package/src/lease.js +2 -2
- package/src/logger.js +6 -4
- package/src/multiplexers/headless.js +4 -2
- package/src/mux-sessions.js +2 -2
- package/src/resumer.js +4 -4
- package/src/sessions.js +19 -1
- package/src/settings.js +7 -6
- package/src/state.js +106 -15
- package/src/update-check.js +7 -5
- package/src/usage.js +22 -8
- package/src/watcher.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,160 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.17.0 — 2026-08-23
|
|
4
|
+
|
|
5
|
+
- **A state directory unsnooze cannot write to no longer pins a CPU core
|
|
6
|
+
forever.** The lock around `~/.unsnooze/state.json` treated every `mkdir`
|
|
7
|
+
failure as contention. `EEXIST` really is contention — someone else holds the
|
|
8
|
+
lock — but `EACCES` on an unwritable state directory, `EROFS` on a read-only
|
|
9
|
+
filesystem and `ENOSPC` on a full one are not, and that branch retried with
|
|
10
|
+
neither a sleep nor a deadline check. Since `mkdir -p` on a directory that
|
|
11
|
+
already exists succeeds as a no-op, there was nothing to break the cycle:
|
|
12
|
+
the loop spun at around 70% of a core, ignored `UNSNOOZE_LOCK_TIMEOUT_MS`
|
|
13
|
+
entirely, and never returned. It took every writer with it — the daemon, any
|
|
14
|
+
CLI command, and the StopFailure hook that runs *inside* your agent. unsnooze
|
|
15
|
+
now repairs a state directory it owns and otherwise fails on the deadline with
|
|
16
|
+
the errno that caused it.
|
|
17
|
+
|
|
18
|
+
- **Everything under `~/.unsnooze` is now owner-only (0700 / 0600).** The
|
|
19
|
+
directory was 0755 and every file inside it 0644, so on a shared machine any
|
|
20
|
+
other local user could read them. That directory holds your ntfy bearer token
|
|
21
|
+
(`config.json`), the text of queued prompts and the paths they run in
|
|
22
|
+
(`state.json`), your fleet's ssh destinations and the commands that fetch
|
|
23
|
+
their passwords (`hosts.json`), a mirror of every remote host's sessions
|
|
24
|
+
(`fleet-cache.json`), a lease file per pane carrying its working directory
|
|
25
|
+
(`leases/`), and — for a headless revival — the entire stdout and stderr of
|
|
26
|
+
an unattended agent run (`headless/*.log`). Every writer now creates its file
|
|
27
|
+
owner-only and its directory 0700.
|
|
28
|
+
|
|
29
|
+
Upgrading an existing install needed more than that, because none of it is
|
|
30
|
+
something a writer can fix: `mkdir`'s mode is ignored for a directory that
|
|
31
|
+
already exists, appending to a log reuses the file's inode, and
|
|
32
|
+
`config.json` and `hosts.json` are written only by `config set` and
|
|
33
|
+
`hosts add`, so an install that never touches them again would have kept
|
|
34
|
+
their old mode — and the token in one of them — indefinitely. So a state
|
|
35
|
+
write repairs the modes directly, walking the `events/`, `leases/`,
|
|
36
|
+
`mux-sessions/` and `headless/` subdirectories as well as the top-level
|
|
37
|
+
files. It re-checks on an interval rather than once per process, because
|
|
38
|
+
`daemon.log` is created by launchd/systemd redirecting the daemon's stdout
|
|
39
|
+
and can appear after a first pass has already run — in a process that then
|
|
40
|
+
lives for days. It strips group and other access without touching the
|
|
41
|
+
owner's own bits, so an executable in there stays executable; it never
|
|
42
|
+
follows a symlink or a hardlink into a file it is about to change; and it
|
|
43
|
+
only ever touches unsnooze's own state directory. `unsnooze doctor` reports
|
|
44
|
+
from the same walk the repair acts on, so the two cannot disagree about what
|
|
45
|
+
counts as exposed. It is inert on Windows, which has no POSIX mode bits —
|
|
46
|
+
libuv synthesises them by mirroring the owner's, so every file there reads
|
|
47
|
+
as world-readable and no `chmod` can change it; access is governed by the
|
|
48
|
+
profile ACL instead. The same is true of a state directory on a filesystem
|
|
49
|
+
that does not implement permissions at all — a CIFS or vfat mount, WSL's
|
|
50
|
+
`drvfs`, a bind mount from a Windows host — and the platform name does not
|
|
51
|
+
identify those, so unsnooze finds out by attempting the change and looking:
|
|
52
|
+
a `chmod` that succeeds while the bits stay put means the filesystem has
|
|
53
|
+
nothing to set, and the directory is left alone rather than rewritten every
|
|
54
|
+
minute forever. `unsnooze doctor --fix` reports those three outcomes
|
|
55
|
+
separately, so "could not" is never printed as "did".
|
|
56
|
+
|
|
57
|
+
The statusline shim's own drop directory, `~/.claude/unsnooze`, gets the
|
|
58
|
+
same treatment: it holds your live rate-limit numbers and was created 0755.
|
|
59
|
+
It is repaired on the shim's next run rather than only for new installs.
|
|
60
|
+
|
|
61
|
+
`unsnooze doctor` now also reports anything under the state directory that
|
|
62
|
+
other users can read, and `doctor --fix` narrows it. That check exists
|
|
63
|
+
because the repair deliberately ignores a failed `chmod` — crashing every
|
|
64
|
+
state write on a filesystem that has no usable one would be the worse bug —
|
|
65
|
+
so something has to be able to tell you it did not take.
|
|
66
|
+
|
|
67
|
+
No credential was ever stored in plaintext: a password source is a
|
|
68
|
+
*reference* to your keychain, environment or secret tool, never the secret.
|
|
69
|
+
On a single-user machine nothing here was reachable by anyone new.
|
|
70
|
+
|
|
71
|
+
- **A hostile `session_id` can no longer steer where unsnooze reads or
|
|
72
|
+
writes.** Three places took that value — which arrives in a payload from the
|
|
73
|
+
agent — and used it directly as a filename. The opt-in Claude Code statusline
|
|
74
|
+
shim built its drop filename by concatenating it, so a `/` or a `..` walked
|
|
75
|
+
the write out of `~/.claude/unsnooze` and over any `.json` file you can
|
|
76
|
+
write. `transcriptPath`, on the StopFailure hook path that everyone has
|
|
77
|
+
enabled, composed a transcript path the same way, turning a read of your own
|
|
78
|
+
transcript into a read of any `.jsonl` you name. And the Kimi adapter probed
|
|
79
|
+
for a session directory by the same concatenation, which leaked whether an
|
|
80
|
+
arbitrary path exists. Claude Code sends a uuid, so all three needed a
|
|
81
|
+
malicious or compromised agent to reach, and the two reads are only ever
|
|
82
|
+
mined for a timestamp or a boolean — never echoed back. None of them was in
|
|
83
|
+
a position to know that. All three now require a filename-shaped id and fall
|
|
84
|
+
back to `unknown` / no-transcript / not-found. If you have the statusline
|
|
85
|
+
shim installed, re-run `unsnooze usage --install-statusline` to pick up the
|
|
86
|
+
fix; the other two need nothing.
|
|
87
|
+
|
|
88
|
+
- **A corrupt `state.json` no longer prints its own first bytes into the log.**
|
|
89
|
+
The quarantine message included V8's `JSON.parse` error, and V8 embeds a
|
|
90
|
+
snippet of the input it choked on — so a state file that had been replaced
|
|
91
|
+
with something else echoed that file's opening characters into
|
|
92
|
+
`unsnooze.log`. The quarantined file is still kept on disk, and the log still
|
|
93
|
+
names it and says why.
|
|
94
|
+
|
|
95
|
+
- **Three narrower lock fixes.** A writer whose stale-lock steal was itself
|
|
96
|
+
stolen no longer deletes the thief's lock on the way out — it only removes a
|
|
97
|
+
lock still stamped with its own pid. The steal itself now re-checks that the
|
|
98
|
+
directory it is about to remove is still the one it judged stale, narrowing
|
|
99
|
+
a window where two contenders could both decide to steal and the slower one
|
|
100
|
+
would delete the winner's fresh lock. And a lock is now stolen on age alone
|
|
101
|
+
once it is very old: liveness was decided by signalling the recorded pid,
|
|
102
|
+
which cannot tell "unsnooze is still working" from "that number now belongs
|
|
103
|
+
to some unrelated long-lived process", so a leaked lock whose pid was later
|
|
104
|
+
recycled wedged every writer permanently, with no recovery but deleting the
|
|
105
|
+
directory by hand.
|
|
106
|
+
|
|
107
|
+
That last one is a trade rather than a free win — stealing from a holder that
|
|
108
|
+
really is still working puts two writers in the critical section, and one
|
|
109
|
+
overwrites the other. It is chosen because a permanent wedge is the worse
|
|
110
|
+
failure, and the exposure is kept small by making sure the lock is only ever
|
|
111
|
+
held for in-memory work plus a single write: `upsertSession` used to compute
|
|
112
|
+
its workspace fingerprint inside the lock, which shells out to git, and
|
|
113
|
+
`execFileSync`'s timeout is a soft bound — it signals the child and then
|
|
114
|
+
waits for it to actually exit, which a git wedged on a hung network mount
|
|
115
|
+
never does. That call now happens before the lock is taken, so nothing
|
|
116
|
+
unbounded runs inside it.
|
|
117
|
+
|
|
118
|
+
*Raised in [#17](https://github.com/saaranshM/unsnooze/issues/17). The report
|
|
119
|
+
also flagged the askpass `command` source as command injection and reading
|
|
120
|
+
`state.json` as a symlink-following bug; neither is a vulnerability. A
|
|
121
|
+
`command` source is your own configured command, in the shape git's
|
|
122
|
+
`credential.helper = !cmd` has always taken, and it can only be set by
|
|
123
|
+
someone who can already write to your home directory. As for the symlink:
|
|
124
|
+
reads do follow one — point `state.json` at a file holding valid JSON and
|
|
125
|
+
its contents are read as state — but planting that link needs write access
|
|
126
|
+
to a directory that is now 0700, which is not a boundary anyone crosses
|
|
127
|
+
without already owning the account. The write side is genuinely safe: the
|
|
128
|
+
quarantine renames the link rather than the file it points at, and a state
|
|
129
|
+
write replaces the link with a real file, so the target is never truncated
|
|
130
|
+
or overwritten.*
|
|
131
|
+
|
|
132
|
+
## 1.16.3 — 2026-08-22
|
|
133
|
+
|
|
134
|
+
- **A dashboard left open overnight no longer runs the machine out of memory.**
|
|
135
|
+
([#16](https://github.com/saaranshM/unsnooze/issues/16)) `unsnooze status` in
|
|
136
|
+
a spare pane is exactly what it is for, and after nine or ten hours it was
|
|
137
|
+
dying on *"FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript
|
|
138
|
+
heap out of memory"*. The dashboard itself was innocent: React's development
|
|
139
|
+
build files a `performance.measure()` entry for every render, commit and
|
|
140
|
+
`setState` — the performance track it publishes for devtools — and Node
|
|
141
|
+
buffers user-timing entries forever with nothing to drain them. The status tab
|
|
142
|
+
repaints about three times a second (a one-second data tick plus the
|
|
143
|
+
450ms logo animation), each repaint left roughly 40 entries behind, and a
|
|
144
|
+
night of that walks into Node's 2GB heap ceiling. Measured at ~12kB retained
|
|
145
|
+
per render, surviving a forced garbage collection. The dashboard now drains
|
|
146
|
+
that buffer while it is mounted; heap use went from climbing without limit to
|
|
147
|
+
flat. Nothing else in unsnooze records user timing, and no other command
|
|
148
|
+
renders long enough to have been affected.
|
|
149
|
+
|
|
150
|
+
- **Every release now creates its GitHub release automatically.** They were
|
|
151
|
+
written by hand afterwards, so 1.16.2 shipped to npm and to the website while
|
|
152
|
+
the repository's Releases tab still showed 1.16.1. The publish workflow now
|
|
153
|
+
creates the release from that version's CHANGELOG section, and waits for the
|
|
154
|
+
npm registry to actually serve the new version before rebuilding the website —
|
|
155
|
+
`npm publish` returns before the registry does, which is why 1.16.2's own
|
|
156
|
+
changelog entry was briefly invisible on unsnooze.dev.
|
|
157
|
+
|
|
3
158
|
## 1.16.2 — 2026-08-22
|
|
4
159
|
|
|
5
160
|
- **A herdr revival now opens a tab in your project's workspace, not a new
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unsnooze",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/src/agents/kimi.js
CHANGED
|
@@ -54,7 +54,15 @@ function sessionsDirFor(cwd, kimiDir) {
|
|
|
54
54
|
return join(kimiDir, 'sessions', createHash('md5').update(cwd).digest('hex'));
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// sessionId comes from a hook payload and is used as a path component here.
|
|
58
|
+
// The result only picks `-r <id>` over `--continue` and the id travels as
|
|
59
|
+
// argv with no shell, so the exposure is an existence oracle rather than a
|
|
60
|
+
// traversal — but it is the same shape as transcriptPath's, and the same
|
|
61
|
+
// filename-shape gate closes it.
|
|
62
|
+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
63
|
+
|
|
57
64
|
function sessionExists(sessionId, kimiDir) {
|
|
65
|
+
if (!SAFE_SESSION_ID_RE.test(String(sessionId ?? ''))) return false;
|
|
58
66
|
let hashes;
|
|
59
67
|
try { hashes = readdirSync(join(kimiDir, 'sessions')); } catch { return false; }
|
|
60
68
|
return hashes.some(h => existsSync(join(kimiDir, 'sessions', h, sessionId)));
|
package/src/askpass.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// anywhere. The SERVICE/ACCOUNT/VAR *names* may appear on argv; the secret
|
|
4
4
|
// itself never does — it only ever comes back on stdout/stdin.
|
|
5
5
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
-
import { writeFileSync, chmodSync,
|
|
6
|
+
import { writeFileSync, chmodSync, renameSync } from 'node:fs';
|
|
7
|
+
import { ensureStateDir } from './config.js';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
|
|
9
10
|
export class AuthError extends Error {
|
|
@@ -163,7 +164,7 @@ export async function cmdAskpass(args = []) {
|
|
|
163
164
|
// by Task 5) because ssh controls the helper's argv — it passes only the
|
|
164
165
|
// prompt text, never the host.
|
|
165
166
|
export function ensureAskpassHelper({ platform = process.platform, stateDir, nodePath = process.execPath, scriptPath }) {
|
|
166
|
-
|
|
167
|
+
ensureStateDir(stateDir);
|
|
167
168
|
if (platform === 'win32') {
|
|
168
169
|
// Native ssh.exe needs a real exe; unix-like win ssh (Git/WSL) accepts a script.
|
|
169
170
|
// The resolution ladder (design §5c) is finalized in Task 7's cross-platform pass;
|
package/src/config.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
mkdirSync, chmodSync, writeFileSync, renameSync, lstatSync, statSync, readdirSync,
|
|
5
|
+
} from 'node:fs';
|
|
3
6
|
|
|
4
7
|
function envInt(name, def) {
|
|
5
8
|
const v = parseInt(process.env[name] ?? '', 10);
|
|
@@ -15,6 +18,172 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
|
|
|
15
18
|
// High-frequency burn accumulator + warn-dedup (daemon single-writer).
|
|
16
19
|
export const USAGE_FILE = join(STATE_DIR, 'usage.json');
|
|
17
20
|
|
|
21
|
+
// Everything under STATE_DIR is owner-only. The directory holds an ntfy
|
|
22
|
+
// bearer token (config.json), queued prompt text and cwd paths (state.json),
|
|
23
|
+
// ssh destinations and credential-source commands (hosts.json) — none of it
|
|
24
|
+
// another local user's business, and the dir mode is what protects the files
|
|
25
|
+
// that don't set their own. mkdir's `mode` only applies when it creates, so
|
|
26
|
+
// a 0755 dir left by a pre-1.17.0 install is repaired explicitly.
|
|
27
|
+
// No-op on Windows (node's chmod only carries the read-only bit there); the
|
|
28
|
+
// user profile ACL already restricts it.
|
|
29
|
+
export function ensureStateDir(dir = STATE_DIR) {
|
|
30
|
+
// Creating a directory owner-only is always right — we are about to put a
|
|
31
|
+
// 0600 file in it.
|
|
32
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
33
|
+
// Narrowing one that ALREADY exists is only ever done for our own state
|
|
34
|
+
// dir. ensureStateDir is also called with a caller-supplied path
|
|
35
|
+
// (writeUsageStore takes one), and re-permissioning someone else's
|
|
36
|
+
// directory — or the files inside it — is not this function's business.
|
|
37
|
+
if (isStateDir(dir)) {
|
|
38
|
+
try { chmodSync(dir, 0o700); } catch { /* shared/CI dir, not ours to fix */ }
|
|
39
|
+
repairModes(dir);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Repairing an install that predates 1.17.0. Three things the writers alone
|
|
45
|
+
// cannot fix: mkdir's `mode` is ignored for a directory that already exists,
|
|
46
|
+
// an append/open reuses an existing file's inode, and config.json/hosts.json
|
|
47
|
+
// are written only by `config set` / `hosts add` — on an install that never
|
|
48
|
+
// touches them again they would keep their old 0644 (and, for config.json,
|
|
49
|
+
// the ntfy token in it) indefinitely. So the modes are repaired directly.
|
|
50
|
+
//
|
|
51
|
+
// Re-checked on an interval rather than once per process: daemon.log is
|
|
52
|
+
// created by launchd/systemd redirecting the daemon's stdout, so it can
|
|
53
|
+
// appear at 0644 AFTER a pass has run — in a process that then lives for
|
|
54
|
+
// days. A walk is a readdir and a handful of lstats, so this is free at
|
|
55
|
+
// daemon cadence.
|
|
56
|
+
const MODE_REPAIR_INTERVAL_MS = envInt('UNSNOOZE_MODE_REPAIR_MS', 60_000);
|
|
57
|
+
const repairedAt = new Map();
|
|
58
|
+
|
|
59
|
+
// Compare resolved paths, not raw strings: callers reach ensureStateDir
|
|
60
|
+
// through dirname(join(STATE_DIR, 'config.json')), which normalizes away a
|
|
61
|
+
// trailing slash that STATE_DIR itself may carry from UNSNOOZE_STATE_DIR. A
|
|
62
|
+
// raw comparison silently answers "not our directory" and skips the repair.
|
|
63
|
+
const isStateDir = (dir) => resolve(dir) === resolve(STATE_DIR);
|
|
64
|
+
|
|
65
|
+
// ONE walk, exported so `unsnooze doctor` reports from exactly what the
|
|
66
|
+
// automatic repair acts on. They were separate before, and drifted: the
|
|
67
|
+
// repair worked from a fixed list of filenames while doctor walked the whole
|
|
68
|
+
// directory, so a quarantined state.json or a crashed writer's tmp file — the
|
|
69
|
+
// two leftovers that hold verbatim copies of the sensitive files — were
|
|
70
|
+
// reported forever and never fixed.
|
|
71
|
+
//
|
|
72
|
+
// Top level plus one level down is the entire layout: a handful of files and
|
|
73
|
+
// four flat subdirectories.
|
|
74
|
+
export function scanStateDir(dir = STATE_DIR, { platform = process.platform } = {}) {
|
|
75
|
+
const out = [];
|
|
76
|
+
// Windows has no POSIX mode bits. libuv synthesises them by mirroring the
|
|
77
|
+
// owner bits into group and other (win/fs.c `fs__stat_impl`), so a file is
|
|
78
|
+
// always 0666 (0444 read-only) and a directory 0777 — `mode & 0o077` is
|
|
79
|
+
// never zero and EVERY entry would look permanently exposed. Meanwhile
|
|
80
|
+
// uv_fs_chmod only toggles FILE_ATTRIBUTE_READONLY, so no repair could ever
|
|
81
|
+
// clear it: doctor would never report healthy, `--fix` would claim success
|
|
82
|
+
// while changing nothing, and the daemon would re-chmod the whole directory
|
|
83
|
+
// every interval forever. Access there is governed by the profile ACL.
|
|
84
|
+
if (platform === 'win32') return out;
|
|
85
|
+
// The root is stat'd, not lstat'd: a state dir that is itself a symlink is
|
|
86
|
+
// a legitimate setup (dotfiles, another volume) and must still be checked
|
|
87
|
+
// and repaired. Entries INSIDE are lstat'd and a link is skipped — chmod
|
|
88
|
+
// follows links, and following one there would reach outside the dir.
|
|
89
|
+
const inspect = (path, rel, isRoot = false) => {
|
|
90
|
+
try {
|
|
91
|
+
const st = isRoot ? statSync(path) : lstatSync(path);
|
|
92
|
+
if (!isRoot && st.isSymbolicLink()) return null;
|
|
93
|
+
const linked = !isRoot && !st.isDirectory() && st.nlink > 1;
|
|
94
|
+
// Strip group/other, but never the owner's execute bit: the state dir
|
|
95
|
+
// holds executables (askpass.sh, and whatever a test or a user drops
|
|
96
|
+
// beside it), and forcing a flat 0600 turns those into EACCES on spawn.
|
|
97
|
+
// Directories need their own traverse bit, hence 0700 rather than 0600.
|
|
98
|
+
const want = st.isDirectory() ? 0o700 : (0o600 | (st.mode & 0o100));
|
|
99
|
+
if ((st.mode & 0o077) !== 0) {
|
|
100
|
+
// `skipRepair` for a hardlink: lstat cannot distinguish one, so chmod
|
|
101
|
+
// would change an inode that also lives outside this directory.
|
|
102
|
+
// Reported anyway — an exposed file the repair will not touch is
|
|
103
|
+
// exactly what the doctor finding exists to surface.
|
|
104
|
+
out.push({ path, rel, mode: st.mode & 0o777, want, skipRepair: linked });
|
|
105
|
+
}
|
|
106
|
+
return st;
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
};
|
|
109
|
+
if (!inspect(dir, '.', true)) return out;
|
|
110
|
+
let names = [];
|
|
111
|
+
try { names = readdirSync(dir); } catch { return out; }
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const path = join(dir, name);
|
|
114
|
+
const st = inspect(path, name);
|
|
115
|
+
if (!st?.isDirectory()) continue;
|
|
116
|
+
try {
|
|
117
|
+
for (const child of readdirSync(path)) inspect(join(path, child), `${name}/${child}`);
|
|
118
|
+
} catch { /* unreadable */ }
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Applies what scanStateDir found. `want` carries 0700 for directories and
|
|
124
|
+
// 0600 for files — a directory chmod'd 0600 would lose its traverse bit and
|
|
125
|
+
// nothing inside it could be opened.
|
|
126
|
+
// Returns { fixed, refused, ineffective }. The three outcomes are genuinely
|
|
127
|
+
// different and the caller needs to tell them apart:
|
|
128
|
+
// fixed — the bits moved.
|
|
129
|
+
// refused — chmod threw (EPERM, EROFS, an immutable flag). A real
|
|
130
|
+
// exposure that cannot be repaired; keep reporting it.
|
|
131
|
+
// ineffective — chmod SUCCEEDED and nothing changed. The filesystem does
|
|
132
|
+
// not implement POSIX modes at all (a CIFS/vfat/NTFS mount
|
|
133
|
+
// with `noperm`, WSL's drvfs, a Docker bind mount from a
|
|
134
|
+
// Windows host). Retrying forever would never converge, and
|
|
135
|
+
// `process.platform` does not identify this case — only
|
|
136
|
+
// attempting it does.
|
|
137
|
+
export function narrowStateDir(entries) {
|
|
138
|
+
let fixed = 0, refused = 0, ineffective = 0;
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
if (e.skipRepair) continue;
|
|
141
|
+
try {
|
|
142
|
+
chmodSync(e.path, e.want);
|
|
143
|
+
if ((lstatSync(e.path).mode & 0o077) === 0) fixed += 1;
|
|
144
|
+
else ineffective += 1;
|
|
145
|
+
} catch { refused += 1; }
|
|
146
|
+
}
|
|
147
|
+
return { fixed, refused, ineffective };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Directories whose filesystem provably ignores chmod. Learned by attempting
|
|
151
|
+
// it, never guessed from the platform.
|
|
152
|
+
const modesUnenforced = new Set();
|
|
153
|
+
|
|
154
|
+
function repairModes(dir) {
|
|
155
|
+
// Key on the resolved path: callers reach this through
|
|
156
|
+
// dirname(join(STATE_DIR, 'x')), which normalizes a trailing slash away, so
|
|
157
|
+
// raw strings would give the same directory two throttle slots.
|
|
158
|
+
const key = resolve(dir);
|
|
159
|
+
if (modesUnenforced.has(key)) return;
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
if (now - (repairedAt.get(key) ?? -Infinity) < MODE_REPAIR_INTERVAL_MS) return;
|
|
162
|
+
repairedAt.set(key, now);
|
|
163
|
+
const entries = scanStateDir(dir);
|
|
164
|
+
if (entries.length === 0) return;
|
|
165
|
+
const { fixed, refused, ineffective } = narrowStateDir(entries);
|
|
166
|
+
// Every attempt succeeded and not one bit moved: this filesystem has no
|
|
167
|
+
// POSIX modes to set. Stop walking it — the alternative is re-chmodding
|
|
168
|
+
// the whole directory every interval for the life of the daemon.
|
|
169
|
+
if (fixed === 0 && refused === 0 && ineffective > 0) modesUnenforced.add(key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// tmp + rename + chmod. writeFileSync's `mode` lands only when it CREATES the
|
|
173
|
+
// file, so a tmp left behind by a crashed predecessor that happened to share
|
|
174
|
+
// this pid would otherwise carry its own 0644 onto the target through the
|
|
175
|
+
// rename — and a target left 0644 by an older version would never be fixed.
|
|
176
|
+
// The chmod after the rename closes both. Same pattern, and the same reason,
|
|
177
|
+
// as ensureAskpassHelper in askpass.js.
|
|
178
|
+
export function writePrivateFile(target, tmp, data) {
|
|
179
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
180
|
+
renameSync(tmp, target);
|
|
181
|
+
// A filesystem with no usable chmod (FAT, some network mounts) must not
|
|
182
|
+
// turn every state write into a crash — the 0700 directory still covers it.
|
|
183
|
+
try { chmodSync(target, 0o600); } catch { /* best-effort */ }
|
|
184
|
+
return target;
|
|
185
|
+
}
|
|
186
|
+
|
|
18
187
|
export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR
|
|
19
188
|
|| process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
|
20
189
|
export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
|
package/src/dashboard/run.js
CHANGED
|
@@ -62,6 +62,41 @@ export function installMouseCleanup(stdout = process.stdout) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// React's development build records a `performance.measure()` entry for every
|
|
66
|
+
// render, commit and setState — the React Performance Track it publishes for
|
|
67
|
+
// devtools. Node buffers user-timing entries and nothing ever drains that
|
|
68
|
+
// buffer, so on a long-lived dashboard it IS the leak: the status tab repaints
|
|
69
|
+
// roughly three times a second (a 1s data tick plus the 450ms logo animation),
|
|
70
|
+
// each repaint leaves ~40 entries behind, and an overnight `unsnooze status`
|
|
71
|
+
// walked into Node's 2GB heap ceiling and aborted (#16). Measured at ~12 kB
|
|
72
|
+
// retained per render, surviving a forced GC.
|
|
73
|
+
//
|
|
74
|
+
// The entries cannot be capped (Node caps resource timing, not user timing) and
|
|
75
|
+
// switching React to its production build would mean setting NODE_ENV for the
|
|
76
|
+
// whole CLI before anything imports React. Draining is the narrow fix: nothing
|
|
77
|
+
// in unsnooze reads user timing, and the sweep lives exactly as long as the
|
|
78
|
+
// render does.
|
|
79
|
+
export const TIMING_SWEEP_MS = 5_000;
|
|
80
|
+
|
|
81
|
+
export function sweepReactTiming(perf = globalThis.performance) {
|
|
82
|
+
try {
|
|
83
|
+
perf?.clearMeasures?.();
|
|
84
|
+
perf?.clearMarks?.();
|
|
85
|
+
} catch { /* a runtime with no user timing has nothing to leak */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Returns the stop function. The timer is unref'd: draining a buffer is never
|
|
89
|
+
// a reason to hold the process open.
|
|
90
|
+
export function startTimingSweep({ intervalMs = TIMING_SWEEP_MS, perf } = {}) {
|
|
91
|
+
sweepReactTiming(perf);
|
|
92
|
+
const timer = setInterval(() => sweepReactTiming(perf), intervalMs);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
return () => {
|
|
95
|
+
clearInterval(timer);
|
|
96
|
+
sweepReactTiming(perf);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
65
100
|
export function shouldUseDashboard({
|
|
66
101
|
force = null,
|
|
67
102
|
json = false,
|
|
@@ -87,13 +122,18 @@ export async function runDashboard({ tab = 'status' } = {}) {
|
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
installMouseCleanup(process.stdout);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
const stopTimingSweep = startTimingSweep();
|
|
126
|
+
try {
|
|
127
|
+
const instance = render(h(App, { initialTab: tab, mouseEnabled: getConfig('mouse') !== false }), {
|
|
128
|
+
exitOnCtrlC: true,
|
|
129
|
+
// Full-screen: separate buffer like vim / htop / less — original scrollback restored on quit
|
|
130
|
+
alternateScreen: true,
|
|
131
|
+
});
|
|
95
132
|
|
|
96
|
-
|
|
133
|
+
await instance.waitUntilExit();
|
|
134
|
+
} finally {
|
|
135
|
+
stopTimingSweep();
|
|
136
|
+
}
|
|
97
137
|
// Belt and braces: normal quit also clears modes (provider already did).
|
|
98
138
|
process.stdout.write(MOUSE_DISABLE_ALL);
|
|
99
139
|
return 0;
|
package/src/doctor.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync, readdirSync, renameSync, existsSync, unlinkSync, lstatSyn
|
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
|
-
import { CLAUDE_SETTINGS, RESUMER_LOCK } from './config.js';
|
|
13
|
+
import { CLAUDE_SETTINGS, RESUMER_LOCK, STATE_DIR, scanStateDir, narrowStateDir } from './config.js';
|
|
14
14
|
import { getMultiplexer } from './multiplexer.js';
|
|
15
15
|
import { makeLogger } from './logger.js';
|
|
16
16
|
import { shouldUseTui, formatDoctorTui } from './tui.js';
|
|
@@ -151,6 +151,26 @@ function daemonRunning() {
|
|
|
151
151
|
* detail, fix? } — `fix` is a machine-actionable descriptor consumed by
|
|
152
152
|
* applyFixes. healthy === no legacy and no health findings.
|
|
153
153
|
*/
|
|
154
|
+
// Both delegate to config.js's walk: the reporter and the automatic repair
|
|
155
|
+
// must never disagree about what counts as exposed, which is exactly what
|
|
156
|
+
// happened when each had its own idea of where to look.
|
|
157
|
+
export function findExposedStateFiles(dir = STATE_DIR, opts = {}) {
|
|
158
|
+
return scanStateDir(dir, opts).map(e => ({ ...e, mode: e.mode.toString(8) }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Deliberately NOT given runDoctor's `platform`. Every other check uses that
|
|
162
|
+
// to simulate an install target — "what would doctor say on Windows?" — but
|
|
163
|
+
// whether mode bits mean anything is a fact about the filesystem actually
|
|
164
|
+
// under the process, not about the platform being simulated. Passing the
|
|
165
|
+
// simulated one made a `platform: 'darwin'` test raise this finding while
|
|
166
|
+
// running on a Windows runner, where the modes are synthetic and no repair
|
|
167
|
+
// could ever clear it. scanStateDir keeps its own injectable platform for
|
|
168
|
+
// direct unit tests.
|
|
169
|
+
|
|
170
|
+
export function narrowStateModes(entries) {
|
|
171
|
+
return narrowStateDir(entries);
|
|
172
|
+
}
|
|
173
|
+
|
|
154
174
|
export async function runDoctor({
|
|
155
175
|
runner = defaultRunner,
|
|
156
176
|
platform = process.platform,
|
|
@@ -164,6 +184,7 @@ export async function runDoctor({
|
|
|
164
184
|
rcContent = undefined,
|
|
165
185
|
profileContent = undefined,
|
|
166
186
|
autostartDir = null,
|
|
187
|
+
stateDir = STATE_DIR,
|
|
167
188
|
} = {}) {
|
|
168
189
|
const findings = [];
|
|
169
190
|
|
|
@@ -220,6 +241,22 @@ export async function runDoctor({
|
|
|
220
241
|
detail: ' run: unsnooze install --yes (then: exec $SHELL)',
|
|
221
242
|
});
|
|
222
243
|
}
|
|
244
|
+
// The state dir is repaired on every write, but chmod is swallowed there —
|
|
245
|
+
// a filesystem that cannot honour it (FAT, some network mounts) would
|
|
246
|
+
// otherwise leave everything world-readable with nothing ever saying so.
|
|
247
|
+
// This is the place that says so, and --fix retries the chmod.
|
|
248
|
+
const exposed = findExposedStateFiles(stateDir);
|
|
249
|
+
if (exposed.length) {
|
|
250
|
+
findings.push({
|
|
251
|
+
id: 'state-permissions', kind: 'health',
|
|
252
|
+
title: `${exposed.length} file(s) in ${stateDir} are readable by other users`,
|
|
253
|
+
detail: exposed.slice(0, 8).map(e => ` ${e.mode} ${e.rel}`).join('\n')
|
|
254
|
+
+ (exposed.length > 8 ? `\n …and ${exposed.length - 8} more` : '')
|
|
255
|
+
+ '\n run: unsnooze doctor --fix',
|
|
256
|
+
fix: { action: 'narrow-state-modes', entries: exposed },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
223
260
|
let muxOk = false;
|
|
224
261
|
try { muxOk = !!mux.available(); } catch { muxOk = false; }
|
|
225
262
|
if (!muxOk) {
|
|
@@ -321,6 +358,11 @@ export async function applyFixes(report, {
|
|
|
321
358
|
} else {
|
|
322
359
|
actions.push({ action: 'error', detail: 'could not determine a PATH that finds the multiplexer — run `unsnooze install --daemon` from your shell' });
|
|
323
360
|
}
|
|
361
|
+
} else if (fix.action === 'narrow-state-modes') {
|
|
362
|
+
const r = narrowStateModes(fix.entries || []);
|
|
363
|
+
actions.push({ action: 'narrowed-modes', ...r });
|
|
364
|
+
log(`doctor: narrowed ${r.fixed} state-dir path(s) to owner-only `
|
|
365
|
+
+ `(${r.refused} refused, ${r.ineffective} ignored by the filesystem)`);
|
|
324
366
|
} else if (fix.action === 'archive-dir') {
|
|
325
367
|
let target = `${fix.dir}.bak`;
|
|
326
368
|
if (existsSync(target)) target = `${fix.dir}.bak.${now}`;
|
|
@@ -379,6 +421,16 @@ export async function cmdDoctor(rest = [], deps = {}) {
|
|
|
379
421
|
if (a.action === 'killed') print(` ✓ stopped csg process ${a.pid}`);
|
|
380
422
|
else if (a.action === 'removed-unit') print(` ✓ removed ${a.unit}`);
|
|
381
423
|
else if (a.action === 'archived') print(` ✓ archived ${a.from} → ${a.to}`);
|
|
424
|
+
else if (a.action === 'narrowed-modes') {
|
|
425
|
+
if (a.fixed) print(` ✓ made ${a.fixed} state-dir path(s) owner-only`);
|
|
426
|
+
// Distinguish "cannot" from "did not": a filesystem with no POSIX
|
|
427
|
+
// modes reports success and changes nothing, and telling the user to
|
|
428
|
+
// try again would be advice that can never work.
|
|
429
|
+
if (a.ineffective) {
|
|
430
|
+
print(` · ${a.ineffective} path(s) unchanged — this filesystem does not enforce POSIX permissions`);
|
|
431
|
+
}
|
|
432
|
+
if (a.refused) print(` ✗ ${a.refused} path(s) could not be changed (permission denied)`);
|
|
433
|
+
}
|
|
382
434
|
else if (a.action === 'error') print(` ! ${a.detail}`);
|
|
383
435
|
}
|
|
384
436
|
const pkg = legacy.find(f => f.id === 'csg-package');
|
|
@@ -386,8 +438,14 @@ export async function cmdDoctor(rest = [], deps = {}) {
|
|
|
386
438
|
return 0;
|
|
387
439
|
}
|
|
388
440
|
|
|
441
|
+
// Offer --fix whenever anything reported is actually machine-fixable, not
|
|
442
|
+
// only for csg leftovers: a health finding that carries a `fix` (state
|
|
443
|
+
// permissions) was otherwise a dead end for the reader.
|
|
444
|
+
const fixable = report.findings.some(f => f.fix);
|
|
389
445
|
print(legacy.length
|
|
390
446
|
? '\nRun `unsnooze doctor --fix` to stop csg processes, remove its autostart, and archive its state.'
|
|
391
|
-
:
|
|
447
|
+
: fixable
|
|
448
|
+
? '\nRun `unsnooze doctor --fix` to apply the repairs above.'
|
|
449
|
+
: '\nSee the hints above to finish the install.');
|
|
392
450
|
return 1;
|
|
393
451
|
}
|
package/src/fleet.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// allowlisted host tokens, closed verb set, sentinel-framed JSON, and
|
|
6
6
|
// control-char-stripped ingest.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
-
import { readFileSync,
|
|
8
|
+
import { readFileSync, existsSync as fsExistsSync } from 'node:fs';
|
|
9
9
|
import { spawn, execFileSync, spawnSync } from 'node:child_process';
|
|
10
|
-
import { STATE_DIR } from './config.js';
|
|
10
|
+
import { STATE_DIR, ensureStateDir, writePrivateFile } from './config.js';
|
|
11
11
|
import { colors, shouldUseTui, makeTable, logoBlock, badge } from './tui.js';
|
|
12
12
|
import { ensureAskpassHelper, resolveSecret, readSecret } from './askpass.js';
|
|
13
13
|
import { UNSNOOZE_BIN } from './spawn.js';
|
|
@@ -288,10 +288,12 @@ export function readHosts() {
|
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
export function writeHosts(hosts) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
291
|
+
ensureStateDir();
|
|
292
|
+
// Owner-only: a `command` source's --cmd string is whatever the user
|
|
293
|
+
// pointed at their secret store, and a keychain source names the
|
|
294
|
+
// service/account to look up.
|
|
295
|
+
writePrivateFile(HOSTS_FILE, HOSTS_FILE + `.tmp.${process.pid}`,
|
|
296
|
+
JSON.stringify(hosts, null, 2) + '\n');
|
|
295
297
|
}
|
|
296
298
|
|
|
297
299
|
function parseFlags(argv) {
|
|
@@ -740,10 +742,11 @@ export function readFleetCache() {
|
|
|
740
742
|
}
|
|
741
743
|
|
|
742
744
|
export function writeFleetCache(results) {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
745
|
+
ensureStateDir();
|
|
746
|
+
// Owner-only: the cache mirrors every remote host's cwd paths and
|
|
747
|
+
// queued-prompt previews.
|
|
748
|
+
writePrivateFile(FLEET_CACHE_FILE, FLEET_CACHE_FILE + `.tmp.${process.pid}`,
|
|
749
|
+
JSON.stringify(results, null, 2) + '\n');
|
|
747
750
|
}
|
|
748
751
|
|
|
749
752
|
// I1: a `prompt`-source host at a real interactive TTY is the one case
|
package/src/hook.js
CHANGED
|
@@ -89,9 +89,10 @@ export async function runHook(rest = []) {
|
|
|
89
89
|
// Seconds-scale problem — leave a marker for the pane's monitor, do NOT
|
|
90
90
|
// record in state.json.
|
|
91
91
|
if (pane) {
|
|
92
|
-
mkdirSync(EVENTS_DIR, { recursive: true });
|
|
92
|
+
mkdirSync(EVENTS_DIR, { recursive: true, mode: 0o700 });
|
|
93
93
|
writeFileSync(join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`),
|
|
94
|
-
JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } })
|
|
94
|
+
JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }),
|
|
95
|
+
{ mode: 0o600 });
|
|
95
96
|
}
|
|
96
97
|
return 0;
|
|
97
98
|
}
|
package/src/lease.js
CHANGED
|
@@ -43,10 +43,10 @@ function leasePath(address, leaseId) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function writeLease(lease) {
|
|
46
|
-
mkdirSync(LEASES_DIR, { recursive: true });
|
|
46
|
+
mkdirSync(LEASES_DIR, { recursive: true, mode: 0o700 });
|
|
47
47
|
const path = leasePath(lease, lease.leaseId);
|
|
48
48
|
const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
|
|
49
|
-
writeFileSync(tmp, JSON.stringify(lease));
|
|
49
|
+
writeFileSync(tmp, JSON.stringify(lease), { mode: 0o600 });
|
|
50
50
|
renameSync(tmp, path);
|
|
51
51
|
return lease;
|
|
52
52
|
}
|
package/src/logger.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { appendFileSync,
|
|
1
|
+
import { appendFileSync, statSync, renameSync, copyFileSync, truncateSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { LOG_FILE } from './config.js';
|
|
3
|
+
import { LOG_FILE, ensureStateDir } from './config.js';
|
|
4
4
|
|
|
5
5
|
// Logs must never grow unbounded (a single upgrade-window crash-loop once
|
|
6
6
|
// produced a 10.3 MB daemon.log). One rotated generation is kept: file → .1.
|
|
@@ -46,11 +46,13 @@ export function log(component, message) {
|
|
|
46
46
|
const line = `${new Date().toISOString()} [${component}] ${message}\n`;
|
|
47
47
|
try {
|
|
48
48
|
if (!dirReady) {
|
|
49
|
-
|
|
49
|
+
ensureStateDir(dirname(LOG_FILE));
|
|
50
50
|
dirReady = true;
|
|
51
51
|
}
|
|
52
52
|
rotateIfLarge(LOG_FILE);
|
|
53
|
-
|
|
53
|
+
// mode applies only when append CREATES the file — an existing log keeps
|
|
54
|
+
// whatever it has, and the 0700 dir is what covers it either way.
|
|
55
|
+
appendFileSync(LOG_FILE, line, { mode: 0o600 });
|
|
54
56
|
} catch {
|
|
55
57
|
// Logging must never crash the hook or monitor paths.
|
|
56
58
|
}
|
|
@@ -96,9 +96,11 @@ export function createHeadless({
|
|
|
96
96
|
// A revive is just a detached child. Its output goes to a per-session log
|
|
97
97
|
// because there is no scrollback to read it out of later.
|
|
98
98
|
async newWindow(sessionName, cwd, launchSpec) {
|
|
99
|
-
|
|
99
|
+
// 0700/0600: this log is the entire stdout+stderr of an unattended
|
|
100
|
+
// agent run — the most revealing thing under the state dir.
|
|
101
|
+
mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
100
102
|
const logPath = join(logDir, `${sessionName}.log`);
|
|
101
|
-
const fd = openSync(logPath, 'a');
|
|
103
|
+
const fd = openSync(logPath, 'a', 0o600);
|
|
102
104
|
const child = spawner(launchSpec.file, launchSpec.args || [], {
|
|
103
105
|
cwd,
|
|
104
106
|
detached: true,
|
package/src/mux-sessions.js
CHANGED
|
@@ -29,10 +29,10 @@ export function recordOwnedSession({ mux, name }) {
|
|
|
29
29
|
if (!mux || !name) return null;
|
|
30
30
|
const record = { mux, name, createdAt: Date.now(), pid: process.pid };
|
|
31
31
|
try {
|
|
32
|
-
mkdirSync(SESSIONS_DIR(), { recursive: true });
|
|
32
|
+
mkdirSync(SESSIONS_DIR(), { recursive: true, mode: 0o700 });
|
|
33
33
|
const path = recordPath(mux, name);
|
|
34
34
|
const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
|
|
35
|
-
writeFileSync(tmp, JSON.stringify(record));
|
|
35
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
36
36
|
renameSync(tmp, path);
|
|
37
37
|
return record;
|
|
38
38
|
} catch {
|
package/src/resumer.js
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// fires on the next tick), then re-opens/continues every due session.
|
|
5
5
|
// Exits when no non-terminal records remain; the next limit event respawns it.
|
|
6
6
|
|
|
7
|
-
import { writeFileSync, readFileSync, unlinkSync
|
|
7
|
+
import { writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import { getMultiplexer, backendCanType } from './multiplexer.js';
|
|
11
11
|
import {
|
|
12
|
-
RESUMER_LOCK,
|
|
12
|
+
RESUMER_LOCK, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS, ensureStateDir,
|
|
13
13
|
BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
|
|
14
14
|
CAPTURE_LINES, PANE_SCAN_LINES, RESUME_SESSION_NAME,
|
|
15
15
|
RESET_MARGIN_MS, FALLBACK_RESET_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
|
|
@@ -64,10 +64,10 @@ function defaultIsResumer(pid) {
|
|
|
64
64
|
// read-check-write window. A held lock is honored only when its pid is alive
|
|
65
65
|
// AND looks like a resumer; stale/garbage/recycled locks are replaced.
|
|
66
66
|
export function acquireSingleton({ isResumer = defaultIsResumer } = {}) {
|
|
67
|
-
|
|
67
|
+
ensureStateDir();
|
|
68
68
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
69
69
|
try {
|
|
70
|
-
writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx' });
|
|
70
|
+
writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
71
71
|
return true;
|
|
72
72
|
} catch { /* lock exists — inspect the holder below */ }
|
|
73
73
|
let pid = NaN;
|
package/src/sessions.js
CHANGED
|
@@ -40,8 +40,26 @@ export function latestSessionId(cwd, aroundTs = null) {
|
|
|
40
40
|
return best ? best.id : null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// sessionId lands here straight off a hook payload / transcript record and is
|
|
44
|
+
// used as a path component — the same untrusted-value-as-filename shape the
|
|
45
|
+
// statusline shim had, on the channel everyone has enabled. dashCwd already
|
|
46
|
+
// sanitizes the cwd half; a '/' or a '..' in the id half would walk this read
|
|
47
|
+
// out of the projects tree.
|
|
48
|
+
//
|
|
49
|
+
// Deliberately a filename-shape check rather than UUID_RE: that regex is the
|
|
50
|
+
// right gate for *discovering* transcripts (it reads Claude's own directory
|
|
51
|
+
// listing) but the wrong one here, where the id arrives from an agent whose
|
|
52
|
+
// naming we do not own. Requiring a uuid would silently turn off the context
|
|
53
|
+
// guard for any session named otherwise; requiring a safe component closes
|
|
54
|
+
// the traversal either way. '.' and '..' are harmless: the '.jsonl' suffix
|
|
55
|
+
// makes them ordinary filenames. Returns null for anything else, which every
|
|
56
|
+
// caller already reports as "no transcript".
|
|
57
|
+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
58
|
+
|
|
43
59
|
export function transcriptPath(cwd, sessionId, { claudeDir = CLAUDE_DIR } = {}) {
|
|
44
|
-
|
|
60
|
+
const id = String(sessionId ?? '');
|
|
61
|
+
if (!SAFE_SESSION_ID_RE.test(id)) return null;
|
|
62
|
+
return join(claudeDir, 'projects', dashCwd(cwd), `${id}.jsonl`);
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
export function claudeRecordEnv(env = process.env) {
|
package/src/settings.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// the global resumeMessage even when the global came from an env var (see
|
|
7
7
|
// resolveResumeMessage).
|
|
8
8
|
|
|
9
|
-
import { readFileSync
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
10
|
import { join, dirname } from 'node:path';
|
|
11
|
-
import { MUX_NAMES, STATE_DIR } from './config.js';
|
|
11
|
+
import { MUX_NAMES, STATE_DIR, ensureStateDir, writePrivateFile } from './config.js';
|
|
12
12
|
|
|
13
13
|
export const CONFIG_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || STATE_DIR, 'config.json');
|
|
14
14
|
|
|
@@ -267,10 +267,11 @@ export function setConfigValue(key, rawValue) {
|
|
|
267
267
|
|
|
268
268
|
export function writeConfig(config) {
|
|
269
269
|
const path = CONFIG_FILE();
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
ensureStateDir(dirname(path));
|
|
271
|
+
// Owner-only: ntfyToken is a Bearer credential, and config.json was
|
|
272
|
+
// world-readable before 1.17.0.
|
|
273
|
+
writePrivateFile(path, join(dirname(path), `.config.tmp.${process.pid}`),
|
|
274
|
+
JSON.stringify(config, null, 2) + '\n');
|
|
274
275
|
}
|
|
275
276
|
|
|
276
277
|
export function configFileExists() {
|
package/src/state.js
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
} from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import {
|
|
13
|
-
STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
|
|
13
|
+
STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS, ensureStateDir,
|
|
14
|
+
writePrivateFile,
|
|
14
15
|
DEDUPE_WINDOW_MS, STALE_AFTER_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
|
|
15
16
|
RESET_MARGIN_MS,
|
|
16
17
|
} from './config.js';
|
|
@@ -48,22 +49,78 @@ function lockHolderAlive() {
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// lockHolderAlive() can only ask "is this pid alive", never "is it still
|
|
53
|
+
// unsnooze". A leaked lock whose pid is later recycled onto some unrelated
|
|
54
|
+
// long-lived process therefore looks live forever, and every writer — daemon,
|
|
55
|
+
// CLI, in-agent hook — wedges permanently with no way out but deleting the
|
|
56
|
+
// directory by hand. Past this ceiling, age wins regardless.
|
|
57
|
+
//
|
|
58
|
+
// This is a deliberate trade, not a free win: stealing from a holder that IS
|
|
59
|
+
// still working puts two writers in the critical section, and the robbed one's
|
|
60
|
+
// rename then clobbers the thief's write — a lost update. It is chosen because
|
|
61
|
+
// a permanent unrecoverable wedge is the worse failure. The exposure is kept
|
|
62
|
+
// small by holding this lock only for in-memory work plus one write:
|
|
63
|
+
// upsertSession's git call is hoisted out for exactly this reason. Five
|
|
64
|
+
// minutes is ~200x the worst measured critical section.
|
|
65
|
+
//
|
|
66
|
+
// Known limit: both sides of the age are wall-clock, so a forward clock step
|
|
67
|
+
// (VM resume, laptop wake, a large NTP correction) can age a fresh lock past
|
|
68
|
+
// the ceiling at once. There is no cross-process monotonic clock to use
|
|
69
|
+
// instead, which is part of why the ceiling is generous rather than tight.
|
|
70
|
+
const HARD_STALE_LOCK_MS = Math.max(STALE_LOCK_MS * 30, 300_000);
|
|
71
|
+
|
|
51
72
|
function acquireLock() {
|
|
52
73
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
74
|
+
let parentRepaired = false;
|
|
53
75
|
for (;;) {
|
|
54
76
|
try {
|
|
55
|
-
mkdirSync(LOCK_DIR);
|
|
77
|
+
mkdirSync(LOCK_DIR, { mode: 0o700 });
|
|
56
78
|
// Record the holder so a slow-but-alive writer is never robbed — age
|
|
57
79
|
// alone can't tell a hung process from a busy one.
|
|
58
|
-
try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid)); } catch { /* best-effort */ }
|
|
80
|
+
try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid), { mode: 0o600 }); } catch { /* best-effort */ }
|
|
59
81
|
return;
|
|
60
82
|
} catch (err) {
|
|
61
|
-
if (err.code !== 'EEXIST') {
|
|
83
|
+
if (err.code !== 'EEXIST') {
|
|
84
|
+
// NOT contention. A missing STATE_DIR is worth exactly one immediate
|
|
85
|
+
// repair-and-retry; every other errno (EACCES on an unwritable
|
|
86
|
+
// ~/.unsnooze, EROFS, ENOSPC) must fall through to the same backoff
|
|
87
|
+
// and deadline as any other failure. This branch used to `continue`
|
|
88
|
+
// unconditionally, skipping both — and since mkdir -p on an existing
|
|
89
|
+
// STATE_DIR succeeds as a no-op, an unwritable state dir spun here
|
|
90
|
+
// forever at ~70% of a core, ignoring LOCK_TIMEOUT_MS, in every writer:
|
|
91
|
+
// the daemon, the CLI, and the StopFailure hook inside the agent.
|
|
92
|
+
if (!parentRepaired) {
|
|
93
|
+
parentRepaired = true;
|
|
94
|
+
try { ensureStateDir(); } catch { /* fall through to the backoff */ }
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() > deadline) {
|
|
98
|
+
throw new Error(`unsnooze: cannot create state lock at ${LOCK_DIR}: ${err.code || err.message}`);
|
|
99
|
+
}
|
|
100
|
+
sleepSync(50);
|
|
101
|
+
// A *recurring* ENOENT means the directory keeps going away under us
|
|
102
|
+
// (an uninstall racing a write), not that the one-shot repair failed.
|
|
103
|
+
// Keep repairing it, but from the backoff — never in a tight loop.
|
|
104
|
+
if (err.code === 'ENOENT') { try { ensureStateDir(); } catch { /* next pass */ } }
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
62
107
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
108
|
+
const before = statSync(LOCK_DIR);
|
|
109
|
+
const age = Date.now() - before.mtimeMs;
|
|
110
|
+
if (age > STALE_LOCK_MS && (age > HARD_STALE_LOCK_MS || lockHolderAlive() !== true)) {
|
|
111
|
+
// Re-stat immediately before removing. The staleness verdict above
|
|
112
|
+
// costs a readFileSync + kill(), and in that gap another contender
|
|
113
|
+
// can have stolen this same lock and created a fresh one — removing
|
|
114
|
+
// that would drop a live holder's lock and break mutual exclusion.
|
|
115
|
+
// A matching inode means it is still the dir we judged stale. This
|
|
116
|
+
// narrows the window rather than closing it: a filesystem that
|
|
117
|
+
// recycles a just-freed directory inode can hand the same number
|
|
118
|
+
// back, and Windows may report 0 for both — in either case this
|
|
119
|
+
// degrades to the old unconditional behaviour, never to a worse one.
|
|
120
|
+
if (statSync(LOCK_DIR).ino === before.ino) {
|
|
121
|
+
rmSync(LOCK_DIR, { recursive: true, force: true }); // steal from a dead/unknown holder
|
|
122
|
+
log(`stole stale lock (age ${Math.round(age)}ms)`);
|
|
123
|
+
}
|
|
67
124
|
continue;
|
|
68
125
|
}
|
|
69
126
|
} catch { /* lock vanished between check and stat — retry */ }
|
|
@@ -74,6 +131,14 @@ function acquireLock() {
|
|
|
74
131
|
}
|
|
75
132
|
|
|
76
133
|
function releaseLock() {
|
|
134
|
+
try {
|
|
135
|
+
// Only drop a lock we still hold. A stale-lock steal can rob a live
|
|
136
|
+
// holder (see acquireLock); if that happened, the dir now belongs to the
|
|
137
|
+
// thief, and removing it would hand a third writer a lock the thief still
|
|
138
|
+
// believes is theirs. An unreadable/absent pid file means the lock is
|
|
139
|
+
// ours-but-unstamped or already gone — both fall through to the remove.
|
|
140
|
+
if (readFileSync(join(LOCK_DIR, 'pid'), 'utf-8') !== String(process.pid)) return;
|
|
141
|
+
} catch { /* no pid file: our own best-effort stamp failed, or it's gone */ }
|
|
77
142
|
try { rmSync(LOCK_DIR, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
78
143
|
}
|
|
79
144
|
|
|
@@ -86,7 +151,12 @@ export function readState() {
|
|
|
86
151
|
try {
|
|
87
152
|
const quarantine = `${STATE_FILE}.corrupt.${Date.now()}`;
|
|
88
153
|
renameSync(STATE_FILE, quarantine);
|
|
89
|
-
|
|
154
|
+
// err.message, NOT logged: V8 embeds the first bytes of the input in
|
|
155
|
+
// its JSON.parse message ("Unexpected token 'S', \"SUPER SECR\"..."),
|
|
156
|
+
// so a state.json that is really a symlink to something else would
|
|
157
|
+
// print that file's opening bytes into unsnooze.log. The quarantined
|
|
158
|
+
// file is right there on disk for anyone who needs to see the content.
|
|
159
|
+
log(`CORRUPT state.json quarantined to ${quarantine} (${err.name || 'parse error'})`);
|
|
90
160
|
} catch { /* someone else quarantined it first */ }
|
|
91
161
|
return EMPTY();
|
|
92
162
|
}
|
|
@@ -137,14 +207,14 @@ function normalizeRecord(rec) {
|
|
|
137
207
|
// Locked read-modify-write. mutator receives the state object and mutates it
|
|
138
208
|
// (or returns a replacement). Returns the final state.
|
|
139
209
|
export function updateState(mutator) {
|
|
140
|
-
|
|
210
|
+
ensureStateDir();
|
|
141
211
|
acquireLock();
|
|
142
212
|
try {
|
|
143
213
|
const state = readState();
|
|
144
214
|
const result = mutator(state) ?? state;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
215
|
+
// Owner-only: session records embed cwd paths and queued prompt text.
|
|
216
|
+
writePrivateFile(STATE_FILE, join(STATE_DIR, `.state.tmp.${process.pid}`),
|
|
217
|
+
JSON.stringify(result, null, 2));
|
|
148
218
|
return result;
|
|
149
219
|
} finally {
|
|
150
220
|
releaseLock();
|
|
@@ -159,6 +229,24 @@ export function updateState(mutator) {
|
|
|
159
229
|
// calibration snapshots so stop + ceiling sample never race (1.13).
|
|
160
230
|
export function upsertSession(record, { after = null } = {}) {
|
|
161
231
|
record = normalizeRecord({ ...record });
|
|
232
|
+
// Computed BEFORE the lock, never inside the mutator. workspaceFingerprint
|
|
233
|
+
// shells out to git, and execFileSync's `timeout` is a soft bound — it
|
|
234
|
+
// sends SIGTERM at the deadline and then waits for the child to actually
|
|
235
|
+
// exit, which a git wedged in uninterruptible I/O on a hung network mount
|
|
236
|
+
// never does. That made it the one unbounded operation under the state
|
|
237
|
+
// lock, and the only way a critical section could outlive
|
|
238
|
+
// HARD_STALE_LOCK_MS and be stolen from a writer that is still working.
|
|
239
|
+
// The cost of hoisting is one extra git call when the record turns out to
|
|
240
|
+
// be a duplicate; the benefit is that the lock is only ever held for
|
|
241
|
+
// in-memory work plus one write.
|
|
242
|
+
// 'resuming' is included deliberately: the `closing` branch inside the
|
|
243
|
+
// mutator can flip such a record to 'stopped', and the apply site below
|
|
244
|
+
// would then want a baseline this precompute never made. No caller passes
|
|
245
|
+
// 'resuming' today — this keeps the hoist from becoming a trap for one that
|
|
246
|
+
// later does.
|
|
247
|
+
const needsFingerprint = ['stopped', 'resuming'].includes(record.status)
|
|
248
|
+
&& record.workspace === undefined;
|
|
249
|
+
const fingerprint = needsFingerprint ? workspaceFingerprint(record.cwd) : undefined;
|
|
162
250
|
return updateState(state => {
|
|
163
251
|
prune(state);
|
|
164
252
|
const closing = state.paneClosures.find(c => c.pane && c.leaseId
|
|
@@ -228,9 +316,12 @@ export function upsertSession(record, { after = null } = {}) {
|
|
|
228
316
|
log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
|
|
229
317
|
} else {
|
|
230
318
|
// Baseline for the stale-workspace guard, captured once at stop time.
|
|
231
|
-
// (Merged duplicates above keep the ORIGINAL baseline — spread
|
|
319
|
+
// (Merged duplicates above keep the ORIGINAL baseline — spread
|
|
320
|
+
// semantics — so this is applied on the non-duplicate branch ONLY.
|
|
321
|
+
// Assigning it before the branch would let the merge spread it over the
|
|
322
|
+
// existing record's baseline and silently reset it.)
|
|
232
323
|
if (record.status === 'stopped' && record.workspace === undefined) {
|
|
233
|
-
record.workspace =
|
|
324
|
+
record.workspace = fingerprint;
|
|
234
325
|
}
|
|
235
326
|
const key = record.sessionId || `pane:${addressHash(record)}:${record.detectedAt}`;
|
|
236
327
|
applied = { ...record, key };
|
package/src/update-check.js
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
// All of it is gated on the `updateCheck` setting / UNSNOOZE_UPDATE_CHECK.
|
|
8
8
|
// The check is a plain GET to registry.npmjs.org — nothing identifying.
|
|
9
9
|
|
|
10
|
-
import { readFileSync,
|
|
10
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { getConfig } from './settings.js';
|
|
15
15
|
import { notify } from './notify.js';
|
|
16
16
|
import { UNSNOOZE_BIN, pidAlive } from './spawn.js';
|
|
17
|
+
import { ensureStateDir, writePrivateFile } from './config.js';
|
|
17
18
|
|
|
18
19
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
20
|
export const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
|
|
@@ -31,10 +32,11 @@ export function readCache() {
|
|
|
31
32
|
export function writeCache(patch) {
|
|
32
33
|
const merged = { ...readCache(), ...patch };
|
|
33
34
|
const path = CACHE_FILE();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// _update-check runs as a detached child, so on a fresh install this can be
|
|
36
|
+
// the process that creates ~/.unsnooze — it must create it owner-only.
|
|
37
|
+
ensureStateDir(dirname(path));
|
|
38
|
+
writePrivateFile(path, join(dirname(path), `.update-check.tmp.${process.pid}`),
|
|
39
|
+
JSON.stringify(merged, null, 2) + '\n');
|
|
38
40
|
return merged;
|
|
39
41
|
}
|
|
40
42
|
|
package/src/usage.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
CLAUDE_DIR, CODEX_DIR, USAGE_FILE, USAGE_ETA_WARN_MIN,
|
|
12
12
|
USAGE_BURN_LOOKBACK_MS, USAGE_BURN_MIN_COVERAGE_MS, USAGE_IDLE_GAP_MS,
|
|
13
13
|
USAGE_WINDOW_IDLE_MS, USAGE_CALIBRATION_RING, USAGE_CALIBRATION_MEDIAN_N,
|
|
14
|
-
USAGE_STATUSLINE_DIR,
|
|
14
|
+
USAGE_STATUSLINE_DIR, ensureStateDir, writePrivateFile,
|
|
15
15
|
} from './config.js';
|
|
16
16
|
import { readState, updateState } from './state.js';
|
|
17
17
|
import { getConfig } from './settings.js';
|
|
@@ -535,10 +535,9 @@ export function readUsageStore(path = USAGE_FILE) {
|
|
|
535
535
|
}
|
|
536
536
|
|
|
537
537
|
export function writeUsageStore(store, path = USAGE_FILE) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
renameSync(tmp, path);
|
|
538
|
+
ensureStateDir(dirname(path));
|
|
539
|
+
writePrivateFile(path, join(dirname(path), `.usage.tmp.${process.pid}`),
|
|
540
|
+
JSON.stringify(store));
|
|
542
541
|
return store;
|
|
543
542
|
}
|
|
544
543
|
|
|
@@ -1239,15 +1238,30 @@ let data = {};
|
|
|
1239
1238
|
try { data = JSON.parse(raw || '{}'); } catch { /* ignore */ }
|
|
1240
1239
|
|
|
1241
1240
|
const dir = path.join(os.homedir(), '.claude', 'unsnooze');
|
|
1242
|
-
|
|
1243
|
-
|
|
1241
|
+
// 0700/0600 like the state dir: these drops carry your live rate-limit
|
|
1242
|
+
// numbers, and the shim is the only thing that creates them. mkdir's mode is
|
|
1243
|
+
// ignored for a directory that already exists, so an install that predates
|
|
1244
|
+
// this is repaired explicitly — the same reasoning ensureStateDir applies one
|
|
1245
|
+
// directory over. Skipped on Windows, where the POSIX bits are synthetic.
|
|
1246
|
+
try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* ignore */ }
|
|
1247
|
+
if (process.platform !== 'win32') {
|
|
1248
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* not ours */ }
|
|
1249
|
+
}
|
|
1250
|
+
// The id becomes a filename. Claude Code sends a uuid, but nothing here is
|
|
1251
|
+
// in a position to trust that: a '/' or a '..' would walk the drop file out
|
|
1252
|
+
// of ~/.claude/unsnooze and overwrite any .json the user can write.
|
|
1253
|
+
const rawSid = String(data.session_id || data.sessionId || 'unknown');
|
|
1254
|
+
const sid = /^[A-Za-z0-9._-]{1,128}$/.test(rawSid) ? rawSid : 'unknown';
|
|
1244
1255
|
const drop = path.join(dir, 'usage-' + sid + '.json');
|
|
1245
1256
|
try {
|
|
1246
1257
|
fs.writeFileSync(drop, JSON.stringify({
|
|
1247
1258
|
rate_limits: data.rate_limits || null,
|
|
1248
1259
|
at: Date.now(),
|
|
1249
1260
|
sessionId: sid,
|
|
1250
|
-
}));
|
|
1261
|
+
}), { mode: 0o600 });
|
|
1262
|
+
// mode only lands when writeFileSync CREATES the file; an existing drop
|
|
1263
|
+
// from before this change keeps its own until chmod'd.
|
|
1264
|
+
if (process.platform !== 'win32') fs.chmodSync(drop, 0o600);
|
|
1251
1265
|
} catch { /* ignore */ }
|
|
1252
1266
|
|
|
1253
1267
|
const orig = process.env.UNSNOOZE_STATUSLINE_ORIG || '';
|
package/src/watcher.js
CHANGED
|
@@ -293,9 +293,9 @@ export function createWatcher({
|
|
|
293
293
|
}
|
|
294
294
|
if (!dirty) return;
|
|
295
295
|
try {
|
|
296
|
-
mkdirSync(dirname(offsetsPath), { recursive: true });
|
|
296
|
+
mkdirSync(dirname(offsetsPath), { recursive: true, mode: 0o700 });
|
|
297
297
|
const tmp = join(dirname(offsetsPath), `.offsets.tmp.${process.pid}`);
|
|
298
|
-
writeFileSync(tmp, JSON.stringify(offsets));
|
|
298
|
+
writeFileSync(tmp, JSON.stringify(offsets), { mode: 0o600 });
|
|
299
299
|
renameSync(tmp, offsetsPath);
|
|
300
300
|
dirty = false;
|
|
301
301
|
} catch (err) {
|