sealcode 1.3.1 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli-watch.js +76 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealcode",
3
- "version": "1.3.1",
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');
@@ -688,7 +688,35 @@ async function runWatch({
688
688
  try {
689
689
  r = await heartbeatOnce(trimmedCode, { waitMs });
690
690
  } catch (err) {
691
- 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;
692
720
  }
693
721
  if (!r.ok) {
694
722
  const now = Date.now();
@@ -840,14 +868,56 @@ async function softLock(projectRoot, config, code, reason, json, daemon) {
840
868
  return;
841
869
  }
842
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
+
843
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.
844
917
  const _preserveUnseen =
845
918
  !!_sessionMeta &&
846
919
  _sessionMeta.meta &&
847
- _sessionMeta.meta.source === 'grant' &&
848
- _sessionMeta.meta.policy &&
849
- Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
850
- _sessionMeta.meta.policy.allowedPaths.length > 0;
920
+ _sessionMeta.meta.source === 'grant';
851
921
 
852
922
  try {
853
923
  const res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });