vigthoria-cli 1.13.22 → 1.13.23

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.
@@ -9,6 +9,7 @@ import { createAPIClient } from '../utils/api-client-factory.js';
9
9
  import { sanitizeUserFacingErrorText } from '../utils/api.js';
10
10
  import { getCliStateFile, isOfflineMode, isUpdateCheckSuppressed } from '../utils/cli-state.js';
11
11
  import { CliCommandError, formatSuccessJson } from '../utils/command-contract.js';
12
+ import { runtimeTempStatus } from '../utils/runtime-temp.js';
12
13
  export function registerDoctorCommand(program, config, logger, version) {
13
14
  program
14
15
  .command('doctor')
@@ -76,6 +77,7 @@ export function registerDoctorCommand(program, config, logger, version) {
76
77
  const envToken = String(process.env.VIGTHORIA_AUTH_TOKEN || process.env.VIGTHORIA_TOKEN || '').trim();
77
78
  const configuredToken = envToken || String(config.get('authToken') || '').trim();
78
79
  const tokenSegments = configuredToken ? configuredToken.split('.').length : 0;
80
+ const tempStorage = runtimeTempStatus();
79
81
  const report = {
80
82
  cliVersion: version,
81
83
  nodeVersion: process.version,
@@ -102,6 +104,7 @@ export function registerDoctorCommand(program, config, logger, version) {
102
104
  writable: sessionStorageWritable,
103
105
  latestCheckpoint,
104
106
  },
107
+ tempStorage,
105
108
  subscriptionPlan: subscription.plan || null,
106
109
  subscriptionStatus: subscription.status || null,
107
110
  offlineMode: offline,
@@ -180,7 +183,11 @@ export function registerDoctorCommand(program, config, logger, version) {
180
183
  console.log(chalk.gray('\nTip: pass --check-api to verify API reachability.'));
181
184
  }
182
185
  };
183
- const requiredLocalChecksPassed = nodeCompatible && missingRuntimePackages.length === 0 && sessionStorageWritable;
186
+ const requiredLocalChecksPassed = nodeCompatible
187
+ && missingRuntimePackages.length === 0
188
+ && sessionStorageWritable
189
+ && tempStorage.writable
190
+ && !tempStorage.sharedSystemTemp;
184
191
  const coderReachable = !options.checkApi || offline || report.apiHealth === 'online';
185
192
  if (!requiredLocalChecksPassed || !coderReachable) {
186
193
  if (!options.json)
@@ -15,14 +15,13 @@
15
15
  import chalk from 'chalk';
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
- import { createRequire } from 'node:module';
19
18
  import { guardedFetch } from '../utils/network-policy.js';
20
19
  import { createSpinner, CH } from '../utils/logger.js';
21
20
  import { createOperationId } from '../utils/mutation-journal.js';
22
21
  import { containsHighConfidenceSecret, isSensitivePath, safeChildProcessEnv } from '../utils/secret-policy.js';
23
22
  import { CliCommandError, commandFailure, formatSuccessJson } from '../utils/command-contract.js';
23
+ import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from '../utils/runtime-temp.js';
24
24
  import { assertSafeDestinationPath, assertSafeRelativePath, inspectZipArchive, mergeValidatedWorkspace, resolveWorkspacePath, validateExtractedWorkspace, } from '../utils/workspace-boundary.js';
25
- const require = createRequire(import.meta.url);
26
25
  import inquirer from 'inquirer';
27
26
  const MAX_REPOSITORY_CONTENT_BYTES = 100 * 1024 * 1024;
28
27
  const MAX_REPOSITORY_REQUEST_BYTES = 120 * 1024 * 1024;
@@ -570,14 +569,13 @@ export class RepoCommand {
570
569
  throw new Error('Failed to download project archive');
571
570
  const archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
572
571
  inspectZipArchive(archiveBuffer);
573
- const os = require('os');
574
- const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vigthoria-pull-'));
572
+ const stagingRoot = createRuntimeTempDirectory('repo-pull-');
575
573
  const tempArchive = path.join(stagingRoot, 'archive.zip');
576
574
  const extractedPath = path.join(stagingRoot, 'extracted');
577
575
  try {
578
576
  fs.mkdirSync(extractedPath, { mode: 0o700 });
579
577
  fs.writeFileSync(tempArchive, archiveBuffer, { mode: 0o600 });
580
- if (os.platform() === 'win32') {
578
+ if (process.platform === 'win32') {
581
579
  const { execFileSync } = await import('child_process');
582
580
  execFileSync('powershell', [
583
581
  '-NoProfile', '-NonInteractive', '-Command',
@@ -600,12 +598,11 @@ export class RepoCommand {
600
598
  mergeValidatedWorkspace(extractedPath, outputPath);
601
599
  }
602
600
  finally {
603
- fs.rmSync(stagingRoot, { recursive: true, force: true });
601
+ removeRuntimeTempDirectory(stagingRoot);
604
602
  }
605
603
  }
606
604
  else if (data.files) {
607
- const os = require('os');
608
- const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vigthoria-pull-inline-'));
605
+ const stagingRoot = createRuntimeTempDirectory('repo-inline-');
609
606
  try {
610
607
  for (const file of data.files) {
611
608
  const filePath = resolveWorkspacePath(stagingRoot, file.path, { allowMissing: true });
@@ -616,7 +613,7 @@ export class RepoCommand {
616
613
  mergeValidatedWorkspace(stagingRoot, outputPath);
617
614
  }
618
615
  finally {
619
- fs.rmSync(stagingRoot, { recursive: true, force: true });
616
+ removeRuntimeTempDirectory(stagingRoot);
620
617
  }
621
618
  }
622
619
  else {
@@ -1,7 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { execFileSync as execFileSyncProcess } from 'node:child_process';
3
3
  import * as fs from 'node:fs';
4
- import * as os from 'node:os';
5
4
  import * as path from 'node:path';
6
5
  import axios from 'axios';
7
6
  import chalk from 'chalk';
@@ -13,6 +12,7 @@ import { CliCommandError, commandFailure } from '../utils/command-contract.js';
13
12
  import { compareSemanticVersions as compareVersions, resolveUpdateSource } from '../utils/update-policy.js';
14
13
  import { assertReleaseTransition, assertReleaseUrl, getReleasePolicy, parseReleaseManifestText, verifyReleaseSignature } from '../utils/release-policy.js';
15
14
  import { installReleaseTransaction } from '../utils/release-install.js';
15
+ import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from '../utils/runtime-temp.js';
16
16
  function maxVersion(...versions) {
17
17
  let best = null;
18
18
  for (const version of versions) {
@@ -42,6 +42,34 @@ async function fetchNpmLatestVersion() {
42
42
  }
43
43
  }
44
44
  const VIGTHORIA_DEFAULT_MANIFEST_URL = getReleasePolicy().origins.manifest;
45
+ const MAX_RELEASE_ARCHIVE_BYTES = 256 * 1024 * 1024;
46
+ async function readBoundedResponse(response, maximumBytes) {
47
+ const declaredLength = Number(response.headers.get('content-length') || 0);
48
+ if (declaredLength > maximumBytes)
49
+ throw new Error(`Release archive exceeds the ${maximumBytes}-byte download limit.`);
50
+ if (!response.body)
51
+ return Buffer.alloc(0);
52
+ const reader = response.body.getReader();
53
+ const chunks = [];
54
+ let total = 0;
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done)
59
+ break;
60
+ total += value.byteLength;
61
+ if (total > maximumBytes) {
62
+ await reader.cancel('release archive size limit exceeded');
63
+ throw new Error(`Release archive exceeds the ${maximumBytes}-byte download limit.`);
64
+ }
65
+ chunks.push(Buffer.from(value));
66
+ }
67
+ }
68
+ finally {
69
+ reader.releaseLock();
70
+ }
71
+ return Buffer.concat(chunks, total);
72
+ }
45
73
  async function downloadFile(url, targetPath, explicitLocal = false) {
46
74
  let bytes;
47
75
  if (explicitLocal) {
@@ -49,6 +77,8 @@ async function downloadFile(url, targetPath, explicitLocal = false) {
49
77
  responseType: 'arraybuffer',
50
78
  timeout: 20000,
51
79
  maxRedirects: 0,
80
+ maxContentLength: MAX_RELEASE_ARCHIVE_BYTES,
81
+ maxBodyLength: MAX_RELEASE_ARCHIVE_BYTES,
52
82
  validateStatus: (status) => status >= 200 && status < 300,
53
83
  });
54
84
  bytes = Buffer.from(response.data);
@@ -57,7 +87,7 @@ async function downloadFile(url, targetPath, explicitLocal = false) {
57
87
  const response = await guardedFetch(url, {}, { audience: 'release' });
58
88
  if (!response.ok)
59
89
  throw new Error(`Release download returned HTTP ${response.status}`);
60
- bytes = Buffer.from(await response.arrayBuffer());
90
+ bytes = await readBoundedResponse(response, MAX_RELEASE_ARCHIVE_BYTES);
61
91
  }
62
92
  fs.writeFileSync(targetPath, bytes, { mode: 0o600, flag: 'wx' });
63
93
  }
@@ -156,7 +186,7 @@ export function registerUpdateCommand(program, version) {
156
186
  let updateTempDirectory = null;
157
187
  try {
158
188
  if (source.kind === 'remote') {
159
- updateTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'vigthoria-update-'));
189
+ updateTempDirectory = createRuntimeTempDirectory('update-');
160
190
  installTarget = path.join(updateTempDirectory, 'candidate.tgz');
161
191
  await downloadFile(source.downloadUrl, installTarget);
162
192
  }
@@ -186,7 +216,7 @@ export function registerUpdateCommand(program, version) {
186
216
  }
187
217
  finally {
188
218
  if (updateTempDirectory)
189
- fs.rmSync(updateTempDirectory, { recursive: true, force: true });
219
+ removeRuntimeTempDirectory(updateTempDirectory);
190
220
  }
191
221
  console.log(chalk.green('Update installed successfully'));
192
222
  console.log(chalk.gray('Please restart the CLI to use the new version'));
@@ -273,7 +303,7 @@ export function registerUpdateCommand(program, version) {
273
303
  && compareVersions(manifestEntry.version, effectiveLatest) >= 0
274
304
  && compareVersions(manifestEntry.version, currentVersion) > 0);
275
305
  if (manifestIsAuthoritative && manifestEntry) {
276
- const updateTempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'vigthoria-update-'));
306
+ const updateTempDirectory = createRuntimeTempDirectory('update-');
277
307
  const tmpFile = path.join(updateTempDirectory, 'candidate.tgz');
278
308
  try {
279
309
  console.log(chalk.cyan(`Downloading release package (${manifestEntry.version})...`));
@@ -306,7 +336,7 @@ export function registerUpdateCommand(program, version) {
306
336
  return;
307
337
  }
308
338
  finally {
309
- fs.rmSync(updateTempDirectory, { recursive: true, force: true });
339
+ removeRuntimeTempDirectory(updateTempDirectory);
310
340
  }
311
341
  }
312
342
  const npmSpec = npmVersion && compareVersions(npmVersion, currentVersion) > 0
package/dist/index.js CHANGED
@@ -50,6 +50,8 @@ import { installAxiosNetworkPolicy, installGlobalFetchPolicy } from './utils/net
50
50
  import { installConsoleRedaction } from './utils/secret-policy.js';
51
51
  import { commandNameFromArgv, failureEnvelope, normalizeCommandError, CliCommandError, } from './utils/command-contract.js';
52
52
  import { commandAuthRequirement, commanderCommandPath } from './utils/command-policy.js';
53
+ import { initializeRuntimeTempStorage } from './utils/runtime-temp.js';
54
+ initializeRuntimeTempStorage();
53
55
  applyLocalTestfarmDefaults();
54
56
  if (process.env.VIGTHORIA_CAPTURE_RUNTIME_MODEL !== '1')
55
57
  installConsoleRedaction();
@@ -8,6 +8,8 @@ export interface PreviewScreenshotPort {
8
8
  export declare class OptionalPuppeteerScreenshotAdapter implements PreviewScreenshotPort {
9
9
  private readonly loadPuppeteer;
10
10
  private readonly environment;
11
- constructor(loadPuppeteer?: () => Promise<unknown>, environment?: NodeJS.ProcessEnv);
11
+ private readonly allocateTemp;
12
+ private readonly releaseTemp;
13
+ constructor(loadPuppeteer?: () => Promise<unknown>, environment?: NodeJS.ProcessEnv, allocateTemp?: (prefix: string) => string, releaseTemp?: (directory: string) => void);
12
14
  capture(entryAbsolutePath: string, screenshotPath: string): Promise<PreviewScreenshotResult>;
13
15
  }
@@ -1,11 +1,16 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import { redactSensitiveText } from './secret-policy.js';
3
+ import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from './runtime-temp.js';
3
4
  export class OptionalPuppeteerScreenshotAdapter {
4
5
  loadPuppeteer;
5
6
  environment;
6
- constructor(loadPuppeteer = () => import('puppeteer'), environment = process.env) {
7
+ allocateTemp;
8
+ releaseTemp;
9
+ constructor(loadPuppeteer = () => import('puppeteer'), environment = process.env, allocateTemp = createRuntimeTempDirectory, releaseTemp = removeRuntimeTempDirectory) {
7
10
  this.loadPuppeteer = loadPuppeteer;
8
11
  this.environment = environment;
12
+ this.allocateTemp = allocateTemp;
13
+ this.releaseTemp = releaseTemp;
9
14
  }
10
15
  async capture(entryAbsolutePath, screenshotPath) {
11
16
  if (this.environment.VIGTHORIA_DISABLE_PREVIEW_SCREENSHOT === '1') {
@@ -17,15 +22,25 @@ export class OptionalPuppeteerScreenshotAdapter {
17
22
  if (!puppeteer || typeof puppeteer.launch !== 'function') {
18
23
  return { captured: false, error: 'puppeteer optional adapter is not installed' };
19
24
  }
20
- const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
25
+ const browserProfile = this.allocateTemp('browser-');
21
26
  try {
22
- const page = await browser.newPage();
23
- await page.setViewport({ width: 1440, height: 960, deviceScaleFactor: 1 });
24
- await page.goto(pathToFileURL(entryAbsolutePath).toString(), { waitUntil: 'networkidle0', timeout: 20_000 });
25
- await page.screenshot({ path: screenshotPath, fullPage: true });
27
+ const browser = await puppeteer.launch({
28
+ headless: true,
29
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
30
+ 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
+ }
26
41
  }
27
42
  finally {
28
- await browser.close();
43
+ this.releaseTemp(browserProfile);
29
44
  }
30
45
  return { captured: true };
31
46
  }
@@ -0,0 +1,76 @@
1
+ export interface RuntimeTempOptions {
2
+ homeDirectory?: string;
3
+ environment?: NodeJS.ProcessEnv;
4
+ platform?: NodeJS.Platform;
5
+ now?: () => number;
6
+ pid?: number;
7
+ isProcessAlive?: (pid: number) => boolean;
8
+ systemTempDirectory?: string;
9
+ }
10
+ export interface RuntimeTempCleanupResult {
11
+ removedEntries: number;
12
+ removedBytes: number;
13
+ skippedActiveEntries: number;
14
+ legacyRemovedEntries: number;
15
+ legacyRemovedBytes: number;
16
+ }
17
+ export interface RuntimeTempStatus extends RuntimeTempCleanupResult {
18
+ root: string;
19
+ source: 'per-user-default' | 'explicit-override';
20
+ writable: boolean;
21
+ sharedSystemTemp: boolean;
22
+ maxBytes: number;
23
+ minimumFreeBytes: number;
24
+ ttlHours: number;
25
+ usedBytes: number;
26
+ entryCount: number;
27
+ freeBytes: number | null;
28
+ error: string | null;
29
+ }
30
+ export declare class RuntimeTempError extends Error {
31
+ readonly code: string;
32
+ readonly details: Record<string, unknown>;
33
+ constructor(message: string, code: string, details?: Record<string, unknown>);
34
+ }
35
+ export declare function resolveRuntimeTempRoot(options?: RuntimeTempOptions): {
36
+ root: string;
37
+ source: RuntimeTempStatus['source'];
38
+ };
39
+ export declare class RuntimeTempManager {
40
+ private readonly environment;
41
+ private readonly platform;
42
+ private readonly homeDirectory;
43
+ private readonly now;
44
+ private readonly pid;
45
+ private readonly isProcessAlive;
46
+ private readonly systemTempRoot;
47
+ private readonly configuredRoot;
48
+ private readonly source;
49
+ private readonly maxBytes;
50
+ private readonly minimumFreeBytes;
51
+ private readonly ttlMs;
52
+ private initializedRoot;
53
+ private initializedIdentity;
54
+ private initializationError;
55
+ private lastCleanup;
56
+ constructor(options?: RuntimeTempOptions);
57
+ initialize(): RuntimeTempStatus;
58
+ createDirectory(prefix?: string): string;
59
+ removeDirectory(directory: string): void;
60
+ scavenge(options?: {
61
+ includeLegacy?: boolean;
62
+ }): RuntimeTempCleanupResult;
63
+ status(): RuntimeTempStatus;
64
+ private requireRoot;
65
+ private readEntries;
66
+ private entrySize;
67
+ private freeBytes;
68
+ private measure;
69
+ private removeEntry;
70
+ private cleanupLegacySystemTemp;
71
+ }
72
+ export declare function getRuntimeTempManager(): RuntimeTempManager;
73
+ export declare function initializeRuntimeTempStorage(): RuntimeTempStatus;
74
+ export declare function runtimeTempStatus(): RuntimeTempStatus;
75
+ export declare function createRuntimeTempDirectory(prefix: string): string;
76
+ export declare function removeRuntimeTempDirectory(directory: string): void;
@@ -0,0 +1,492 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ const MIB = 1024 * 1024;
5
+ const DEFAULT_MAX_BYTES = 1024 * MIB;
6
+ const DEFAULT_MIN_FREE_BYTES = 128 * MIB;
7
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const CLEANUP_LOCK_MAX_AGE_MS = 10 * 60 * 1000;
9
+ const MAX_SCAN_ENTRIES = 100_000;
10
+ const LEASE_FILE = '.vigthoria-temp-lease.json';
11
+ const CLEANUP_LOCK = '.vigthoria-temp-cleanup.lock';
12
+ const ROOT_MARKER = '.vigthoria-temp-root.json';
13
+ const LEGACY_CLEANUP_MARKER = '.vigthoria-legacy-temp-cleanup-v1';
14
+ const LEGACY_PREFIXES = [
15
+ 'vigthoria-pull-',
16
+ 'vigthoria-pull-inline-',
17
+ 'vigthoria-update-',
18
+ 'vigthoria-cli-install-',
19
+ 'puppeteer_dev_chrome_profile-',
20
+ ];
21
+ export class RuntimeTempError extends Error {
22
+ code;
23
+ details;
24
+ constructor(message, code, details = {}) {
25
+ super(message);
26
+ this.name = 'RuntimeTempError';
27
+ this.code = code;
28
+ this.details = details;
29
+ }
30
+ }
31
+ function numericSetting(name, value, fallback, minimum, maximum) {
32
+ if (!value)
33
+ return fallback;
34
+ const parsed = Number(value);
35
+ if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
36
+ throw new RuntimeTempError(`${name} must be a number between ${minimum} and ${maximum}.`, 'TEMP_SETTING_INVALID', {
37
+ setting: name,
38
+ });
39
+ }
40
+ return Math.floor(parsed);
41
+ }
42
+ function pathIsContained(root, candidate, pathApi) {
43
+ const relative = pathApi.relative(root, candidate);
44
+ return relative === '' || (!relative.startsWith(`..${pathApi.sep}`) && relative !== '..' && !pathApi.isAbsolute(relative));
45
+ }
46
+ function rejectUnsafeWindowsPath(value) {
47
+ const normalized = value.replace(/\//g, '\\');
48
+ if (/^\\\\[?.]\\/i.test(normalized) || /^\\\\/.test(normalized)) {
49
+ throw new RuntimeTempError('VIGTHORIA_TEMP_DIR cannot use a UNC or Windows device path.', 'TEMP_ROOT_UNSAFE');
50
+ }
51
+ }
52
+ function isKnownSharedPosixTemp(value) {
53
+ const normalized = path.posix.resolve(value);
54
+ return normalized === '/tmp' || normalized === '/var/tmp' || normalized === '/usr/tmp';
55
+ }
56
+ function pathsEqual(left, right, platform) {
57
+ const pathApi = platform === 'win32' ? path.win32 : path.posix;
58
+ const normalize = (value) => {
59
+ const resolved = pathApi.resolve(value);
60
+ return platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved;
61
+ };
62
+ return normalize(left) === normalize(right);
63
+ }
64
+ export function resolveRuntimeTempRoot(options = {}) {
65
+ const environment = options.environment || process.env;
66
+ const platform = options.platform || process.platform;
67
+ const pathApi = platform === 'win32' ? path.win32 : path.posix;
68
+ const homeDirectory = options.homeDirectory || os.homedir();
69
+ const override = String(environment.VIGTHORIA_TEMP_DIR || '').trim();
70
+ if (override.includes('\0'))
71
+ throw new RuntimeTempError('VIGTHORIA_TEMP_DIR contains a null byte.', 'TEMP_ROOT_UNSAFE');
72
+ if (platform === 'win32' && override)
73
+ rejectUnsafeWindowsPath(override);
74
+ if (override && !pathApi.isAbsolute(override)) {
75
+ throw new RuntimeTempError('VIGTHORIA_TEMP_DIR must be an absolute local path.', 'TEMP_ROOT_UNSAFE');
76
+ }
77
+ const root = pathApi.resolve(override || pathApi.join(homeDirectory, '.vigthoria', 'tmp'));
78
+ if (root === pathApi.parse(root).root) {
79
+ throw new RuntimeTempError('The filesystem root cannot be used for Vigthoria temporary storage.', 'TEMP_ROOT_UNSAFE');
80
+ }
81
+ return { root, source: override ? 'explicit-override' : 'per-user-default' };
82
+ }
83
+ function defaultProcessAlive(pid) {
84
+ if (!Number.isSafeInteger(pid) || pid <= 0)
85
+ return false;
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ return error?.code === 'EPERM';
92
+ }
93
+ }
94
+ export class RuntimeTempManager {
95
+ environment;
96
+ platform;
97
+ homeDirectory;
98
+ now;
99
+ pid;
100
+ isProcessAlive;
101
+ systemTempRoot;
102
+ configuredRoot;
103
+ source;
104
+ maxBytes;
105
+ minimumFreeBytes;
106
+ ttlMs;
107
+ initializedRoot = null;
108
+ initializedIdentity = null;
109
+ initializationError = null;
110
+ lastCleanup = {
111
+ removedEntries: 0,
112
+ removedBytes: 0,
113
+ skippedActiveEntries: 0,
114
+ legacyRemovedEntries: 0,
115
+ legacyRemovedBytes: 0,
116
+ };
117
+ constructor(options = {}) {
118
+ this.environment = options.environment || process.env;
119
+ this.platform = options.platform || process.platform;
120
+ this.homeDirectory = options.homeDirectory || os.homedir();
121
+ this.now = options.now || Date.now;
122
+ this.pid = options.pid || process.pid;
123
+ this.isProcessAlive = options.isProcessAlive || defaultProcessAlive;
124
+ this.systemTempRoot = options.systemTempDirectory || os.tmpdir();
125
+ const resolved = resolveRuntimeTempRoot({ ...options, environment: this.environment, platform: this.platform, homeDirectory: this.homeDirectory });
126
+ this.configuredRoot = resolved.root;
127
+ this.source = resolved.source;
128
+ this.maxBytes = numericSetting('VIGTHORIA_TEMP_MAX_BYTES', this.environment.VIGTHORIA_TEMP_MAX_BYTES, DEFAULT_MAX_BYTES, 64 * MIB, 64 * 1024 * MIB);
129
+ this.minimumFreeBytes = numericSetting('VIGTHORIA_TEMP_MIN_FREE_BYTES', this.environment.VIGTHORIA_TEMP_MIN_FREE_BYTES, DEFAULT_MIN_FREE_BYTES, 64 * MIB, 64 * 1024 * MIB);
130
+ const ttlHours = numericSetting('VIGTHORIA_TEMP_TTL_HOURS', this.environment.VIGTHORIA_TEMP_TTL_HOURS, DEFAULT_TTL_MS / 3_600_000, 1, 720);
131
+ this.ttlMs = ttlHours * 3_600_000;
132
+ }
133
+ initialize() {
134
+ if (this.initializedRoot || this.initializationError)
135
+ return this.status();
136
+ try {
137
+ if (this.source === 'per-user-default' && !fs.existsSync(this.homeDirectory)) {
138
+ throw new RuntimeTempError('The user home directory does not exist; managed temporary storage cannot be initialized.', 'TEMP_HOME_UNAVAILABLE');
139
+ }
140
+ const existed = fs.existsSync(this.configuredRoot);
141
+ if (existed && fs.lstatSync(this.configuredRoot).isSymbolicLink()) {
142
+ throw new RuntimeTempError('Vigthoria temporary storage root cannot be a symbolic link or junction.', 'TEMP_ROOT_SYMLINK');
143
+ }
144
+ fs.mkdirSync(this.configuredRoot, { recursive: true, mode: 0o700 });
145
+ if (fs.lstatSync(this.configuredRoot).isSymbolicLink()) {
146
+ throw new RuntimeTempError('Vigthoria temporary storage root cannot be a symbolic link or junction.', 'TEMP_ROOT_SYMLINK');
147
+ }
148
+ const realRoot = fs.realpathSync(this.configuredRoot);
149
+ const markerPath = path.join(realRoot, ROOT_MARKER);
150
+ if (this.source === 'explicit-override' && existed && !fs.existsSync(markerPath)) {
151
+ const existingEntries = fs.readdirSync(realRoot);
152
+ if (existingEntries.length > 0) {
153
+ throw new RuntimeTempError('Explicit temporary storage must be empty or already marked as Vigthoria-managed.', 'TEMP_ROOT_NOT_DEDICATED');
154
+ }
155
+ }
156
+ const pathApi = this.platform === 'win32' ? path.win32 : path.posix;
157
+ if (pathsEqual(realRoot, this.systemTempRoot, this.platform)
158
+ || (this.platform !== 'win32' && isKnownSharedPosixTemp(realRoot))) {
159
+ throw new RuntimeTempError('The shared operating-system temporary directory cannot be used for Vigthoria runtime storage.', 'TEMP_ROOT_SHARED');
160
+ }
161
+ if (this.source === 'per-user-default') {
162
+ const realHome = fs.realpathSync(this.homeDirectory);
163
+ if (!pathIsContained(realHome, realRoot, pathApi)) {
164
+ throw new RuntimeTempError('Default temporary storage escaped the user home through a link.', 'TEMP_ROOT_ESCAPE');
165
+ }
166
+ }
167
+ if (this.platform !== 'win32')
168
+ fs.chmodSync(realRoot, 0o700);
169
+ if (!fs.existsSync(markerPath)) {
170
+ try {
171
+ fs.writeFileSync(markerPath, `${JSON.stringify({ schemaVersion: 1, owner: 'vigthoria-cli', createdAt: new Date(this.now()).toISOString() })}\n`, {
172
+ flag: 'wx',
173
+ mode: 0o600,
174
+ });
175
+ }
176
+ catch (error) {
177
+ if (error?.code !== 'EEXIST')
178
+ throw error;
179
+ }
180
+ }
181
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
182
+ if (marker.schemaVersion !== 1 || marker.owner !== 'vigthoria-cli') {
183
+ throw new RuntimeTempError('Temporary storage ownership marker is invalid.', 'TEMP_ROOT_MARKER_INVALID');
184
+ }
185
+ const rootStat = fs.statSync(realRoot, { bigint: true });
186
+ const probe = path.join(realRoot, `.write-probe-${this.pid}-${this.now()}`);
187
+ fs.writeFileSync(probe, '', { flag: 'wx', mode: 0o600 });
188
+ fs.unlinkSync(probe);
189
+ this.initializedRoot = realRoot;
190
+ this.initializedIdentity = { dev: rootStat.dev, ino: rootStat.ino };
191
+ this.environment.TMPDIR = realRoot;
192
+ this.environment.TMP = realRoot;
193
+ this.environment.TEMP = realRoot;
194
+ this.lastCleanup = this.scavenge({ includeLegacy: true });
195
+ }
196
+ catch (error) {
197
+ this.initializationError = error instanceof Error ? error.message : String(error);
198
+ }
199
+ return this.status();
200
+ }
201
+ createDirectory(prefix = 'run-') {
202
+ this.initialize();
203
+ const root = this.requireRoot();
204
+ if (!/^[a-z0-9][a-z0-9-]{0,47}-$/i.test(prefix)) {
205
+ throw new RuntimeTempError('Temporary directory prefix is invalid.', 'TEMP_PREFIX_INVALID');
206
+ }
207
+ this.lastCleanup = this.scavenge({ includeLegacy: false });
208
+ const before = this.measure(root);
209
+ if (before.usedBytes >= this.maxBytes || (before.freeBytes !== null && before.freeBytes < this.minimumFreeBytes)) {
210
+ throw new RuntimeTempError('Vigthoria temporary storage has insufficient capacity after cleanup.', 'TEMP_CAPACITY_EXHAUSTED', {
211
+ root,
212
+ usedBytes: before.usedBytes,
213
+ maxBytes: this.maxBytes,
214
+ freeBytes: before.freeBytes,
215
+ minimumFreeBytes: this.minimumFreeBytes,
216
+ });
217
+ }
218
+ const directory = fs.mkdtempSync(path.join(root, prefix));
219
+ if (this.platform !== 'win32')
220
+ fs.chmodSync(directory, 0o700);
221
+ fs.writeFileSync(path.join(directory, LEASE_FILE), `${JSON.stringify({ schemaVersion: 1, pid: this.pid, createdAt: new Date(this.now()).toISOString() })}\n`, {
222
+ flag: 'wx',
223
+ mode: 0o600,
224
+ });
225
+ return directory;
226
+ }
227
+ removeDirectory(directory) {
228
+ const root = this.requireRoot();
229
+ const absolute = path.resolve(directory);
230
+ if (path.dirname(absolute) !== root || absolute === root) {
231
+ throw new RuntimeTempError('Refusing to remove a path outside the Vigthoria temporary root.', 'TEMP_CLEANUP_ESCAPE');
232
+ }
233
+ fs.rmSync(absolute, { recursive: true, force: true });
234
+ }
235
+ scavenge(options = {}) {
236
+ const root = this.requireRoot();
237
+ const result = {
238
+ removedEntries: 0,
239
+ removedBytes: 0,
240
+ skippedActiveEntries: 0,
241
+ legacyRemovedEntries: 0,
242
+ legacyRemovedBytes: 0,
243
+ };
244
+ const lockPath = path.join(root, CLEANUP_LOCK);
245
+ let lock = null;
246
+ try {
247
+ for (let attempt = 0; attempt < 3 && lock === null; attempt += 1) {
248
+ try {
249
+ lock = fs.openSync(lockPath, 'wx', 0o600);
250
+ }
251
+ catch (error) {
252
+ if (error?.code !== 'EEXIST')
253
+ throw error;
254
+ let lockAge = 0;
255
+ try {
256
+ lockAge = this.now() - fs.statSync(lockPath).mtimeMs;
257
+ }
258
+ catch (statError) {
259
+ if (statError?.code === 'ENOENT')
260
+ continue;
261
+ throw statError;
262
+ }
263
+ if (lockAge <= CLEANUP_LOCK_MAX_AGE_MS)
264
+ return result;
265
+ try {
266
+ fs.unlinkSync(lockPath);
267
+ }
268
+ catch (unlinkError) {
269
+ if (unlinkError?.code !== 'ENOENT')
270
+ throw unlinkError;
271
+ }
272
+ }
273
+ }
274
+ if (lock === null)
275
+ return result;
276
+ fs.writeFileSync(lock, `${this.pid}\n`);
277
+ let entries = this.readEntries(root);
278
+ for (const entry of entries) {
279
+ if (entry.active) {
280
+ result.skippedActiveEntries += 1;
281
+ continue;
282
+ }
283
+ if (this.now() - entry.modifiedAt > this.ttlMs)
284
+ this.removeEntry(entry, result);
285
+ }
286
+ entries = this.readEntries(root);
287
+ let measurement = this.measure(root, entries);
288
+ for (const entry of entries.filter((candidate) => !candidate.active).sort((left, right) => left.modifiedAt - right.modifiedAt)) {
289
+ if (measurement.usedBytes <= this.maxBytes && (measurement.freeBytes === null || measurement.freeBytes >= this.minimumFreeBytes))
290
+ break;
291
+ this.removeEntry(entry, result);
292
+ measurement = this.measure(root);
293
+ }
294
+ if (options.includeLegacy && !fs.existsSync(path.join(root, LEGACY_CLEANUP_MARKER))) {
295
+ this.cleanupLegacySystemTemp(result);
296
+ try {
297
+ fs.writeFileSync(path.join(root, LEGACY_CLEANUP_MARKER), `${new Date(this.now()).toISOString()}\n`, { flag: 'wx', mode: 0o600 });
298
+ }
299
+ catch (error) {
300
+ if (error?.code !== 'EEXIST')
301
+ throw error;
302
+ }
303
+ }
304
+ return result;
305
+ }
306
+ finally {
307
+ if (lock !== null) {
308
+ fs.closeSync(lock);
309
+ try {
310
+ fs.unlinkSync(lockPath);
311
+ }
312
+ catch { /* another process may have recovered a stale lock */ }
313
+ }
314
+ }
315
+ }
316
+ status() {
317
+ const root = this.initializedRoot || this.configuredRoot;
318
+ let measurement = { usedBytes: 0, entryCount: 0, freeBytes: null };
319
+ let runtimeError = this.initializationError;
320
+ if (this.initializedRoot) {
321
+ try {
322
+ measurement = this.measure(this.requireRoot());
323
+ }
324
+ catch (error) {
325
+ runtimeError = error instanceof Error ? error.message : String(error);
326
+ }
327
+ }
328
+ return {
329
+ root,
330
+ source: this.source,
331
+ writable: Boolean(this.initializedRoot) && !runtimeError,
332
+ sharedSystemTemp: this.initializedRoot ? pathsEqual(this.initializedRoot, this.systemTempRoot, this.platform) : false,
333
+ maxBytes: this.maxBytes,
334
+ minimumFreeBytes: this.minimumFreeBytes,
335
+ ttlHours: this.ttlMs / 3_600_000,
336
+ usedBytes: measurement.usedBytes,
337
+ entryCount: measurement.entryCount,
338
+ freeBytes: measurement.freeBytes,
339
+ error: runtimeError,
340
+ ...this.lastCleanup,
341
+ };
342
+ }
343
+ requireRoot() {
344
+ if (!this.initializedRoot) {
345
+ throw new RuntimeTempError(this.initializationError || 'Vigthoria temporary storage is unavailable.', 'TEMP_STORAGE_UNAVAILABLE', {
346
+ root: this.configuredRoot,
347
+ });
348
+ }
349
+ try {
350
+ const stat = fs.lstatSync(this.initializedRoot);
351
+ const identity = fs.statSync(this.initializedRoot, { bigint: true });
352
+ if (stat.isSymbolicLink()
353
+ || fs.realpathSync(this.initializedRoot) !== this.initializedRoot
354
+ || !this.initializedIdentity
355
+ || identity.dev !== this.initializedIdentity.dev
356
+ || identity.ino !== this.initializedIdentity.ino) {
357
+ throw new Error('root identity changed');
358
+ }
359
+ }
360
+ catch {
361
+ throw new RuntimeTempError('Vigthoria temporary storage root changed after initialization.', 'TEMP_ROOT_CHANGED', {
362
+ root: this.initializedRoot,
363
+ });
364
+ }
365
+ return this.initializedRoot;
366
+ }
367
+ readEntries(root) {
368
+ const entries = [];
369
+ for (const name of fs.readdirSync(root)) {
370
+ if (name === CLEANUP_LOCK || name === ROOT_MARKER || name === LEGACY_CLEANUP_MARKER)
371
+ continue;
372
+ const absolutePath = path.join(root, name);
373
+ let stat;
374
+ try {
375
+ stat = fs.lstatSync(absolutePath);
376
+ }
377
+ catch (error) {
378
+ if (error?.code === 'ENOENT')
379
+ continue;
380
+ throw error;
381
+ }
382
+ let active = false;
383
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
384
+ const leasePath = path.join(absolutePath, LEASE_FILE);
385
+ try {
386
+ const lease = JSON.parse(fs.readFileSync(leasePath, 'utf8'));
387
+ active = typeof lease.pid === 'number' && this.isProcessAlive(lease.pid);
388
+ }
389
+ catch { /* third-party and abandoned directories have no live lease */ }
390
+ }
391
+ entries.push({ absolutePath, name, bytes: this.entrySize(absolutePath), modifiedAt: stat.mtimeMs, active });
392
+ }
393
+ return entries;
394
+ }
395
+ entrySize(entryPath) {
396
+ let bytes = 0;
397
+ let visited = 0;
398
+ const walk = (candidate) => {
399
+ visited += 1;
400
+ if (visited > MAX_SCAN_ENTRIES) {
401
+ bytes = Math.max(bytes, this.maxBytes + 1);
402
+ return;
403
+ }
404
+ const stat = fs.lstatSync(candidate);
405
+ bytes += stat.size;
406
+ if (!stat.isDirectory() || stat.isSymbolicLink())
407
+ return;
408
+ for (const name of fs.readdirSync(candidate))
409
+ walk(path.join(candidate, name));
410
+ };
411
+ try {
412
+ walk(entryPath);
413
+ }
414
+ catch {
415
+ return this.maxBytes + 1;
416
+ }
417
+ return bytes;
418
+ }
419
+ freeBytes(root) {
420
+ try {
421
+ const stat = fs.statfsSync(root);
422
+ return Number(stat.bavail) * Number(stat.bsize);
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ }
428
+ measure(root, entries = this.readEntries(root)) {
429
+ return {
430
+ usedBytes: entries.reduce((total, entry) => total + entry.bytes, 0),
431
+ entryCount: entries.length,
432
+ freeBytes: this.freeBytes(root),
433
+ };
434
+ }
435
+ removeEntry(entry, result) {
436
+ if (path.dirname(entry.absolutePath) !== this.requireRoot()) {
437
+ throw new RuntimeTempError('Temporary cleanup target escaped its root.', 'TEMP_CLEANUP_ESCAPE');
438
+ }
439
+ fs.rmSync(entry.absolutePath, { recursive: true, force: true });
440
+ result.removedEntries += 1;
441
+ result.removedBytes += entry.bytes;
442
+ }
443
+ cleanupLegacySystemTemp(result) {
444
+ const legacyRoot = this.systemTempRoot;
445
+ if (!fs.existsSync(legacyRoot) || path.resolve(legacyRoot) === this.requireRoot())
446
+ return;
447
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
448
+ if (currentUid === null)
449
+ return;
450
+ let scanned = 0;
451
+ for (const name of fs.readdirSync(legacyRoot)) {
452
+ scanned += 1;
453
+ if (scanned > MAX_SCAN_ENTRIES)
454
+ break;
455
+ if (!LEGACY_PREFIXES.some((prefix) => name.startsWith(prefix)))
456
+ continue;
457
+ const candidate = path.join(legacyRoot, name);
458
+ let stat;
459
+ try {
460
+ stat = fs.lstatSync(candidate);
461
+ }
462
+ catch {
463
+ continue;
464
+ }
465
+ if (currentUid !== null && stat.uid !== currentUid)
466
+ continue;
467
+ if (this.now() - stat.mtimeMs <= this.ttlMs)
468
+ continue;
469
+ const bytes = this.entrySize(candidate);
470
+ fs.rmSync(candidate, { recursive: true, force: true });
471
+ result.legacyRemovedEntries += 1;
472
+ result.legacyRemovedBytes += bytes;
473
+ }
474
+ }
475
+ }
476
+ let defaultManager = null;
477
+ export function getRuntimeTempManager() {
478
+ defaultManager ||= new RuntimeTempManager();
479
+ return defaultManager;
480
+ }
481
+ export function initializeRuntimeTempStorage() {
482
+ return getRuntimeTempManager().initialize();
483
+ }
484
+ export function runtimeTempStatus() {
485
+ return getRuntimeTempManager().initialize();
486
+ }
487
+ export function createRuntimeTempDirectory(prefix) {
488
+ return getRuntimeTempManager().createDirectory(prefix);
489
+ }
490
+ export function removeRuntimeTempDirectory(directory) {
491
+ getRuntimeTempManager().removeDirectory(directory);
492
+ }
@@ -28,6 +28,7 @@ import { assertContainedAbsolutePath } from './workspace-boundary.js';
28
28
  import { assertProcessNetworkAllowed, buildApprovalScope, hardenGitArguments, parseGitArguments, parseSupportedProcess, safeGitEnvironment, } from './process-policy.js';
29
29
  import { containsHighConfidenceSecret, isSensitivePath, safeChildProcessEnv } from './secret-policy.js';
30
30
  import { isOfflineNetworkMode } from './network-policy.js';
31
+ import { createRuntimeTempDirectory, removeRuntimeTempDirectory } from './runtime-temp.js';
31
32
  import { ToolApprovalService } from './tool-approval-service.js';
32
33
  import { DelegationToolProvider, FilesystemToolProvider, NetworkBrowserToolProvider, ProcessToolProvider, RepositoryToolProvider, SearchToolProvider, SshToolProvider, ToolCapabilityRegistry, } from './tool-capability-providers.js';
33
34
  const STREAM_RESPONSE_MAX_YIELD_CHARS = 32 * 1024;
@@ -1341,7 +1342,14 @@ export class AgenticTools {
1341
1342
  return errors;
1342
1343
  }
1343
1344
  validateJavaScriptSyntax(source) {
1344
- const tempFile = path.join(this.cwd, `.vigthoria-temp-${Date.now()}.js`);
1345
+ let tempDirectory;
1346
+ try {
1347
+ tempDirectory = createRuntimeTempDirectory('syntax-');
1348
+ }
1349
+ catch (error) {
1350
+ return this.formatExternalToolError('syntax_check', 'allocate bounded temporary storage', error);
1351
+ }
1352
+ const tempFile = path.join(tempDirectory, 'candidate.js');
1345
1353
  try {
1346
1354
  try {
1347
1355
  fs.writeFileSync(tempFile, source, 'utf-8');
@@ -1360,9 +1368,7 @@ export class AgenticTools {
1360
1368
  }
1361
1369
  finally {
1362
1370
  this.cleanupAfterToolError('syntax_check', `remove temporary file ${tempFile}`, () => {
1363
- if (fs.existsSync(tempFile)) {
1364
- fs.unlinkSync(tempFile);
1365
- }
1371
+ removeRuntimeTempDirectory(tempDirectory);
1366
1372
  });
1367
1373
  }
1368
1374
  }
package/install.ps1 CHANGED
@@ -129,12 +129,42 @@ function Test-Prerequisites {
129
129
  return $true
130
130
  }
131
131
 
132
+ function Get-VigthoriaTempRoot {
133
+ $explicitRoot = $env:VIGTHORIA_TEMP_DIR
134
+ $candidate = if ($explicitRoot) { $explicitRoot } else { Join-Path $env:USERPROFILE ".vigthoria\tmp" }
135
+ if (-not [IO.Path]::IsPathRooted($candidate)) { throw "VIGTHORIA_TEMP_DIR must be an absolute local path" }
136
+ if ($candidate.StartsWith("\\") -or $candidate.StartsWith("\\?\") -or $candidate.StartsWith("\\.\")) {
137
+ throw "VIGTHORIA_TEMP_DIR cannot use a UNC or Windows device path"
138
+ }
139
+ $fullRoot = [IO.Path]::GetFullPath($candidate).TrimEnd([IO.Path]::DirectorySeparatorChar)
140
+ $volumeRoot = [IO.Path]::GetPathRoot($fullRoot).TrimEnd([IO.Path]::DirectorySeparatorChar)
141
+ if ($fullRoot -eq $volumeRoot -or $fullRoot -eq [IO.Path]::GetTempPath().TrimEnd([IO.Path]::DirectorySeparatorChar)) {
142
+ throw "Refusing shared or filesystem-root temporary storage"
143
+ }
144
+ New-Item -ItemType Directory -Path $fullRoot -Force -ErrorAction Stop | Out-Null
145
+ $cursor = Get-Item -LiteralPath $fullRoot -Force -ErrorAction Stop
146
+ while ($cursor) {
147
+ if (($cursor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
148
+ throw "Refusing a reparse-point temporary storage path"
149
+ }
150
+ if ($cursor.FullName -eq [IO.Path]::GetPathRoot($cursor.FullName)) { break }
151
+ $cursor = $cursor.Parent
152
+ }
153
+ if (-not $explicitRoot) {
154
+ $homeRoot = [IO.Path]::GetFullPath($env:USERPROFILE).TrimEnd([IO.Path]::DirectorySeparatorChar)
155
+ if (-not ($fullRoot.Equals($homeRoot, [StringComparison]::OrdinalIgnoreCase) -or $fullRoot.StartsWith($homeRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase))) {
156
+ throw "Default temporary storage escaped the user profile"
157
+ }
158
+ }
159
+ return $fullRoot
160
+ }
161
+
132
162
  function Install-VigthoriaCLI-NPM {
133
163
  Write-Host ""
134
164
  Write-Host "[INSTALL] Installing Vigthoria CLI..." -ForegroundColor Cyan
135
165
 
136
166
  if ($HOSTED_TARBALL_SHA256) {
137
- $releaseTemp = Join-Path ([IO.Path]::GetTempPath()) ("vigthoria-cli-install-" + [Guid]::NewGuid().ToString("N"))
167
+ $releaseTemp = Join-Path (Get-VigthoriaTempRoot) ("install-" + [Guid]::NewGuid().ToString("N"))
138
168
  $releaseArchive = Join-Path $releaseTemp "candidate.tgz"
139
169
  try {
140
170
  New-Item -ItemType Directory -Path $releaseTemp -Force | Out-Null
@@ -178,7 +208,7 @@ function Install-VigthoriaCLI-NPM {
178
208
  function Install-VigthoriaCLI-Direct {
179
209
  Write-Host ""
180
210
  Write-Host "[INSTALL] Installing Vigthoria CLI (Direct Download)..." -ForegroundColor Cyan
181
- $releaseTemp = Join-Path ([IO.Path]::GetTempPath()) ("vigthoria-cli-direct-" + [Guid]::NewGuid().ToString("N"))
211
+ $releaseTemp = Join-Path (Get-VigthoriaTempRoot) ("direct-" + [Guid]::NewGuid().ToString("N"))
182
212
  $tarballPath = Join-Path $releaseTemp "candidate.tgz"
183
213
  try {
184
214
  if (-not $HOSTED_TARBALL_SHA256) { throw "Direct installation requires a signed manifest SHA-256" }
package/install.sh CHANGED
@@ -211,6 +211,41 @@ check_requirements() {
211
211
  }
212
212
 
213
213
  # Install CLI
214
+ prepare_temp_root() {
215
+ local requested_root="${VIGTHORIA_TEMP_DIR:-$HOME/.vigthoria/tmp}"
216
+ case "$requested_root" in
217
+ /*) ;;
218
+ *) echo -e "${RED}VIGTHORIA_TEMP_DIR must be an absolute local path${NC}" >&2; return 1 ;;
219
+ esac
220
+ if [[ "$requested_root" == "/" || "$requested_root" == "${TMPDIR:-/tmp}" ]]; then
221
+ echo -e "${RED}Refusing shared or filesystem-root temporary storage${NC}" >&2
222
+ return 1
223
+ fi
224
+ mkdir -p "$requested_root"
225
+ local path_cursor="$requested_root"
226
+ while [[ "$path_cursor" != "/" ]]; do
227
+ if [[ -L "$path_cursor" ]]; then
228
+ echo -e "${RED}Refusing a symbolic-link temporary storage path${NC}" >&2
229
+ return 1
230
+ fi
231
+ path_cursor="$(dirname -- "$path_cursor")"
232
+ done
233
+ local resolved_root resolved_home
234
+ resolved_root="$(cd "$requested_root" && pwd -P)"
235
+ resolved_home="$(cd "$HOME" && pwd -P)"
236
+ case "$resolved_root" in
237
+ /tmp|/var/tmp|/usr/tmp) echo -e "${RED}Refusing shared temporary storage${NC}" >&2; return 1 ;;
238
+ esac
239
+ if [[ -z "${VIGTHORIA_TEMP_DIR:-}" ]]; then
240
+ case "$resolved_root/" in
241
+ "$resolved_home/"*) ;;
242
+ *) echo -e "${RED}Default temporary storage escaped the user home${NC}" >&2; return 1 ;;
243
+ esac
244
+ fi
245
+ chmod 700 "$resolved_root"
246
+ printf '%s\n' "$resolved_root"
247
+ }
248
+
214
249
  install_cli() {
215
250
  echo -e "${CYAN}Installing Vigthoria CLI...${NC}"
216
251
 
@@ -220,7 +255,8 @@ install_cli() {
220
255
  # Option 1: Download and verify the exact hosted release package.
221
256
  if [[ -n "$HOSTED_TARBALL_SHA256" ]]; then
222
257
  echo "Downloading checksum-verified hosted release package..."
223
- RELEASE_TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/vigthoria-cli-install.XXXXXX")"
258
+ VIGTHORIA_INSTALL_TEMP_ROOT="$(prepare_temp_root)" || return 1
259
+ RELEASE_TMP_DIR="$(mktemp -d "$VIGTHORIA_INSTALL_TEMP_ROOT/install.XXXXXX")"
224
260
  RELEASE_ARCHIVE="$RELEASE_TMP_DIR/vigthoria-cli-${CLI_VERSION}.tgz"
225
261
  if ! validate_release_url "$HOSTED_TARBALL_URL"; then
226
262
  echo -e "${RED}Refusing untrusted release artifact URL${NC}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigthoria-cli",
3
- "version": "1.13.22",
3
+ "version": "1.13.23",
4
4
  "description": "Vigthoria Coder CLI - AI-powered terminal coding assistant",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,7 +18,10 @@ if ! curl --silent --show-error --fail --connect-timeout 5 --max-time 15 "$MODEL
18
18
  block "balanced-model infrastructure is unavailable at the configured endpoint"
19
19
  fi
20
20
 
21
- PROBE_TMP="$(mktemp -d "${TMPDIR:-/tmp}/vigthoria-balanced-live.XXXXXX")"
21
+ PROBE_TEMP_ROOT="${VIGTHORIA_TEMP_DIR:-$HOME/.vigthoria/tmp}/live-tests"
22
+ mkdir -p "$PROBE_TEMP_ROOT"
23
+ chmod 700 "$PROBE_TEMP_ROOT"
24
+ PROBE_TMP="$(mktemp -d "$PROBE_TEMP_ROOT/balanced.XXXXXX")"
22
25
  trap 'rm -rf "$PROBE_TMP"' EXIT
23
26
 
24
27
  for attempt in 1 2 3; do
@@ -15,7 +15,10 @@ block() {
15
15
  [[ -n "${VIGTHORIA_TEMPLATE_SERVICE_URL:-}" ]] || block "the live Template Service endpoint is not configured"
16
16
  [[ -x dist/index.js ]] || block "dist/index.js is unavailable; build the candidate first"
17
17
 
18
- LIVE_TMP="$(mktemp -d "${TMPDIR:-/tmp}/vigthoria-live-services.XXXXXX")"
18
+ LIVE_TEMP_ROOT="${VIGTHORIA_TEMP_DIR:-$HOME/.vigthoria/tmp}/live-tests"
19
+ mkdir -p "$LIVE_TEMP_ROOT"
20
+ chmod 700 "$LIVE_TEMP_ROOT"
21
+ LIVE_TMP="$(mktemp -d "$LIVE_TEMP_ROOT/services.XXXXXX")"
19
22
  trap 'rm -rf "$LIVE_TMP"' EXIT
20
23
  export VIGTHORIA_NO_BANNER=1
21
24
  export VIGTHORIA_NO_UPDATE_CHECK=1
@@ -4,12 +4,21 @@ set -euo pipefail
4
4
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5
5
  cd "$ROOT"
6
6
 
7
- VALIDATION_TMP="$(mktemp -d "${TMPDIR:-/tmp}/vigthoria-no-go.XXXXXX")"
7
+ VALIDATION_TEMP_ROOT="${VIGTHORIA_VALIDATION_TEMP_DIR:-$(dirname "$ROOT")/.vigthoria-cli-validation}"
8
+ mkdir -p "$VALIDATION_TEMP_ROOT"
9
+ chmod 700 "$VALIDATION_TEMP_ROOT"
10
+ find "$VALIDATION_TEMP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'run.*' -mmin +1440 -user "$(id -un)" -exec rm -rf -- {} +
11
+ VALIDATION_TMP="$(mktemp -d "$VALIDATION_TEMP_ROOT/run.XXXXXX")"
8
12
  trap 'rm -rf "$VALIDATION_TMP"' EXIT
9
13
  mkdir -p "$VALIDATION_TMP/home"
14
+ mkdir -p "$VALIDATION_TMP/runtime-tmp"
15
+ chmod 700 "$VALIDATION_TMP/runtime-tmp"
10
16
 
11
17
  export HOME="$VALIDATION_TMP/home"
12
18
  export USERPROFILE="$HOME"
19
+ export TMPDIR="$VALIDATION_TMP/runtime-tmp"
20
+ export TMP="$TMPDIR"
21
+ export TEMP="$TMPDIR"
13
22
  export npm_config_cache="$VALIDATION_TMP/npm-cache"
14
23
  export npm_config_update_notifier=false
15
24
  export VIGTHORIA_NO_BANNER=1
@@ -77,6 +86,7 @@ node scripts/test-chat-prompt-policy.mjs
77
86
  node scripts/test-tool-capability-providers.mjs
78
87
  node scripts/test-agent-stream-state.mjs
79
88
  node scripts/test-direct-output-policy.mjs
89
+ node scripts/test-runtime-temp.mjs
80
90
 
81
91
  echo "[0.8] authoritative Phase 2 state/auth/session contracts"
82
92
  node scripts/test-durable-json-state.mjs