vigthoria-cli 1.13.34 → 1.13.42

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.
Files changed (51) hide show
  1. package/README.md +11 -56
  2. package/SECURITY_HARDENING.md +2 -4
  3. package/dist/commands/auth.js +16 -14
  4. package/dist/commands/cancel.d.ts +1 -0
  5. package/dist/commands/cancel.js +21 -4
  6. package/dist/commands/chat.d.ts +13 -0
  7. package/dist/commands/chat.js +170 -14
  8. package/dist/commands/fork.js +8 -3
  9. package/dist/commands/history.js +8 -3
  10. package/dist/commands/legion.js +1 -1
  11. package/dist/commands/replay.js +8 -3
  12. package/dist/commands/update-registration.js +63 -206
  13. package/dist/commands/v4-menu.d.ts +0 -1
  14. package/dist/commands/v4-menu.js +8 -6
  15. package/dist/commands/v4-registration.js +0 -2
  16. package/dist/commands/v4.js +9 -4
  17. package/dist/commands/wallet.d.ts +1 -1
  18. package/dist/commands/wallet.js +3 -3
  19. package/dist/index.js +2 -3
  20. package/dist/utils/agent-stream-state.d.ts +7 -0
  21. package/dist/utils/agent-stream-state.js +31 -0
  22. package/dist/utils/agentRunOutcome.js +5 -1
  23. package/dist/utils/api.d.ts +19 -4
  24. package/dist/utils/api.js +322 -550
  25. package/dist/utils/frontend-preview-service.d.ts +10 -1
  26. package/dist/utils/frontend-preview-service.js +162 -17
  27. package/dist/utils/javascript-syntax.d.ts +19 -0
  28. package/dist/utils/javascript-syntax.js +35 -0
  29. package/dist/utils/localTestMode.js +1 -1
  30. package/dist/utils/mutation-journal.d.ts +10 -0
  31. package/dist/utils/mutation-journal.js +104 -76
  32. package/dist/utils/network-policy.js +7 -10
  33. package/dist/utils/preview-screenshot-adapter.d.ts +81 -0
  34. package/dist/utils/preview-screenshot-adapter.js +615 -38
  35. package/dist/utils/release-install.d.ts +0 -1
  36. package/dist/utils/release-install.js +44 -7
  37. package/dist/utils/requestIntent.d.ts +1 -1
  38. package/dist/utils/requestIntent.js +17 -3
  39. package/dist/utils/tools.js +21 -15
  40. package/dist/utils/update-policy.d.ts +0 -4
  41. package/dist/utils/update-policy.js +5 -9
  42. package/dist/utils/v3-agent-client.js +25 -11
  43. package/install.ps1 +30 -50
  44. package/install.sh +13 -24
  45. package/package.json +4 -2
  46. package/release-policy.json +1 -5
  47. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +6 -7
  48. package/scripts/release/install-release.mjs +5 -4
  49. package/scripts/release/publish-cli-release.mjs +21 -8
  50. package/scripts/release/test-balanced-model-live.sh +4 -1
  51. package/scripts/release/validate-live-service-gates.sh +27 -5
