@thegitai/cli 1.0.0-beta.15 → 1.0.0-beta.16

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.
@@ -65,7 +65,9 @@ function snapshotForServer(session) {
65
65
  return snapshot;
66
66
  }
67
67
  function imageAttachmentsForServer(attachments) {
68
- return (attachments ?? []).map(({ filePath: _filePath, ...attachment }) => attachment);
68
+ return (attachments ?? []).map(({ filePath, ...attachment }) => attachment.source === 'file' && filePath
69
+ ? { ...attachment, filePath }
70
+ : attachment);
69
71
  }
70
72
  function userHistoryText(entry) {
71
73
  return (entry.parts ?? [])
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants, readFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { ensureSessionScratchDir } from './scratch-dir.js';
4
5
  const PACKAGE_MANAGER_CANDIDATES = [
5
6
  'apt',
6
7
  'apt-get',
@@ -123,5 +124,6 @@ export function collectClientEnvironment(options = {}) {
123
124
  shell: detectShell(platform, env),
124
125
  ...linuxDistro,
125
126
  packageManagers: detectPackageManagers(env, platform, executableExists),
127
+ scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
126
128
  };
127
129
  }
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { loadImageFromFile } from './clipboard.js';
@@ -9,12 +9,51 @@ const IMAGE_PATH_PATTERN = new RegExp(`"([^"]*\\.${EXT})"` +
9
9
  `|'([^']*\\.${EXT})'` +
10
10
  `|file://(\\S*\\.${EXT})` +
11
11
  `|(${BARE_PATH})`, 'gi');
12
+ const EXTENSION_TOKEN = new RegExp(`^(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+$`);
13
+ const MAX_EXTENSION_TOKENS = 8;
14
+ function isFile(p) {
15
+ try {
16
+ return statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function extendBareMatchAcrossSpaces(input, matchStart, raw, cwd) {
23
+ const baseInner = raw.replace(/\\ /g, ' ');
24
+ const baseExists = isFile(path.isAbsolute(baseInner) ? baseInner : path.resolve(cwd, baseInner));
25
+ let best = null;
26
+ let candidate = raw;
27
+ let start = matchStart;
28
+ let addedSeparator = false;
29
+ for (let hops = 0; hops < MAX_EXTENSION_TOKENS; hops++) {
30
+ if (start < 2 || input[start - 1] !== ' ' || /\s/.test(input[start - 2]))
31
+ break;
32
+ let i = start - 2;
33
+ while (i >= 0 && !/\s/.test(input[i]))
34
+ i--;
35
+ const tokenStart = i + 1;
36
+ const token = input.slice(tokenStart, start - 1);
37
+ if (!EXTENSION_TOKEN.test(token))
38
+ break;
39
+ candidate = `${token} ${candidate}`;
40
+ start = tokenStart;
41
+ addedSeparator = addedSeparator || /[\\/]/.test(token);
42
+ if (baseExists && !addedSeparator)
43
+ continue;
44
+ const inner = candidate.replace(/\\ /g, ' ');
45
+ const resolved = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
46
+ if (isFile(resolved))
47
+ best = candidate;
48
+ }
49
+ return best ?? raw;
50
+ }
12
51
  function detectImagePaths(input, cwd) {
13
52
  const regex = new RegExp(IMAGE_PATH_PATTERN.source, IMAGE_PATH_PATTERN.flags);
14
- const rawsByPath = new Map();
53
+ const detected = [];
15
54
  let match;
16
55
  while ((match = regex.exec(input)) !== null) {
17
- const raw = match[0];
56
+ let raw = match[0];
18
57
  if (match[4] != null && raw.includes('://'))
19
58
  continue;
20
59
  let inner;
@@ -46,9 +85,21 @@ function detectImagePaths(input, cwd) {
46
85
  }
47
86
  }
48
87
  else {
49
- inner = match[4].replace(/\\ /g, ' ');
88
+ raw = extendBareMatchAcrossSpaces(input, match.index, raw, cwd);
89
+ inner = raw.replace(/\\ /g, ' ');
50
90
  }
51
91
  const resolvedPath = path.isAbsolute(inner) ? inner : path.resolve(cwd, inner);
92
+ const start = match.index - (raw.length - match[0].length);
93
+ while (detected.length > 0) {
94
+ const prev = detected[detected.length - 1];
95
+ if (prev.start + prev.raw.length <= start)
96
+ break;
97
+ detected.pop();
98
+ }
99
+ detected.push({ resolvedPath, raw, start });
100
+ }
101
+ const rawsByPath = new Map();
102
+ for (const { resolvedPath, raw } of detected) {
52
103
  const existing = rawsByPath.get(resolvedPath);
53
104
  if (existing) {
54
105
  existing.push(raw);
@@ -6,6 +6,7 @@ import os from 'os';
6
6
  import path from 'path';
7
7
  import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
8
8
  import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
9
+ import { ensureSessionScratchDir, isInsideTheGitAiScratch, sessionScratchDir, } from './scratch-dir.js';
9
10
  const requireFromHere = createRequire(import.meta.url);
10
11
  let nodePtyCache;
11
12
  function loadNodePty() {
@@ -217,6 +218,7 @@ function isExistingDirectory(absPath) {
217
218
  return false;
218
219
  }
219
220
  }
221
+ const OS_TEMP_BLOCK_MARKER = 'os-temp:';
220
222
  function getBlockedOsTempInspection(rawToken, rootDir) {
221
223
  const token = normalizeToken(rawToken);
222
224
  if (!token || !path.isAbsolute(token))
@@ -224,6 +226,8 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
224
226
  const resolved = path.resolve(token);
225
227
  if (!isInsideOsTemp(resolved))
226
228
  return null;
229
+ if (isInsideTheGitAiScratch(resolved))
230
+ return null;
227
231
  if (rootDir) {
228
232
  const resolvedRoot = path.resolve(rootDir);
229
233
  if (resolved === resolvedRoot ||
@@ -234,7 +238,7 @@ function getBlockedOsTempInspection(rawToken, rootDir) {
234
238
  if (resolved === path.resolve(os.tmpdir()) ||
235
239
  hasPathGlob(token) ||
236
240
  isExistingDirectory(resolved)) {
237
- return path.basename(os.tmpdir()) || os.tmpdir();
241
+ return `${OS_TEMP_BLOCK_MARKER}${path.basename(os.tmpdir()) || os.tmpdir()}`;
238
242
  }
239
243
  return null;
240
244
  }
@@ -250,6 +254,9 @@ function findBlockedDirForToken(rawToken, rootDir, baseDir, allowBareDirMatch =
250
254
  const resolved = path.isAbsolute(token)
251
255
  ? path.resolve(token)
252
256
  : path.resolve(baseDir ?? rootDir, token);
257
+ const osTempResolved = getBlockedOsTempInspection(resolved, rootDir);
258
+ if (osTempResolved)
259
+ return osTempResolved;
253
260
  return getBlockedProjectPathDir(resolved, rootDir);
254
261
  }
255
262
  if (!allowBareDirMatch || !BLOCKED_PATH_INSPECT_DIRS.has(token)) {
@@ -386,11 +393,16 @@ function findBlockedDirInCommandTokens(command, rootDir, baseDir) {
386
393
  }
387
394
  return null;
388
395
  }
396
+ function expandTempEnvRefs(command) {
397
+ return command
398
+ .replace(/\$\{THEGITAI_SCRATCH_DIR[^}]*\}|\$THEGITAI_SCRATCH_DIR\b/g, sessionScratchDir())
399
+ .replace(/\$\{TMPDIR[^}]*\}|\$TMPDIR\b/g, process.env.TMPDIR || os.tmpdir());
400
+ }
389
401
  function findIgnoredPathInspection(command, rootDir) {
390
402
  if (!FILE_INSPECTION_COMMAND_PATTERN.test(command)) {
391
403
  return null;
392
404
  }
393
- const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(command));
405
+ const haystack = maskNonPathIgnoreDirTokens(maskHereDocumentBodies(expandTempEnvRefs(command)));
394
406
  const baseDir = getCommandBaseDir(haystack, rootDir);
395
407
  const blockedDir = findBlockedDirInCommandTokens(haystack, rootDir, baseDir);
396
408
  if (blockedDir) {
@@ -550,10 +562,17 @@ function terminateChild(child, signal) {
550
562
  }
551
563
  }
552
564
  export function getBlockedPathInspectDir(command, rootDir) {
553
- return findIgnoredPathInspection(command, rootDir);
565
+ const blocked = findIgnoredPathInspection(command, rootDir);
566
+ return blocked?.startsWith(OS_TEMP_BLOCK_MARKER)
567
+ ? blocked.slice(OS_TEMP_BLOCK_MARKER.length)
568
+ : blocked;
554
569
  }
555
570
  export function getBlockedCommandReason(command, hasTimeout, rootDir) {
556
571
  const ignoredDir = findIgnoredPathInspection(command, rootDir);
572
+ if (ignoredDir?.startsWith(OS_TEMP_BLOCK_MARKER)) {
573
+ const tempName = ignoredDir.slice(OS_TEMP_BLOCK_MARKER.length);
574
+ return `Listing or scanning the shared OS temp directory (${tempName}) is blocked because it can contain other users' and processes' files. Use the session scratch directory ${ensureSessionScratchDir()} for temporary scripts and files — creating, running, and listing are all allowed there — or reference an exact file path.`;
575
+ }
557
576
  if (ignoredDir) {
558
577
  return `Command inspects an off-limits generated or dependency directory (${ignoredDir}). Avoid that path.`;
559
578
  }
@@ -615,6 +634,7 @@ function buildCommandEnv(cwd) {
615
634
  npm_config_fund: 'false',
616
635
  npm_config_audit: 'false',
617
636
  NUXI_INIT_SKIP_PROMPT: 'true',
637
+ THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
618
638
  };
619
639
  }
620
640
  function sanitizePtyOutput(command, output, cwd, secrets) {
@@ -0,0 +1,57 @@
1
+ import { chmodSync, lstatSync, mkdirSync, mkdtempSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ let cachedScratchDir = null;
5
+ export function sessionScratchDir() {
6
+ if (!cachedScratchDir) {
7
+ cachedScratchDir = path.join(os.tmpdir(), `thegitai-${process.pid}`);
8
+ }
9
+ return cachedScratchDir;
10
+ }
11
+ function isSquattedScratchRoot(dir) {
12
+ try {
13
+ const st = lstatSync(dir);
14
+ if (st.isSymbolicLink() || !st.isDirectory())
15
+ return true;
16
+ if (process.platform !== 'win32' &&
17
+ typeof process.getuid === 'function' &&
18
+ st.uid !== process.getuid()) {
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export function ensureSessionScratchDir() {
28
+ const dir = sessionScratchDir();
29
+ try {
30
+ if (isSquattedScratchRoot(dir)) {
31
+ cachedScratchDir = mkdtempSync(path.join(os.tmpdir(), 'thegitai-'));
32
+ return cachedScratchDir;
33
+ }
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ if (process.platform !== 'win32') {
36
+ chmodSync(dir, 0o700);
37
+ }
38
+ }
39
+ catch {
40
+ }
41
+ return sessionScratchDir();
42
+ }
43
+ export function isInsideTheGitAiScratch(absPath) {
44
+ const tempRoot = path.resolve(os.tmpdir());
45
+ const relPath = path.relative(tempRoot, path.resolve(absPath));
46
+ if (!relPath || relPath.startsWith('..') || path.isAbsolute(relPath)) {
47
+ return false;
48
+ }
49
+ const first = relPath.split(/[\\/]/, 1)[0] ?? '';
50
+ if (!/^thegitai(?:$|[-.])/i.test(first)) {
51
+ return false;
52
+ }
53
+ if (/[*?[\]{}]/.test(first)) {
54
+ return false;
55
+ }
56
+ return !isSquattedScratchRoot(path.join(tempRoot, first));
57
+ }
@@ -45,7 +45,7 @@ export async function runShellCommand(context, args) {
45
45
  ok: false,
46
46
  skipped: true,
47
47
  command,
48
- error: 'User declined command execution',
48
+ error: 'User declined command execution. Do not rerun this command or try a broader variant of it; either continue without it or ask the user one specific question about how to proceed.',
49
49
  };
50
50
  }
51
51
  }
@@ -3,6 +3,7 @@ import { execFileSync, spawn } from 'node:child_process';
3
3
  import { syncIndexFromDisk } from '../project-index.js';
4
4
  import { isTuiMode } from '../runtime-mode.js';
5
5
  import { buildDeferredShellDiagnostics, invalidateShellDiagnosticsCache, } from './shell-diagnostics.js';
6
+ import { ensureSessionScratchDir } from '../scratch-dir.js';
6
7
  const DEFAULT_TIMEOUT = 5 * 60 * 1000;
7
8
  const MAX_OUTPUT_CHARS = 4000;
8
9
  const MAX_CAPTURE_CHARS = 1024 * 1024;
@@ -99,6 +100,7 @@ function executeNodeScript(rootDir, script, timeout) {
99
100
  npm_config_progress: 'false',
100
101
  npm_config_fund: 'false',
101
102
  npm_config_audit: 'false',
103
+ THEGITAI_SCRATCH_DIR: ensureSessionScratchDir(),
102
104
  },
103
105
  stdio: ['pipe', 'pipe', 'pipe'],
104
106
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.15",
3
+ "version": "1.0.0-beta.16",
4
4
  "description": "TheGitAI CLI client (source-visible, proprietary)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://thegit.ai",
@@ -25,10 +25,10 @@
25
25
  "@lydell/node-pty-linux-x64": "1.1.0",
26
26
  "@lydell/node-pty-win32-arm64": "1.1.0",
27
27
  "@lydell/node-pty-win32-x64": "1.1.0",
28
- "@thegitai/tui-darwin-arm64": "1.0.0-beta.15",
29
- "@thegitai/tui-darwin-x64": "1.0.0-beta.15",
30
- "@thegitai/tui-linux-x64": "1.0.0-beta.15",
31
- "@thegitai/tui-win32-x64": "1.0.0-beta.15",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.16",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.16",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.16",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.16",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {