sealcode 1.3.5 → 1.4.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.
@@ -34,6 +34,10 @@ const path = require('path');
34
34
  const { execFileSync } = require('child_process');
35
35
 
36
36
  const SERVICE_NAME = 'dev.sealcode.watcher';
37
+ // sealcode@1.3.6 — Windows Task Scheduler name. No dots allowed in path
38
+ // components, so we use a hyphen-cased name. Stored at the user level
39
+ // (no Administrator prompt required).
40
+ const WIN_TASK_NAME = 'SealCodeWatcherSupervisor';
37
41
  const ui = require('./ui');
38
42
 
39
43
  function launchdPath() {
@@ -129,6 +133,77 @@ WantedBy=timers.target
129
133
  }
130
134
  }
131
135
 
136
+ /**
137
+ * sealcode@1.3.6 — Windows scheduled task. Runs at user logon AND every
138
+ * 1 minute thereafter. We use `schtasks` (built-in, no admin required
139
+ * for user-level tasks) instead of an actual Windows Service (which
140
+ * requires Administrator and a service wrapper).
141
+ *
142
+ * /SC ONLOGON → fires when the user logs in (so the watcher
143
+ * supervisor is running before they start work)
144
+ * /RI 1 → repeat interval of 1 minute (combined with /DU)
145
+ * /DU 9999:59 → for ~10000 hours = effectively forever
146
+ * /F → force overwrite if a task already exists (idempotent)
147
+ *
148
+ * Two separate tasks are needed: ONLOGON does NOT support /RI on its
149
+ * own, so we create:
150
+ * - "SealCodeWatcherSupervisor-Logon" (one-shot at logon)
151
+ * - "SealCodeWatcherSupervisor-Minute" (every minute)
152
+ *
153
+ * The supervise command itself is idempotent so dual-invocation is safe.
154
+ */
155
+ function writeWindowsTasks() {
156
+ const node = nodeBin();
157
+ const cli = cliEntry();
158
+ const logDir = path.join(os.homedir(), '.sealcode', 'logs');
159
+ mkdirP(logDir);
160
+
161
+ // Wrap in a tiny .cmd shim so schtasks doesn't have to deal with
162
+ // node + cli paths containing spaces (Windows path quoting via
163
+ // schtasks /TR is famously fragile). The shim writes output to a
164
+ // log file so we have evidence the supervisor ran.
165
+ const shimPath = path.join(os.homedir(), '.sealcode', 'supervise.cmd');
166
+ const shim = `@echo off\r\n`
167
+ + `"${node}" "${cli}" supervise >> "${path.join(logDir, 'supervise.out.log')}" 2>> "${path.join(logDir, 'supervise.err.log')}"\r\n`;
168
+ mkdirP(path.dirname(shimPath));
169
+ fs.writeFileSync(shimPath, shim, { mode: 0o644 });
170
+
171
+ // Task 1 — run once at every user logon.
172
+ try {
173
+ execFileSync('schtasks', [
174
+ '/Create', '/F',
175
+ '/TN', `${WIN_TASK_NAME}-Logon`,
176
+ '/TR', shimPath,
177
+ '/SC', 'ONLOGON',
178
+ '/RL', 'LIMITED',
179
+ ], { stdio: 'inherit' });
180
+ } catch (err) {
181
+ ui.warn(`schtasks (logon) failed: ${err.message || err}`);
182
+ }
183
+
184
+ // Task 2 — repeat every 1 minute forever.
185
+ // schtasks doesn't have an "every minute" trigger directly — we use
186
+ // /SC MINUTE /MO 1 which runs every minute.
187
+ try {
188
+ execFileSync('schtasks', [
189
+ '/Create', '/F',
190
+ '/TN', `${WIN_TASK_NAME}-Minute`,
191
+ '/TR', shimPath,
192
+ '/SC', 'MINUTE',
193
+ '/MO', '1',
194
+ '/RL', 'LIMITED',
195
+ ], { stdio: 'inherit' });
196
+ } catch (err) {
197
+ ui.warn(`schtasks (minute) failed: ${err.message || err}`);
198
+ }
199
+ }
200
+
201
+ function removeWindowsTasks() {
202
+ try { execFileSync('schtasks', ['/Delete', '/F', '/TN', `${WIN_TASK_NAME}-Logon`], { stdio: 'ignore' }); } catch (_) { /* ok */ }
203
+ try { execFileSync('schtasks', ['/Delete', '/F', '/TN', `${WIN_TASK_NAME}-Minute`], { stdio: 'ignore' }); } catch (_) { /* ok */ }
204
+ try { fs.unlinkSync(path.join(os.homedir(), '.sealcode', 'supervise.cmd')); } catch (_) { /* ok */ }
205
+ }
206
+
132
207
  function runInstallService() {
133
208
  if (process.platform === 'darwin') {
134
209
  writePlist();
@@ -141,11 +216,14 @@ function runInstallService() {
141
216
  ui.ok(`Installed systemd user units: sealcode-watcher.{service,timer}`);
142
217
  return;
143
218
  }
144
- ui.warn(
145
- `'sealcode install-service' is not yet supported on ${process.platform}. `
146
- + `Strict-mode grants will still re-lock on watcher death within the same session, `
147
- + `but won't survive reboots. Tracked at https://sealcode.dev/docs/team#service-windows`,
148
- );
219
+ if (process.platform === 'win32') {
220
+ writeWindowsTasks();
221
+ ui.ok(`Installed Windows scheduled tasks: ${WIN_TASK_NAME}-{Logon,Minute}`);
222
+ ui.hint(' They run as your user, with no Administrator prompt, every minute.');
223
+ ui.hint(` Logs: ${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log')}`);
224
+ return;
225
+ }
226
+ ui.warn(`'sealcode install-service' is not supported on ${process.platform}.`);
149
227
  process.exitCode = 1;
150
228
  }
151
229
 
@@ -163,60 +241,95 @@ function runUninstallService() {
163
241
  ui.ok('Removed systemd user units.');
164
242
  return;
165
243
  }
244
+ if (process.platform === 'win32') {
245
+ removeWindowsTasks();
246
+ ui.ok('Removed Windows scheduled tasks.');
247
+ return;
248
+ }
166
249
  ui.hint('Nothing to uninstall on this platform.');
167
250
  }
