envseed 0.2.0 → 0.3.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/lib/container-replicator.mjs +20 -0
- package/lib/log-incident.mjs +109 -16
- package/lib/polling-agent.mjs +249 -0
- package/lib/redaction-review.mjs +5 -1
- package/lib/source-query-client.mjs +99 -0
- package/package.json +1 -1
- package/postinstall.mjs +20 -9
|
@@ -16,6 +16,7 @@ import path from 'node:path';
|
|
|
16
16
|
import { execSync, spawn } from 'node:child_process';
|
|
17
17
|
import { DATA_DIR, INSTALL_DIR } from './utils.mjs';
|
|
18
18
|
import { launchRedactionReview } from './redaction-review.mjs';
|
|
19
|
+
import { querySource } from './source-query-client.mjs';
|
|
19
20
|
|
|
20
21
|
const REPLICAS_DIR = path.join(DATA_DIR, 'replicas');
|
|
21
22
|
const LOCK_DIR = path.join(DATA_DIR, '.locks');
|
|
@@ -440,6 +441,25 @@ If something is fundamentally broken, document it in env-status.json and do your
|
|
|
440
441
|
envStatus = JSON.parse(fs.readFileSync(envStatusPath, 'utf8'));
|
|
441
442
|
} catch {}
|
|
442
443
|
|
|
444
|
+
// If running remotely and Opus reported missing files, fetch them via source queries
|
|
445
|
+
if (process.env.ENVSEED_REMOTE === '1' && envStatus.missingFiles?.length > 0) {
|
|
446
|
+
const incidentId = process.env.ENVSEED_INCIDENT_ID;
|
|
447
|
+
const apiEndpoint = process.env.ENVSEED_API_ENDPOINT;
|
|
448
|
+
const apiKey = loadConfig()?.apiKey;
|
|
449
|
+
if (incidentId && apiEndpoint) {
|
|
450
|
+
for (const filePath of envStatus.missingFiles) {
|
|
451
|
+
try {
|
|
452
|
+
const result = await querySource(incidentId, filePath, apiEndpoint, apiKey || '');
|
|
453
|
+
if (result.success && result.data) {
|
|
454
|
+
const destPath = path.join(replicaOutputDir, '..', 'workspace', filePath);
|
|
455
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
456
|
+
fs.writeFileSync(destPath, result.data);
|
|
457
|
+
}
|
|
458
|
+
} catch {}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
443
463
|
// If Opus generated a Dockerfile, try building it
|
|
444
464
|
if (fs.existsSync(generatedDockerfile)) {
|
|
445
465
|
try {
|
package/lib/log-incident.mjs
CHANGED
|
@@ -195,12 +195,31 @@ async function main() {
|
|
|
195
195
|
const cwd = process.argv[3] || process.cwd();
|
|
196
196
|
const userNotes = process.argv.slice(4).join(' ') || '';
|
|
197
197
|
|
|
198
|
+
const log = (msg) => process.stdout.write(msg + '\n');
|
|
199
|
+
|
|
200
|
+
// Check upload credentials before doing any work
|
|
201
|
+
let config = {};
|
|
202
|
+
try {
|
|
203
|
+
config = JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'config.json'), 'utf8'));
|
|
204
|
+
} catch {}
|
|
205
|
+
|
|
206
|
+
const hasDirectS3 = config.s3Bucket && config.s3Profile;
|
|
207
|
+
const hasApiKey = !!config.apiKey;
|
|
208
|
+
|
|
209
|
+
if (!hasDirectS3 && !hasApiKey) {
|
|
210
|
+
log('');
|
|
211
|
+
log('\x1b[31mError: No upload credentials configured.\x1b[0m');
|
|
212
|
+
log('');
|
|
213
|
+
log('Run \x1b[1menvseed login\x1b[0m to authenticate via GitHub and get an API key.');
|
|
214
|
+
log('');
|
|
215
|
+
log('Or set s3Profile in ~/.envseed/config.json for direct S3 upload.');
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
|
|
198
219
|
const incidentId = generateId();
|
|
199
220
|
const incidentDir = path.join(INCIDENTS_DIR, incidentId);
|
|
200
221
|
ensureDir(incidentDir);
|
|
201
222
|
|
|
202
|
-
const log = (msg) => process.stdout.write(msg + '\n');
|
|
203
|
-
|
|
204
223
|
log(`Incident ID: ${incidentId}`);
|
|
205
224
|
log(`Session: ${sessionId}`);
|
|
206
225
|
log(`CWD: ${cwd}`);
|
|
@@ -276,24 +295,53 @@ async function main() {
|
|
|
276
295
|
log(' (incident saved locally, upload can be retried with: envseed incident <id> upload)');
|
|
277
296
|
}
|
|
278
297
|
|
|
279
|
-
// 6.
|
|
298
|
+
// 6. Start simulations (remote or local)
|
|
280
299
|
log('5/5 Starting simulations...');
|
|
281
|
-
|
|
300
|
+
// Re-read config (already loaded at top for credential check, but use fresh copy)
|
|
301
|
+
const simConfig = {};
|
|
282
302
|
try {
|
|
283
|
-
Object.assign(
|
|
303
|
+
Object.assign(simConfig, JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'config.json'), 'utf8')));
|
|
284
304
|
} catch {}
|
|
285
305
|
|
|
286
|
-
|
|
306
|
+
const libDir = path.dirname(new URL(import.meta.url).pathname);
|
|
307
|
+
|
|
308
|
+
if (simConfig.enableSimulations === false) {
|
|
287
309
|
log(' Simulations disabled in config');
|
|
310
|
+
} else if (simConfig.remoteReplication && s3Result.success) {
|
|
311
|
+
// Remote mode: POST /reconstruct to launch EC2 worker, spawn polling agent
|
|
312
|
+
log(' Triggering remote replication...');
|
|
313
|
+
try {
|
|
314
|
+
const reconstructRes = await httpPost(
|
|
315
|
+
`${simConfig.uploadEndpoint}/reconstruct/${incidentId}`,
|
|
316
|
+
{ apiEndpoint: simConfig.uploadEndpoint },
|
|
317
|
+
simConfig.apiKey,
|
|
318
|
+
);
|
|
319
|
+
if (reconstructRes.success) {
|
|
320
|
+
log(` Worker launched: instance ${reconstructRes.body?.instanceId || 'pending'}`);
|
|
321
|
+
log(` Job ID: ${reconstructRes.body?.jobId || 'unknown'}`);
|
|
322
|
+
|
|
323
|
+
// Spawn polling agent to serve source queries
|
|
324
|
+
const pollingScript = path.join(libDir, 'polling-agent.mjs');
|
|
325
|
+
const pollingChild = spawn('node', [
|
|
326
|
+
pollingScript, incidentId, cwd, simConfig.uploadEndpoint, simConfig.apiKey,
|
|
327
|
+
], { detached: true, stdio: 'ignore' });
|
|
328
|
+
pollingChild.unref();
|
|
329
|
+
log(` Polling agent spawned (PID ${pollingChild.pid})`);
|
|
330
|
+
} else {
|
|
331
|
+
log(` Remote replication failed: ${reconstructRes.error || 'unknown error'}`);
|
|
332
|
+
log(' Falling back to local simulations...');
|
|
333
|
+
spawnLocalOrchestrator(libDir, incidentId, log);
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
log(` Remote replication error: ${err.message}`);
|
|
337
|
+
log(' Falling back to local simulations...');
|
|
338
|
+
spawnLocalOrchestrator(libDir, incidentId, log);
|
|
339
|
+
}
|
|
288
340
|
} else {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
});
|
|
294
|
-
child.unref();
|
|
295
|
-
log(` Orchestrator spawned (PID ${child.pid})`);
|
|
296
|
-
log(` Check progress: envseed incident ${incidentId}`);
|
|
341
|
+
if (simConfig.remoteReplication && !s3Result.success) {
|
|
342
|
+
log(' Remote replication requires S3 upload — falling back to local');
|
|
343
|
+
}
|
|
344
|
+
spawnLocalOrchestrator(libDir, incidentId, log);
|
|
297
345
|
}
|
|
298
346
|
|
|
299
347
|
// Write initial status
|
|
@@ -301,8 +349,9 @@ async function main() {
|
|
|
301
349
|
started: new Date().toISOString(),
|
|
302
350
|
archivalComplete: true,
|
|
303
351
|
s3Uploaded: s3Result.success,
|
|
304
|
-
|
|
305
|
-
|
|
352
|
+
remote: !!(simConfig.remoteReplication && s3Result.success),
|
|
353
|
+
simulationsStarted: simConfig.enableSimulations !== false,
|
|
354
|
+
totalPlanned: simConfig.simulationCount || 2,
|
|
306
355
|
completed: 0,
|
|
307
356
|
failed: 0,
|
|
308
357
|
running: 0,
|
|
@@ -314,6 +363,50 @@ async function main() {
|
|
|
314
363
|
if (s3Result.success) log(`S3 path: ${s3Result.s3Path}`);
|
|
315
364
|
}
|
|
316
365
|
|
|
366
|
+
function spawnLocalOrchestrator(libDir, incidentId, log) {
|
|
367
|
+
const orchestratorScript = path.join(libDir, 'simulation-orchestrator.mjs');
|
|
368
|
+
const child = spawn('node', [orchestratorScript, incidentId], {
|
|
369
|
+
detached: true,
|
|
370
|
+
stdio: 'ignore',
|
|
371
|
+
});
|
|
372
|
+
child.unref();
|
|
373
|
+
log(` Local orchestrator spawned (PID ${child.pid})`);
|
|
374
|
+
log(` Check progress: envseed incident ${incidentId}`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function httpPost(url, body, apiKey) {
|
|
378
|
+
const https = await import('node:https');
|
|
379
|
+
const { URL } = await import('node:url');
|
|
380
|
+
return new Promise((resolve, reject) => {
|
|
381
|
+
const parsed = new URL(url);
|
|
382
|
+
const bodyStr = JSON.stringify(body);
|
|
383
|
+
const req = https.request({
|
|
384
|
+
method: 'POST',
|
|
385
|
+
hostname: parsed.hostname,
|
|
386
|
+
path: parsed.pathname,
|
|
387
|
+
headers: {
|
|
388
|
+
'Content-Type': 'application/json',
|
|
389
|
+
'Content-Length': Buffer.byteLength(bodyStr),
|
|
390
|
+
'x-api-key': apiKey,
|
|
391
|
+
},
|
|
392
|
+
}, (res) => {
|
|
393
|
+
let data = '';
|
|
394
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
395
|
+
res.on('end', () => {
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(data);
|
|
398
|
+
resolve({ success: res.statusCode === 200, body: parsed, error: parsed.error });
|
|
399
|
+
} catch {
|
|
400
|
+
resolve({ success: false, error: data });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
req.on('error', reject);
|
|
405
|
+
req.write(bodyStr);
|
|
406
|
+
req.end();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
317
410
|
main().catch(err => {
|
|
318
411
|
process.stderr.write(`log-incident error: ${err.message}\n${err.stack}\n`);
|
|
319
412
|
process.exit(1);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Polling agent — runs on the user's machine during remote replication.
|
|
5
|
+
* Polls the API for source-query requests from the remote worker,
|
|
6
|
+
* executes them locally (read_file, list_dir, glob), and replies.
|
|
7
|
+
*
|
|
8
|
+
* Spawned as detached process by log-incident.mjs.
|
|
9
|
+
*
|
|
10
|
+
* Usage: node polling-agent.mjs <incidentId> <projectDir> <apiEndpoint> <apiKey>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import https from 'node:https';
|
|
16
|
+
import { URL } from 'node:url';
|
|
17
|
+
import { globSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
const POLL_INTERVAL_MS = 3000;
|
|
20
|
+
const STATUS_CHECK_INTERVAL_MS = 30000;
|
|
21
|
+
const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
22
|
+
const IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min with no queries after replication starts
|
|
23
|
+
const MAX_FILE_SIZE = 512 * 1024; // 512KB per file
|
|
24
|
+
|
|
25
|
+
const [,, incidentId, projectDir, apiEndpoint, apiKey] = process.argv;
|
|
26
|
+
|
|
27
|
+
if (!incidentId || !projectDir || !apiEndpoint || !apiKey) {
|
|
28
|
+
process.stderr.write('Usage: node polling-agent.mjs <incidentId> <projectDir> <apiEndpoint> <apiKey>\n');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const log = (msg) => process.stderr.write(`[polling-agent] ${msg}\n`);
|
|
33
|
+
|
|
34
|
+
// ── Security: path validation ──
|
|
35
|
+
|
|
36
|
+
function isWithinProject(filePath) {
|
|
37
|
+
const resolved = path.resolve(projectDir, filePath);
|
|
38
|
+
const realProjectDir = fs.realpathSync(projectDir);
|
|
39
|
+
try {
|
|
40
|
+
const realResolved = fs.realpathSync(resolved);
|
|
41
|
+
return realResolved.startsWith(realProjectDir + path.sep) || realResolved === realProjectDir;
|
|
42
|
+
} catch {
|
|
43
|
+
// File doesn't exist yet — check the resolved path without symlink resolution
|
|
44
|
+
return resolved.startsWith(realProjectDir + path.sep) || resolved === realProjectDir;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Query execution ──
|
|
49
|
+
|
|
50
|
+
function executeQuery(query) {
|
|
51
|
+
const { queryType, path: queryPath, args } = query;
|
|
52
|
+
|
|
53
|
+
// All paths must be within project directory
|
|
54
|
+
if (!isWithinProject(queryPath)) {
|
|
55
|
+
return { success: false, error: `Path outside project directory: ${queryPath}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fullPath = path.resolve(projectDir, queryPath);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
switch (queryType) {
|
|
62
|
+
case 'read_file': {
|
|
63
|
+
if (!fs.existsSync(fullPath)) {
|
|
64
|
+
return { success: false, error: `File not found: ${queryPath}` };
|
|
65
|
+
}
|
|
66
|
+
const stat = fs.statSync(fullPath);
|
|
67
|
+
if (!stat.isFile()) {
|
|
68
|
+
return { success: false, error: `Not a file: ${queryPath}` };
|
|
69
|
+
}
|
|
70
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
71
|
+
return { success: false, error: `File too large (${stat.size} bytes, max ${MAX_FILE_SIZE}): ${queryPath}` };
|
|
72
|
+
}
|
|
73
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
74
|
+
return { success: true, data: content };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case 'list_dir': {
|
|
78
|
+
if (!fs.existsSync(fullPath)) {
|
|
79
|
+
return { success: false, error: `Directory not found: ${queryPath}` };
|
|
80
|
+
}
|
|
81
|
+
const entries = fs.readdirSync(fullPath, { withFileTypes: true });
|
|
82
|
+
const listing = entries.map(e => ({
|
|
83
|
+
name: e.name,
|
|
84
|
+
type: e.isDirectory() ? 'dir' : e.isFile() ? 'file' : 'other',
|
|
85
|
+
}));
|
|
86
|
+
return { success: true, data: JSON.stringify(listing) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'glob': {
|
|
90
|
+
const pattern = args?.pattern || queryPath;
|
|
91
|
+
// Use Node's glob — restrict to project dir
|
|
92
|
+
const { globSync } = require('node:fs');
|
|
93
|
+
try {
|
|
94
|
+
const matches = globSync(pattern, { cwd: projectDir }).slice(0, 500);
|
|
95
|
+
return { success: true, data: JSON.stringify(matches) };
|
|
96
|
+
} catch {
|
|
97
|
+
// Fallback for older Node without fs.globSync
|
|
98
|
+
return { success: false, error: 'Glob not supported on this Node version' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
default:
|
|
103
|
+
return { success: false, error: `Unknown query type: ${queryType}` };
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return { success: false, error: err.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── HTTP helpers ──
|
|
111
|
+
|
|
112
|
+
function httpRequest(urlStr, options = {}) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const url = new URL(urlStr);
|
|
115
|
+
const reqOptions = {
|
|
116
|
+
method: options.method || 'GET',
|
|
117
|
+
hostname: url.hostname,
|
|
118
|
+
path: url.pathname + url.search,
|
|
119
|
+
headers: {
|
|
120
|
+
'x-api-key': apiKey,
|
|
121
|
+
'Content-Type': 'application/json',
|
|
122
|
+
...options.headers,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (options.body) {
|
|
127
|
+
reqOptions.headers['Content-Length'] = Buffer.byteLength(options.body);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const req = https.request(reqOptions, (res) => {
|
|
131
|
+
let data = '';
|
|
132
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
133
|
+
res.on('end', () => {
|
|
134
|
+
try {
|
|
135
|
+
resolve({ statusCode: res.statusCode, body: JSON.parse(data) });
|
|
136
|
+
} catch {
|
|
137
|
+
resolve({ statusCode: res.statusCode, body: data });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
req.on('error', reject);
|
|
142
|
+
if (options.body) req.write(options.body);
|
|
143
|
+
req.end();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function pollForQueries() {
|
|
148
|
+
try {
|
|
149
|
+
const res = await httpRequest(`${apiEndpoint}/source-query/${incidentId}/poll`);
|
|
150
|
+
if (res.statusCode === 200 && res.body?.queries?.length > 0) {
|
|
151
|
+
return res.body.queries;
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
log(`Poll error: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function replyToQuery(queryId, result) {
|
|
160
|
+
try {
|
|
161
|
+
await httpRequest(`${apiEndpoint}/source-query/${incidentId}/reply`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
queryId,
|
|
165
|
+
success: result.success,
|
|
166
|
+
data: result.data || '',
|
|
167
|
+
error: result.error || '',
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
log(`Reply error for ${queryId}: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function checkReconstructionStatus() {
|
|
176
|
+
try {
|
|
177
|
+
const res = await httpRequest(`${apiEndpoint}/reconstruct/${incidentId}`);
|
|
178
|
+
if (res.statusCode === 200) {
|
|
179
|
+
const status = res.body?.status;
|
|
180
|
+
return status === 'completed' || status === 'failed' || status === 'terminated';
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// ignore — we'll keep polling
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Main loop ──
|
|
189
|
+
|
|
190
|
+
async function main() {
|
|
191
|
+
log(`Starting for incident ${incidentId}`);
|
|
192
|
+
log(`Project dir: ${projectDir}`);
|
|
193
|
+
log(`API endpoint: ${apiEndpoint}`);
|
|
194
|
+
|
|
195
|
+
const startTime = Date.now();
|
|
196
|
+
let lastQueryTime = Date.now();
|
|
197
|
+
let totalQueries = 0;
|
|
198
|
+
let reconstructionStarted = false;
|
|
199
|
+
|
|
200
|
+
while (true) {
|
|
201
|
+
const elapsed = Date.now() - startTime;
|
|
202
|
+
|
|
203
|
+
// Hard timeout
|
|
204
|
+
if (elapsed > TIMEOUT_MS) {
|
|
205
|
+
log(`Timeout reached (${Math.round(TIMEOUT_MS / 60000)} min), exiting`);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Poll for queries
|
|
210
|
+
const queries = await pollForQueries();
|
|
211
|
+
|
|
212
|
+
if (queries.length > 0) {
|
|
213
|
+
lastQueryTime = Date.now();
|
|
214
|
+
reconstructionStarted = true;
|
|
215
|
+
|
|
216
|
+
for (const query of queries) {
|
|
217
|
+
log(`Query ${query.queryId}: ${query.queryType} ${query.path}`);
|
|
218
|
+
const result = executeQuery(query);
|
|
219
|
+
log(` → ${result.success ? 'OK' : 'FAIL'}: ${result.success ? `${(result.data || '').length} bytes` : result.error}`);
|
|
220
|
+
await replyToQuery(query.queryId, result);
|
|
221
|
+
totalQueries++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check if reconstruction is done (less frequently)
|
|
226
|
+
if (elapsed > 0 && elapsed % STATUS_CHECK_INTERVAL_MS < POLL_INTERVAL_MS) {
|
|
227
|
+
const done = await checkReconstructionStatus();
|
|
228
|
+
if (done) {
|
|
229
|
+
log(`Reconstruction finished, exiting (served ${totalQueries} queries)`);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Idle timeout — only after reconstruction has started
|
|
235
|
+
if (reconstructionStarted && (Date.now() - lastQueryTime > IDLE_TIMEOUT_MS)) {
|
|
236
|
+
log(`No queries for ${Math.round(IDLE_TIMEOUT_MS / 60000)} min, exiting`);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
log(`Done. Served ${totalQueries} source queries.`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
main().catch(err => {
|
|
247
|
+
log(`Fatal error: ${err.message}`);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
});
|
package/lib/redaction-review.mjs
CHANGED
|
@@ -193,13 +193,17 @@ function openTerminalWithClaude(stagingDir, log) {
|
|
|
193
193
|
fs.writeFileSync(promptFilePath, initialPrompt);
|
|
194
194
|
|
|
195
195
|
fs.writeFileSync(launcherPath, `#!/bin/bash
|
|
196
|
+
# Mark review complete on exit (normal, SIGHUP from terminal close, or SIGTERM)
|
|
197
|
+
mark_complete() {
|
|
198
|
+
touch "${stagingDir}/.redaction-complete"
|
|
199
|
+
}
|
|
200
|
+
trap mark_complete EXIT
|
|
196
201
|
cd "${stagingDir}" || exit 1
|
|
197
202
|
PROMPT=$(cat "${promptFilePath}")
|
|
198
203
|
claude \\
|
|
199
204
|
--allowedTools ${allowedTools} \\
|
|
200
205
|
--append-system-prompt "${APPEND_SYSTEM_PROMPT.replace(/"/g, '\\"')}" \\
|
|
201
206
|
"$PROMPT"
|
|
202
|
-
touch "${stagingDir}/.redaction-complete"
|
|
203
207
|
echo ""
|
|
204
208
|
echo "Redaction complete. You can close this tab."
|
|
205
209
|
`);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source query client — used by the remote worker to request files
|
|
3
|
+
* from the user's machine via the polling agent pattern.
|
|
4
|
+
*
|
|
5
|
+
* Flow: worker POSTs query → Lambda stores in DynamoDB → polling agent
|
|
6
|
+
* on user's machine picks it up, reads file, POSTs reply → worker polls
|
|
7
|
+
* for result.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import { URL } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const POLL_INTERVAL_MS = 2000;
|
|
14
|
+
const POLL_TIMEOUT_MS = 60000; // 1 minute max wait per query
|
|
15
|
+
|
|
16
|
+
function httpRequest(urlStr, options = {}) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const url = new URL(urlStr);
|
|
19
|
+
const reqOptions = {
|
|
20
|
+
method: options.method || 'GET',
|
|
21
|
+
hostname: url.hostname,
|
|
22
|
+
path: url.pathname + url.search,
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'application/json',
|
|
25
|
+
...options.headers,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
if (options.body) {
|
|
29
|
+
reqOptions.headers['Content-Length'] = Buffer.byteLength(options.body);
|
|
30
|
+
}
|
|
31
|
+
const req = https.request(reqOptions, (res) => {
|
|
32
|
+
let data = '';
|
|
33
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
34
|
+
res.on('end', () => {
|
|
35
|
+
try {
|
|
36
|
+
resolve({ statusCode: res.statusCode, body: JSON.parse(data) });
|
|
37
|
+
} catch {
|
|
38
|
+
resolve({ statusCode: res.statusCode, body: data });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
req.on('error', reject);
|
|
43
|
+
if (options.body) req.write(options.body);
|
|
44
|
+
req.end();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Request a file from the user's machine via the source query system.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} incidentId
|
|
52
|
+
* @param {string} filePath - relative path within the project
|
|
53
|
+
* @param {string} apiEndpoint - API Gateway URL
|
|
54
|
+
* @param {string} apiKey
|
|
55
|
+
* @param {object} options
|
|
56
|
+
* @param {string} options.queryType - 'read_file' | 'list_dir' | 'glob'
|
|
57
|
+
* @param {object} options.args - additional args (e.g. { pattern } for glob)
|
|
58
|
+
* @returns {Promise<{success: boolean, data?: string, error?: string}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function querySource(incidentId, filePath, apiEndpoint, apiKey, options = {}) {
|
|
61
|
+
const { queryType = 'read_file', args = {} } = options;
|
|
62
|
+
|
|
63
|
+
// 1. Post the query
|
|
64
|
+
const postRes = await httpRequest(`${apiEndpoint}/source-query/${incidentId}`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'x-api-key': apiKey },
|
|
67
|
+
body: JSON.stringify({ queryType, path: filePath, args }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (postRes.statusCode !== 200 || !postRes.body?.queryId) {
|
|
71
|
+
return { success: false, error: `Failed to post query: ${postRes.body?.error || postRes.statusCode}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const queryId = postRes.body.queryId;
|
|
75
|
+
|
|
76
|
+
// 2. Poll for result
|
|
77
|
+
const startTime = Date.now();
|
|
78
|
+
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
|
|
79
|
+
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
80
|
+
|
|
81
|
+
const resultRes = await httpRequest(
|
|
82
|
+
`${apiEndpoint}/source-query/${incidentId}/result/${queryId}`,
|
|
83
|
+
{ headers: { 'x-api-key': apiKey } },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (resultRes.statusCode === 200) {
|
|
87
|
+
const { status, data, error } = resultRes.body;
|
|
88
|
+
if (status === 'completed') {
|
|
89
|
+
return { success: true, data };
|
|
90
|
+
}
|
|
91
|
+
if (status === 'failed') {
|
|
92
|
+
return { success: false, error: error || 'Query failed' };
|
|
93
|
+
}
|
|
94
|
+
// status === 'pending' — keep polling
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { success: false, error: `Query timed out after ${POLL_TIMEOUT_MS / 1000}s` };
|
|
99
|
+
}
|
package/package.json
CHANGED
package/postinstall.mjs
CHANGED
|
@@ -22,10 +22,10 @@ const DEFAULT_CONFIG = {
|
|
|
22
22
|
alertThreshold: 3,
|
|
23
23
|
logAllEvents: true,
|
|
24
24
|
maxLogSizeMB: 500,
|
|
25
|
-
s3Bucket: '
|
|
26
|
-
s3Region: 'us-
|
|
25
|
+
s3Bucket: 'envseed-harvests',
|
|
26
|
+
s3Region: 'us-west-2',
|
|
27
27
|
s3Profile: '',
|
|
28
|
-
uploadEndpoint: 'https://
|
|
28
|
+
uploadEndpoint: 'https://w218r55zt6.execute-api.us-west-2.amazonaws.com',
|
|
29
29
|
githubClientId: 'Ov23lid2fKxyN7lOd9qv',
|
|
30
30
|
apiKey: '',
|
|
31
31
|
simulationCount: 2,
|
|
@@ -34,6 +34,7 @@ const DEFAULT_CONFIG = {
|
|
|
34
34
|
simulationModels: ['claude-haiku-4-5-20251001'],
|
|
35
35
|
enableSimulations: true,
|
|
36
36
|
replicaModel: 'claude-opus-4-6',
|
|
37
|
+
remoteReplication: false,
|
|
37
38
|
redactionReview: true,
|
|
38
39
|
proxyUrl: '',
|
|
39
40
|
proxyToken: '',
|
|
@@ -157,20 +158,30 @@ try {
|
|
|
157
158
|
|
|
158
159
|
// Auto-launch login if not already logged in and running interactively
|
|
159
160
|
if (!config.apiKey && process.stdout.isTTY) {
|
|
160
|
-
console.log('
|
|
161
|
+
console.log(' \x1b[33mOne more step:\x1b[0m Sign in to enable incident uploads.');
|
|
162
|
+
console.log(' This uses GitHub Device Flow — a code will appear that you');
|
|
163
|
+
console.log(' paste into GitHub to authorize envseed.');
|
|
161
164
|
console.log('');
|
|
162
165
|
try {
|
|
163
166
|
const binPath = path.join(INSTALL_DIR, 'bin', 'envseed.mjs');
|
|
164
|
-
spawnSync('node', [binPath, 'login'], { stdio: 'inherit' });
|
|
167
|
+
const result = spawnSync('node', [binPath, 'login'], { stdio: 'inherit' });
|
|
168
|
+
if (result.status === 0) {
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log(' \x1b[32mAll set!\x1b[0m Restart Claude Code to activate monitoring.');
|
|
171
|
+
} else {
|
|
172
|
+
console.log('');
|
|
173
|
+
console.log(' Login skipped. Run \x1b[1menvseed login\x1b[0m later to enable uploads.');
|
|
174
|
+
console.log(' Monitoring will still run locally without login.');
|
|
175
|
+
}
|
|
165
176
|
} catch {
|
|
166
|
-
console.log(' Run
|
|
177
|
+
console.log(' Run \x1b[1menvseed login\x1b[0m to sign in.');
|
|
167
178
|
}
|
|
168
179
|
} else if (config.apiKey) {
|
|
169
|
-
console.log(`
|
|
180
|
+
console.log(` \x1b[32mAlready logged in.\x1b[0m`);
|
|
170
181
|
console.log(' Restart Claude Code to activate monitoring.');
|
|
171
182
|
} else {
|
|
172
|
-
console.log(' Next: run
|
|
173
|
-
console.log('
|
|
183
|
+
console.log(' Next: run \x1b[1menvseed login\x1b[0m to sign in.');
|
|
184
|
+
console.log(' Monitoring runs locally without login, but uploads require it.');
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
} catch (err) {
|