sealcode 1.3.0 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Lock your source code in your own git repo. Stop AI agents, scrapers, and curious eyes from reading what's yours.",
5
5
  "keywords": [
6
6
  "encryption",
package/src/cli-watch.js CHANGED
@@ -54,7 +54,7 @@ const {
54
54
  clearSession,
55
55
  projectId,
56
56
  } = require('./keystore');
57
- const { runLock } = require('./seal');
57
+ const { runLock, collectFiles } = require('./seal');
58
58
  const { runUnlock } = require('./open');
59
59
  const { getDeviceFingerprint } = require('./device');
60
60
  const { SealcodeError } = require('./errors');
@@ -314,6 +314,7 @@ function startExfilWatchers({
314
314
  encoding: 'utf8',
315
315
  timeout: 1500,
316
316
  stdio: ['ignore', 'pipe', 'ignore'],
317
+ windowsHide: true,
317
318
  })
318
319
  .trim();
319
320
  } catch (_) { /* repo may not have git */ }
@@ -417,6 +418,7 @@ function startExfilWatchers({
417
418
  const cur = cp
418
419
  .execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
419
420
  encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
421
+ windowsHide: true,
420
422
  })
421
423
  .trim();
422
424
  if (initialRemotes && cur !== initialRemotes) {
@@ -686,7 +688,35 @@ async function runWatch({
686
688
  try {
687
689
  r = await heartbeatOnce(trimmedCode, { waitMs });
688
690
  } catch (err) {
689
- throw err;
691
+ // sealcode@1.3.2 — previously this rethrew, which propagated out
692
+ // of runWatch and turned the watcher into a silent zombie: the
693
+ // process kept running because exfilCtrl's inotify kept the event
694
+ // loop alive, but the polling loop was dead and the watcher
695
+ // stopped receiving pause/resume/unlock signals from the server.
696
+ // We now treat unexpected throws from heartbeatOnce as transient,
697
+ // back off, and try again — same path as `!r.ok`. If we exceed
698
+ // the offline grace we'll hard-lock cleanly via the regular path.
699
+ const now = Date.now();
700
+ if (consecutiveTransient === 0) firstTransientAt = now;
701
+ consecutiveTransient += 1;
702
+ const offlineSec = Math.floor((now - firstTransientAt) / 1000);
703
+ const errMsg = String(err?.message || err);
704
+ const errDetail = err?.detail || err?.hint || null;
705
+ const errCode = err?.code || err?.apiCode || err?.status || null;
706
+ appendLog(projectRoot, {
707
+ type: 'heartbeat_throw',
708
+ error: errMsg,
709
+ detail: errDetail,
710
+ code: errCode,
711
+ stack: err?.stack ? String(err.stack).split('\n').slice(0, 4).join(' | ') : null,
712
+ offlineSec,
713
+ });
714
+ if (!daemon && !json) ui.warn(`[${ts()}] heartbeat raised: ${errMsg} (offline ${offlineSec}s / ${offlineGraceSec}s grace)`);
715
+ if (offlineSec >= offlineGraceSec) {
716
+ return finalLock(projectRoot, config, trimmedCode, 'offline_grace_exceeded', json, daemon);
717
+ }
718
+ await sleep(TRANSIENT_BACKOFF_SEC * 1000);
719
+ continue;
690
720
  }
691
721
  if (!r.ok) {
692
722
  const now = Date.now();
@@ -838,14 +868,56 @@ async function softLock(projectRoot, config, code, reason, json, daemon) {
838
868
  return;
839
869
  }
840
870
 
871
+ // sealcode@1.3.2 — CRITICAL safety check. If the project has no
872
+ // plaintext files on disk (i.e. it's already in its locked state),
873
+ // calling runLock here would DESTROY the locked vendor/ directory:
874
+ // runLock does `rmIfExists(lockedRoot)` and then re-writes only files
875
+ // that are currently plaintext PLUS any blobs covered by
876
+ // preserveUnseen. preserveUnseen is only true for path-scoped grants
877
+ // (allowedPaths set), so for the common case of a full-project grant
878
+ // the wipe would leave just the keystore + 0 blobs and the manifest
879
+ // would shrink from 553 entries to 0. A subsequent `sealcode unlock`
880
+ // would then "restore" 0 files — irrecoverable data loss for the
881
+ // recipient if they don't have a separate copy.
882
+ //
883
+ // This is exactly Rejoan's "unlocked 1 files" bug: his watcher
884
+ // restarted (or auto-spawned by redeem) against an already-paused
885
+ // grant on Windows where files were already locked, softLock fired
886
+ // with no plaintext to encrypt, wiped vendor/, and his next unlock
887
+ // restored just the 1 stub file.
888
+ //
889
+ // The fix: if there's nothing to encrypt, softLock is a no-op. The
890
+ // files are already in the encrypted state we wanted them in.
891
+ let plaintextFiles = [];
892
+ try {
893
+ plaintextFiles = await collectFiles(projectRoot, config);
894
+ } catch (_) {
895
+ // If collectFiles itself fails, fall through to runLock which has
896
+ // its own error handling — at worst we hit the catch below and
897
+ // log soft_lock_error.
898
+ }
899
+ if (plaintextFiles.length === 0) {
900
+ K.fill(0);
901
+ if (daemon) {
902
+ appendLog(projectRoot, { type: 'soft_locked', count: 0, reason: label, note: 'already-locked' });
903
+ } else if (!json) {
904
+ ui.ok(`[${ts()}] already locked on disk — no re-encryption needed (waiting for resume)`);
905
+ }
906
+ return;
907
+ }
908
+
841
909
  const _sessionMeta = loadSessionMeta(projectRoot);
910
+ // sealcode@1.3.2 — always preserve unseen blobs for grant-derived
911
+ // sessions, not just path-scoped ones. Even without allowedPaths,
912
+ // the recipient may have a mix of plaintext and still-locked files
913
+ // (e.g. they manually `sealcode lock`ed a subset, or RO mode set
914
+ // 0444 prevented some files from materializing on disk). Without
915
+ // preserveUnseen, runLock would drop those unseen blobs and the
916
+ // resume + unlock flow would restore an incomplete project.
842
917
  const _preserveUnseen =
843
918
  !!_sessionMeta &&
844
919
  _sessionMeta.meta &&
845
- _sessionMeta.meta.source === 'grant' &&
846
- _sessionMeta.meta.policy &&
847
- Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
848
- _sessionMeta.meta.policy.allowedPaths.length > 0;
920
+ _sessionMeta.meta.source === 'grant';
849
921
 
850
922
  try {
851
923
  const res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
@@ -932,9 +1004,33 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
932
1004
  * Helper: spawn a detached `sealcode watch <code> --daemon` child. Used
933
1005
  * by runUnlock when the unlock came from a grant-derived session.
934
1006
  * Returns the child pid (already detached). Best-effort.
1007
+ *
1008
+ * Windows specifics (sealcode@1.3.1):
1009
+ * - `windowsHide: true` keeps the spawned process from popping a
1010
+ * console window. Without this, every daemon spawn flashes a black
1011
+ * cmd window which users (rightly) find alarming.
1012
+ * - We also guard against re-spawn loops: if a daemon is already
1013
+ * alive for this project we just return its pid instead of
1014
+ * spawning a new one. Without this guard, repeated `redeem` calls
1015
+ * (or any startup hook that re-runs redeem) accumulate orphan
1016
+ * daemons that each show up as a pop-and-vanish window when they
1017
+ * periodically run their child-process sweeps.
935
1018
  */
936
1019
  function spawnDaemonWatcher({ projectRoot, code }) {
937
1020
  try {
1021
+ // If a healthy daemon is already running for this project, don't
1022
+ // start another one. This is the single most important defense
1023
+ // against the "windows pop in and out" loop on Windows.
1024
+ const existing = readWatcherStatus(projectRoot);
1025
+ if (existing.state === 'alive') {
1026
+ return { pid: existing.pid, reused: true };
1027
+ }
1028
+ // Stale / dead state file should be cleared before respawn, otherwise
1029
+ // the new daemon may collide with leftover bookkeeping.
1030
+ if (existing.state === 'stale' || existing.state === 'dead') {
1031
+ try { fs.unlinkSync(watchStateFile(projectRoot)); } catch (_) { /* ignore */ }
1032
+ }
1033
+
938
1034
  ensureDir(WATCH_LOG_DIR);
939
1035
  const bin = process.execPath;
940
1036
  const entry = require.resolve('../bin/sealcode.js');
@@ -945,6 +1041,9 @@ function spawnDaemonWatcher({ projectRoot, code }) {
945
1041
  cwd: projectRoot,
946
1042
  detached: true,
947
1043
  stdio: 'ignore',
1044
+ // Critical on Windows — without this, the spawned daemon
1045
+ // opens a visible cmd window. macOS / Linux ignore the flag.
1046
+ windowsHide: true,
948
1047
  env: { ...process.env, SEALCODE_AUTO_DAEMON: '1' },
949
1048
  },
950
1049
  );
package/src/device.js CHANGED
@@ -72,6 +72,9 @@ function readMachineId() {
72
72
  encoding: 'utf8',
73
73
  timeout: 1500,
74
74
  stdio: ['ignore', 'pipe', 'ignore'],
75
+ // Stop reg.exe from flashing a console window — happens on every
76
+ // command run because device.js is loaded by `clientInfo()`.
77
+ windowsHide: true,
75
78
  });
76
79
  const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]+)/i.exec(out);
77
80
  return m ? m[1] : null;