168
251
 
169
252
  /**
170
- * Called by the supervisor every minute (launchd / systemd timer).
171
- * Walks ~/.sealcode/sessions/*.watch.json, finds any whose pid is dead,
172
- * and re-spawns a watcher for that code. Cheap, idempotent, silent.
253
+ * Called by the supervisor every minute (launchd / systemd timer /
254
+ * Windows schtasks). Two responsibilities:
255
+ *
256
+ * 1. Stale-watch-state sweep — pre-1.3.6 behavior. Walk
257
+ * ~/.sealcode/sessions/*.watch.json, find any whose pid is dead,
258
+ * and remove the stale watch state file. This makes cli-guard
259
+ * lock the next time the user runs sealcode in that project.
260
+ *
261
+ * 2. Registry-based orphan lock (sealcode@1.3.6+). The session-file
262
+ * sweep above is good but only catches projects whose watch.json
263
+ * file is still present on disk. If the user killed the watcher
264
+ * AND wiped the session dir (the Saad scenario), there's nothing
265
+ * in ~/.sealcode/sessions/ to walk — but the project is still
266
+ * unlocked. The known-projects registry at ~/.sealcode/projects.json
267
+ * lets us catch this: anything marked `unlocked` with no live
268
+ * watcher gets a detached panic-lock dispatched.
269
+ *
270
+ * Cheap, idempotent, silent. Runs as the logged-in user.
173
271
  */
174
272
  function runSupervise() {
273
+ // -----------------------------------------------------------------
274
+ // Step 1 — old session-file sweep
275
+ // -----------------------------------------------------------------
175
276
  const dir = path.join(os.homedir(), '.sealcode', 'sessions');
176
277
  let files;
177
278
  try {
178
279
  files = fs.readdirSync(dir).filter((f) => f.endsWith('.watch.json'));
179
280
  } catch (_) {
180
- return;
281
+ files = [];
181
282
  }
182
- const { spawn } = require('child_process');
183
- let respawned = 0;
283
+ let cleared = 0;
184
284
  for (const f of files) {
185
285
  let state;
186
286
  try {
187
287
  state = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
188
- } catch (_) {
189
- continue;
190
- }
191
- if (!state || !state.code || !state.pid) continue;
288
+ } catch (_) { continue; }
289
+ if (!state || !state.pid) continue;
192
290
  let alive = false;
193
291
  try { process.kill(state.pid, 0); alive = true; } catch (_) { alive = false; }
194
292
  if (alive) continue;
195
- // We don't know the project root from the watch state file alone,
196
- // but the file name encodes the sha256(projectAbs).slice(0,16) which
197
- // we can't reverse. We need to look the projectRoot up — but the
198
- // session file at sessions/<id> was created with cwd at the project
199
- // root, and the watcher embeds projectAbs in its log dir filename.
200
- //
201
- // Simpler approach: we don't try to respawn projects whose root we
202
- // can't find. Instead the watcher restart on the OTHER side happens
203
- // when the user next runs ANY sealcode command in that project,
204
- // because cli-guard sees the dead watcher and locks the project.
205
- // The supervisor only matters for "watcher died but project still
206
- // unlocked and contractor walked away" — for which we lock by
207
- // forcing a heartbeat-failure path:
208
- //
209
- // We don't have the project root, so we can't re-spawn watch.
210
- // But we CAN remove the stale watch.json so cli-guard fires next
211
- // time the user runs anything. That's enough.
212
- try { fs.unlinkSync(path.join(dir, f)); respawned += 1; } catch (_) { /* ignore */ }
293
+ try { fs.unlinkSync(path.join(dir, f)); cleared += 1; } catch (_) { /* ignore */ }
213
294
  }
