sealcode 0.1.0 → 1.1.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 +15 -0
- package/package.json +1 -1
- package/src/api.js +14 -1
- package/src/cli-auth.js +26 -0
- package/src/cli-ci-tokens.js +123 -0
- package/src/cli-escrow.js +236 -0
- package/src/cli-grants.js +385 -17
- package/src/cli-guard.js +117 -0
- package/src/cli-link.js +10 -1
- package/src/cli-service.js +230 -0
- package/src/cli-watch.js +862 -0
- package/src/cli.js +293 -2
- package/src/device.js +163 -0
- package/src/errors.js +18 -0
- package/src/grant-policy.js +119 -0
- package/src/keypair.js +159 -0
- package/src/keystore.js +76 -6
- package/src/open.js +69 -6
- package/src/seal.js +68 -3
- package/src/share-crypto.js +155 -0
- package/src/watermark.js +212 -0
package/src/cli-watch.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `sealcode watch <code>` — long-running agent for an access-code recipient.
|
|
5
|
+
*
|
|
6
|
+
* This is the missing piece of the "real-time revoke" story:
|
|
7
|
+
*
|
|
8
|
+
* On the OWNER side: `sealcode share` mints a code, `sealcode revoke`
|
|
9
|
+
* (or the dashboard) kills it.
|
|
10
|
+
*
|
|
11
|
+
* On the RECIPIENT side: `sealcode redeem` validates the code once and
|
|
12
|
+
* (since 1.0.0) unwraps a team-shared K straight into the session
|
|
13
|
+
* cache; then `sealcode unlock` materializes plaintext. While they're
|
|
14
|
+
* working, `sealcode watch <code>` polls the heartbeat endpoint and:
|
|
15
|
+
*
|
|
16
|
+
* - prints a live status line so the demo is visible
|
|
17
|
+
* - on `action: "lock"` (revoked / expired / auto-lock / strict-mode
|
|
18
|
+
* device mismatch), it immediately runs the same lock pipeline as
|
|
19
|
+
* `sealcode lock`, wipes the cached session, and exits status 0.
|
|
20
|
+
* - on `action: "warn"` (close to expiry), warns the user.
|
|
21
|
+
* - on transient network failure, retries with backoff; after the
|
|
22
|
+
* grant's `offlineGraceSeconds` it self-locks (strict mode) or
|
|
23
|
+
* warns (lenient mode).
|
|
24
|
+
* - watches the project root with fs.watch and raises EXFIL ALERTS to
|
|
25
|
+
* the server when heuristics trip (mass reads, mass bytes, a tar/zip
|
|
26
|
+
* process touching the dir, a new git remote, dir moved/renamed).
|
|
27
|
+
*
|
|
28
|
+
* The watcher does NOT need an account / login — the access code itself
|
|
29
|
+
* is the authentication for these endpoints. That matches the contractor
|
|
30
|
+
* flow: they may not have a sealcode.dev account at all.
|
|
31
|
+
*
|
|
32
|
+
* --daemon mode (sealcode@1.0.0+):
|
|
33
|
+
* When invoked with --daemon, the watcher detaches from the parent's
|
|
34
|
+
* stdio and writes structured JSONL events to ~/.sealcode/logs/<id>.jsonl.
|
|
35
|
+
* This is the form auto-spawned by `runUnlock` when the session was
|
|
36
|
+
* derived from a grant (so the contractor doesn't have to remember to
|
|
37
|
+
* run watch by hand).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const os = require('os');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const crypto = require('crypto');
|
|
44
|
+
const { spawn } = require('child_process');
|
|
45
|
+
|
|
46
|
+
const { request, ApiError, clientInfo } = require('./api');
|
|
47
|
+
const { loadConfig } = require('./config');
|
|
48
|
+
const { detectPreset } = require('./presets');
|
|
49
|
+
const {
|
|
50
|
+
isInitialized,
|
|
51
|
+
loadSession,
|
|
52
|
+
loadSessionMeta,
|
|
53
|
+
updateSessionMeta,
|
|
54
|
+
clearSession,
|
|
55
|
+
projectId,
|
|
56
|
+
} = require('./keystore');
|
|
57
|
+
const { runLock } = require('./seal');
|
|
58
|
+
const { getDeviceFingerprint } = require('./device');
|
|
59
|
+
const { SealcodeError } = require('./errors');
|
|
60
|
+
const grantPolicy = require('./grant-policy');
|
|
61
|
+
const pkg = require('../package.json');
|
|
62
|
+
const ui = require('./ui');
|
|
63
|
+
|
|
64
|
+
const DEFAULT_INTERVAL_SEC = 30;
|
|
65
|
+
const MIN_INTERVAL_SEC = 5;
|
|
66
|
+
const MAX_INTERVAL_SEC = 600;
|
|
67
|
+
const TRANSIENT_BACKOFF_SEC = 5;
|
|
68
|
+
|
|
69
|
+
// Where the watcher writes its heartbeat sidecar — cli-guard reads this to
|
|
70
|
+
// decide whether a grant-derived session is still being supervised.
|
|
71
|
+
const WATCH_STATE_DIR = path.join(os.homedir(), '.sealcode', 'sessions');
|
|
72
|
+
const WATCH_LOG_DIR = path.join(os.homedir(), '.sealcode', 'logs');
|
|
73
|
+
|
|
74
|
+
function watchStateFile(projectRoot) {
|
|
75
|
+
return path.join(WATCH_STATE_DIR, `${projectId(projectRoot)}.watch.json`);
|
|
76
|
+
}
|
|
77
|
+
function watchLogFile(projectRoot) {
|
|
78
|
+
return path.join(WATCH_LOG_DIR, `${projectId(projectRoot)}.jsonl`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getActiveConfig(projectRoot) {
|
|
82
|
+
const fromFile = loadConfig(projectRoot);
|
|
83
|
+
if (fromFile) return fromFile;
|
|
84
|
+
const preset = detectPreset(projectRoot);
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
preset: preset.id,
|
|
88
|
+
lockedDir: preset.lockedDir,
|
|
89
|
+
include: preset.include,
|
|
90
|
+
exclude: preset.exclude,
|
|
91
|
+
stubs: preset.stubs || {},
|
|
92
|
+
_file: null,
|
|
93
|
+
_implicit: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fmtRemaining(ms) {
|
|
98
|
+
if (ms == null) return '';
|
|
99
|
+
if (ms <= 0) return 'expired';
|
|
100
|
+
const sec = Math.floor(ms / 1000);
|
|
101
|
+
if (sec < 60) return `${sec}s`;
|
|
102
|
+
const min = Math.floor(sec / 60);
|
|
103
|
+
if (min < 60) return `${min}m ${sec % 60}s`;
|
|
104
|
+
const hr = Math.floor(min / 60);
|
|
105
|
+
if (hr < 24) return `${hr}h ${min % 60}m`;
|
|
106
|
+
return `${Math.floor(hr / 24)}d ${hr % 24}h`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function ts() {
|
|
110
|
+
return new Date().toTimeString().slice(0, 8);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureDir(p) {
|
|
118
|
+
fs.mkdirSync(p, { recursive: true, mode: 0o700 });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeWatchState(projectRoot, patch) {
|
|
122
|
+
ensureDir(WATCH_STATE_DIR);
|
|
123
|
+
const p = watchStateFile(projectRoot);
|
|
124
|
+
let existing = {};
|
|
125
|
+
try {
|
|
126
|
+
existing = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
127
|
+
} catch (_) { /* fresh file */ }
|
|
128
|
+
const merged = { ...existing, ...patch, updatedAt: new Date().toISOString() };
|
|
129
|
+
fs.writeFileSync(p, JSON.stringify(merged) + '\n', { mode: 0o600 });
|
|
130
|
+
return merged;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function deleteWatchState(projectRoot) {
|
|
134
|
+
try {
|
|
135
|
+
fs.unlinkSync(watchStateFile(projectRoot));
|
|
136
|
+
} catch (_) { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function appendLog(projectRoot, event) {
|
|
140
|
+
ensureDir(WATCH_LOG_DIR);
|
|
141
|
+
try {
|
|
142
|
+
fs.appendFileSync(
|
|
143
|
+
watchLogFile(projectRoot),
|
|
144
|
+
JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n',
|
|
145
|
+
);
|
|
146
|
+
} catch (_) { /* best-effort */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Single heartbeat round-trip.
|
|
151
|
+
*/
|
|
152
|
+
async function heartbeatOnce(code, extras) {
|
|
153
|
+
try {
|
|
154
|
+
const res = await request('POST', '/api/v1/access/heartbeat', {
|
|
155
|
+
body: {
|
|
156
|
+
code,
|
|
157
|
+
watcherPid: process.pid,
|
|
158
|
+
watcherVersion: pkg.version,
|
|
159
|
+
deviceFingerprint: getDeviceFingerprint(),
|
|
160
|
+
...(extras || {}),
|
|
161
|
+
},
|
|
162
|
+
// sealcode@1.1.0 — long-poll allowed up to ~30s; we leave ~5s of
|
|
163
|
+
// headroom over the server's `waitMs` for the TCP round trip
|
|
164
|
+
// before our local request timeout fires.
|
|
165
|
+
timeoutMs: extras && extras.waitMs ? extras.waitMs + 5000 : undefined,
|
|
166
|
+
});
|
|
167
|
+
return { ok: true, response: res };
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof ApiError) {
|
|
170
|
+
if (err.status === 0 || err.status >= 500) {
|
|
171
|
+
return { ok: false, transient: true, err };
|
|
172
|
+
}
|
|
173
|
+
throw new SealcodeError('SEALCODE_WATCH_BAD_CODE', {
|
|
174
|
+
detail: `Heartbeat rejected (${err.status} ${err.apiCode}): ${err.message}`,
|
|
175
|
+
hint: 'Try: sealcode redeem <code> (re-validate the access code)',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return { ok: false, transient: true, err };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Post a batch of per-file activity events. sealcode@1.1.0 — these feed
|
|
184
|
+
* the dashboard's per-grant "what did they touch" timeline. Best-effort.
|
|
185
|
+
*/
|
|
186
|
+
async function postFileEvents(code, events) {
|
|
187
|
+
if (!events || events.length === 0) return;
|
|
188
|
+
try {
|
|
189
|
+
await request('POST', '/api/v1/access/file-events', {
|
|
190
|
+
body: {
|
|
191
|
+
code,
|
|
192
|
+
events,
|
|
193
|
+
deviceFingerprint: getDeviceFingerprint(),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
} catch (_) { /* never tear down the watcher over telemetry */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Post a structured exfil alert to the server. Best-effort — failure is
|
|
201
|
+
* logged locally but doesn't tear down the watcher.
|
|
202
|
+
*/
|
|
203
|
+
async function postAlert(code, alert) {
|
|
204
|
+
try {
|
|
205
|
+
await request('POST', '/api/v1/access/alerts', {
|
|
206
|
+
body: {
|
|
207
|
+
code,
|
|
208
|
+
kind: alert.kind,
|
|
209
|
+
severity: alert.severity || 'warn',
|
|
210
|
+
summary: alert.summary,
|
|
211
|
+
detail: alert.detail || {},
|
|
212
|
+
deviceFingerprint: getDeviceFingerprint(),
|
|
213
|
+
hostname: os.hostname(),
|
|
214
|
+
...clientInfo(),
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
} catch (_) {
|
|
218
|
+
// We never let a failed alert bring down the watcher.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Lightweight exfiltration heuristics. Runs inside the watcher process,
|
|
224
|
+
* polls cheap signals every few seconds, and posts alerts when thresholds
|
|
225
|
+
* trip. Deliberately heuristic — false positives are fine (owner can
|
|
226
|
+
* dismiss), false negatives are the failure mode.
|
|
227
|
+
*
|
|
228
|
+
* Tracked signals:
|
|
229
|
+
* - file count opened in last 60s (uses fs.watch counts)
|
|
230
|
+
* - bytes read in last 60s (uses fs.watch + stat sizes, capped)
|
|
231
|
+
* - presence of suspicious processes (tar/zip/cp/rsync/scp) on Unix
|
|
232
|
+
* - new git remotes added (diff vs initial remote set)
|
|
233
|
+
* - project root moved or renamed (we hold the inode; if it disappears
|
|
234
|
+
* the watcher just locks because the manifest is gone)
|
|
235
|
+
*
|
|
236
|
+
* @returns {{stop: () => void}}
|
|
237
|
+
*/
|
|
238
|
+
function startExfilWatchers({
|
|
239
|
+
projectRoot,
|
|
240
|
+
code,
|
|
241
|
+
config,
|
|
242
|
+
onAlert,
|
|
243
|
+
// sealcode@1.1.0 — per-event hook for the per-file activity log + the
|
|
244
|
+
// idle-lock + RO-violation detectors. All optional; legacy callers
|
|
245
|
+
// (no per-file reporting) can omit.
|
|
246
|
+
onFileEvent = null,
|
|
247
|
+
}) {
|
|
248
|
+
const lockedDir = config.lockedDir || 'vendor';
|
|
249
|
+
const projectAbs = path.resolve(projectRoot);
|
|
250
|
+
const lockedAbs = path.join(projectAbs, lockedDir);
|
|
251
|
+
|
|
252
|
+
let stopped = false;
|
|
253
|
+
|
|
254
|
+
// Rolling 60s window of file events.
|
|
255
|
+
const windowMs = 60_000;
|
|
256
|
+
const events = []; // { at, kind, path, size? }
|
|
257
|
+
function prune(now) {
|
|
258
|
+
const cutoff = now - windowMs;
|
|
259
|
+
while (events.length && events[0].at < cutoff) events.shift();
|
|
260
|
+
}
|
|
261
|
+
function bytesInWindow() {
|
|
262
|
+
let total = 0;
|
|
263
|
+
for (const e of events) total += e.size || 0;
|
|
264
|
+
return total;
|
|
265
|
+
}
|
|
266
|
+
function countInWindow() { return events.length; }
|
|
267
|
+
|
|
268
|
+
// Watch the project root recursively for change/rename events. macOS
|
|
269
|
+
// and Linux both support recursive watch on the root; Linux gives
|
|
270
|
+
// per-file events via inotify.
|
|
271
|
+
let watcher;
|
|
272
|
+
try {
|
|
273
|
+
watcher = fs.watch(projectAbs, { recursive: true, persistent: false }, (event, filename) => {
|
|
274
|
+
if (stopped || !filename) return;
|
|
275
|
+
// Ignore everything under lockedDir (those are the encrypted blobs
|
|
276
|
+
// we ourselves write during lock — would self-trigger constantly).
|
|
277
|
+
const rel = String(filename);
|
|
278
|
+
if (rel.startsWith(lockedDir + path.sep) || rel === lockedDir) return;
|
|
279
|
+
// Don't try to stat — fs.watch can fire AFTER the file was deleted.
|
|
280
|
+
let size = 0;
|
|
281
|
+
try {
|
|
282
|
+
const st = fs.statSync(path.join(projectAbs, rel));
|
|
283
|
+
if (st.isFile()) size = st.size;
|
|
284
|
+
} catch (_) { /* fine */ }
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
events.push({ at: now, kind: event, path: rel, size });
|
|
287
|
+
prune(now);
|
|
288
|
+
// sealcode@1.1.0 — surface per-event signal upward. We translate
|
|
289
|
+
// fs.watch's "rename"/"change" into the canonical kinds the
|
|
290
|
+
// server's file-events table expects.
|
|
291
|
+
if (onFileEvent) {
|
|
292
|
+
let kind = 'modified';
|
|
293
|
+
if (event === 'rename') {
|
|
294
|
+
kind = fs.existsSync(path.join(projectAbs, rel)) ? 'renamed' : 'deleted';
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
onFileEvent({ path: rel, kind, sizeBytes: size, occurredAt: new Date(now).toISOString() });
|
|
298
|
+
} catch (_) { /* observer must not crash the watcher */ }
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
watcher.on('error', () => { /* fs.watch can race; ignore */ });
|
|
302
|
+
} catch (_) {
|
|
303
|
+
// Some platforms (notably some container images) can't do recursive
|
|
304
|
+
// watch. Heuristic just won't fire — not fatal.
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Pin initial git remote set so we can detect "new remote added".
|
|
308
|
+
let initialRemotes = '';
|
|
309
|
+
try {
|
|
310
|
+
const cp = require('child_process');
|
|
311
|
+
initialRemotes = cp
|
|
312
|
+
.execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
|
|
313
|
+
encoding: 'utf8',
|
|
314
|
+
timeout: 1500,
|
|
315
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
316
|
+
})
|
|
317
|
+
.trim();
|
|
318
|
+
} catch (_) { /* repo may not have git */ }
|
|
319
|
+
|
|
320
|
+
// Pin initial project-root inode/mtime so we can detect "dir moved".
|
|
321
|
+
let initialStat = null;
|
|
322
|
+
try {
|
|
323
|
+
initialStat = fs.statSync(projectAbs);
|
|
324
|
+
} catch (_) { /* nothing we can do */ }
|
|
325
|
+
|
|
326
|
+
// Thresholds. Configurable via env so power-users can tune; defaults
|
|
327
|
+
// chosen so a normal coding session (saving a few files / running
|
|
328
|
+
// tests) never trips them, but `tar -czf` of the project does.
|
|
329
|
+
const FILES_THRESHOLD = parseInt(process.env.SEALCODE_EXFIL_FILES || '120', 10);
|
|
330
|
+
const BYTES_THRESHOLD = parseInt(process.env.SEALCODE_EXFIL_BYTES || String(50 * 1024 * 1024), 10);
|
|
331
|
+
const SUSPICIOUS_BINARIES = new Set(['tar', 'gtar', 'bsdtar', 'zip', 'gzip', '7z', 'rsync', 'scp']);
|
|
332
|
+
|
|
333
|
+
// Process scanner — only on Unix; uses /proc on Linux, ps on macOS.
|
|
334
|
+
function suspiciousProcesses() {
|
|
335
|
+
try {
|
|
336
|
+
if (process.platform === 'linux') {
|
|
337
|
+
const dir = '/proc';
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const pid of fs.readdirSync(dir)) {
|
|
340
|
+
if (!/^\d+$/.test(pid)) continue;
|
|
341
|
+
let cmdline = '';
|
|
342
|
+
try {
|
|
343
|
+
cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
344
|
+
} catch (_) { continue; }
|
|
345
|
+
if (!cmdline) continue;
|
|
346
|
+
const argv = cmdline.split('\0').filter(Boolean);
|
|
347
|
+
const exe = path.basename(argv[0] || '').toLowerCase();
|
|
348
|
+
if (SUSPICIOUS_BINARIES.has(exe) && argv.slice(1).some((a) => a.includes(projectAbs))) {
|
|
349
|
+
out.push({ pid, exe, cmd: argv.join(' ').slice(0, 200) });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
if (process.platform === 'darwin') {
|
|
355
|
+
const cp = require('child_process');
|
|
356
|
+
const out = cp
|
|
357
|
+
.execFileSync('ps', ['-axo', 'pid=,comm=,args='], {
|
|
358
|
+
encoding: 'utf8',
|
|
359
|
+
timeout: 1500,
|
|
360
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
361
|
+
})
|
|
362
|
+
.split('\n');
|
|
363
|
+
const matches = [];
|
|
364
|
+
for (const line of out) {
|
|
365
|
+
const m = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
|
366
|
+
if (!m) continue;
|
|
367
|
+
const [, pid, comm, args] = m;
|
|
368
|
+
const exe = path.basename(comm).toLowerCase();
|
|
369
|
+
if (SUSPICIOUS_BINARIES.has(exe) && args.includes(projectAbs)) {
|
|
370
|
+
matches.push({ pid, exe, cmd: args.slice(0, 200) });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return matches;
|
|
374
|
+
}
|
|
375
|
+
} catch (_) { /* ignore */ }
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Sweep loop. We dedupe alerts so a single tar invocation doesn't
|
|
380
|
+
// spam the server.
|
|
381
|
+
const recentlyFired = new Map(); // kind -> lastFiredAt
|
|
382
|
+
function maybeFire(kind, severity, summary, detail) {
|
|
383
|
+
const last = recentlyFired.get(kind) || 0;
|
|
384
|
+
if (Date.now() - last < 60_000) return;
|
|
385
|
+
recentlyFired.set(kind, Date.now());
|
|
386
|
+
onAlert({ kind, severity, summary, detail });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const sweepTimer = setInterval(() => {
|
|
390
|
+
if (stopped) return;
|
|
391
|
+
prune(Date.now());
|
|
392
|
+
|
|
393
|
+
const files = countInWindow();
|
|
394
|
+
const bytes = bytesInWindow();
|
|
395
|
+
if (files > FILES_THRESHOLD) {
|
|
396
|
+
maybeFire('mass_read', 'alert',
|
|
397
|
+
`Read or wrote ${files} files in the last 60s under ${projectAbs}.`,
|
|
398
|
+
{ files, projectAbs });
|
|
399
|
+
}
|
|
400
|
+
if (bytes > BYTES_THRESHOLD) {
|
|
401
|
+
maybeFire('mass_bytes', 'alert',
|
|
402
|
+
`Touched ${(bytes / 1024 / 1024).toFixed(1)} MB of project files in the last 60s.`,
|
|
403
|
+
{ bytes, projectAbs });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const susp = suspiciousProcesses();
|
|
407
|
+
if (susp.length > 0) {
|
|
408
|
+
maybeFire('archiver_process', 'alert',
|
|
409
|
+
`Detected ${susp[0].exe} touching the project root (${susp[0].cmd}).`,
|
|
410
|
+
{ processes: susp });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Git remote drift
|
|
414
|
+
try {
|
|
415
|
+
const cp = require('child_process');
|
|
416
|
+
const cur = cp
|
|
417
|
+
.execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
|
|
418
|
+
encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
|
|
419
|
+
})
|
|
420
|
+
.trim();
|
|
421
|
+
if (initialRemotes && cur !== initialRemotes) {
|
|
422
|
+
maybeFire('git_remote_changed', 'alert',
|
|
423
|
+
`git remotes changed since the watcher started.`,
|
|
424
|
+
{ before: initialRemotes, after: cur });
|
|
425
|
+
initialRemotes = cur;
|
|
426
|
+
}
|
|
427
|
+
} catch (_) { /* repo may not have git */ }
|
|
428
|
+
|
|
429
|
+
// Project moved/renamed
|
|
430
|
+
if (initialStat) {
|
|
431
|
+
try {
|
|
432
|
+
const s = fs.statSync(projectAbs);
|
|
433
|
+
if (s.ino !== initialStat.ino) {
|
|
434
|
+
maybeFire('project_moved', 'alert',
|
|
435
|
+
`Project root inode changed — directory may have been moved or replaced.`,
|
|
436
|
+
{ wasIno: initialStat.ino, nowIno: s.ino });
|
|
437
|
+
}
|
|
438
|
+
} catch (_) {
|
|
439
|
+
maybeFire('project_disappeared', 'alert',
|
|
440
|
+
`Project root no longer exists at ${projectAbs}.`, {});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}, 5000);
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
stop() {
|
|
447
|
+
stopped = true;
|
|
448
|
+
clearInterval(sweepTimer);
|
|
449
|
+
try { watcher && watcher.close(); } catch (_) { /* ignore */ }
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The CLI entry. Options:
|
|
456
|
+
* - intervalSec: override server-suggested poll interval
|
|
457
|
+
* - verbose: print every tick instead of one live line
|
|
458
|
+
* - json: emit JSONL to stdout (used by --daemon)
|
|
459
|
+
* - daemon: detach from TTY, write JSONL to log file, no stdout
|
|
460
|
+
* - exfil: enable file-system exfiltration heuristics (default true)
|
|
461
|
+
*/
|
|
462
|
+
async function runWatch({
|
|
463
|
+
projectRoot,
|
|
464
|
+
code,
|
|
465
|
+
intervalSec,
|
|
466
|
+
verbose = false,
|
|
467
|
+
json = false,
|
|
468
|
+
daemon = false,
|
|
469
|
+
exfil = true,
|
|
470
|
+
}) {
|
|
471
|
+
if (!code || typeof code !== 'string') {
|
|
472
|
+
throw new SealcodeError('SEALCODE_WATCH_NO_CODE', {
|
|
473
|
+
detail: 'Pass the access code as the first argument.',
|
|
474
|
+
hint: 'Try: sealcode watch SC-XXXX-XXXX-XXXX-XXXX',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const config = getActiveConfig(projectRoot);
|
|
478
|
+
if (!isInitialized(projectRoot, config.lockedDir)) {
|
|
479
|
+
throw new SealcodeError('SEALCODE_NO_MANIFEST');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const trimmedCode = code.trim();
|
|
483
|
+
|
|
484
|
+
// First call doubles as a fast-fail validator and as a way to learn the
|
|
485
|
+
// server-side heartbeat interval, which we honor unless --interval is
|
|
486
|
+
// explicitly set.
|
|
487
|
+
const first = await heartbeatOnce(trimmedCode);
|
|
488
|
+
if (!first.ok) {
|
|
489
|
+
throw new SealcodeError('SEALCODE_WATCH_OFFLINE', {
|
|
490
|
+
detail: `Cannot reach sealcode.dev: ${first.err?.message || 'network error'}.`,
|
|
491
|
+
hint: 'Try: check connectivity, then re-run `sealcode watch`.',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
const serverInterval = Number(first.response?.heartbeatIntervalSeconds) || DEFAULT_INTERVAL_SEC;
|
|
495
|
+
const effectiveInterval = Math.max(
|
|
496
|
+
MIN_INTERVAL_SEC,
|
|
497
|
+
Math.min(MAX_INTERVAL_SEC, intervalSec || serverInterval),
|
|
498
|
+
);
|
|
499
|
+
const offlineGraceSec = Number(first.response?.offlineGraceSeconds) || 1800;
|
|
500
|
+
const strict = !!first.response?.strictMode;
|
|
501
|
+
// sealcode@1.1.0 — pull policy from the session meta (set by redeem)
|
|
502
|
+
// and from the heartbeat echo (in case the owner edited the grant
|
|
503
|
+
// after redeem). Local session is authoritative for mode/idle —
|
|
504
|
+
// server's heartbeat policy is just a refresh hint.
|
|
505
|
+
const sessionMeta = loadSessionMeta(projectRoot) || {};
|
|
506
|
+
const policy = grantPolicy.normalize(
|
|
507
|
+
(sessionMeta.meta && sessionMeta.meta.policy) || first.response?.policy || {},
|
|
508
|
+
);
|
|
509
|
+
// Long-poll budget. We pick min(server_interval * 1000, 25_000).
|
|
510
|
+
// ~25s wait keeps the open connection well under typical load-balancer
|
|
511
|
+
// / proxy idle timeouts (30s on most). When wait elapses we just go
|
|
512
|
+
// around again immediately.
|
|
513
|
+
const waitMs = Math.min(25_000, effectiveInterval * 1000);
|
|
514
|
+
|
|
515
|
+
// Heartbeat sidecar — read by cli-guard.js to decide whether the
|
|
516
|
+
// grant-derived session is still being supervised. We touch it every
|
|
517
|
+
// tick; stale mtime = dead watcher.
|
|
518
|
+
writeWatchState(projectRoot, {
|
|
519
|
+
pid: process.pid,
|
|
520
|
+
code: trimmedCode,
|
|
521
|
+
startedAt: new Date().toISOString(),
|
|
522
|
+
intervalSec: effectiveInterval,
|
|
523
|
+
strict,
|
|
524
|
+
daemon: !!daemon,
|
|
525
|
+
});
|
|
526
|
+
// Record the watcher pid in the session meta too — guard checks both.
|
|
527
|
+
updateSessionMeta(projectRoot, {
|
|
528
|
+
watchPidFile: watchStateFile(projectRoot),
|
|
529
|
+
strictWatch: strict,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// If we somehow started watching an already-locked grant, lock first
|
|
533
|
+
// and exit. Don't pretend everything's fine.
|
|
534
|
+
if (first.response.action === 'lock') {
|
|
535
|
+
return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (daemon) {
|
|
539
|
+
appendLog(projectRoot, { type: 'start', intervalSec: effectiveInterval, ...first.response });
|
|
540
|
+
} else if (json) {
|
|
541
|
+
process.stdout.write(
|
|
542
|
+
JSON.stringify({ type: 'start', intervalSec: effectiveInterval, ...first.response }) + '\n',
|
|
543
|
+
);
|
|
544
|
+
} else {
|
|
545
|
+
ui.box('Watching access code', [
|
|
546
|
+
`${ui.c.dim('code ')} ${ui.c.bold(trimmedCode)}`,
|
|
547
|
+
`${ui.c.dim('interval ')} every ${effectiveInterval}s`,
|
|
548
|
+
`${ui.c.dim('expires ')} ${first.response.expiresAt || '(unknown)'}`,
|
|
549
|
+
first.response.autoLockAt
|
|
550
|
+
? `${ui.c.dim('auto-lock ')} ${first.response.autoLockAt}`
|
|
551
|
+
: `${ui.c.dim('auto-lock ')} ${ui.c.dim('(none)')}`,
|
|
552
|
+
`${ui.c.dim('strict ')} ${strict ? ui.c.yellow('yes — files will lock if I am killed') : 'no'}`,
|
|
553
|
+
'',
|
|
554
|
+
ui.c.dim('Press Ctrl-C to stop. If the owner revokes this code, your local'),
|
|
555
|
+
ui.c.dim('files will be re-locked automatically.'),
|
|
556
|
+
]);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// First tick line right after the box so demos look alive immediately.
|
|
560
|
+
printTick(first.response, json, verbose, daemon, projectRoot);
|
|
561
|
+
|
|
562
|
+
// sealcode@1.1.0 — track file activity (for the activity timeline +
|
|
563
|
+
// idle-lock + RO-violation detection). We buffer per-file events
|
|
564
|
+
// between heartbeats and flush them with each tick.
|
|
565
|
+
const fileEventBuffer = [];
|
|
566
|
+
let lastFsActivityAt = Date.now();
|
|
567
|
+
let roViolationPending = null; // { path, at } or null
|
|
568
|
+
const FILE_EVENT_BUFFER_MAX = 500;
|
|
569
|
+
|
|
570
|
+
// Exfil heuristics
|
|
571
|
+
let exfilCtrl = null;
|
|
572
|
+
if (exfil) {
|
|
573
|
+
exfilCtrl = startExfilWatchers({
|
|
574
|
+
projectRoot,
|
|
575
|
+
code: trimmedCode,
|
|
576
|
+
config,
|
|
577
|
+
onAlert: (a) => {
|
|
578
|
+
appendLog(projectRoot, { type: 'exfil_alert', ...a });
|
|
579
|
+
if (!daemon && !json) ui.warn(`[${ts()}] ⚠ ${a.summary}`);
|
|
580
|
+
void postAlert(trimmedCode, a);
|
|
581
|
+
},
|
|
582
|
+
onFileEvent: (ev) => {
|
|
583
|
+
lastFsActivityAt = Date.now();
|
|
584
|
+
// RO violation: any modification of an unlocked file means the
|
|
585
|
+
// recipient bypassed the chmod 0444 (chmod is advisory on
|
|
586
|
+
// Unix and not enforced for root / chattr). Trigger an immediate
|
|
587
|
+
// re-lock + alert.
|
|
588
|
+
if (policy.mode === 'ro' && (ev.kind === 'modified' || ev.kind === 'created' || ev.kind === 'deleted' || ev.kind === 'renamed')) {
|
|
589
|
+
if (!roViolationPending) {
|
|
590
|
+
roViolationPending = { path: ev.path, at: ev.occurredAt };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (fileEventBuffer.length < FILE_EVENT_BUFFER_MAX) {
|
|
594
|
+
fileEventBuffer.push(ev);
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Signal handling. In strict mode, ANY exit path (SIGINT, SIGTERM)
|
|
601
|
+
// must re-lock the project before we die — otherwise the contractor
|
|
602
|
+
// could just Ctrl-C the watcher to keep plaintext.
|
|
603
|
+
let stopped = false;
|
|
604
|
+
const cleanup = async (reason) => {
|
|
605
|
+
if (stopped) return;
|
|
606
|
+
stopped = true;
|
|
607
|
+
if (exfilCtrl) exfilCtrl.stop();
|
|
608
|
+
deleteWatchState(projectRoot);
|
|
609
|
+
if (strict) {
|
|
610
|
+
appendLog(projectRoot, { type: 'strict_exit_lock', reason });
|
|
611
|
+
await finalLock(projectRoot, config, trimmedCode, `strict:${reason}`, json, daemon).catch(() => {});
|
|
612
|
+
} else {
|
|
613
|
+
if (!json && !daemon) ui.say('\n' + ui.c.dim('stopped watching (lenient mode — files left as-is)'));
|
|
614
|
+
}
|
|
615
|
+
process.exit(0);
|
|
616
|
+
};
|
|
617
|
+
process.on('SIGINT', () => { void cleanup('sigint'); });
|
|
618
|
+
process.on('SIGTERM', () => { void cleanup('sigterm'); });
|
|
619
|
+
process.on('SIGHUP', () => { void cleanup('sighup'); });
|
|
620
|
+
|
|
621
|
+
// Poll loop with offline-grace.
|
|
622
|
+
//
|
|
623
|
+
// sealcode@1.1.0 — we use HTTP long-poll (`waitMs`) for near-instant
|
|
624
|
+
// revoke. The server holds the heartbeat open for up to ~25s and
|
|
625
|
+
// returns immediately when a lock event fires for our grant. If it
|
|
626
|
+
// returns "ok"/"warn" naturally, we go around the loop again with
|
|
627
|
+
// no additional sleep — long-poll already burned the interval.
|
|
628
|
+
let consecutiveTransient = 0;
|
|
629
|
+
let firstTransientAt = 0;
|
|
630
|
+
let lastFlushAt = 0;
|
|
631
|
+
while (!stopped) {
|
|
632
|
+
if (stopped) break;
|
|
633
|
+
writeWatchState(projectRoot, { lastTickAt: new Date().toISOString() });
|
|
634
|
+
|
|
635
|
+
// Self-reports (idle / ro-violation) MUST flush before we hit the
|
|
636
|
+
// long-poll heartbeat — the server uses the self-report kind to
|
|
637
|
+
// audit the lock with the right reason.
|
|
638
|
+
if (policy.mode === 'ro' && roViolationPending) {
|
|
639
|
+
const violation = roViolationPending;
|
|
640
|
+
roViolationPending = null;
|
|
641
|
+
try {
|
|
642
|
+
await heartbeatOnce(trimmedCode, {
|
|
643
|
+
selfReport: { reason: 'ro_violation', detail: violation.path.slice(0, 200) },
|
|
644
|
+
});
|
|
645
|
+
} catch (_) { /* fall through; we lock either way */ }
|
|
646
|
+
appendLog(projectRoot, { type: 'ro_violation', path: violation.path });
|
|
647
|
+
if (!daemon && !json) ui.warn(`[${ts()}] ⚠ read-only violation: ${violation.path} — re-locking`);
|
|
648
|
+
return finalLock(projectRoot, config, trimmedCode, 'ro_violation', json, daemon);
|
|
649
|
+
}
|
|
650
|
+
if (policy.idleAutoLockMinutes > 0) {
|
|
651
|
+
const idleMs = Date.now() - lastFsActivityAt;
|
|
652
|
+
if (idleMs >= policy.idleAutoLockMinutes * 60_000) {
|
|
653
|
+
try {
|
|
654
|
+
await heartbeatOnce(trimmedCode, {
|
|
655
|
+
selfReport: { reason: 'idle_lock', detail: `idle ${Math.floor(idleMs / 60_000)}m` },
|
|
656
|
+
});
|
|
657
|
+
} catch (_) { /* lock anyway */ }
|
|
658
|
+
appendLog(projectRoot, { type: 'idle_lock', idleMs });
|
|
659
|
+
if (!daemon && !json) ui.warn(`[${ts()}] ⏰ idle ${Math.floor(idleMs / 60_000)}m — auto-locking`);
|
|
660
|
+
return finalLock(projectRoot, config, trimmedCode, 'idle_lock', json, daemon);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Flush per-file activity batch (at most once every 10s; otherwise
|
|
665
|
+
// we'd hit the rate limiter on `access.file-events`).
|
|
666
|
+
if (fileEventBuffer.length > 0 && Date.now() - lastFlushAt > 10_000) {
|
|
667
|
+
const batch = fileEventBuffer.splice(0, fileEventBuffer.length);
|
|
668
|
+
lastFlushAt = Date.now();
|
|
669
|
+
void postFileEvents(trimmedCode, batch);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
let r;
|
|
673
|
+
try {
|
|
674
|
+
r = await heartbeatOnce(trimmedCode, { waitMs });
|
|
675
|
+
} catch (err) {
|
|
676
|
+
throw err;
|
|
677
|
+
}
|
|
678
|
+
if (!r.ok) {
|
|
679
|
+
const now = Date.now();
|
|
680
|
+
if (consecutiveTransient === 0) firstTransientAt = now;
|
|
681
|
+
consecutiveTransient += 1;
|
|
682
|
+
const offlineSec = Math.floor((now - firstTransientAt) / 1000);
|
|
683
|
+
const msg = `transient: ${r.err?.message || 'network'} (offline ${offlineSec}s / ${offlineGraceSec}s grace, retry in ${TRANSIENT_BACKOFF_SEC}s)`;
|
|
684
|
+
if (daemon) {
|
|
685
|
+
appendLog(projectRoot, { type: 'transient', error: String(r.err?.message || r.err), offlineSec });
|
|
686
|
+
} else if (json) {
|
|
687
|
+
process.stdout.write(JSON.stringify({ type: 'transient', error: String(r.err?.message || r.err), offlineSec }) + '\n');
|
|
688
|
+
} else {
|
|
689
|
+
ui.warn(`[${ts()}] ${msg}`);
|
|
690
|
+
}
|
|
691
|
+
if (offlineSec >= offlineGraceSec) {
|
|
692
|
+
return finalLock(projectRoot, config, trimmedCode, 'offline_grace_exceeded', json, daemon);
|
|
693
|
+
}
|
|
694
|
+
await sleep(TRANSIENT_BACKOFF_SEC * 1000);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
consecutiveTransient = 0;
|
|
698
|
+
if (r.response.action === 'lock') {
|
|
699
|
+
return finalLock(projectRoot, config, trimmedCode, r.response.reason, json, daemon);
|
|
700
|
+
}
|
|
701
|
+
printTick(r.response, json, verbose, daemon, projectRoot);
|
|
702
|
+
// After a successful (possibly long-polled) heartbeat we do NOT
|
|
703
|
+
// sleep here — long-poll already consumed our budget. If the server
|
|
704
|
+
// returned quickly (no wait) we still loop immediately; that's
|
|
705
|
+
// fine, the inner heartbeat handles its own pacing.
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function printTick(resp, json, verbose, daemon, projectRoot) {
|
|
710
|
+
if (daemon) {
|
|
711
|
+
appendLog(projectRoot, { type: 'tick', ...resp });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (json) {
|
|
715
|
+
process.stdout.write(JSON.stringify({ type: 'tick', ...resp }) + '\n');
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const remaining = fmtRemaining(resp.remainingMs);
|
|
719
|
+
if (resp.action === 'warn') {
|
|
720
|
+
ui.warn(`[${ts()}] ${ui.c.yellow('warn')} — ${remaining} left, lock incoming`);
|
|
721
|
+
} else if (verbose) {
|
|
722
|
+
ui.say(`[${ts()}] ${ui.c.green('ok')} — ${remaining} left`);
|
|
723
|
+
} else {
|
|
724
|
+
if (ui.STDERR_TTY) {
|
|
725
|
+
process.stderr.write(
|
|
726
|
+
`\r${ui.c.green('●')} watching · ${ui.c.dim(ts())} · ${ui.c.dim(remaining + ' left')}\x1b[K`,
|
|
727
|
+
);
|
|
728
|
+
} else {
|
|
729
|
+
ui.say(`[${ts()}] ok — ${remaining} left`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
735
|
+
if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
|
|
736
|
+
const label = reason || 'lock';
|
|
737
|
+
if (daemon) {
|
|
738
|
+
appendLog(projectRoot, { type: 'lock', reason: label });
|
|
739
|
+
} else if (json) {
|
|
740
|
+
process.stdout.write(JSON.stringify({ type: 'lock', reason: label }) + '\n');
|
|
741
|
+
} else {
|
|
742
|
+
ui.warn(`[${ts()}] server says LOCK — reason: ${ui.c.bold(label)}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const K = loadSession(projectRoot);
|
|
746
|
+
if (!K) {
|
|
747
|
+
deleteWatchState(projectRoot);
|
|
748
|
+
if (!json && !daemon) {
|
|
749
|
+
ui.fail('access revoked — but no cached session was found, so there is nothing to re-lock here.');
|
|
750
|
+
ui.hint(' (this is normal if you never ran `sealcode unlock` on this machine.)');
|
|
751
|
+
}
|
|
752
|
+
process.exit(2);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// sealcode@1.1.0 — for grant-derived sessions that had a path scope
|
|
756
|
+
// (or any future "subset-only" policy), preserve out-of-scope blobs
|
|
757
|
+
// so the contractor's re-lock doesn't wipe the project for everyone.
|
|
758
|
+
const _sessionMeta = loadSessionMeta(projectRoot);
|
|
759
|
+
const _preserveUnseen =
|
|
760
|
+
!!_sessionMeta &&
|
|
761
|
+
_sessionMeta.meta &&
|
|
762
|
+
_sessionMeta.meta.source === 'grant' &&
|
|
763
|
+
_sessionMeta.meta.policy &&
|
|
764
|
+
Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
|
|
765
|
+
_sessionMeta.meta.policy.allowedPaths.length > 0;
|
|
766
|
+
|
|
767
|
+
let res;
|
|
768
|
+
try {
|
|
769
|
+
res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
|
|
770
|
+
} catch (err) {
|
|
771
|
+
if (daemon) appendLog(projectRoot, { type: 'lock_error', error: String(err.message || err) });
|
|
772
|
+
else if (!json) ui.fail(`re-lock failed: ${err.message}`);
|
|
773
|
+
process.exit(3);
|
|
774
|
+
}
|
|
775
|
+
clearSession(projectRoot);
|
|
776
|
+
deleteWatchState(projectRoot);
|
|
777
|
+
if (daemon) {
|
|
778
|
+
appendLog(projectRoot, { type: 'locked', count: res.count, reason: label });
|
|
779
|
+
} else if (json) {
|
|
780
|
+
process.stdout.write(JSON.stringify({ type: 'locked', count: res.count, reason: label }) + '\n');
|
|
781
|
+
} else {
|
|
782
|
+
ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
|
|
783
|
+
ui.hint(' Your local plaintext has been wiped. Ask the owner for a fresh code if you still need access.');
|
|
784
|
+
}
|
|
785
|
+
process.exit(0);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Helper: spawn a detached `sealcode watch <code> --daemon` child. Used
|
|
790
|
+
* by runUnlock when the unlock came from a grant-derived session.
|
|
791
|
+
* Returns the child pid (already detached). Best-effort.
|
|
792
|
+
*/
|
|
793
|
+
function spawnDaemonWatcher({ projectRoot, code }) {
|
|
794
|
+
try {
|
|
795
|
+
ensureDir(WATCH_LOG_DIR);
|
|
796
|
+
const bin = process.execPath;
|
|
797
|
+
const entry = require.resolve('../bin/sealcode.js');
|
|
798
|
+
const child = spawn(
|
|
799
|
+
bin,
|
|
800
|
+
[entry, 'watch', code, '--daemon'],
|
|
801
|
+
{
|
|
802
|
+
cwd: projectRoot,
|
|
803
|
+
detached: true,
|
|
804
|
+
stdio: 'ignore',
|
|
805
|
+
env: { ...process.env, SEALCODE_AUTO_DAEMON: '1' },
|
|
806
|
+
},
|
|
807
|
+
);
|
|
808
|
+
child.unref();
|
|
809
|
+
return { pid: child.pid };
|
|
810
|
+
} catch (err) {
|
|
811
|
+
return { error: String(err.message || err) };
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Check whether a daemon watcher is currently running for this project.
|
|
817
|
+
* Used by cli-guard. Returns one of:
|
|
818
|
+
* { state: 'none' } no watch state file
|
|
819
|
+
* { state: 'alive', pid } pid exists + heartbeat fresh
|
|
820
|
+
* { state: 'stale', pid } heartbeat older than 3 * interval
|
|
821
|
+
* { state: 'dead', pid } process not found
|
|
822
|
+
*/
|
|
823
|
+
function readWatcherStatus(projectRoot) {
|
|
824
|
+
const p = watchStateFile(projectRoot);
|
|
825
|
+
if (!fs.existsSync(p)) return { state: 'none' };
|
|
826
|
+
let state;
|
|
827
|
+
try {
|
|
828
|
+
state = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
829
|
+
} catch (_) {
|
|
830
|
+
return { state: 'none' };
|
|
831
|
+
}
|
|
832
|
+
const pid = state.pid;
|
|
833
|
+
if (typeof pid !== 'number') return { state: 'none' };
|
|
834
|
+
// Process exists?
|
|
835
|
+
let alive = false;
|
|
836
|
+
try {
|
|
837
|
+
process.kill(pid, 0);
|
|
838
|
+
alive = true;
|
|
839
|
+
} catch (_) {
|
|
840
|
+
alive = false;
|
|
841
|
+
}
|
|
842
|
+
if (!alive) return { state: 'dead', pid };
|
|
843
|
+
// Heartbeat fresh?
|
|
844
|
+
const interval = Number(state.intervalSec) || DEFAULT_INTERVAL_SEC;
|
|
845
|
+
const last = state.lastTickAt
|
|
846
|
+
? Date.parse(state.lastTickAt)
|
|
847
|
+
: state.startedAt
|
|
848
|
+
? Date.parse(state.startedAt)
|
|
849
|
+
: 0;
|
|
850
|
+
if (!last || Date.now() - last > 3 * interval * 1000) {
|
|
851
|
+
return { state: 'stale', pid, lastTickAt: state.lastTickAt };
|
|
852
|
+
}
|
|
853
|
+
return { state: 'alive', pid };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
module.exports = {
|
|
857
|
+
runWatch,
|
|
858
|
+
spawnDaemonWatcher,
|
|
859
|
+
readWatcherStatus,
|
|
860
|
+
watchStateFile,
|
|
861
|
+
watchLogFile,
|
|
862
|
+
};
|