vigthoria-cli 1.13.25 → 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.
@@ -2931,15 +2931,24 @@ export class ChatCommand {
2931
2931
  if (this.streamState.streamingStarted && !this.streamState.streamedAnswerDisplayed) {
2932
2932
  this.displayV3StreamedAnswer();
2933
2933
  }
2934
- const previewGate = (response.metadata?.previewGate || null);
2934
+ const serverPreviewGate = (response.metadata?.previewGate || null);
2935
2935
  const workspaceHasOutput = this.api.hasAgentWorkspaceOutput(workspaceContext);
2936
2936
  const changedFileCount = response.changedFiles ? Object.keys(response.changedFiles).length : 0;
2937
2937
  const requiresWorkspaceChanges = taskRequiresWorkspaceChangesWithContext(contextualPrompt, intentContext);
2938
+ const previewGate = requiresWorkspaceChanges
2939
+ ? serverPreviewGate
2940
+ : { required: false, passed: true, skipped: false };
2938
2941
  const answerContent = normalizeAgentAnswerContent(response.content, this.streamState.streamedAnswerBuffer);
2939
2942
  liveOutcome.changedFileCount = changedFileCount;
2940
2943
  liveOutcome.requiresWorkspaceChanges = requiresWorkspaceChanges;
2941
2944
  liveOutcome.workspaceHasOutput = requiresWorkspaceChanges ? workspaceHasOutput : false;
2942
2945
  liveOutcome.answerContent = answerContent;
2946
+ const workflowError = response.metadata?.workflowError;
2947
+ if (workflowError && !liveOutcome.executorError) {
2948
+ liveOutcome.executorError = typeof workflowError === 'string'
2949
+ ? workflowError
2950
+ : String(workflowError.message || 'Agent execution failed.');
2951
+ }
2943
2952
  const success = previewGate?.required === true
2944
2953
  ? previewGate?.passed === true && previewGate?.skipped !== true
2945
2954
  : true;
@@ -39,7 +39,11 @@ export function gameProcessInvocation(pm, args, platform = process.platform, com
39
39
  category: 'usage',
40
40
  });
41
41
  }
42
- const command = [executable, ...args].map((token) => `"${token}"`).join(' ');
42
+ // Every token is already restricted to an inert alphabet above. Passing the
43
+ // tokens without embedded quotes is important: on Windows, cmd.exe can hand
44
+ // the quote characters to the .cmd shim as part of argv[0], producing the
45
+ // observed literal `"npm.cmd"` launch failure.
46
+ const command = [executable, ...args].join(' ');
43
47
  return { executable: comSpec, args: ['/d', '/s', '/c', command] };
44
48
  }