214
- // Best-effort log line so the user can see the supervisor is running.
215
- if (respawned > 0) {
295
+
296
+ // -----------------------------------------------------------------
297
+ // Step 2 — registry-based orphan lock (1.3.6+)
298
+ // -----------------------------------------------------------------
299
+ let orphansHandled = 0;
300
+ try {
301
+ const registry = require('./cli-registry');
302
+ const orphans = registry.findOrphanedUnlocks({ maxStaleMin: 5 });
303
+ if (orphans.length > 0) {
304
+ const { spawn } = require('child_process');
305
+ const node = process.execPath;
306
+ const cli = cliEntry();
307
+ for (const o of orphans) {
308
+ try {
309
+ const child = spawn(
310
+ node,
311
+ [cli, 'panic', '--project', o.projectRoot, '--from-selfcheck'],
312
+ {
313
+ detached: true,
314
+ stdio: 'ignore',
315
+ windowsHide: true,
316
+ cwd: o.projectRoot,
317
+ },
318
+ );
319
+ child.unref();
320
+ orphansHandled += 1;
321
+ } catch (_) { /* best-effort */ }
322
+ }
323
+ }
324
+ } catch (_) { /* registry module may not load on partial installs */ }
325
+
326
+ if (cleared > 0 || orphansHandled > 0) {
216
327
  try {
328
+ const logDir = path.join(os.homedir(), '.sealcode', 'logs');
329
+ fs.mkdirSync(logDir, { recursive: true });
217
330
  fs.appendFileSync(
218
- path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log'),
219
- `${new Date().toISOString()} cleared ${respawned} stale watch state files\n`,
331
+ path.join(logDir, 'supervise.out.log'),
332
+ `${new Date().toISOString()} cleared=${cleared} orphan_locks=${orphansHandled}\n`,
220
333
  );
221
334
  } catch (_) { /* ignore */ }
222
335
  }
package/src/cli-watch.js CHANGED
@@ -612,20 +612,72 @@ async function runWatch({
612
612
  });
613
613
  }
614
614
 
615
- // Signal handling. In strict mode, ANY exit path (SIGINT, SIGTERM)
616
- // must re-lock the project before we die otherwise the contractor
617
- // could just Ctrl-C the watcher to keep plaintext.
615
+ // Signal handling. We hit this path for clean exits (Ctrl-C, terminal
616
+ // close, OS shutdown, `pm2 stop`). The signals we DON'T hit on are
617
+ // SIGKILL / kill -9 / Stop-Process -Force by design that's the
618
+ // signal the server-side dead-watcher sweeper is built to detect.
619
+ //
620
+ // Behavior:
621
+ // - strict + lenient both re-lock on clean exit (sealcode@1.3.6 —
622
+ // previously only strict locked; lenient left plaintext for
623
+ // "convenience" but that was a foot-gun. A clean shutdown is a
624
+ // signal that the recipient is walking away, and the right
625
+ // default is to leave their disk safe.)
626
+ // - both POST /api/v1/access/exit so the server records a
627
+ // `gracefullyExitedAt` timestamp and the sweeper skips them. The
628
+ // POST is best-effort; if the network is down (laptop closing
629
+ // mid-VPN drop), we still tear down locally and the sweeper will
630
+ // eventually email — fail-safe in the alerting direction.
618
631
  let stopped = false;
