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.
- package/README.md +68 -0
- package/package.json +4 -3
- package/src/cli-grants.js +100 -4
- package/src/cli-link.js +147 -8
- package/src/cli-registry.js +256 -0
- package/src/cli-remove.js +281 -0
- package/src/cli-service.js +150 -37
- package/src/cli-watch.js +73 -17
- package/src/cli.js +274 -16
- package/src/discovery.js +1004 -0
- package/src/errors.js +39 -4
- package/src/init.js +152 -11
- package/src/keystore.js +31 -0
- package/src/presets.js +98 -54
- package/src/seal.js +91 -9
package/src/cli-service.js
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
*
|
|
172
|
-
*
|
|
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
|
-
|
|
281
|
+
files = [];
|
|
181
282
|
}
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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(
|
|
219
|
-
`${new Date().toISOString()} cleared
|
|
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.
|
|
616
|
-
//
|
|
617
|
-
//
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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.
|
|
963
|
-
//
|
|
964
|
-
//
|
|
1014
|
+
// sealcode@1.3.6 — ALWAYS 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
|
};
|