colana 1.0.0-beta.65 → 1.0.0-beta.67

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "colana",
3
- "version": "1.0.0-beta.65",
3
+ "version": "1.0.0-beta.67",
4
4
  "description": "Agent-First. Multiplied. Multi-agent command center for AI coding agents.",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -31,107 +31,155 @@ const MODULE = 'auto-installer';
31
31
  * standard binary directories and adds them to the running process's PATH,
32
32
  * allowing subsequent commandExistsSync / PTY spawns to find newly installed CLIs.
33
33
  *
34
+ * Windows: npm global dirs, Python scripts dirs, pipx/uv bin dirs, MS Store paths.
35
+ * macOS: /opt/homebrew/bin (Apple Silicon), /usr/local/bin, ~/.local/bin, npm global.
36
+ * Linux: /usr/local/bin, ~/.local/bin, ~/.cargo/bin, npm global.
37
+ *
34
38
  * Safe to call multiple times — skips directories already in PATH.
35
- * Safe to call on non-Windows — returns immediately.
36
39
  */
37
40
  export function refreshPathAfterInstall() {
38
- if (process.platform !== 'win32') return;
41
+ const isWin = process.platform === 'win32';
42
+ const separator = isWin ? ';' : ':';
39
43
  const additions = [];
40
- const currentPath = (process.env.PATH || '').toLowerCase();
44
+ const currentPath = process.env.PATH || '';
41
45
 
42
46
  /**
43
47
  * Helper: add a directory to PATH if it exists and isn't already present.
48
+ * Windows: case-insensitive substring check (existing behaviour).
49
+ * Unix: exact segment match (prevents /usr/local/bin matching /usr/local/bin2).
44
50
  * @param {string|undefined} dir
45
51
  */
46
52
  function addIfMissing(dir) {
47
53
  if (!dir) return;
48
54
  const trimmed = dir.trim();
49
- if (trimmed && !currentPath.includes(trimmed.toLowerCase())) {
50
- additions.push(trimmed);
55
+ if (!trimmed) return;
56
+ if (isWin) {
57
+ if (!currentPath.toLowerCase().includes(trimmed.toLowerCase())) {
58
+ additions.push(trimmed);
59
+ }
60
+ } else {
61
+ const segments = currentPath.split(':');
62
+ if (!segments.includes(trimmed)) {
63
+ additions.push(trimmed);
64
+ }
51
65
  }
52
66
  }
53
67
 
54
- // 1. npm global bin directory via `npm config get prefix`
55
- // On Windows, global .cmd wrappers live directly in the prefix dir.
56
- let npmPrefix = null;
57
- try {
58
- npmPrefix = execFileSync('npm', ['config', 'get', 'prefix'], {
59
- timeout: 5000, encoding: 'utf-8', shell: true,
60
- }).trim();
61
- addIfMissing(npmPrefix);
62
- } catch { /* npm might not be installed */ }
63
-
64
- // 2. Standard %APPDATA%\npm — the default npm global bin dir on Windows.
65
- // Some Node.js installs (especially MS Store) don't add this to system PATH.
66
- // This is a hard-coded fallback that covers the most common case.
67
- const appData = process.env.APPDATA;
68
- if (appData) {
69
- addIfMissing(`${appData}\\npm`);
70
- }
68
+ if (isWin) {
69
+ // ——— Windows-specific directories ———
71
70
 
72
- // 3. %LOCALAPPDATA%\npm alternate npm global location on some setups
73
- const localAppData = process.env.LOCALAPPDATA;
74
- if (localAppData) {
75
- addIfMissing(`${localAppData}\\npm`);
76
- }
71
+ // 1. npm global bin directory via `npm config get prefix`
72
+ // On Windows, global .cmd wrappers live directly in the prefix dir.
73
+ try {
74
+ const npmPrefix = execFileSync('npm', ['config', 'get', 'prefix'], {
75
+ timeout: 5000, encoding: 'utf-8', shell: true,
76
+ }).trim();
77
+ addIfMissing(npmPrefix);
78
+ } catch { /* npm might not be installed */ }
77
79
 
78
- // 4. Python user scripts directory (for pip --user installs like aider)
79
- // Use stdio: ['ignore','pipe','ignore'] to suppress SyntaxError spam from MS Store Python 3.13
80
- try {
81
- const pyScripts = execFileSync('python', [
82
- '-c', "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user'))",
83
- ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
84
- addIfMissing(pyScripts);
85
- } catch { /* python might not be installed */ }
80
+ // 2. Standard %APPDATA%\npm the default npm global bin dir on Windows.
81
+ const appData = process.env.APPDATA;
82
+ if (appData) {
83
+ addIfMissing(`${appData}\\npm`);
84
+ }
86
85
 
87
- // 5. Python system scripts directory
88
- try {
89
- const sysScripts = execFileSync('python', [
90
- '-c', "import sysconfig; print(sysconfig.get_path('scripts'))",
91
- ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
92
- addIfMissing(sysScripts);
93
- } catch { /* ignore */ }
94
-
95
- // 6. pipx default bin directory (%USERPROFILE%\.local\bin on Windows)
96
- const userProfile = process.env.USERPROFILE;
97
- if (userProfile) {
98
- addIfMissing(`${userProfile}\\.local\\bin`);
99
- }
86
+ // 3. %LOCALAPPDATA%\npm alternate npm global location on some setups
87
+ const localAppData = process.env.LOCALAPPDATA;
88
+ if (localAppData) {
89
+ addIfMissing(`${localAppData}\\npm`);
90
+ }
100
91
 
101
- // 7. uv tool bin directory (%USERPROFILE%\.local\\bin is shared; also check %APPDATA%\uv)
102
- if (appData) {
103
- addIfMissing(`${appData}\\uv\\bin`);
104
- }
92
+ // 4. Python user scripts directory (for pip --user installs like aider)
93
+ try {
94
+ const pyScripts = execFileSync('python', [
95
+ '-c', "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user'))",
96
+ ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
97
+ addIfMissing(pyScripts);
98
+ } catch { /* python might not be installed */ }
105
99
 
106
- // 8. npm global bin directory via `npm bin -g` — may differ from prefix on some setups
107
- // (e.g. nvm-windows, volta, fnm). This is the most reliable method.
108
- try {
109
- const npmBin = execFileSync('npm', ['bin', '-g'], {
110
- timeout: 5000, encoding: 'utf-8', shell: true,
111
- }).trim();
112
- addIfMissing(npmBin);
113
- } catch { /* ignore */ }
114
-
115
- // 9. MS Store Python user scripts — site.getusersitepackages() returns the real
116
- // sandboxed path (e.g. ...\Packages\PythonSoftwareFoundation...\LocalCache\...),
117
- // unlike sysconfig which may return the standard (wrong) location.
118
- try {
119
- const msStoreScripts = execFileSync('python', [
120
- '-c', "import site, os; print(os.path.normpath(os.path.join(site.getusersitepackages(), '..', 'Scripts')))",
121
- ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
122
- addIfMissing(msStoreScripts);
123
- } catch { /* python might not be MS Store or not installed */ }
100
+ // 5. Python system scripts directory
101
+ try {
102
+ const sysScripts = execFileSync('python', [
103
+ '-c', "import sysconfig; print(sysconfig.get_path('scripts'))",
104
+ ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
105
+ addIfMissing(sysScripts);
106
+ } catch { /* ignore */ }
107
+
108
+ // 6. pipx default bin directory (%USERPROFILE%\.local\bin on Windows)
109
+ const userProfile = process.env.USERPROFILE;
110
+ if (userProfile) {
111
+ addIfMissing(`${userProfile}\\.local\\bin`);
112
+ }
124
113
 
125
- // 10. Python base Scripts dir (next to python.exe — covers standard python.org installs)
126
- try {
127
- const pyBaseScripts = execFileSync('python', [
128
- '-c', "import sys, os; print(os.path.join(os.path.dirname(sys.executable), 'Scripts'))",
129
- ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
130
- addIfMissing(pyBaseScripts);
131
- } catch { /* ignore */ }
114
+ // 7. uv tool bin directory
115
+ if (appData) {
116
+ addIfMissing(`${appData}\\uv\\bin`);
117
+ }
118
+
119
+ // 8. npm global bin directory via `npm bin -g`
120
+ try {
121
+ const npmBin = execFileSync('npm', ['bin', '-g'], {
122
+ timeout: 5000, encoding: 'utf-8', shell: true,
123
+ }).trim();
124
+ addIfMissing(npmBin);
125
+ } catch { /* ignore */ }
126
+
127
+ // 9. MS Store Python user scripts
128
+ try {
129
+ const msStoreScripts = execFileSync('python', [
130
+ '-c', "import site, os; print(os.path.normpath(os.path.join(site.getusersitepackages(), '..', 'Scripts')))",
131
+ ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
132
+ addIfMissing(msStoreScripts);
133
+ } catch { /* ignore */ }
134
+
135
+ // 10. Python base Scripts dir (next to python.exe)
136
+ try {
137
+ const pyBaseScripts = execFileSync('python', [
138
+ '-c', "import sys, os; print(os.path.join(os.path.dirname(sys.executable), 'Scripts'))",
139
+ ], { timeout: 5000, encoding: 'utf-8', shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
140
+ addIfMissing(pyBaseScripts);
141
+ } catch { /* ignore */ }
142
+
143
+ } else {
144
+ // ——— macOS + Linux directories ———
145
+ const home = os.homedir();
146
+
147
+ // 1. Homebrew (Apple Silicon macOS installs to /opt/homebrew)
148
+ if (process.platform === 'darwin') {
149
+ addIfMissing('/opt/homebrew/bin');
150
+ addIfMissing('/opt/homebrew/sbin');
151
+ }
152
+
153
+ // 2. /usr/local/bin (Homebrew Intel macOS + manual installs on Linux)
154
+ addIfMissing('/usr/local/bin');
155
+
156
+ // 3. ~/.local/bin (pipx, uv, user-installed Python tools)
157
+ addIfMissing(`${home}/.local/bin`);
158
+
159
+ // 4. ~/.cargo/bin (Rust-based tools)
160
+ addIfMissing(`${home}/.cargo/bin`);
161
+
162
+ // 5. npm global bin directory (handles nvm, volta, fnm, Homebrew node)
163
+ try {
164
+ const npmBin = execFileSync('npm', ['bin', '-g'], {
165
+ timeout: 5000, encoding: 'utf-8',
166
+ }).trim();
167
+ addIfMissing(npmBin);
168
+ } catch { /* npm might not be installed */ }
169
+
170
+ // 6. npm prefix/bin (alternative layout via nvm, volta, fnm)
171
+ try {
172
+ const npmPrefix = execFileSync('npm', ['config', 'get', 'prefix'], {
173
+ timeout: 5000, encoding: 'utf-8',
174
+ }).trim();
175
+ if (npmPrefix) {
176
+ addIfMissing(`${npmPrefix}/bin`);
177
+ }
178
+ } catch { /* ignore */ }
179
+ }
132
180
 
133
181
  if (additions.length > 0) {
134
- process.env.PATH = additions.join(';') + ';' + process.env.PATH;
182
+ process.env.PATH = additions.join(separator) + separator + process.env.PATH;
135
183
  logger.info(MODULE, 'Refreshed PATH', { added: additions });
136
184
  }
137
185
  }
@@ -937,7 +985,25 @@ async function runInstallCommand(provider, providerConfig, progress) {
937
985
  }
938
986
  }
939
987
 
940
- // Fallback 1: invoke uv via `python -m uv` bypasses PATH entirely
988
+ // Fallback 1: Homebrew (macOS or Linuxbrew) more reliable than pip on Python 3.14+
989
+ if (!hasUv && process.platform !== 'win32') {
990
+ try {
991
+ const brewExists = await commandExistsAsync('brew').then(() => true).catch(() => false);
992
+ if (brewExists) {
993
+ progress.log('Trying: brew install uv (Homebrew)...');
994
+ await runShellCommand('brew', ['install', 'uv'], progress, 180_000);
995
+ refreshPathAfterInstall();
996
+ hasUv = await commandExistsAsync('uv').then(() => true).catch(() => false);
997
+ if (hasUv) {
998
+ progress.log('uv installed via Homebrew.');
999
+ }
1000
+ }
1001
+ } catch {
1002
+ progress.log('brew install uv failed or Homebrew not available.');
1003
+ }
1004
+ }
1005
+
1006
+ // Fallback 2: invoke uv via `python -m uv` — bypasses PATH entirely
941
1007
  // since Python can find its own installed modules regardless of Scripts dir.
942
1008
  if (!hasUv) {
943
1009
  progress.log('Trying python -m uv (bypasses PATH)...');
@@ -950,7 +1016,7 @@ async function runInstallCommand(provider, providerConfig, progress) {
950
1016
  }
951
1017
  }
952
1018
 
953
- // Fallback 2: direct binary search in known Python Scripts dirs.
1019
+ // Fallback 3: direct binary search in known Python Scripts dirs (Windows only).
954
1020
  // Uses semicolons (not newlines) for Windows cmd.exe compatibility.
955
1021
  if (!hasUv && process.platform === 'win32') {
956
1022
  progress.log('Searching for uv binary in Python Scripts directories...');
@@ -130,15 +130,14 @@ function checkBinaryInstalled(provider, providerConfig) {
130
130
  return { ok: true };
131
131
  }
132
132
 
133
- // Recovery: on Windows, PATH may be stale. Refresh and retry once.
134
- if (process.platform === 'win32') {
135
- refreshPathAfterInstall();
136
- if (commandExistsSync(binary)) {
137
- return { ok: true };
138
- }
139
- if (fallbackBinary && commandExistsSync(fallbackBinary)) {
140
- return { ok: true };
141
- }
133
+ // Recovery: PATH may be stale if a CLI was installed after the server started
134
+ // (e.g. via wizard). Refresh from common tool directories and retry once.
135
+ refreshPathAfterInstall();
136
+ if (commandExistsSync(binary)) {
137
+ return { ok: true };
138
+ }
139
+ if (fallbackBinary && commandExistsSync(fallbackBinary)) {
140
+ return { ok: true };
142
141
  }
143
142
 
144
143
  return {
@@ -21,7 +21,7 @@ import {
21
21
  import { getAgentEnv, getSetting, resolveSessionAuthMode } from './settings-store.js';
22
22
  import { recordUsageWithSource } from './usage-tracker.js';
23
23
  import config from './config.js';
24
- import { commandExistsSync, resolveNpmGlobalEntryPoint } from './platform.js';
24
+ import { commandExistsSync, getCommandPath, resolveNpmGlobalEntryPoint } from './platform.js';
25
25
  import { isGitRepo, getHeadSha, getWorkingTreeStatus } from './git-ops.js';
26
26
  import { createCheckpoint } from './checkpoint-store.js';
27
27
  import { syncBeforeSpawn } from './context-sync.js';
@@ -349,6 +349,10 @@ function verifyGatewayToken(_port, token) {
349
349
  * Records the token, auth method, and timestamp so subsequent startups can verify.
350
350
  */
351
351
  function writeGatewayMarker(token) {
352
+ // Cache the token in memory so subsequent sequential calls within this process
353
+ // can verify against it without re-reading config (which may hold a different,
354
+ // local token after we adopted a foreign gateway).
355
+ _activeGatewayToken = token;
352
356
  try {
353
357
  const dir = path.dirname(GATEWAY_MARKER_PATH);
354
358
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -481,6 +485,16 @@ async function killGatewayOnPort(port) {
481
485
  // Concurrent callers piggyback on the in-flight promise.
482
486
  let _gatewayEnsurePromise = null;
483
487
 
488
+ // In-memory cache of the last token written to the gateway marker file.
489
+ // After adopting a foreign gateway token, this prevents subsequent sequential
490
+ // calls from re-triggering the mismatch/kill/adopt cycle (because the local
491
+ // config token differs from the adopted marker token).
492
+ let _activeGatewayToken = null;
493
+
494
+ /** Test-only: get/reset _activeGatewayToken for test isolation. */
495
+ function _getActiveGatewayToken() { return _activeGatewayToken; }
496
+ function _resetActiveGatewayToken() { _activeGatewayToken = null; }
497
+
484
498
  /**
485
499
  * Ensure the OpenClaw gateway daemon is running on the given port.
486
500
  * Uses a singleton promise so concurrent callers coalesce into one execution.
@@ -537,7 +551,10 @@ async function _ensureOpenClawGatewayImpl(port) {
537
551
  // Fall through to spawn new gateway
538
552
  } else {
539
553
  // Gateway is listening — check if we spawned it with our current token
540
- const tokenValid = verifyGatewayToken(port, gatewayToken);
554
+ // Use the in-memory cached token if available (set after a previous adoption
555
+ // in this process), otherwise fall back to the config-derived token.
556
+ const verifyToken = _activeGatewayToken || gatewayToken;
557
+ const tokenValid = verifyGatewayToken(port, verifyToken);
541
558
  if (tokenValid) {
542
559
  console.log('[pty-manager] OpenClaw gateway already running, token valid (marker match)');
543
560
  return;
@@ -856,6 +873,17 @@ export async function startPtyAgent(projectId, projectPath, task, options = {})
856
873
  }
857
874
  }
858
875
 
876
+ // On macOS/Linux, resolve the binary to a full path. node-pty's posix_spawnp
877
+ // uses the spawned env's PATH, which may be stale if the CLI was installed
878
+ // after the server started (e.g. via wizard). getCommandPath() calls `which`
879
+ // in a subshell that inherits the refreshed process.env.PATH.
880
+ if (process.platform !== 'win32') {
881
+ const fullPath = getCommandPath(binary);
882
+ if (fullPath) {
883
+ binary = fullPath;
884
+ }
885
+ }
886
+
859
887
  // 1. Create session record with mode='pty'
860
888
  const session = createSession({
861
889
  projectId,
@@ -2276,6 +2304,10 @@ export {
2276
2304
  extractCodexSessionIdFromRolloutFile as _extractCodexSessionIdFromRolloutFile,
2277
2305
  detectCodexSessionIdFromFilesystem as _detectCodexSessionIdFromFilesystem,
2278
2306
  isValidProviderSessionId as _isValidProviderSessionId,
2307
+ writeGatewayMarker as _writeGatewayMarker,
2308
+ verifyGatewayToken as _verifyGatewayToken,
2309
+ _getActiveGatewayToken,
2310
+ _resetActiveGatewayToken,
2279
2311
  };
2280
2312
 
2281
2313
  /**
@@ -2291,6 +2323,8 @@ export async function restartOpenClawGateway() {
2291
2323
  console.log('[pty-manager] Restarting OpenClaw gateway to reload MCP config...');
2292
2324
  // Clear any in-flight singleton promise so the ensure call below runs fresh
2293
2325
  _gatewayEnsurePromise = null;
2326
+ // Clear cached adopted token so the fresh ensure call re-derives from config
2327
+ _activeGatewayToken = null;
2294
2328
  // Clear marker before kill so ensureOpenClawGateway knows to respawn
2295
2329
  try { fs.unlinkSync(GATEWAY_MARKER_PATH); } catch { /* may not exist */ }
2296
2330
  await killGatewayOnPort(gwPort);
@@ -2317,7 +2351,7 @@ startIdleCleanup();
2317
2351
  * Used by personal-agent-routes to distinguish gateway-level 401 from provider-level 401.
2318
2352
  */
2319
2353
  export function isGatewaySpawnedByColana() {
2320
- const token = getOrCreateGatewayToken();
2354
+ const token = _activeGatewayToken || getOrCreateGatewayToken();
2321
2355
  return verifyGatewayToken(null, token);
2322
2356
  }
2323
2357