619
632
  const cleanup = async (reason) => {
620
633
  if (stopped) return;
621
634
  stopped = true;
622
635
  if (exfilCtrl) exfilCtrl.stop();
623
636
  deleteWatchState(projectRoot);
624
- if (strict) {
625
- appendLog(projectRoot, { type: 'strict_exit_lock', reason });
626
- await finalLock(projectRoot, config, trimmedCode, `strict:${reason}`, json, daemon).catch(() => {});
627
- } else {
628
- if (!json && !daemon) ui.say('\n' + ui.c.dim('stopped watching (lenient mode files left as-is)'));
637
+
638
+ appendLog(projectRoot, { type: 'graceful_exit_lock', reason, strict });
639
+
640
+ // Best-effort: re-lock the recipient's plaintext before we die.
641
+ // Errors are swallowed we'd rather exit and let the sweeper alert
642
+ // than block the OS shutdown waiting on a runLock that hung on a
643
+ // disk write.
644
+ let relocked = false;
645
+ try {
646
+ const K = loadSession(projectRoot);
647
+ if (K) {
648
+ const sm = loadSessionMeta(projectRoot);
649
+ const preserveUnseen = !!sm && sm.meta && sm.meta.source === 'grant';
650
+ await runLock({ projectRoot, config, K, preserveUnseen }).catch(() => {});
651
+ try { K.fill(0); } catch (_) { /* ignore */ }
652
+ relocked = true;
653
+ }
654
+ } catch (_) { /* best-effort */ }
655
+ try { clearSession(projectRoot); } catch (_) { /* ignore */ }
656
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
657
+
658
+ // Best-effort: tell the server we exited cleanly so the sweeper
659
+ // doesn't dead-watcher-alert. Hard-bounded timeout: if the server
660
+ // is slow/unreachable we'd rather die quickly than block shutdown.
661
+ try {
662
+ const timeoutMs = 1500;
663
+ const exitPromise = request('POST', `/api/v1/access/exit`, {
664
+ body: {
665
+ code: trimmedCode,
666
+ reason: reason === 'sigint' || reason === 'sigterm' || reason === 'sighup' ? reason : 'manual',
667
+ watcherPid: process.pid,
668
+ watcherVersion: pkg.version,
669
+ relockedFiles: relocked,
670
+ },
671
+ });
672
+ await Promise.race([
673
+ exitPromise,
674
+ new Promise((resolve) => setTimeout(resolve, timeoutMs)),
675
+ ]).catch(() => {});
676
+ } catch (_) { /* best-effort */ }
677
+
678
+ if (!json && !daemon) {
679
+ if (relocked) ui.say('\n' + ui.c.dim('stopped watching — files re-locked'));
680
+ else ui.say('\n' + ui.c.dim('stopped watching'));
629
681
  }
630
682
  process.exit(0);
631
683
  };
@@ -959,17 +1011,18 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
959
1011
  process.exit(2);
960
1012
  }
961
1013
 
962
- // sealcode@1.1.0 — for grant-derived sessions that had a path scope
963
- // (or any future "subset-only" policy), preserve out-of-scope blobs
964
- // so the contractor's re-lock doesn't wipe the project for everyone.
1014
+ // sealcode@1.3.6ALWAYS preserve unseen blobs for grant-derived
1015
+ // sessions, not just path-scoped ones. The recipient-side re-lock
1016
+ // (revoke / pause / device-mismatch / expiry) is supposed to wipe
1017
+ // THEIR plaintext copy; it must not wipe the encrypted blobs that
1018
+ // were sealed by the OWNER. Without preserveUnseen, any race that
1019
+ // makes collectFiles return fewer paths than the manifest expected
1020
+ // permanently destroys those blobs (the "revoked → 1 file restored"
1021
+ // bug). Owner-side passphrase re-locks still use preserveUnseen=false
1022
+ // since the owner has all plaintext on hand by definition.
965
1023
  const _sessionMeta = loadSessionMeta(projectRoot);
966
1024
  const _preserveUnseen =
967
- !!_sessionMeta &&
968
- _sessionMeta.meta &&
969
- _sessionMeta.meta.source === 'grant' &&
970
- _sessionMeta.meta.policy &&
971
- Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
972
- _sessionMeta.meta.policy.allowedPaths.length > 0;
1025
+ !!_sessionMeta && _sessionMeta.meta && _sessionMeta.meta.source === 'grant';
973
1026
 
974
1027
  let res;
975
1028
  try {
@@ -981,6 +1034,7 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
981
1034
  }
982
1035
  clearSession(projectRoot);
983
1036
  deleteWatchState(projectRoot);
1037
+ try { require('./cli-registry').markLocked(projectRoot); } catch (_) { /* ignore */ }
984
1038
  if (daemon) {
985
1039
  appendLog(projectRoot, { type: 'locked', count: res.count, reason: label });
986
1040
  } else if (json) {
@@ -1101,4 +1155,6 @@ module.exports = {
1101
1155
  readWatcherStatus,
1102
1156
  watchStateFile,
1103
1157
  watchLogFile,
1158
+ // Exported for tests. Not part of the public surface.
1159
+ _internal: { heartbeatOnce },
1104
1160
  };