envseed 0.2.1 → 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.
@@ -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 {
@@ -295,24 +295,53 @@ async function main() {
295
295
  log(' (incident saved locally, upload can be retried with: envseed incident <id> upload)');
296
296
  }
297
297
 
298
- // 6. Spawn simulation orchestrator
298
+ // 6. Start simulations (remote or local)
299
299
  log('5/5 Starting simulations...');
300
- const config = {};
300
+ // Re-read config (already loaded at top for credential check, but use fresh copy)
301
+ const simConfig = {};
301
302
  try {
302
- Object.assign(config, JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'config.json'), 'utf8')));
303
+ Object.assign(simConfig, JSON.parse(fs.readFileSync(path.join(INSTALL_DIR, 'config.json'), 'utf8')));
303
304
  } catch {}
304
305
 
305
- if (config.enableSimulations === false) {
306
+ const libDir = path.dirname(new URL(import.meta.url).pathname);
307
+
308
+ if (simConfig.enableSimulations === false) {
306
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
+ }
307
340
  } else {
308
- const orchestratorScript = path.join(path.dirname(new URL(import.meta.url).pathname), 'simulation-orchestrator.mjs');
309
- const child = spawn('node', [orchestratorScript, incidentId], {
310
- detached: true,
311
- stdio: 'ignore',
312
- });
313
- child.unref();
314
- log(` Orchestrator spawned (PID ${child.pid})`);
315
- 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);
316
345
  }
317
346
 
318
347
  // Write initial status
@@ -320,8 +349,9 @@ async function main() {
320
349
  started: new Date().toISOString(),
321
350
  archivalComplete: true,
322
351
  s3Uploaded: s3Result.success,
323
- simulationsStarted: config.enableSimulations !== false,
324
- totalPlanned: config.simulationCount || 2,
352
+ remote: !!(simConfig.remoteReplication && s3Result.success),
353
+ simulationsStarted: simConfig.enableSimulations !== false,
354
+ totalPlanned: simConfig.simulationCount || 2,
325
355
  completed: 0,
326
356
  failed: 0,
327
357
  running: 0,
@@ -333,6 +363,50 @@ async function main() {
333
363
  if (s3Result.success) log(`S3 path: ${s3Result.s3Path}`);
334
364
  }
335
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
+
336
410
  main().catch(err => {
337
411
  process.stderr.write(`log-incident error: ${err.message}\n${err.stack}\n`);
338
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
+ });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "envseed",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Cultivate AI safety evals from real Claude Code sessions",
5
5
  "type": "module",
6
6
  "bin": {
package/postinstall.mjs CHANGED
@@ -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: '',