vigthoria-cli 1.13.24 → 1.13.26

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 (47) hide show
  1. package/completions/_vigthoria +1 -1
  2. package/completions/vigthoria.fish +1 -1
  3. package/dist/commands/auth.js +14 -5
  4. package/dist/commands/chat.js +51 -2
  5. package/dist/commands/config.d.ts +1 -0
  6. package/dist/commands/config.js +33 -9
  7. package/dist/commands/game.d.ts +4 -0
  8. package/dist/commands/game.js +23 -1
  9. package/dist/commands/hub.d.ts +2 -0
  10. package/dist/commands/hub.js +61 -78
  11. package/dist/commands/legion.d.ts +1 -0
  12. package/dist/commands/legion.js +7 -7
  13. package/dist/commands/platform-registration.js +1 -1
  14. package/dist/commands/preview.d.ts +1 -0
  15. package/dist/commands/preview.js +30 -14
  16. package/dist/commands/product-run-registration.js +4 -2
  17. package/dist/commands/repo.d.ts +37 -2
  18. package/dist/commands/repo.js +99 -32
  19. package/dist/commands/security.d.ts +3 -0
  20. package/dist/commands/security.js +29 -9
  21. package/dist/commands/update-registration.js +2 -1
  22. package/dist/index.js +16 -2
  23. package/dist/utils/api.js +26 -22
  24. package/dist/utils/chat-prompt-policy.js +1 -1
  25. package/dist/utils/code-operations-service.js +43 -14
  26. package/dist/utils/config.js +5 -2
  27. package/dist/utils/local-security-service.d.ts +23 -0
  28. package/dist/utils/local-security-service.js +210 -0
  29. package/dist/utils/model-transport-service.js +66 -6
  30. package/dist/utils/network-policy.d.ts +1 -1
  31. package/dist/utils/network-policy.js +3 -2
  32. package/dist/utils/preview-screenshot-adapter.d.ts +42 -1
  33. package/dist/utils/preview-screenshot-adapter.js +66 -14
  34. package/dist/utils/runtime-temp.d.ts +1 -0
  35. package/dist/utils/runtime-temp.js +22 -2
  36. package/dist/utils/secret-policy.js +27 -18
  37. package/dist/utils/subscription-policy.d.ts +11 -0
  38. package/dist/utils/subscription-policy.js +32 -0
  39. package/dist/utils/v3-stream-events.d.ts +8 -0
  40. package/dist/utils/v3-stream-events.js +62 -0
  41. package/dist/utils/v3-workspace-service.js +5 -1
  42. package/dist/utils/vigflow-client.js +2 -2
  43. package/package.json +3 -11
  44. package/release-policy.json +2 -1
  45. package/scripts/release/generate-release-evidence.mjs +49 -0
  46. package/scripts/release/publish-cli-release.mjs +19 -8
  47. package/scripts/release/validate-no-go-gates.sh +2 -0