@@ -44,6 +44,7 @@ export interface FrontendPreviewGateResult {
44
44
  export interface FrontendPreviewServiceDependencies {
45
45
  getBaseUrls(): string[];
46
46
  getAccessToken(): string | null;
47
+ getServiceKey?(): string | null;
47
48
  fetch(input: string, init: RequestInit): Promise<Response>;
48
49
  resolveTargetPath(context: Record<string, any>): string;
49
50
  extractExpectedFiles(message: string, context: Record<string, any>): string[];
@@ -52,10 +53,17 @@ export interface FrontendPreviewServiceDependencies {
52
53
  screenshotAdapter: PreviewScreenshotPort;
53
54
  }
54
55
  export declare function isConstrainedStaticFrontendRequest(message?: string): boolean;
56
+ /**
57
+ * A code artifact can belong to a browser product without itself being a
58
+ * previewable application. Requiring an HTML entry for an explicitly scoped
59
+ * module/library request turns a successful native verification into a false
60
+ * failure and rolls the requested file back.
61
+ */
62
+ export declare function isFocusedNonPreviewableCodeArtifactRequest(message?: string, expectedFiles?: string[]): boolean;
55
63
  export declare class FrontendPreviewService {
56
64
  private readonly dependencies;
57
65
  constructor(dependencies: FrontendPreviewServiceDependencies);
58
- private isFrontendTask;
66
+ isFrontendTask(message?: string, context?: Record<string, any>): boolean;
59
67
  /**
60
68
  * Returns true when the prompt describes a read-only / analysis task.
61
69
  * Used to suppress preview gate and other write-oriented side effects.
@@ -70,6 +78,7 @@ export declare class FrontendPreviewService {
70
78
  js: string[];
71
79
  assets: string[];
72
80
  };
81
+ private expandLinkedFrontendDependencyGraph;
73
82
  private buildFrontendForPreview;
74
83
  private gatherFrontendPreviewArtifacts;
75
84
  private evaluateFrontendVisualProof;
@@ -11,6 +11,47 @@ export function isConstrainedStaticFrontendRequest(message = '') {
11
11
  && /\b(?:single[- ]file|one\s+paragraph|exactly\s+one|current\s+directory\s+only|write\s+the\s+file\b[^.\n]{0,80}\bonly)\b/.test(prompt)
12
12
  && !/\b(?:premium|signature|showcase|redesign|overhaul|dashboard|portal|saas|interactive|animation|motion|form|checkout|booking|commerce)\b/.test(prompt);
13
13
  }
14
+ /**
15
+ * A code artifact can belong to a browser product without itself being a
16
+ * previewable application. Requiring an HTML entry for an explicitly scoped
17
+ * module/library request turns a successful native verification into a false
18
+ * failure and rolls the requested file back.
19
+ */
20
+ export function isFocusedNonPreviewableCodeArtifactRequest(message = '', expectedFiles = []) {
21
+ const prompt = String(message || '').toLowerCase();
22
+ const normalizedFiles = Array.from(new Set(expectedFiles
23
+ .map((filePath) => String(filePath || '').trim().replace(/\\/g, '/').replace(/^\.\//, ''))
24
+ .filter(Boolean)));
25
+ if (normalizedFiles.length === 0 || normalizedFiles.length > 4)
26
+ return false;
27
+ if (normalizedFiles.some((filePath) => /\.(?:html?|css|scss|sass|less)$/i.test(filePath)))
28
+ return false;
29
+ if (!normalizedFiles.every((filePath) => /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|swift|rb|php|cs|cpp|cc|c|h|hpp)$/i.test(filePath))) {
30
+ return false;
31
+ }
32
+ const explicitArtifactScope = /\b(?:es\s+module|module|library|utility|helper|class|function|service|adapter|middleware|route|schema)\b/i.test(prompt)
33
+ || /\bexport\s+(?:exactly|only)\b/i.test(prompt)
34
+ || /\b(?:create|write|implement|update|fix|repair)\b[^.\n]{0,120}\b(?:file|files)\b/i.test(prompt);
35
+ if (!explicitArtifactScope)
36
+ return false;
37
+ const wholeProductScope = /\b(?:build|create|implement|deliver|make)\b[^.\n]{0,100}\b(?:complete|full|entire|end-to-end|from scratch|production-ready)\b[^.\n]{0,80}\b(?:website|site|page|dashboard|portal|app|application|game)\b/i.test(prompt)
38
+ || /\b(?:complete|full|entire|end-to-end|playable)\s+(?:website|site|page|dashboard|portal|app|application|game)\b/i.test(prompt);
39
+ return !wholeProductScope;
40
+ }
41
+ function htmlAttributeValue(openingTag, name) {
42
+ const match = String(openingTag || '').match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s'"\\x60=<>]+))`, 'i'));
43
+ return match ? String(match[1] ?? match[2] ?? match[3] ?? '') : '';
44
+ }
45
+ function hasViewportMeta(html) {
46
+ return Array.from(String(html || '').matchAll(/<meta\b[^>]*>/gi)).some((match) => htmlAttributeValue(match[0], 'name').toLowerCase() === 'viewport');
47
+ }
48
+ function canonicalizePreviewHtmlAttributes(html) {
49
+ // The browser accepts valid unquoted attributes. Some older preview-service
50
+ // deployments still parse only quoted name/src/href/type tokens. Send a
51
+ // semantically equivalent canonical proof document without rewriting the
52
+ // user's source file, so standards-compliant markup is not falsely rejected.
53
+ return String(html || '').replace(/\b(name|href|src|rel|type)\s*=\s*([^\s'"\x60=<>]+)/gi, (_match, name, value) => `${name}="${value}"`);
54
+ }
14
55
  export class FrontendPreviewService {
15
56
  dependencies;
16
57
  constructor(dependencies) {
@@ -28,6 +69,9 @@ export class FrontendPreviewService {
28
69
  const prompt = String(message || '');
29
70
  const expectedFiles = this.dependencies.extractExpectedFiles(message, context);
30
71
  const hasHtmlEntry = expectedFiles.some((filePath) => /\.html?$/i.test(filePath));
72
+ if (isFocusedNonPreviewableCodeArtifactRequest(prompt, expectedFiles)) {
73
+ return false;
74
+ }
31
75
  return /(premium|polished|landing|site|page|dashboard|saas|frontend|ui|pricing|showcase|hero|responsive|interactive|game|canvas)/i.test(prompt)
32
76
  || hasHtmlEntry;
33
77
  }
@@ -156,24 +200,30 @@ export class FrontendPreviewService {
156
200
  }
157
201
  return this.normalizeWorkspaceRelativePath(path.posix.normalize(path.posix.join(entryDir, withoutQuery)));
158
202
  };
159
- const cssPattern = /<link[^>]+href=["']([^"']+\.css(?:[?#][^"']*)?)["'][^>]*>/gi;
160
- const jsPattern = /<script[^>]+src=["']([^"']+\.(?:js|mjs|cjs)(?:[?#][^"']*)?)["'][^>]*>/gi;
161
203
  let match;
162
- while ((match = cssPattern.exec(String(html || ''))) !== null) {
163
- const resolved = normalizeAsset(match[1]);
204
+ const linkPattern = /<link\b[^>]*>/gi;
205
+ while ((match = linkPattern.exec(String(html || ''))) !== null) {
206
+ const href = htmlAttributeValue(match[0], 'href');
207
+ if (!/\.css(?:[?#]|$)/i.test(href))
208
+ continue;
209
+ const resolved = normalizeAsset(href);
164
210
  if (resolved) {
165
211
  css.add(resolved);
166
212
  }
167
213
  }
168
- while ((match = jsPattern.exec(String(html || ''))) !== null) {
169
- const resolved = normalizeAsset(match[1]);
214
+ const scriptPattern = /<script\b[^>]*>/gi;
215
+ while ((match = scriptPattern.exec(String(html || ''))) !== null) {
216
+ const src = htmlAttributeValue(match[0], 'src');
217
+ if (!/\.(?:js|mjs|cjs)(?:[?#]|$)/i.test(src))
218
+ continue;
219
+ const resolved = normalizeAsset(src);
170
220
  if (resolved) {
171
221
  js.add(resolved);
172
222
  }
173
223
  }
174
- const assetPattern = /<(?:link|img|source|video|audio)\b[^>]+(?:href|src)=["']([^"']+)["'][^>]*>/gi;
224
+ const assetPattern = /<(?:link|img|source|video|audio)\b[^>]*>/gi;
175
225
  while ((match = assetPattern.exec(String(html || ''))) !== null) {
176
- const resolved = normalizeAsset(match[1]);
226
+ const resolved = normalizeAsset(htmlAttributeValue(match[0], 'href') || htmlAttributeValue(match[0], 'src'));
177
227
  if (resolved)
178
228
  assets.add(resolved);
179
229
  }
@@ -183,6 +233,93 @@ export class FrontendPreviewService {
183
233
  assets: Array.from(assets),
184
234
  };
185
235
  }
236
+ expandLinkedFrontendDependencyGraph(rootPath, entryFiles, kind) {
237
+ const selected = [];
238
+ const seen = new Set();
239
+ const queued = new Set();
240
+ const queue = entryFiles
241
+ .map((filePath) => this.normalizeWorkspaceRelativePath(filePath))
242
+ .filter(Boolean);
243
+ for (const filePath of queue)
244
+ queued.add(filePath);
245
+ const extensions = kind === 'css' ? ['.css'] : ['.js', '.mjs', '.cjs'];
246
+ const resolveImport = (ownerPath, specifier) => {
247
+ const clean = String(specifier || '').trim().split('?')[0].split('#')[0];
248
+ if (!clean
249
+ || clean.startsWith('#')
250
+ || clean.startsWith('//')
251
+ || /^(?:https?:|data:|node:)/i.test(clean)
252
+ || (!clean.startsWith('.') && !clean.startsWith('/'))) {
253
+ return null;
254
+ }
255
+ const ownerDir = path.posix.dirname(this.normalizeWorkspaceRelativePath(ownerPath) || '.');
256
+ const base = this.normalizeWorkspaceRelativePath(path.posix.normalize(clean.startsWith('/')
257
+ ? clean.slice(1)
258
+ : path.posix.join(ownerDir, clean)));
259
+ const candidates = [base];
260
+ if (!path.posix.extname(base)) {
261
+ for (const extension of extensions)
262
+ candidates.push(`${base}${extension}`);
263
+ for (const extension of extensions)
264
+ candidates.push(path.posix.join(base, `index${extension}`));
265
+ }
266
+ for (const candidate of candidates) {
267
+ if (!extensions.includes(path.posix.extname(candidate).toLowerCase()))
268
+ continue;
269
+ try {
270
+ const absolute = resolveWorkspacePath(rootPath, candidate);
271
+ if (fs.statSync(absolute).isFile())
272
+ return candidate;
273
+ }
274
+ catch {
275
+ // The candidate is absent or outside the workspace boundary.
276
+ }
277
+ }
278
+ return null;
279
+ };
280
+ // A dependency graph is authoritative because every admitted file is
281
+ // reachable from the HTML entry. The cap prevents cyclic or adversarial
282
+ // graphs from inflating a preview payload; the final payload has its own
283
+ // byte limit as a second boundary.
284
+ while (queue.length > 0 && selected.length < 64) {
285
+ const ownerPath = queue.shift();
286
+ if (!ownerPath || seen.has(ownerPath))
287
+ continue;
288
+ seen.add(ownerPath);
289
+ let source = '';
290
+ try {
291
+ source = fs.readFileSync(resolveWorkspacePath(rootPath, ownerPath), 'utf8');
292
+ }
293
+ catch {
294
+ continue;
295
+ }
296
+ selected.push(ownerPath);
297
+ const specifiers = [];
298
+ if (kind === 'css') {
299
+ const importPattern = /@import\s+(?:url\(\s*)?(?:["']([^"']+)["']|([^\s)'";]+))\s*\)?/gi;
300
+ let match;
301
+ while ((match = importPattern.exec(source)) !== null)
302
+ specifiers.push(match[1] || match[2]);
303
+ }
304
+ else {
305
+ const staticPattern = /\b(?:import|export)\s+(?:[^'";]*?\sfrom\s*)?["']([^"']+)["']/g;
306
+ const dynamicPattern = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
307
+ let match;
308
+ while ((match = staticPattern.exec(source)) !== null)
309
+ specifiers.push(match[1]);
310
+ while ((match = dynamicPattern.exec(source)) !== null)
311
+ specifiers.push(match[1]);
312
+ }
313
+ for (const specifier of specifiers) {
314
+ const dependency = resolveImport(ownerPath, specifier);
315
+ if (dependency && !seen.has(dependency) && !queued.has(dependency)) {
316
+ queue.push(dependency);
317
+ queued.add(dependency);
318
+ }
319
+ }
320
+ }
321
+ return selected;
322
+ }
186
323
  async buildFrontendForPreview(rootPath) {
187
324
  const packagePath = path.join(rootPath, 'package.json');
188
325
  if (!fs.existsSync(packagePath)) {
@@ -256,12 +393,14 @@ export class FrontendPreviewService {
256
393
  // Pulling every CSS/JS file from the workspace also ingested audit copies,
257
394
  // rejected model drafts, and test harnesses into the production proof body.
258
395
  // Fall back to expected/workspace files only for documents without links.
259
- const selectedCssFiles = (linkedAssets.css.length > 0
396
+ const directCssFiles = (linkedAssets.css.length > 0
260
397
  ? chooseExisting(linkedAssets.css, [])
261
398
  : chooseExisting(expectedCss, cssFiles)).slice(0, 12);
262
- const selectedJsFiles = (linkedAssets.js.length > 0
399
+ const directJsFiles = (linkedAssets.js.length > 0
263
400
  ? chooseExisting(linkedAssets.js, [])
264
401
  : chooseExisting(expectedJs, jsFiles)).slice(0, 12);
402
+ const selectedCssFiles = this.expandLinkedFrontendDependencyGraph(rootPath, directCssFiles, 'css');
403
+ const selectedJsFiles = this.expandLinkedFrontendDependencyGraph(rootPath, directJsFiles, 'js');
265
404
  const css = selectedCssFiles.map((filePath) => fs.readFileSync(resolveWorkspacePath(rootPath, filePath), 'utf8')).join('\n\n');
266
405
  const js = selectedJsFiles.map((filePath) => fs.readFileSync(resolveWorkspacePath(rootPath, filePath), 'utf8')).join('\n\n');
267
406
  return { htmlPath, html, css, js, cssPaths: selectedCssFiles, jsPaths: selectedJsFiles };
@@ -318,11 +457,11 @@ export class FrontendPreviewService {
318
457
  : null;
319
458
  const runtimeInteraction = runtimeOptions
320
459
  ? (typeof this.dependencies.screenshotAdapter.captureRuntime === 'function'
321
- ? await this.dependencies.screenshotAdapter.captureRuntime(entryAbsolutePath, screenshotPath, runtimeOptions)
460
+ ? await this.dependencies.screenshotAdapter.captureRuntime(entryAbsolutePath, screenshotPath, { ...runtimeOptions, workspaceRoot: rootPath })
322
461
  : {
323
462
  captured: false,
324
463
  passed: false,
325
- error: 'required direct-file browser interaction adapter is unavailable',
464
+ error: 'required loopback browser interaction adapter is unavailable',
326
465
  })
327
466
  : null;
328
467
  const screenshot = runtimeInteraction
@@ -335,7 +474,7 @@ export class FrontendPreviewService {
335
474
  ...previewGate,
336
475
  passed: false,
337
476
  error: `${previewGate.error ? `${previewGate.error} ` : ''}${runtimeFailure
338
- ? (runtimeInteraction.error || 'Required direct-file browser interaction proof failed.')
477
+ ? (runtimeInteraction.error || 'Required loopback browser interaction proof failed.')
339
478
  : `Required screenshot proof is unavailable: ${screenshot.error || 'capture failed'}.`}`,
340
479
  }
341
480
  : previewGate;
@@ -444,7 +583,7 @@ export class FrontendPreviewService {
444
583
  const errors = [];
445
584
  if (context.localScreenshotProof === true) {
446
585
  const summary = {
447
- hasViewportMeta: /<meta\b[^>]*name=["']viewport["'][^>]*>/i.test(html),
586
+ hasViewportMeta: hasViewportMeta(html),
448
587
  hasResponsiveSignals: /@media\b/i.test(`${html}\n${css}`),
449
588
  hasInteractiveSignals: /<script[\s>]/i.test(html) || js.trim().length > 0,
450
589
  sectionCount: (html.match(/<section\b/gi) || []).length,
@@ -486,7 +625,7 @@ export class FrontendPreviewService {
486
625
  qualityProfile: isConstrainedStaticFrontendRequest(String(context.rawPrompt || message || ''))
487
626
  ? 'constrained-static'
488
627
  : 'auto',
489
- html,
628
+ html: canonicalizePreviewHtmlAttributes(html),
490
629
  css,
491
630
  js,
492
631
  entryPath: artifacts.htmlPath,
@@ -505,8 +644,14 @@ export class FrontendPreviewService {
505
644
  error: 'Preview proof skipped: payload exceeds size limit.',
506
645
  };
507
646
  }
508
- const internalServiceKey = process.env.VIGTHORIA_TEMPLATE_SERVICE_KEY
509
- || process.env.V3_SERVICE_KEY;
647
+ const internalServiceKeyCandidate = process.env.VIGTHORIA_TEMPLATE_SERVICE_KEY
648
+ || process.env.VIGTHORIA_V3_SERVICE_KEY
649
+ || process.env.V3_SERVICE_KEY
650
+ || this.dependencies.getServiceKey?.();
651
+ const internalServiceKey = String(internalServiceKeyCandidate || '').trim();
652
+ if (internalServiceKey && /[\r\n\0]/.test(internalServiceKey)) {
653
+ throw new Error('Stored Template Service capability contains invalid header characters.');
654
+ }
510
655
  const authToken = this.dependencies.getAccessToken();
511
656
  const response = await this.dependencies.fetch(`${baseUrl}/preview-proof`, {
512
657
  method: 'POST',
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Detect static ESM syntax even when a compact generator places exports after
3
+ * declarations on the same line. Dynamic import() alone remains valid in
4
+ * CommonJS syntax mode and does not require ESM parsing.
5
+ */
6
+ export declare function usesJavaScriptModuleSyntax(source: string, filePath?: string): boolean;
7
+ export type JavaScriptSyntaxMode = 'script' | 'module';
8
+ /**
9
+ * Return the grammars that may legitimately own a JavaScript source file.
10
+ *
11
+ * A plain `.js` file is ambiguous until its package or HTML loader is known.
12
+ * In particular, top-level await can make otherwise valid browser-module code
13
+ * fail a CommonJS-only preflight without leaving a static import/export token
14
+ * for the lightweight detector above. Trying both grammars keeps the gate
15
+ * strict (one parser must accept the complete source) without rejecting valid
16
+ * browser modules or classic scripts. Explicit `.mjs` and `.cjs` identities
17
+ * remain single-grammar and therefore fail closed.
18
+ */
19
+ export declare function resolveJavaScriptSyntaxModes(source: string, filePath?: string): JavaScriptSyntaxMode[];
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Detect static ESM syntax even when a compact generator places exports after
4
+ * declarations on the same line. Dynamic import() alone remains valid in
5
+ * CommonJS syntax mode and does not require ESM parsing.
6
+ */
7
+ export function usesJavaScriptModuleSyntax(source, filePath = '.js') {
8
+ const extension = path.extname(filePath).toLowerCase();
9
+ if (extension === '.mjs')
10
+ return true;
11
+ if (extension === '.cjs')
12
+ return false;
13
+ return /(?:^|[;}\n]|\*\/)[ \t]*(?:import(?!\s*\()|export\b)/m.test(source);
14
+ }
15
+ /**
16
+ * Return the grammars that may legitimately own a JavaScript source file.
17
+ *
18
+ * A plain `.js` file is ambiguous until its package or HTML loader is known.
19
+ * In particular, top-level await can make otherwise valid browser-module code
20
+ * fail a CommonJS-only preflight without leaving a static import/export token
21
+ * for the lightweight detector above. Trying both grammars keeps the gate
22
+ * strict (one parser must accept the complete source) without rejecting valid
23
+ * browser modules or classic scripts. Explicit `.mjs` and `.cjs` identities
24
+ * remain single-grammar and therefore fail closed.
25
+ */
26
+ export function resolveJavaScriptSyntaxModes(source, filePath = '.js') {
27
+ const extension = path.extname(filePath).toLowerCase();
28
+ if (extension === '.mjs')
29
+ return ['module'];
30
+ if (extension === '.cjs')
31
+ return ['script'];
32
+ if (usesJavaScriptModuleSyntax(source, filePath))
33
+ return ['module'];
34
+ return ['script', 'module'];
35
+ }
@@ -20,7 +20,7 @@ export function applyLocalTestfarmDefaults() {
20
20
  process.env.VIGTHORIA_HYPERLOOP_URL ??= 'http://127.0.0.1:8020/api/hyperloop/health';
21
21
  process.env.VIGTHORIA_HYPERLOOP_EXECUTE_URL ??= 'http://127.0.0.1:8020/api/hyperloop/execute';
22
22
  process.env.VIGTHORIA_HYPERLOOP_MODULES_URL ??= 'http://127.0.0.1:8020/api/hyperloop/modules';
23
- process.env.VIGTHORIA_V3_AGENT_URL ??= 'http://127.0.0.1:8030';
23
+ process.env.VIGTHORIA_V3_AGENT_URL ??= 'http://127.0.0.1:8032';
24
24
  process.env.VIGTHORIA_TEMPLATE_SERVICE_URL ??= 'http://127.0.0.1:4011';
25
25
  process.env.VIGTHORIA_HYPERLOOP_TIMEOUT_MS ??= '180000';
26
26
  const testToken = String(process.env.VIGTHORIA_LOCAL_TEST_TOKEN || '').trim();
@@ -39,4 +39,14 @@ export declare class MutationJournal {
39
39
  commit(): MutationJournalReport;
40
40
  rollback(): MutationJournalReport;
41
41
  }
42
+ /**
43
+ * Last-resort synchronous rollback for process signals.
44
+ *
45
+ * APIClient normally owns the foreground journal, but command teardown or a
46
+ * second lifecycle wrapper must never be able to detach an open transaction
47
+ * from SIGINT/SIGTERM protection. Journals register at construction and
48
+ * unregister only after commit/rollback, so this fallback closes every still
49
+ * open workspace transaction before the original signal is re-raised.
50
+ */
51
+ export declare function rollbackActiveMutationJournals(): MutationJournalReport[];
42
52
  export declare function finalizeMutationTransaction(journal: MutationJournal, proofSucceeded: boolean): MutationJournalReport;
@@ -6,6 +6,7 @@ const IGNORE_NAMES = new Set(['.git', 'node_modules', '.vigthoria']);
6
6
  const DEFAULT_MAX_BACKUP_BYTES = 128 * 1024 * 1024;
7
7
  const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
8
8
  const DEFAULT_MAX_FILES = 20_000;
9
+ const ACTIVE_MUTATION_JOURNALS = new Set();
9
10
  function hashFile(filePath) {
10
11
  const hash = createHash('sha256');
11
12
  const descriptor = fs.openSync(filePath, 'r');
@@ -52,6 +53,7 @@ export class MutationJournal {
52
53
  this.manifestPath = path.join(this.journalDirectory, 'journal.json');
53
54
  this.captureBaseline();
54
55
  this.persist('open', [], [], false);
56
+ ACTIVE_MUTATION_JOURNALS.add(this);
55
57
  }
56
58
  listWorkspaceDirectories() {
57
59
  const directories = [];
@@ -284,104 +286,130 @@ export class MutationJournal {
284
286
  };
285
287
  }
286
288
  commit() {
287
- const affectedPaths = this.changedPaths();
288
- this.persist('committed', affectedPaths, [], false);
289
- fs.rmSync(this.backupDirectory, { recursive: true, force: true });
290
- return this.report(affectedPaths, [], false);
289
+ try {
290
+ const affectedPaths = this.changedPaths();
291
+ this.persist('committed', affectedPaths, [], false);
292
+ fs.rmSync(this.backupDirectory, { recursive: true, force: true });
293
+ return this.report(affectedPaths, [], false);
294
+ }
295
+ finally {
296
+ ACTIVE_MUTATION_JOURNALS.delete(this);
297
+ }
291
298
  }
292
299
  rollback() {
293
- const affectedPaths = this.changedPaths();
294
- const failures = [];
295
- for (const relativePath of affectedPaths.filter((entry) => !this.baseline.has(entry))) {
296
- try {
297
- const safe = assertSafeRelativePath(relativePath);
298
- const absolute = path.resolve(this.rootPath, ...safe.split('/'));
299
- const relative = path.relative(this.rootPath, absolute);
300
- if (relative.startsWith('..') || path.isAbsolute(relative))
301
- throw new Error('path escaped workspace');
300
+ try {
301
+ const affectedPaths = this.changedPaths();
302
+ const failures = [];
303
+ for (const relativePath of affectedPaths.filter((entry) => !this.baseline.has(entry))) {
302
304
  try {
303
- fs.lstatSync(absolute);
304
- fs.unlinkSync(absolute);
305
+ const safe = assertSafeRelativePath(relativePath);
306
+ const absolute = path.resolve(this.rootPath, ...safe.split('/'));
307
+ const relative = path.relative(this.rootPath, absolute);
308
+ if (relative.startsWith('..') || path.isAbsolute(relative))
309
+ throw new Error('path escaped workspace');
310
+ try {
311
+ fs.lstatSync(absolute);
312
+ fs.unlinkSync(absolute);
313
+ }
314
+ catch (error) {
315
+ if (error?.code !== 'ENOENT')
316
+ throw error;
317
+ }
305
318
  }
306
319
  catch (error) {
307
- if (error?.code !== 'ENOENT')
308
- throw error;
320
+ failures.push(`${relativePath}: could not remove created file: ${error.message}`);
309
321
  }
310
322
  }
311
- catch (error) {
312
- failures.push(`${relativePath}: could not remove created file: ${error.message}`);
313
- }
314
- }
315
- for (const relativePath of affectedPaths.filter((entry) => this.baseline.has(entry))) {
316
- const baseline = this.baseline.get(relativePath);
317
- if (baseline.kind === 'file' && (!baseline.backupPath || !fs.existsSync(baseline.backupPath))) {
318
- failures.push(`${relativePath}: rollback backup unavailable`);
319
- continue;
320
- }
321
- try {
322
- const destination = this.resolveRollbackDestination(relativePath);
323
- this.removeRollbackDestination(destination);
324
- if (baseline.kind === 'symlink') {
325
- if (baseline.linkTarget === undefined)
326
- throw new Error('rollback link target unavailable');
327
- fs.symlinkSync(baseline.linkTarget, destination, baseline.linkType);
323
+ for (const relativePath of affectedPaths.filter((entry) => this.baseline.has(entry))) {
324
+ const baseline = this.baseline.get(relativePath);
325
+ if (baseline.kind === 'file' && (!baseline.backupPath || !fs.existsSync(baseline.backupPath))) {
326
+ failures.push(`${relativePath}: rollback backup unavailable`);
327
+ continue;
328
328
  }
329
- else {
330
- if (!baseline.backupPath || !fs.existsSync(baseline.backupPath))
331
- throw new Error('rollback backup unavailable');
332
- const temporary = `${destination}.rollback-${randomUUID()}`;
333
- try {
334
- fs.copyFileSync(baseline.backupPath, temporary, fs.constants.COPYFILE_EXCL);
335
- fs.chmodSync(temporary, baseline.mode ?? 0o600);
336
- fs.renameSync(temporary, destination);
329
+ try {
330
+ const destination = this.resolveRollbackDestination(relativePath);
331
+ this.removeRollbackDestination(destination);
332
+ if (baseline.kind === 'symlink') {
333
+ if (baseline.linkTarget === undefined)
334
+ throw new Error('rollback link target unavailable');
335
+ fs.symlinkSync(baseline.linkTarget, destination, baseline.linkType);
337
336
  }
338
- finally {
337
+ else {
338
+ if (!baseline.backupPath || !fs.existsSync(baseline.backupPath))
339
+ throw new Error('rollback backup unavailable');
340
+ const temporary = `${destination}.rollback-${randomUUID()}`;
339
341
  try {
340
- fs.unlinkSync(temporary);
342
+ fs.copyFileSync(baseline.backupPath, temporary, fs.constants.COPYFILE_EXCL);
343
+ fs.chmodSync(temporary, baseline.mode ?? 0o600);
344
+ fs.renameSync(temporary, destination);
341
345
  }
342
- catch (error) {
343
- if (error?.code !== 'ENOENT')
344
- throw error;
346
+ finally {
347
+ try {
348
+ fs.unlinkSync(temporary);
349
+ }
350
+ catch (error) {
351
+ if (error?.code !== 'ENOENT')
352
+ throw error;
353
+ }
345
354
  }
346
355
  }
347
356
  }
357
+ catch (error) {
358
+ failures.push(`${relativePath}: restore failed: ${error.message}`);
359
+ }
348
360
  }
349
- catch (error) {
350
- failures.push(`${relativePath}: restore failed: ${error.message}`);
351
- }
352
- }
353
- for (const relativePath of [...this.baselineDirectories].sort((a, b) => a.length - b.length)) {
354
- try {
355
- fs.mkdirSync(resolveWorkspacePath(this.rootPath, relativePath, { allowMissing: true }), { recursive: true });
356
- }
357
- catch (error) {
358
- failures.push(`${relativePath}: directory restore failed: ${error.message}`);
361
+ for (const relativePath of [...this.baselineDirectories].sort((a, b) => a.length - b.length)) {
362
+ try {
363
+ fs.mkdirSync(resolveWorkspacePath(this.rootPath, relativePath, { allowMissing: true }), { recursive: true });
364
+ }
365
+ catch (error) {
366
+ failures.push(`${relativePath}: directory restore failed: ${error.message}`);
367
+ }
359
368
  }
360
- }
361
- for (const relativePath of this.listWorkspaceDirectories().sort((a, b) => b.length - a.length)) {
362
- if (this.baselineDirectories.has(relativePath))
363
- continue;
364
- try {
365
- const directory = resolveWorkspacePath(this.rootPath, relativePath);
366
- if (fs.readdirSync(directory).length === 0)
367
- fs.rmdirSync(directory);
369
+ for (const relativePath of this.listWorkspaceDirectories().sort((a, b) => b.length - a.length)) {
370
+ if (this.baselineDirectories.has(relativePath))
371
+ continue;
372
+ try {
373
+ const directory = resolveWorkspacePath(this.rootPath, relativePath);
374
+ if (fs.readdirSync(directory).length === 0)
375
+ fs.rmdirSync(directory);
376
+ }
377
+ catch (error) {
378
+ failures.push(`${relativePath}: created directory remains: ${error.message}`);
379
+ }
368
380
  }
369
- catch (error) {
370
- failures.push(`${relativePath}: created directory remains: ${error.message}`);
381
+ const residual = this.changedPaths();
382
+ for (const relativePath of residual) {
383
+ if (!failures.some((failure) => failure.startsWith(`${relativePath}:`))) {
384
+ failures.push(`${relativePath}: mutation remains after rollback`);
385
+ }
371
386
  }
387
+ const partial = failures.length > 0;
388
+ this.persist(partial ? 'partial_mutation' : 'rolled_back', affectedPaths, failures, !partial);
389
+ fs.rmSync(this.backupDirectory, { recursive: true, force: true });
390
+ return this.report(affectedPaths, failures, !partial);
372
391
  }
373
- const residual = this.changedPaths();
374
- for (const relativePath of residual) {
375
- if (!failures.some((failure) => failure.startsWith(`${relativePath}:`))) {
376
- failures.push(`${relativePath}: mutation remains after rollback`);
377
- }
392
+ finally {
393
+ ACTIVE_MUTATION_JOURNALS.delete(this);
378
394
  }
379
- const partial = failures.length > 0;
380
- this.persist(partial ? 'partial_mutation' : 'rolled_back', affectedPaths, failures, !partial);
381
- fs.rmSync(this.backupDirectory, { recursive: true, force: true });
382
- return this.report(affectedPaths, failures, !partial);
383
395
  }
384
396
  }
397
+ /**
398
+ * Last-resort synchronous rollback for process signals.
399
+ *
400
+ * APIClient normally owns the foreground journal, but command teardown or a
401
+ * second lifecycle wrapper must never be able to detach an open transaction
402
+ * from SIGINT/SIGTERM protection. Journals register at construction and
403
+ * unregister only after commit/rollback, so this fallback closes every still
404
+ * open workspace transaction before the original signal is re-raised.
405
+ */
406
+ export function rollbackActiveMutationJournals() {
407
+ const reports = [];
408
+ for (const journal of [...ACTIVE_MUTATION_JOURNALS]) {
409
+ reports.push(journal.rollback());
410
+ }
411
+ return reports;
412
+ }
385
413
  export function finalizeMutationTransaction(journal, proofSucceeded) {
386
414
  return proofSucceeded ? journal.commit() : journal.rollback();
387
415
  }
@@ -14,14 +14,7 @@ const AUDIENCE_HOSTS = {
14
14
  hyperloop: new Set(['coder.vigthoria.io', 'hyperloop.vigthoria.io']),
15
15
  vigflow: new Set(['coder.vigthoria.io', 'workflow.vigthoria.io']),
16
16
  bridge: new Set(['bridge.vigthoria.io', 'devtools.vigthoria.io']),
17
- release: new Set([
18
- 'extension.vigthoria.io',
19
- 'landing.vigthoria.io',
20
- 'cli.vigthoria.io',
21
- 'coder.vigthoria.io',
22
- 'market.vigthoria.io',
23
- 'registry.npmjs.org',
24
- ]),
17
+ release: new Set(['extension.vigthoria.io']),
25
18
  };
26
19
  // `auto` exists only as a compatibility guard for legacy calls to the primary
27
20
  // Coder origin. It must never mean "any Vigthoria host": doing so allows a
@@ -30,9 +23,13 @@ const AUDIENCE_HOSTS = {
30
23
  // its audience explicitly.
31
24
  const AUTO_CREDENTIAL_HOSTS = AUDIENCE_HOSTS.coder;
32
25
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
33
- const LOOPBACK_PORTS = new Set(['4008', '4009', '4011', '4016', '49160', '8030', '8020', '5060', '5050', '8016', '9000']);
26
+ // 18032/18041 are the isolated CLI-lane candidate ports used by OS TestFarm.
27
+ // They remain unreachable unless the caller also opts into loopback services.
28
+ const LOOPBACK_PORTS = new Set(['4008', '4009', '4011', '4016', '49160', '8032', '18032', '18041', '8020', '5060', '5050', '8016', '9000']);
34
29
  const TESTFARM_BRIDGE_HOSTS = new Set(['host.lan']);
35
- const TESTFARM_BRIDGE_PORTS = new Set(['14009', '18020', '18030', '4011']);
30
+ // Remote TestFarm CLI guests reach the CLI-exclusive V3 runtime through
31
+ // 18032. Port 18030 is deliberately absent because it belongs to Desktop/IDE.
32
+ const TESTFARM_BRIDGE_PORTS = new Set(['14009', '18020', '18032', '4011']);
36
33
  const SENSITIVE_HEADER_NAMES = new Set(['authorization', 'cookie', 'proxy-authorization', 'x-api-key', 'x-service-key']);
37
34
  export const DEFAULT_NETWORK_TIMEOUT_MS = 30_000;
38
35
  export class NetworkPolicyError extends Error {