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 +1 -1
- package/src/cli-watch.js +105 -6
- package/src/device.js +3 -0
package/package.json
CHANGED
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
|
-
|
|
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;
|