@@ -46,6 +46,8 @@ export class CodeOperationsService {
46
46
  let code = await this.chatComplete(systemPrompt, buildScopedPrompt(false), model);
47
47
  // Strip markdown fences if model included them
48
48
  code = code.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
49
+ if (!code)
50
+ throw new Error('Code generation backend returned an empty response.');
49
51
  // Client-side validation: reject DOM-polluted or over-engineered responses for non-HTML languages
50
52
  const needsRetry = isNonHtmlLang && (this.codeContainsDomPollution(code) ||
51
53
  this.codeIsOverEngineered(code, prompt));
@@ -53,6 +55,8 @@ export class CodeOperationsService {
53
55
  // Retry once with stronger constraint — via Model Router
54
56
  code = await this.chatComplete(systemPrompt, buildScopedPrompt(true), model);
55
57
  code = code.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
58
+ if (!code)
59
+ throw new Error('Code generation retry returned an empty response.');
56
60
  // If still polluted, strip DOM code client-side
57
61
  if (this.codeContainsDomPollution(code)) {
58
62
  code = this.stripDomPollution(code, language);
@@ -234,6 +238,8 @@ export class CodeOperationsService {
234
238
  'Return ONLY the JSON object, no markdown fences.',
235
239
  ].join('\n');
236
240
  const raw = await this.chatComplete(sysPrompt, prompt, model, 8192);
241
+ if (!raw.trim())
242
+ throw new Error('Project generation backend returned an empty response.');
237
243
  try {
238
244
  const cleaned = raw.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
239
245
  const parsed = JSON.parse(cleaned);
@@ -259,7 +265,10 @@ export class CodeOperationsService {
259
265
  '- Do NOT use raw HTML or excessive blank lines.',
260
266
  '- Do NOT nest numbered lists inside sections.',
261
267
  ].join('\n');
262
- return this.chatComplete(sysPrompt, code);
268
+ const explanation = (await this.chatComplete(sysPrompt, code)).trim();
269
+ if (!explanation)
270
+ throw new Error('Code explanation backend returned an empty response.');
271
+ return explanation;
263
272
  }
264
273
  async reviewCode(code, language) {
265
274
  const sysPrompt = [
@@ -277,18 +286,29 @@ export class CodeOperationsService {
277
286
  '- Do NOT suggest adding error handling, input validation, or documentation as issues unless the user explicitly asked for a style review.',
278
287
  '- Return ONLY the JSON object, no markdown fences or extra text.',
279
288
  ].join('\n');
280
- let raw = {};
289
+ const result = await this.chatComplete(sysPrompt, code);
290
+ let raw;
281
291
  try {
282
- const result = await this.chatComplete(sysPrompt, code);
283
292
  const cleaned = result.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
284
293
  raw = JSON.parse(cleaned);
285
294
  }
286
- catch {
287
- // If parsing fails, return minimal review
295
+ catch (error) {
296
+ throw new Error('Code review backend returned malformed JSON.', { cause: error });
297
+ }
298
+ if (!raw || typeof raw !== 'object' || !Number.isFinite(raw.score) || raw.score < 0 || raw.score > 100 || !Array.isArray(raw.issues) || !Array.isArray(raw.suggestions)) {
299
+ throw new Error('Code review backend response does not satisfy the review contract.');
300
+ }
301
+ const lineCount = Math.max(1, code.split('\n').length);
302
+ const validSeverities = new Set(['error', 'warning', 'info']);
303
+ if (raw.issues.some((issue) => !issue || typeof issue.type !== 'string' || !Number.isInteger(issue.line) || issue.line < 1 || issue.line > lineCount || typeof issue.message !== 'string' || !validSeverities.has(issue.severity))) {
304
+ throw new Error('Code review backend returned an invalid or out-of-range finding.');
305
+ }
306
+ if (raw.suggestions.some((suggestion) => typeof suggestion !== 'string')) {
307
+ throw new Error('Code review backend returned an invalid suggestion.');
288
308
  }
289
- const score = typeof raw.score === 'number' ? raw.score : 0;
290
- const issues = Array.isArray(raw.issues) ? raw.issues : [];
291
- const suggestions = Array.isArray(raw.suggestions) ? raw.suggestions : [];
309
+ const score = raw.score;
310
+ const issues = raw.issues;
311
+ const suggestions = raw.suggestions;
292
312
  // Merge client-side heuristics, but with tight dedup to avoid
293
313
  // redundant over-reporting when the model already found the bug.
294
314
  const modelFoundError = issues.some(i => i.severity === 'error');
@@ -480,17 +500,23 @@ export class CodeOperationsService {
480
500
  '- Do not add comments, do not restructure beyond the minimal fix.',
481
501
  '- Return ONLY the JSON object, no markdown fences.',
482
502
  ].join('\n');
483
- let raw = {};
503
+ const result = await this.chatComplete(sysPrompt, augmentedCode);
504
+ let raw;
484
505
  try {
485
- const result = await this.chatComplete(sysPrompt, augmentedCode);
486
506
  const cleaned = result.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '').trim();
487
507
  raw = JSON.parse(cleaned);
488
508
  }
489
- catch {
490
- // If parsing fails, fall through to client-side handling
509
+ catch (error) {
510
+ throw new Error('Code fix backend returned malformed JSON.', { cause: error });
511
+ }
512
+ if (!raw || typeof raw !== 'object' || typeof raw.fixed !== 'string' || !raw.fixed.trim() || !Array.isArray(raw.changes)) {
513
+ throw new Error('Code fix backend response does not satisfy the fix contract.');
491
514
  }
492
- let fixed = typeof raw.fixed === 'string' ? raw.fixed : (typeof raw.code === 'string' ? raw.code : code);
493
- let changes = Array.isArray(raw.changes) ? raw.changes : [];
515
+ if (raw.changes.some((change) => !change || !Number.isInteger(change.line) || change.line < 1 || typeof change.before !== 'string' || typeof change.after !== 'string' || typeof change.reason !== 'string')) {
516
+ throw new Error('Code fix backend returned invalid change evidence.');
517
+ }
518
+ let fixed = raw.fixed;
519
+ let changes = raw.changes;
494
520
  // If server returned no changes but we found issues, strip
495
521
  // our injected comment prefix from the returned code and attempt
496
522
  // a basic client-side repair.
@@ -545,6 +571,9 @@ export class CodeOperationsService {
545
571
  : 'AI-suggested fix';
546
572
  changes = this.computeSemanticDiff(code, fixed, cleanReason);
547
573
  }
574
+ if (fixed === code && changes.length === 0) {
575
+ throw new Error('Code fix backend returned no verified mutation evidence.');
576
+ }
548
577
  return { fixed, changes };
549
578
  }
550
579
  /**
@@ -129,7 +129,10 @@ function isConfigValue(value) {
129
129
  }
130
130
  export class Config {
131
131
  store;
132
- static OPERATOR_PLANS = new Set(['enterprise', 'admin', 'master_admin']);
132
+ static OPERATOR_PLANS = new Set([
133
+ 'basic', 'pro', 'professional', 'professional_ai', 'enterprise',
134
+ 'enterprise_ai', 'whale', 'admin', 'master_admin', 'master_admin_plan',
135
+ ]);
133
136
  constructor(options = {}) {
134
137
  const stateRoot = path.resolve(options.stateRoot || os.homedir());
135
138
  const canonicalPath = path.join(stateRoot, '.vigthoria', 'config.json');
@@ -264,7 +267,7 @@ export class Config {
264
267
  return true;
265
268
  }
266
269
  getNormalizedPlan() {
267
- return (this.get('subscription').plan || '').trim().toLowerCase();
270
+ return (this.get('subscription').plan || '').trim().toLowerCase().replace(/-/g, '_');
268
271
  }
269
272
  hasOperatorAccess() {
270
273
  return Config.OPERATOR_PLANS.has(this.getNormalizedPlan());
@@ -0,0 +1,23 @@
1
+ export type SecuritySeverity = 'critical' | 'high' | 'medium' | 'low';
2
+ export interface LocalSecurityIssue {
3
+ id: string;
4
+ rule: string;
5
+ severity: SecuritySeverity;
6
+ file: string;
7
+ line: number;
8
+ message: string;
9
+ }
10
+ export interface LocalSecurityResult {
11
+ rootDir: string;
12
+ scannedFiles: number;
13
+ scannedBytes: number;
14
+ skippedFiles: number;
15
+ score: number;
16
+ grade: string;
17
+ issueCount: number;
18
+ issues: LocalSecurityIssue[];
19
+ bounded: true;
20
+ localOnly: true;
21
+ }
22
+ export declare function scanLocalWorkspace(inputRoot?: string): LocalSecurityResult;
23
+ export declare function localSecurityFixPlan(result: LocalSecurityResult, issueId?: string, requestedApply?: boolean): Record<string, unknown>;
@@ -0,0 +1,210 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { isSensitivePath } from './secret-policy.js';
5
+ import { WorkspaceBoundaryError } from './workspace-boundary.js';
6
+ const MAX_FILES = 5_000;
7
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
8
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
9
+ const SCANNABLE_EXTENSIONS = new Set([
10
+ '.cjs', '.css', '.go', '.html', '.htm', '.java', '.js', '.jsx', '.json',
11
+ '.mjs', '.php', '.ps1', '.py', '.rb', '.sh', '.sql', '.ts', '.tsx',
12
+ '.vue', '.xml', '.yaml', '.yml',
13
+ ]);
14
+ const EXCLUDED_DIRECTORIES = new Set([
15
+ '.git', '.hg', '.svn', '.vigthoria', 'build', 'coverage', 'dist',
16
+ 'node_modules', 'target', 'vendor', '__pycache__', '.venv', 'venv',
17
+ ]);
18
+ // These are intentionally high-confidence source patterns. The scanner does
19
+ // not classify ordinary bind addresses, DOM APIs, or configuration examples as
20
+ // vulnerabilities without the context required to prove exploitability.
21
+ const RULES = [
22
+ {
23
+ id: 'hardcoded-private-key',
24
+ severity: 'critical',
25
+ expression: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g,
26
+ message: 'Private key material is embedded in source',
27
+ },
28
+ {
29
+ id: 'hardcoded-access-token',
30
+ severity: 'critical',
31
+ expression: /\b(?:ghp|gho|ghu|ghs|github_pat|xox[baprs])_[A-Za-z0-9_-]{20,}\b/g,
32
+ message: 'A high-confidence access token is embedded in source',
33
+ },
34
+ {
35
+ id: 'hardcoded-cloud-key',
36
+ severity: 'critical',
37
+ expression: /\bAKIA[0-9A-Z]{16}\b/g,
38
+ message: 'An AWS access key identifier is embedded in source',
39
+ },
40
+ {
41
+ id: 'child-process-shell',
42
+ severity: 'high',
43
+ expression: /\b(?:exec|execSync)\s*\(\s*(?:`|[^'"\s][^,\n]*)/g,
44
+ message: 'A child process appears to execute dynamically constructed shell text',
45
+ },
46
+ {
47
+ id: 'dynamic-code-evaluation',
48
+ severity: 'high',
49
+ expression: /\b(?:eval|Function)\s*\(\s*(?!['"](?:use strict)?['"])/g,
50
+ message: 'Dynamic code evaluation requires manual security review',
51
+ },
52
+ {
53
+ id: 'unsafe-tls-disable',
54
+ severity: 'high',
55
+ expression: /\b(?:NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0|rejectUnauthorized\s*:\s*false)\b/g,
56
+ message: 'TLS certificate validation is explicitly disabled',
57
+ },
58
+ ];
59
+ function assertScanRoot(input) {
60
+ const resolved = path.resolve(input || process.cwd());
61
+ if (process.platform === 'win32' && /^(?:\\\\[?.]\\|\\\\(?!localhost\\))/i.test(resolved)) {
62
+ throw new WorkspaceBoundaryError('UNC and Windows device paths are not valid security scan roots', 'UNSAFE_WINDOWS_PATH');
63
+ }
64
+ const stat = fs.lstatSync(resolved);
65
+ if (stat.isSymbolicLink()) {
66
+ throw new WorkspaceBoundaryError('A symbolic link or junction cannot be used as a security scan root', 'WORKSPACE_LINK_ESCAPE');
67
+ }
68
+ if (!stat.isDirectory())
69
+ throw new WorkspaceBoundaryError('Security scan root must be a directory', 'WORKSPACE_PATH_INVALID');
70
+ return fs.realpathSync(resolved);
71
+ }
72
+ function stableIssueId(rule, file, line) {
73
+ return `SEC-${createHash('sha256').update(`${rule}\0${file}\0${line}`).digest('hex').slice(0, 12).toUpperCase()}`;
74
+ }
75
+ function lineAt(content, offset) {
76
+ let line = 1;
77
+ for (let index = 0; index < offset; index += 1)
78
+ if (content.charCodeAt(index) === 10)
79
+ line += 1;
80
+ return line;
81
+ }
82
+ function scoreFromIssues(issues) {
83
+ const penalty = { critical: 20, high: 10, medium: 4, low: 1 };
84
+ return Math.max(0, 100 - issues.reduce((sum, issue) => sum + penalty[issue.severity], 0));
85
+ }
86
+ function gradeFromScore(score) {
87
+ if (score >= 90)
88
+ return 'A';
89
+ if (score >= 75)
90
+ return 'B';
91
+ if (score >= 50)
92
+ return 'C';
93
+ if (score >= 30)
94
+ return 'D';
95
+ return 'F';
96
+ }
97
+ export function scanLocalWorkspace(inputRoot = process.cwd()) {
98
+ const rootDir = assertScanRoot(inputRoot);
99
+ const stack = [rootDir];
100
+ const issues = [];
101
+ let scannedFiles = 0;
102
+ let scannedBytes = 0;
103
+ let skippedFiles = 0;
104
+ while (stack.length > 0) {
105
+ const directory = stack.pop();
106
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
107
+ for (const entry of entries) {
108
+ const absolute = path.join(directory, entry.name);
109
+ const relative = path.relative(rootDir, absolute).split(path.sep).join('/');
110
+ if (entry.isSymbolicLink()) {
111
+ skippedFiles += 1;
112
+ continue;
113
+ }
114
+ if (entry.isDirectory()) {
115
+ if (!EXCLUDED_DIRECTORIES.has(entry.name) && !isSensitivePath(relative))
116
+ stack.push(absolute);
117
+ continue;
118
+ }
119
+ if (!entry.isFile()) {
120
+ skippedFiles += 1;
121
+ continue;
122
+ }
123
+ if (isSensitivePath(relative)) {
124
+ const basename = path.basename(relative).toLowerCase();
125
+ if (basename === '.env' || basename.startsWith('.env.')) {
126
+ issues.push({
127
+ id: stableIssueId('sensitive-env-file', relative, 1),
128
+ rule: 'sensitive-env-file',
129
+ severity: 'medium',
130
+ file: relative,
131
+ line: 1,
132
+ message: 'Sensitive environment file exists in the scanned tree; verify it is excluded from version control and uploads',
133
+ });
134
+ }
135
+ skippedFiles += 1;
136
+ continue;
137
+ }
138
+ if (!SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
139
+ continue;
140
+ if (scannedFiles >= MAX_FILES)
141
+ throw new Error(`Security scan file limit exceeded (${MAX_FILES})`);
142
+ const stat = fs.statSync(absolute);
143
+ if (stat.size > MAX_FILE_BYTES) {
144
+ skippedFiles += 1;
145
+ continue;
146
+ }
147
+ if (scannedBytes + stat.size > MAX_TOTAL_BYTES)
148
+ throw new Error(`Security scan byte limit exceeded (${MAX_TOTAL_BYTES})`);
149
+ const content = fs.readFileSync(absolute, 'utf8');
150
+ scannedFiles += 1;
151
+ scannedBytes += stat.size;
152
+ for (const rule of RULES) {
153
+ rule.expression.lastIndex = 0;
154
+ let match;
155
+ while ((match = rule.expression.exec(content)) !== null) {
156
+ const line = lineAt(content, match.index);
157
+ issues.push({
158
+ id: stableIssueId(rule.id, relative, line),
159
+ rule: rule.id,
160
+ severity: rule.severity,
161
+ file: relative,
162
+ line,
163
+ message: rule.message,
164
+ });
165
+ if (match[0].length === 0)
166
+ rule.expression.lastIndex += 1;
167
+ }
168
+ }
169
+ }
170
+ }
171
+ issues.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.rule.localeCompare(right.rule));
172
+ const score = scoreFromIssues(issues);
173
+ return {
174
+ rootDir,
175
+ scannedFiles,
176
+ scannedBytes,
177
+ skippedFiles,
178
+ score,
179
+ grade: gradeFromScore(score),
180
+ issueCount: issues.length,
181
+ issues,
182
+ bounded: true,
183
+ localOnly: true,
184
+ };
185
+ }
186
+ export function localSecurityFixPlan(result, issueId, requestedApply = false) {
187
+ const selected = issueId ? result.issues.filter((issue) => issue.id === issueId) : result.issues;
188
+ if (issueId && selected.length === 0)
189
+ throw new Error(`Security issue was not found in the current scan: ${issueId}`);
190
+ const actions = selected.slice(0, 100).map((issue) => ({
191
+ issue_id: issue.id,
192
+ file: issue.file,
193
+ line: issue.line,
194
+ rule: issue.rule,
195
+ action: issue.rule === 'sensitive-env-file'
196
+ ? 'Confirm the file is ignored and excluded from every upload/context boundary; rotate any exposed values'
197
+ : 'Review the exact source location and replace the unsafe construct without weakening validation or trust boundaries',
198
+ }));
199
+ return {
200
+ success: true,
201
+ applied: false,
202
+ requestedApply,
203
+ confirmRequired: false,
204
+ plannedActions: actions,
205
+ message: requestedApply
206
+ ? 'No mutation was performed: local security fixes require an explicit reviewed source patch.'
207
+ : 'Local security fix plan generated; no files were changed.',
208
+ localOnly: true,
209
+ };
210
+ }
@@ -1,4 +1,4 @@
1
- import { scanOutboundContext } from './secret-policy.js';
1
+ import { redactSensitiveText, scanOutboundContext } from './secret-policy.js';
2
2
  export class ModelTransportService {
3
3
  dependencies;
4
4
  lastErrors = [];
@@ -28,15 +28,75 @@ export class ModelTransportService {
28
28
  }
29
29
  async complete(systemPrompt, userPrompt, model, maxTokens) {
30
30
  const resolvedModel = model ? this.dependencies.resolvePermittedModelId(model) : 'Vigthoria-v4-Code-27B';
31
- const response = await this.dependencies.postModels({
31
+ const messages = scanOutboundContext([{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }]).value;
32
+ const body = {
32
33
  model: resolvedModel,
33
- messages: scanOutboundContext([{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }]).value,
34
+ messages,
34
35
  max_tokens: maxTokens || this.dependencies.getMaxTokens() || 4096,
35
36
  temperature: 0.3,
36
37
  stream: false,
37
- });
38
- const content = response?.choices?.[0]?.message?.content || response?.choices?.[0]?.text || '';
39
- return typeof content === 'string' ? content : '';
38
+ };
39
+ const failures = [];
40
+ const safeFailure = (value) => redactSensitiveText(String(value || 'transport failed')).slice(0, 120);
41
+ const extract = (response) => {
42
+ const content = response?.choices?.[0]?.message?.content
43
+ || response?.choices?.[0]?.text
44
+ || response?.response
45
+ || response?.message
46
+ || response?.content;
47
+ return response?.success === false || typeof content !== 'string' ? '' : content.trim();
48
+ };
49
+ if (!this.dependencies.shouldSkipCloudRoutes(resolvedModel) && !this.dependencies.isCloudModelId(resolvedModel)) {
50
+ try {
51
+ const content = extract(await this.dependencies.postModels(body));
52
+ if (content)
53
+ return content;
54
+ failures.push('models:empty response');
55
+ }
56
+ catch (error) {
57
+ failures.push(`models:${safeFailure(error?.response?.data?.error || error?.message)}`);
58
+ }
59
+ }
60
+ if (this.dependencies.isAuthenticated() && !this.dependencies.shouldSkipCloudRoutes(resolvedModel)) {
61
+ const coderBody = {
62
+ messages,
63
+ model: resolvedModel,
64
+ maxTokens: body.max_tokens,
65
+ temperature: body.temperature,
66
+ cloudConsent: this.dependencies.isCloudModelId(resolvedModel),
67
+ };
68
+ try {
69
+ const content = extract(await this.dependencies.postCoder(coderBody));
70
+ if (content)
71
+ return content;
72
+ failures.push('coder:empty response');
73
+ }
74
+ catch (error) {
75
+ failures.push(`coder:${safeFailure(error?.response?.data?.error || error?.message)}`);
76
+ }
77
+ try {
78
+ const content = extract(await this.dependencies.postCanonicalCoder(coderBody));
79
+ if (content)
80
+ return content;
81
+ failures.push('coder-canonical:empty response');
82
+ }
83
+ catch (error) {
84
+ failures.push(`coder-canonical:${safeFailure(error?.response?.data?.error || error?.message)}`);
85
+ }
86
+ }
87
+ if (this.dependencies.hasSelfHostedTransport() && this.dependencies.shouldTrySelfHosted(resolvedModel, model || resolvedModel)) {
88
+ try {
89
+ const content = extract(await this.dependencies.postSelfHosted(body));
90
+ if (content)
91
+ return content;
92
+ failures.push('self-hosted:empty response');
93
+ }
94
+ catch (error) {
95
+ failures.push(`self-hosted:${safeFailure(error?.message)}`);
96
+ }
97
+ }
98
+ this.lastErrors = failures;
99
+ throw new Error(`No permitted code-completion backend returned a response${failures.length ? ` (${failures.join(' | ')})` : ''}.`);
40
100
  }
41
101
  response(data, model, requested, prefix) {
42
102
  const content = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || data?.response || data?.message || data?.content;
@@ -1,6 +1,6 @@
1
1
  import type { AxiosInstance } from 'axios';
2
2
  export { redactSensitiveText } from './secret-policy.js';
3
- export type EndpointAudience = 'auto' | 'coder' | 'models' | 'hub' | 'community' | 'music' | 'brain-hub' | 'mcp' | 'v3' | 'template' | 'hyperloop' | 'vigflow' | 'bridge' | 'release' | 'public';
3
+ export type EndpointAudience = 'auto' | 'coder' | 'models' | 'operator' | 'hub' | 'community' | 'music' | 'brain-hub' | 'mcp' | 'v3' | 'template' | 'hyperloop' | 'vigflow' | 'bridge' | 'release' | 'public';
4
4
  export declare const DEFAULT_NETWORK_TIMEOUT_MS = 30000;
5
5
  export declare class NetworkPolicyError extends Error {
6
6
  readonly code: 'offline' | 'untrusted_endpoint' | 'credential_audience' | 'unsafe_redirect';
@@ -3,7 +3,8 @@ export { redactSensitiveText } from './secret-policy.js';
3
3
  const AUDIENCE_HOSTS = {
4
4
  coder: new Set(['coder.vigthoria.io']),
5
5
  models: new Set(['api.vigthoria.io']),
6
- hub: new Set(['api.vigthoria.io']),
6
+ operator: new Set(['agent.vigthoria.io', 'operator.vigthoria.io']),
7
+ hub: new Set(['hub.vigthoria.io']),
7
8
  community: new Set(['community.vigthoria.io']),
8
9
  music: new Set(['music.vigthoria.io']),
9
10
  'brain-hub': new Set(['coder.vigthoria.io']),
@@ -11,7 +12,7 @@ const AUDIENCE_HOSTS = {
11
12
  v3: new Set(['coder.vigthoria.io']),
12
13
  template: new Set(['coder.vigthoria.io', 'template.vigthoria.io']),
13
14
  hyperloop: new Set(['coder.vigthoria.io', 'hyperloop.vigthoria.io']),
14
- vigflow: new Set(['coder.vigthoria.io', 'vigflow.vigthoria.io']),
15
+ vigflow: new Set(['coder.vigthoria.io', 'workflow.vigthoria.io']),
15
16
  bridge: new Set(['bridge.vigthoria.io', 'devtools.vigthoria.io']),
16
17
  release: new Set([
17
18
  'extension.vigthoria.io',
@@ -5,11 +5,52 @@ export type PreviewScreenshotResult = {
5
5
  export interface PreviewScreenshotPort {
6
6
  capture(entryAbsolutePath: string, screenshotPath: string): Promise<PreviewScreenshotResult>;
7
7
  }
8
+ type BrowserPage = {
9
+ setViewport(options: {
10
+ width: number;
11
+ height: number;
12
+ deviceScaleFactor: number;
13
+ }): Promise<void>;
14
+ goto(url: string, options: {
15
+ waitUntil: string;
16
+ timeout: number;
17
+ }): Promise<void>;
18
+ screenshot(options: {
19
+ path: string;
20
+ fullPage: boolean;
21
+ }): Promise<unknown>;
22
+ };
23
+ type Browser = {
24
+ newPage(): Promise<BrowserPage>;
25
+ close(): Promise<void>;
26
+ process?(): {
27
+ kill(signal?: NodeJS.Signals): boolean;
28
+ } | null;
29
+ };
30
+ type PuppeteerLike = {
31
+ executablePath?(): string;
32
+ launch(options: {
33
+ headless: true | 'shell';
34
+ args: string[];
35
+ userDataDir: string;
36
+ executablePath?: string;
37
+ timeout: number;
38
+ protocolTimeout: number;
39
+ }): Promise<Browser>;
40
+ };
41
+ export type PreviewBrowserResolution = {
42
+ executablePath?: string;
43
+ headless: true | 'shell';
44
+ source: 'packaged' | 'system' | 'injected';
45
+ };
46
+ export declare function resolvePreviewBrowserExecutable(puppeteer: PuppeteerLike, environment?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (candidate: string) => boolean): PreviewBrowserResolution | null;
8
47
  export declare class OptionalPuppeteerScreenshotAdapter implements PreviewScreenshotPort {
9
48
  private readonly loadPuppeteer;
10
49
  private readonly environment;
11
50
  private readonly allocateTemp;
12
51
  private readonly releaseTemp;
13
- constructor(loadPuppeteer?: () => Promise<unknown>, environment?: NodeJS.ProcessEnv, allocateTemp?: (prefix: string) => string, releaseTemp?: (directory: string) => void);
52
+ private readonly platform;
53
+ constructor(loadPuppeteer?: () => Promise<unknown>, environment?: NodeJS.ProcessEnv, allocateTemp?: (prefix: string) => string, releaseTemp?: (directory: string) => void, platform?: NodeJS.Platform);
14
54
  capture(entryAbsolutePath: string, screenshotPath: string): Promise<PreviewScreenshotResult>;
15
55
  }
56
+ export {};
@@ -1,16 +1,54 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
2
4
  import { redactSensitiveText } from './secret-policy.js';
3
5
  import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from './runtime-temp.js';
6
+ export function resolvePreviewBrowserExecutable(puppeteer, environment = process.env, platform = process.platform, exists = fs.existsSync) {
7
+ if (typeof puppeteer.executablePath !== 'function')
8
+ return { headless: 'shell', source: 'injected' };
9
+ try {
10
+ const packaged = puppeteer.executablePath();
11
+ if (packaged && exists(packaged))
12
+ return { executablePath: packaged, headless: 'shell', source: 'packaged' };
13
+ }
14
+ catch {
15
+ // Continue to trusted platform browser locations.
16
+ }
17
+ if (platform !== 'win32')
18
+ return null;
19
+ const bases = [environment['PROGRAMFILES(X86)'], environment.ProgramFiles, environment.LOCALAPPDATA]
20
+ .filter((value) => typeof value === 'string' && value.length > 0);
21
+ const relatives = [
22
+ ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
23
+ ['Google', 'Chrome', 'Application', 'chrome.exe'],
24
+ ];
25
+ for (const base of bases) {
26
+ for (const relative of relatives) {
27
+ const candidate = path.win32.join(base, ...relative);
28
+ if (exists(candidate))
29
+ return { executablePath: candidate, headless: true, source: 'system' };
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ function withTimeout(promise, timeoutMs, label) {
35
+ return new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
37
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
38
+ });
39
+ }
4
40
  export class OptionalPuppeteerScreenshotAdapter {
5
41
  loadPuppeteer;
6
42
  environment;
7
43
  allocateTemp;
8
44
  releaseTemp;
9
- constructor(loadPuppeteer = () => import('puppeteer'), environment = process.env, allocateTemp = createRuntimeTempDirectory, releaseTemp = removeRuntimeTempDirectory) {
45
+ platform;
46
+ constructor(loadPuppeteer = () => import('puppeteer'), environment = process.env, allocateTemp = createRuntimeTempDirectory, releaseTemp = removeRuntimeTempDirectory, platform = process.platform) {
10
47
  this.loadPuppeteer = loadPuppeteer;
11
48
  this.environment = environment;
12
49
  this.allocateTemp = allocateTemp;
13
50
  this.releaseTemp = releaseTemp;
51
+ this.platform = platform;
14
52
  }
15
53
  async capture(entryAbsolutePath, screenshotPath) {
16
54
  if (this.environment.VIGTHORIA_DISABLE_PREVIEW_SCREENSHOT === '1') {
@@ -20,26 +58,40 @@ export class OptionalPuppeteerScreenshotAdapter {
20
58
  const loaded = await this.loadPuppeteer().catch(() => null);
21
59
  const puppeteer = (loaded?.default || loaded);
22
60
  if (!puppeteer || typeof puppeteer.launch !== 'function') {
23
- return { captured: false, error: 'puppeteer optional adapter is not installed' };
61
+ return { captured: false, error: 'required Puppeteer screenshot runtime is not installed' };
62
+ }
63
+ const browserResolution = resolvePreviewBrowserExecutable(puppeteer, this.environment, this.platform);
64
+ if (!browserResolution) {
65
+ return { captured: false, error: 'required browser executable is unavailable; install Chrome/Edge or the packaged Puppeteer browser' };
24
66
  }
25
67
  const browserProfile = this.allocateTemp('browser-');
68
+ let browser = null;
26
69
  try {
27
- const browser = await puppeteer.launch({
28
- headless: true,
70
+ browser = await withTimeout(puppeteer.launch({
71
+ headless: browserResolution.headless,
29
72
  args: ['--no-sandbox', '--disable-setuid-sandbox'],
30
73
  userDataDir: browserProfile,
31
- });
32
- try {
33
- const page = await browser.newPage();
34
- await page.setViewport({ width: 1440, height: 960, deviceScaleFactor: 1 });
35
- await page.goto(pathToFileURL(entryAbsolutePath).toString(), { waitUntil: 'networkidle0', timeout: 20_000 });
36
- await page.screenshot({ path: screenshotPath, fullPage: true });
37
- }
38
- finally {
39
- await browser.close();
40
- }
74
+ executablePath: browserResolution.executablePath,
75
+ timeout: 20_000,
76
+ protocolTimeout: 20_000,
77
+ }), 25_000, 'browser launch');
78
+ const page = await withTimeout(browser.newPage(), 10_000, 'browser page creation');
79
+ await withTimeout(page.setViewport({ width: 800, height: 600, deviceScaleFactor: 1 }), 10_000, 'browser viewport setup');
80
+ await page.goto(pathToFileURL(entryAbsolutePath).toString(), { waitUntil: 'networkidle0', timeout: 20_000 });
81
+ await withTimeout(page.screenshot({ path: screenshotPath, fullPage: false }), 20_000, 'screenshot capture');
41
82
  }
42
83
  finally {
84
+ if (browser) {
85
+ try {
86
+ await withTimeout(browser.close(), 5_000, 'browser shutdown');
87
+ }
88
+ catch {
89
+ try {
90
+ browser.process?.()?.kill('SIGKILL');
91
+ }
92
+ catch { /* exact owned browser only */ }
93
+ }
94
+ }
43
95
  this.releaseTemp(browserProfile);
44
96
  }
45
97
  return { captured: true };
@@ -44,6 +44,7 @@ export declare class RuntimeTempManager {
44
44
  private readonly pid;
45
45
  private readonly isProcessAlive;
46
46
  private readonly systemTempRoot;
47
+ private readonly sharedTempRoots;
47
48
  private readonly configuredRoot;
48
49
  private readonly source;
49
50
  private readonly maxBytes;