45
49
  const run = (pm, args, cwd, timeoutMs = 10 * 60_000) => new Promise((resolve, reject) => { const invocation = gameProcessInvocation(pm, args); const child = spawn(invocation.executable, invocation.args, { cwd, stdio: 'inherit', windowsHide: true, env: safeChildProcessEnv() }); let interrupted = false; const onInterrupt = () => { interrupted = true; child.kill('SIGINT'); }; process.once('SIGINT', onInterrupt); const timer = timeoutMs > 0 ? setTimeout(() => { child.kill('SIGTERM'); reject(new CliCommandError(`${pm} command timed out after ${timeoutMs}ms`, { code: 'GAME_PROCESS_TIMEOUT' })); }, timeoutMs) : null; const finish = () => { if (timer)
@@ -20,6 +20,7 @@ interface PreviewOptions {
20
20
  proof?: boolean;
21
21
  screenshot?: boolean;
22
22
  }
23
+ export declare function isPreviewProofRequested(options: Pick<PreviewOptions, 'proof' | 'screenshot'>): boolean;
23
24
  export declare class PreviewCommand {
24
25
  private logger;
25
26
  private api;
@@ -51,6 +51,9 @@ const MIME_TYPES = {
51
51
  '.txt': 'text/plain',
52
52
  '.map': 'application/json',
53
53
  };
54
+ export function isPreviewProofRequested(options) {
55
+ return options.proof === true || options.screenshot === true;
56
+ }
54
57
  export class PreviewCommand {
55
58
  logger;
56
59
  api;
@@ -76,11 +79,17 @@ export class PreviewCommand {
76
79
  await this.showConsolidatedDiff(projectPath);
77
80
  }
78
81
  // Run Template Service preview proof gate
79
- if (options.proof) {
80
- await this.runProofGate(projectPath, options.screenshot);
82
+ if (isPreviewProofRequested(options)) {
83
+ try {
84
+ await this.runProofGate(projectPath, options.screenshot);
85
+ }
86
+ catch (error) {
87
+ this.api.destroy();
88
+ throw error;
89
+ }
81
90
  }
82
91
  // If only --diff or --proof was requested (no explicit entry/port), just exit
83
- if (options.diff || options.proof) {
92
+ if (options.diff || isPreviewProofRequested(options)) {
84
93
  if (!options.entry && !options.port) {
85
94
  this.api.destroy();
86
95
  return;
@@ -90,7 +99,7 @@ export class PreviewCommand {
90
99
  const entryFile = this.detectEntryFile(projectPath, options.entry);
91
100
  if (!entryFile) {
92
101
  this.logger.warn('No HTML entry file found. Use --entry <file> to specify one.');
93
- if (!options.diff && !options.proof) {
102
+ if (!options.diff && !options.proof && !options.screenshot) {
94
103
  this.api.destroy();
95
104
  throw new CliCommandError('No HTML entry file found. Use --entry <file> to specify one.', {
96
105
  code: 'PREVIEW_ENTRY_NOT_FOUND', category: 'configuration',
@@ -162,7 +171,7 @@ export class PreviewCommand {
162
171
  * Start a local HTTP server for preview
163
172
  */
164
173
  async startServer(projectPath, entryFile, port, autoOpen) {
165
- return new Promise((resolve) => {
174
+ return new Promise((resolve, reject) => {
166
175
  this.server = http.createServer((req, res) => {
167
176
  let filePath;
168
177
  try {
@@ -210,6 +219,20 @@ export class PreviewCommand {
210
219
  res.end('Internal server error');
211
220
  }
212
221
  });
222
+ const finish = (error) => {
223
+ process.removeListener('SIGINT', shutdown);
224
+ process.removeListener('SIGTERM', shutdown);
225
+ this.api.destroy();
226
+ this.server = null;
227
+ error ? reject(error) : resolve();
228
+ };
229
+ const shutdown = () => {
230
+ console.log(chalk.gray('\n Stopping preview server...'));
231
+ if (!this.server)
232
+ return finish();
233
+ this.server.close((error) => finish(error || undefined));
234
+ };
235
+ this.server.once('error', finish);
213
236
  this.server.listen(port, () => {
214
237
  const url = `http://localhost:${port}/${entryFile}`;
215
238
  console.log(chalk.green(` ${CH.success} Preview server running`));
@@ -221,15 +244,8 @@ export class PreviewCommand {
221
244
  this.openBrowser(url);
222
245
  }
223
246
  });
224
- // Handle graceful shutdown
225
- const shutdown = () => {
226
- console.log(chalk.gray('\n Stopping preview server...'));
227
- this.server?.close();
228
- this.api.destroy();
229
- resolve();
230
- };
231
- process.on('SIGINT', shutdown);
232
- process.on('SIGTERM', shutdown);
247
+ process.once('SIGINT', shutdown);
248
+ process.once('SIGTERM', shutdown);
233
249
  });
234
250
  }
235
251
  /**
@@ -14,6 +14,22 @@
14
14
  */
15
15
  import { Config } from '../utils/config.js';
16
16
  import { Logger } from '../utils/logger.js';
17
+ interface Project {
18
+ id: number;
19
+ project_name: string;
20
+ project_path: string;
21
+ description: string;
22
+ tech_stack: string;
23
+ visibility: 'public' | 'private' | 'restricted';
24
+ is_public: boolean;
25
+ demo_url: string | null;
26
+ repo_url: string | null;
27
+ status: string;
28
+ storage_usage_mb: number;
29
+ created_at: string;
30
+ updated_at: string;
31
+ last_synced_at: string | null;
32
+ }
17
33
  interface PushOptions {
18
34
  path?: string;
19
35
  name?: string;
@@ -34,6 +50,25 @@ interface ListOptions {
34
50
  interface ShareOptions {
35
51
  expires?: string;
36
52
  }
53
+ interface RepositoryMaterialization {
54
+ project?: Project;
55
+ projectName?: string;
56
+ description?: string;
57
+ downloadUrl?: string;
58
+ inlineComplete?: boolean;
59
+ files?: Array<{
60
+ path: string;
61
+ content?: string;
62
+ encoding?: 'utf8' | 'base64';
63
+ size?: number;
64
+ binary?: boolean;
65
+ }>;
66
+ }
67
+ export declare function isCompleteRepositoryInlinePayload(data: RepositoryMaterialization): boolean;
68
+ export declare function resolveRepositoryDownloadRoute(rawUrl: string, coderApiBase: string, publicDownload?: boolean): {
69
+ url: string;
70
+ audience: 'coder' | 'community';
71
+ };
37
72
  export declare class RepositoryMutationAmbiguousError extends Error {
38
73
  readonly operationId: string;
39
74
  readonly mutationOutcome = "unknown";
@@ -48,8 +83,6 @@ export declare class RepoCommand {
48
83
  private communityToken;
49
84
  constructor(config: Config, _logger: Logger);
50
85
  private getAuthHeaders;
51
- private getRepositoryAudienceForUrl;
52
- private getAuthHeadersForUrl;
53
86
  private ensureCommunityAuth;
54
87
  private repoFetch;
55
88
  private collectProjectFiles;
@@ -23,6 +23,35 @@ import { CliCommandError, commandFailure, formatSuccessJson } from '../utils/com
23
23
  import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from '../utils/runtime-temp.js';
24
24
  import { assertSafeDestinationPath, assertSafeRelativePath, inspectZipArchive, mergeValidatedWorkspace, resolveWorkspacePath, validateExtractedWorkspace, } from '../utils/workspace-boundary.js';
25
25
  import inquirer from 'inquirer';
26
+ export function isCompleteRepositoryInlinePayload(data) {
27
+ if (!Array.isArray(data.files) || data.files.length === 0 || data.inlineComplete === false)
28
+ return false;
29
+ return data.files.every((file) => typeof file?.path === 'string'
30
+ && typeof file?.content === 'string'
31
+ && (file.encoding === undefined || file.encoding === 'utf8' || file.encoding === 'base64')
32
+ && file.binary !== true);
33
+ }
34
+ export function resolveRepositoryDownloadRoute(rawUrl, coderApiBase, publicDownload = false) {
35
+ const parsed = new URL(rawUrl);
36
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) {
37
+ throw new Error('Repository download URL is not an exact trusted HTTPS origin');
38
+ }
39
+ if (parsed.hostname.toLowerCase() === 'coder.vigthoria.io')
40
+ return { url: parsed.href, audience: 'coder' };
41
+ if (parsed.hostname.toLowerCase() !== 'community.vigthoria.io') {
42
+ throw new Error('Repository download URL is outside the Coder and Community trust boundary');
43
+ }
44
+ if (publicDownload)
45
+ return { url: parsed.href, audience: 'community' };
46
+ const match = parsed.pathname.match(/^\/api\/showcase\/(\d+)\/download$/);
47
+ if (!match || parsed.search || parsed.hash) {
48
+ throw new Error('Authenticated repository archive URL has an unsupported path');
49
+ }
50
+ return {
51
+ url: `${coderApiBase.replace(/\/$/, '')}/api/community-repo/archive/${encodeURIComponent(match[1])}`,
52
+ audience: 'coder',
53
+ };
54
+ }
26
55
  const MAX_REPOSITORY_CONTENT_BYTES = 100 * 1024 * 1024;
27
56
  const MAX_REPOSITORY_REQUEST_BYTES = 120 * 1024 * 1024;
28
57
  class RepositoryApiError extends Error {
@@ -87,17 +116,6 @@ export class RepoCommand {
87
116
  'Content-Type': 'application/json',
88
117
  };
89
118
  }
90
- getRepositoryAudienceForUrl(rawUrl) {
91
- const hostname = new URL(rawUrl).hostname.toLowerCase();
92
- if (hostname === 'coder.vigthoria.io')
93
- return 'coder';
94
- if (hostname === 'community.vigthoria.io')
95
- return 'community';
96
- throw new Error('Repository download URL is outside the Coder and Community trust boundary');
97
- }
98
- getAuthHeadersForUrl(rawUrl) {
99
- return this.getAuthHeaders(this.getRepositoryAudienceForUrl(rawUrl));
100
- }
101
119
  async ensureCommunityAuth() {
102
120
  if (this.communityToken) {
103
121
  return;
@@ -596,12 +614,38 @@ export class RepoCommand {
596
614
  try {
597
615
  if (fs.existsSync(outputPath))
598
616
  validateExtractedWorkspace(outputPath);
599
- if (data.downloadUrl) {
600
- const archiveResponse = await guardedFetch(data.downloadUrl, {
617
+ if (isCompleteRepositoryInlinePayload(data)) {
618
+ const stagingRoot = createRuntimeTempDirectory('repo-inline-');
619
+ try {
620
+ let totalBytes = 0;
621
+ for (const file of data.files) {
622
+ const filePath = resolveWorkspacePath(stagingRoot, file.path, { allowMissing: true });
623
+ const content = file.encoding === 'base64'
624
+ ? Buffer.from(file.content, 'base64')
625
+ : Buffer.from(file.content, 'utf8');
626
+ if (typeof file.size === 'number' && file.size !== content.byteLength) {
627
+ throw new Error(`Repository file size mismatch: ${file.path}`);
628
+ }
629
+ totalBytes += content.byteLength;
630
+ if (totalBytes > MAX_REPOSITORY_CONTENT_BYTES)
631
+ throw new Error('Repository inline payload exceeds the materialization limit');
632
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
633
+ fs.writeFileSync(filePath, content, { mode: 0o600 });
634
+ }
635
+ validateExtractedWorkspace(stagingRoot);
636
+ mergeValidatedWorkspace(stagingRoot, outputPath);
637
+ }
638
+ finally {
639
+ removeRuntimeTempDirectory(stagingRoot);
640
+ }
641
+ }
642
+ else if (data.downloadUrl) {
643
+ const route = resolveRepositoryDownloadRoute(data.downloadUrl, this.apiBase, publicDownload);
644
+ const archiveResponse = await guardedFetch(route.url, {
601
645
  headers: publicDownload
602
646
  ? { 'Content-Type': 'application/json' }
603
- : this.getAuthHeadersForUrl(data.downloadUrl)
604
- }, { audience: this.getRepositoryAudienceForUrl(data.downloadUrl) });
647
+ : this.getAuthHeaders(route.audience)
648
+ }, { audience: route.audience });
605
649
  if (!archiveResponse.ok)
606
650
  throw new Error('Failed to download project archive');
607
651
  const archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
@@ -638,23 +682,8 @@ export class RepoCommand {
638
682
  removeRuntimeTempDirectory(stagingRoot);
639
683
  }
640
684
  }
641
- else if (data.files) {
642
- const stagingRoot = createRuntimeTempDirectory('repo-inline-');
643
- try {
644
- for (const file of data.files) {
645
- const filePath = resolveWorkspacePath(stagingRoot, file.path, { allowMissing: true });
646
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
647
- fs.writeFileSync(filePath, file.content, { mode: 0o600 });
648
- }
649
- validateExtractedWorkspace(stagingRoot);
650
- mergeValidatedWorkspace(stagingRoot, outputPath);
651
- }
652
- finally {
653
- removeRuntimeTempDirectory(stagingRoot);
654
- }
655
- }
656
685
  else {
657
- throw new Error('Repository response contained neither an archive nor inline files');
686
+ throw new Error('Repository response contained neither a complete inline payload nor an archive');
658
687
  }
659
688
  downloadSpinner.succeed(chalk.green('Project materialized successfully!'));
660
689
  console.log(chalk.cyan('\n📁 Project extracted to:'));
@@ -891,7 +920,8 @@ export class RepoCommand {
891
920
  const spinner = createSpinner('Deleting project...').start();
892
921
  try {
893
922
  const operationId = createOperationId();
894
- const response = await this.repoFetch(`/api/repo/projects/${encodeURIComponent(projectName)}`, {
923
+ const repo = await this.resolveRepoByName(projectName);
924
+ const response = await this.repoFetch(`/api/repo/projects/${encodeURIComponent(String(repo.id))}`, {
895
925
  method: 'DELETE',
896
926
  headers: { 'X-Vigthoria-Operation-Id': operationId },
897
927
  }, { operationId });
@@ -252,7 +252,7 @@ export function registerUpdateCommand(program, version) {
252
252
  if (!signatureResponse.ok)
253
253
  throw new Error(`Detached signature returned HTTP ${signatureResponse.status}`);
254
254
  verifyReleaseSignature(channel, manifestEntry, await signatureResponse.text());
255
- assertReleaseTransition(currentVersion, manifestEntry, channel, allowDowngrade);
255
+ console.log(chalk.gray(`Verified ${channel} manifest: version=${manifestEntry.version} package=${manifestEntry.packageName}@${manifestEntry.packageVersion} size=${manifestEntry.size} sha256=${manifestEntry.sha256}`));
256
256
  }
257
257
  catch (error) {
258
258
  if (error instanceof CliCommandError)
@@ -303,6 +303,7 @@ export function registerUpdateCommand(program, version) {
303
303
  && compareVersions(manifestEntry.version, effectiveLatest) >= 0
304
304
  && compareVersions(manifestEntry.version, currentVersion) > 0);
305
305
  if (manifestIsAuthoritative && manifestEntry) {
306
+ assertReleaseTransition(currentVersion, manifestEntry, channel, allowDowngrade);
306
307
  const updateTempDirectory = createRuntimeTempDirectory('update-');
307
308
  const tmpFile = path.join(updateTempDirectory, 'candidate.tgz');
308
309
  try {
package/dist/utils/api.js CHANGED
@@ -15,7 +15,7 @@ import { buildSemanticContext } from './context-ranker.js';
15
15
  import { getChangedFiles } from './workspace-cache.js';
16
16
  import { isSubstantiveAgentAnswer, isToolEvidenceStubAnswer } from './agentRunOutcome.js';
17
17
  import { buildExecutionHints, isPlannerBuildTask, resolvePlannerAgentTimeoutMs } from './requestIntent.js';
18
- import { isV3StreamKeepaliveEvent } from './v3-stream-events.js';
18
+ import { assertValidAgentPlanEvent, isV3StreamKeepaliveEvent } from './v3-stream-events.js';
19
19
  import { resolveV3ContextCharLimit, WORKSPACE_FILE_CHAR_CAP, } from './contextBudget.js';
20
20
  import { isLocalTestfarmMode, fetchWithServiceTimeout } from './localTestMode.js';
21
21
  import { buildClientManifest } from './clientManifest.js';
@@ -710,13 +710,10 @@ export class APIClient {
710
710
  return this.v3AgentClient.approvalUrl(baseUrl);
711
711
  }
712
712
  getOperatorBaseUrls() {
713
- const configuredModelsApiUrl = String(this.config.get('modelsApiUrl') || 'https://api.vigthoria.io').replace(/\/$/, '');
714
713
  const urls = [
715
714
  process.env.VIGTHORIA_OPERATOR_URL,
716
715
  process.env.OPERATOR_URL,
717
- 'http://127.0.0.1:4009',
718
- configuredModelsApiUrl,
719
- 'https://api.vigthoria.io',
716
+ 'https://agent.vigthoria.io',
720
717
  ].filter(Boolean).map((url) => String(url).replace(/\/$/, ''));
721
718
  return [...new Set(urls)];
722
719
  }
@@ -3109,6 +3106,7 @@ document.addEventListener('DOMContentLoaded', () => {
3109
3106
  if (explicitEventType && (!event.type || event.type === 'message')) {
3110
3107
  event.type = explicitEventType;
3111
3108
  }
3109
+ assertValidAgentPlanEvent(event);
3112
3110
  const userEvent = this.sanitizeV3AgentEventForUser(event);
3113
3111
  events.push(userEvent);
3114
3112
  if (!contextId && typeof event.context_id === 'string' && event.context_id.trim()) {
@@ -3360,12 +3358,15 @@ document.addEventListener('DOMContentLoaded', () => {
3360
3358
  // A pre-existing local project is not proof that this run produced
3361
3359
  // valid output. A terminal planner/executor error fails proof and
3362
3360
  // causes the operation journal to roll back streamed mutations.
3363
- const failedPreviewGate = {
3364
- required: true,
3365
- passed: false,
3366
- skipped: true,
3367
- error: 'Agent execution failed before preview validation.',
3368
- };
3361
+ const analysisOnly = this.isAnalysisOnlyTask(message, executionContext);
3362
+ const failedPreviewGate = analysisOnly
3363
+ ? { required: false, passed: true, skipped: false }
3364
+ : {
3365
+ required: true,
3366
+ passed: false,
3367
+ skipped: true,
3368
+ error: 'Agent execution failed before preview validation.',
3369
+ };
3369
3370
  return this.finalizeV3AgentWorkflowResponse(data, {
3370
3371
  content: '',
3371
3372
  taskId: data.task_id || null,
@@ -3,7 +3,7 @@ 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
- operator: new Set(['api.vigthoria.io', 'operator.vigthoria.io']),
6
+ operator: new Set(['agent.vigthoria.io', 'operator.vigthoria.io']),
7
7
  hub: new Set(['hub.vigthoria.io']),
8
8
  community: new Set(['community.vigthoria.io']),
9
9
  music: new Set(['music.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') {
@@ -22,24 +60,38 @@ export class OptionalPuppeteerScreenshotAdapter {
22
60
  if (!puppeteer || typeof puppeteer.launch !== 'function') {
23
61
  return { captured: false, error: 'required Puppeteer screenshot runtime is not installed' };
24
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' };
66
+ }
25
67
  const browserProfile = this.allocateTemp('browser-');
68
+ let browser = null;
26
69
  try {
27
- const browser = await puppeteer.launch({
28
- headless: 'shell',
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;
@@ -99,6 +99,7 @@ export class RuntimeTempManager {
99
99
  pid;
100
100
  isProcessAlive;
101
101
  systemTempRoot;
102
+ sharedTempRoots;
102
103
  configuredRoot;
103
104
  source;
104
105
  maxBytes;
@@ -121,7 +122,18 @@ export class RuntimeTempManager {
121
122
  this.now = options.now || Date.now;
122
123
  this.pid = options.pid || process.pid;
123
124
  this.isProcessAlive = options.isProcessAlive || defaultProcessAlive;
124
- this.systemTempRoot = options.systemTempDirectory || os.tmpdir();
125
+ const platformTemp = this.platform === 'win32'
126
+ ? String(this.environment.TEMP || this.environment.TMP || '').trim()
127
+ : String(this.environment.TMPDIR || '').trim();
128
+ this.systemTempRoot = options.systemTempDirectory || platformTemp || os.tmpdir();
129
+ this.sharedTempRoots = [
130
+ options.systemTempDirectory,
131
+ platformTemp,
132
+ this.environment.TEMP,
133
+ this.environment.TMP,
134
+ this.environment.TMPDIR,
135
+ this.platform === process.platform ? os.tmpdir() : undefined,
136
+ ].filter((value) => typeof value === 'string' && value.trim().length > 0);
125
137
  const resolved = resolveRuntimeTempRoot({ ...options, environment: this.environment, platform: this.platform, homeDirectory: this.homeDirectory });
126
138
  this.configuredRoot = resolved.root;
127
139
  this.source = resolved.source;
@@ -134,6 +146,14 @@ export class RuntimeTempManager {
134
146
  if (this.initializedRoot || this.initializationError)
135
147
  return this.status();
136
148
  try {
149
+ // Reject the shared OS temp path before touching the filesystem. This is
150
+ // also what makes cross-platform policy probes deterministic: a Windows
151
+ // policy instance running in a non-Windows test host must not attempt to
152
+ // create a drive-letter path before it can reject `%TEMP%`.
153
+ if (this.sharedTempRoots.some((root) => pathsEqual(this.configuredRoot, root, this.platform))
154
+ || (this.platform !== 'win32' && isKnownSharedPosixTemp(this.configuredRoot))) {
155
+ throw new RuntimeTempError('The shared operating-system temporary directory cannot be used for Vigthoria runtime storage.', 'TEMP_ROOT_SHARED');
156
+ }
137
157
  if (this.source === 'per-user-default' && !fs.existsSync(this.homeDirectory)) {
138
158
  throw new RuntimeTempError('The user home directory does not exist; managed temporary storage cannot be initialized.', 'TEMP_HOME_UNAVAILABLE');
139
159
  }
@@ -154,7 +174,7 @@ export class RuntimeTempManager {
154
174
  }
155
175
  }
156
176
  const pathApi = this.platform === 'win32' ? path.win32 : path.posix;
157
- if (pathsEqual(realRoot, this.systemTempRoot, this.platform)
177
+ if (this.sharedTempRoots.some((root) => pathsEqual(realRoot, root, this.platform))
158
178
  || (this.platform !== 'win32' && isKnownSharedPosixTemp(realRoot))) {
159
179
  throw new RuntimeTempError('The shared operating-system temporary directory cannot be used for Vigthoria runtime storage.', 'TEMP_ROOT_SHARED');
160
180
  }
@@ -3,3 +3,11 @@
3
3
  * connections alive — not substantive work the CLI should surface as progress.
4
4
  */
5
5
  export declare function isV3StreamKeepaliveEvent(event: unknown): boolean;
6
+ export declare class AgentPlanContractError extends Error {
7
+ readonly code = "AGENT_PLAN_INVALID";
8
+ constructor(message: string);
9
+ }
10
+ /** Validate a server-authored execution graph before any task can be credited.
11
+ * A cyclic, duplicate, self-referencing, or dangling graph is never runnable
12
+ * and must not reach the UI callback where it could later be counted as 2/2. */
13
+ export declare function assertValidAgentPlanEvent(event: unknown): void;
@@ -39,3 +39,65 @@ export function isV3StreamKeepaliveEvent(event) {
39
39
  }
40
40
  return false;
41
41
  }
42
+ export class AgentPlanContractError extends Error {
43
+ code = 'AGENT_PLAN_INVALID';
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'AgentPlanContractError';
47
+ }
48
+ }
49
+ function dependencyId(value) {
50
+ if (typeof value === 'string' || typeof value === 'number')
51
+ return String(value).trim();
52
+ if (!value || typeof value !== 'object')
53
+ return '';
54
+ const record = value;
55
+ return String(record.id || record.task_id || record.taskId || '').trim();
56
+ }
57
+ /** Validate a server-authored execution graph before any task can be credited.
58
+ * A cyclic, duplicate, self-referencing, or dangling graph is never runnable
59
+ * and must not reach the UI callback where it could later be counted as 2/2. */
60
+ export function assertValidAgentPlanEvent(event) {
61
+ if (!event || typeof event !== 'object')
62
+ return;
63
+ const record = event;
64
+ if (record.type !== 'plan' || !Array.isArray(record.plan?.tasks))
65
+ return;
66
+ const tasks = record.plan.tasks;
67
+ const ids = tasks.map((task, index) => {
68
+ const id = dependencyId(task.id ?? task.task_id ?? task.taskId);
69
+ if (!id)
70
+ throw new AgentPlanContractError(`Agent plan task at index ${index} has no stable ID.`);
71
+ return id;
72
+ });
73
+ if (new Set(ids).size !== ids.length)
74
+ throw new AgentPlanContractError('Agent plan contains duplicate task IDs.');
75
+ const idSet = new Set(ids);
76
+ const graph = new Map();
77
+ tasks.forEach((task, index) => {
78
+ const raw = task.depends_on ?? task.dependsOn ?? task.dependencies ?? [];
79
+ const dependencies = (Array.isArray(raw) ? raw : [raw]).map(dependencyId).filter(Boolean);
80
+ for (const dependency of dependencies) {
81
+ if (dependency === ids[index])
82
+ throw new AgentPlanContractError(`Agent plan task ${ids[index]} depends on itself.`);
83
+ if (!idSet.has(dependency))
84
+ throw new AgentPlanContractError(`Agent plan task ${ids[index]} depends on missing task ${dependency}.`);
85
+ }
86
+ graph.set(ids[index], dependencies);
87
+ });
88
+ const visiting = new Set();
89
+ const visited = new Set();
90
+ const visit = (id, trail) => {
91
+ if (visiting.has(id))
92
+ throw new AgentPlanContractError(`Agent plan contains a dependency cycle: ${[...trail, id].join(' -> ')}.`);
93
+ if (visited.has(id))
94
+ return;
95
+ visiting.add(id);
96
+ for (const dependency of graph.get(id) || [])
97
+ visit(dependency, [...trail, id]);
98
+ visiting.delete(id);
99
+ visited.add(id);
100
+ };
101
+ for (const id of ids)
102
+ visit(id, []);
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigthoria-cli",
3
- "version": "1.13.25",
3
+ "version": "1.13.26",
4
4
  "description": "Vigthoria Coder CLI - AI-powered terminal coding assistant",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",