@hamp10/agentforge 0.2.25 → 0.2.27
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 +1 -1
- package/scripts/check-task-semantics.js +56 -0
- package/src/OpenClawCLI.js +53 -4
- package/src/browser.js +51 -13
- package/src/selfUpdate.js +41 -5
package/package.json
CHANGED
|
@@ -668,6 +668,7 @@ try {
|
|
|
668
668
|
const openClawSource = readFileSync(new URL('../src/OpenClawCLI.js', import.meta.url), 'utf-8');
|
|
669
669
|
const workerSource = readFileSync(new URL('../src/worker.js', import.meta.url), 'utf-8');
|
|
670
670
|
const workerBinSource = readFileSync(new URL('../bin/agentforge.js', import.meta.url), 'utf-8');
|
|
671
|
+
const selfUpdateSource = readFileSync(new URL('../src/selfUpdate.js', import.meta.url), 'utf-8');
|
|
671
672
|
const defaultGuidesSource = readFileSync(new URL('../src/default-task-guides.js', import.meta.url), 'utf-8');
|
|
672
673
|
const browserSource = readFileSync(new URL('../src/browser.js', import.meta.url), 'utf-8');
|
|
673
674
|
const previewServerSource = readFileSync(new URL('../src/preview-server.js', import.meta.url), 'utf-8');
|
|
@@ -838,16 +839,41 @@ assert.match(
|
|
|
838
839
|
/app\.delete\('\/api\/agents\/delete\/cleanup-empty'[\s\S]*activeTasks[\s\S]*type: 'agents_pruned'/i,
|
|
839
840
|
'empty-agent cleanup should preserve live tasks and broadcast removed agents'
|
|
840
841
|
);
|
|
842
|
+
assert.match(
|
|
843
|
+
serverSource,
|
|
844
|
+
/CREATE TABLE IF NOT EXISTS agent_flows[\s\S]*config JSONB DEFAULT NULL/i,
|
|
845
|
+
'agent flow emergency schema patch should include config so model routing survives skipped migrations'
|
|
846
|
+
);
|
|
847
|
+
assert.match(
|
|
848
|
+
serverSource,
|
|
849
|
+
/ALTER TABLE agent_flows ADD COLUMN IF NOT EXISTS config JSONB DEFAULT NULL/i,
|
|
850
|
+
'agent flow startup should self-heal missing config column'
|
|
851
|
+
);
|
|
852
|
+
assert.match(
|
|
853
|
+
serverSource,
|
|
854
|
+
/ON CONFLICT \(id\)[\s\S]*WHERE agent_flows\.user_id = EXCLUDED\.user_id[\s\S]*RETURNING id/i,
|
|
855
|
+
'flow upserts should not update another user flow with the same id'
|
|
856
|
+
);
|
|
841
857
|
assert.match(
|
|
842
858
|
dashboardSource,
|
|
843
859
|
/confirmDeleteAgent[\s\S]*await fetch[\s\S]*removeAgentFromLocalState/i,
|
|
844
860
|
'single-agent delete should only remove local UI after the server confirms deletion'
|
|
845
861
|
);
|
|
862
|
+
assert.match(
|
|
863
|
+
dashboardSource,
|
|
864
|
+
/confirmPruneAgents[\s\S]*\/api\/agents\/delete\/prune\?keep=15[\s\S]*removeAgentFromLocalState/i,
|
|
865
|
+
'dashboard should expose old-agent pruning through the safe keep-latest endpoint'
|
|
866
|
+
);
|
|
846
867
|
assert.match(
|
|
847
868
|
dashboardSource,
|
|
848
869
|
/function finalizeLiveFeedBeforeGuide[\s\S]*commitChunkBuffer[\s\S]*hideTypingIndicator/i,
|
|
849
870
|
'live Guide messages should close the current visible agent feed before rendering the user bubble'
|
|
850
871
|
);
|
|
872
|
+
assert.match(
|
|
873
|
+
selfUpdateSource,
|
|
874
|
+
/ensureWritableGlobalInstallEnv[\s\S]*canWriteNpmRoot[\s\S]*configureUserOwnedNpmGlobalEnv/i,
|
|
875
|
+
'self-update should preflight npm global permissions before printing a failed system-prefix install'
|
|
876
|
+
);
|
|
851
877
|
assert.match(
|
|
852
878
|
dashboardSource,
|
|
853
879
|
/async function sendGuideMessage[\s\S]*agent\._guideInflight[\s\S]*finalizeLiveFeedBeforeGuide\(agentId\)[\s\S]*addMessage/i,
|
|
@@ -918,6 +944,16 @@ assert.match(
|
|
|
918
944
|
/__agentforge_verify/i,
|
|
919
945
|
'direct browser verification should cache-bust local verification URLs'
|
|
920
946
|
);
|
|
947
|
+
assert.match(
|
|
948
|
+
openClawSource,
|
|
949
|
+
/normalizeForNavigationCompare\(target\?\.url \|\| ''\) === normalizeForNavigationCompare\(effectiveRequestedUrl\)/,
|
|
950
|
+
'direct browser verification should reuse an existing cache-busted tab for the same requested local URL'
|
|
951
|
+
);
|
|
952
|
+
assert.match(
|
|
953
|
+
openClawSource,
|
|
954
|
+
/isLocalVerificationUrl[\s\S]*__agentforge_verify[\s\S]*closeDuplicateVerificationTargets[\s\S]*\/json\/close\//i,
|
|
955
|
+
'direct browser verification should close duplicate cache-busted local verification tabs'
|
|
956
|
+
);
|
|
921
957
|
assert.match(
|
|
922
958
|
openClawSource,
|
|
923
959
|
/forgetLocalScopedUrl[\s\S]*rendered page content did not identify target|rendered page content did not identify target[\s\S]*forgetLocalScopedUrl/i,
|
|
@@ -933,6 +969,16 @@ assert.match(
|
|
|
933
969
|
/__agentforge_verify/i,
|
|
934
970
|
'AgentForge browser tool should cache-bust localhost navigations'
|
|
935
971
|
);
|
|
972
|
+
assert.match(
|
|
973
|
+
browserSource,
|
|
974
|
+
/sameBrowserTargetUrl[\s\S]*stripLocalCacheBust[\s\S]*getPage\(agentId, url\)/i,
|
|
975
|
+
'AgentForge browser tool should reuse cache-busted tabs for the same requested local URL'
|
|
976
|
+
);
|
|
977
|
+
assert.match(
|
|
978
|
+
browserSource,
|
|
979
|
+
/isLocalVerificationUrl[\s\S]*__agentforge_verify[\s\S]*closeStaleVerificationTabs[\s\S]*page\.close/i,
|
|
980
|
+
'AgentForge browser tool should close stale cache-busted localhost verification tabs'
|
|
981
|
+
);
|
|
936
982
|
assert.match(
|
|
937
983
|
previewServerSource,
|
|
938
984
|
/extname\(urlPath\)[\s\S]*writeHead\(404[\s\S]*no-store/i,
|
|
@@ -953,6 +999,16 @@ assert.doesNotMatch(
|
|
|
953
999
|
/taskRequiresVisualVerification\s*&&\s*directIteration\s*<=\s*1\s*&&\s*explicitScopeForTask\.pageOnly/s,
|
|
954
1000
|
'comparable UI context gate should apply on retries, not only first iteration'
|
|
955
1001
|
);
|
|
1002
|
+
assert.match(
|
|
1003
|
+
openClawSource,
|
|
1004
|
+
/read-only project context, not as a target page/,
|
|
1005
|
+
'read-only comparable page inspection should not be narrated as target-page work'
|
|
1006
|
+
);
|
|
1007
|
+
assert.match(
|
|
1008
|
+
openClawSource,
|
|
1009
|
+
/verifiedLocalUrl = isLocalUiUrl\(resultUrl \|\| url\)/,
|
|
1010
|
+
'scoped auto-verification should count a clean inferred local target URL even when browser output formatting is imperfect'
|
|
1011
|
+
);
|
|
956
1012
|
assert.match(
|
|
957
1013
|
openClawSource,
|
|
958
1014
|
/const minRatio = isLargeText \? 3 : 4\.5;/,
|
package/src/OpenClawCLI.js
CHANGED
|
@@ -2761,6 +2761,8 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2761
2761
|
const normalizeForNavigationCompare = (value) => stripAgentForgeCacheBust(normalize(value));
|
|
2762
2762
|
const isLocalHttpUrl = (value) =>
|
|
2763
2763
|
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i.test(String(value || ''));
|
|
2764
|
+
const isLocalVerificationUrl = (value) =>
|
|
2765
|
+
isLocalHttpUrl(value) && /[?&]__agentforge_verify=/.test(String(value || ''));
|
|
2764
2766
|
const withVerificationCacheBust = (value) => {
|
|
2765
2767
|
if (!value || !isLocalHttpUrl(value)) return value;
|
|
2766
2768
|
try {
|
|
@@ -2790,9 +2792,35 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2790
2792
|
const usablePages = !taskIsAboutAgentForge
|
|
2791
2793
|
? pages.filter(p => !isAgentForgeControlSurface(p.url))
|
|
2792
2794
|
: pages;
|
|
2795
|
+
const matchesEffectiveRequestedUrl = (target) =>
|
|
2796
|
+
!!effectiveRequestedUrl && normalizeForNavigationCompare(target?.url || '') === normalizeForNavigationCompare(effectiveRequestedUrl);
|
|
2797
|
+
const requestedPageMatches = effectiveRequestedUrl
|
|
2798
|
+
? pages.filter(matchesEffectiveRequestedUrl)
|
|
2799
|
+
: [];
|
|
2793
2800
|
let page = effectiveRequestedUrl
|
|
2794
|
-
?
|
|
2801
|
+
? requestedPageMatches.find(p => !isLocalVerificationUrl(p.url)) || requestedPageMatches[0] || null
|
|
2795
2802
|
: usablePages.find(p => p.url && p.url !== 'about:blank') || null;
|
|
2803
|
+
const closeDuplicateVerificationTargets = async (keepTarget) => {
|
|
2804
|
+
if (!effectiveRequestedUrl || !keepTarget) return;
|
|
2805
|
+
const staleTargets = requestedPageMatches.filter(target =>
|
|
2806
|
+
target.id !== keepTarget.id &&
|
|
2807
|
+
isLocalVerificationUrl(target.url) &&
|
|
2808
|
+
normalizeForNavigationCompare(target.url) === normalizeForNavigationCompare(effectiveRequestedUrl)
|
|
2809
|
+
);
|
|
2810
|
+
if (staleTargets.length === 0) return;
|
|
2811
|
+
let closed = 0;
|
|
2812
|
+
await Promise.all(staleTargets.map(async (target) => {
|
|
2813
|
+
try {
|
|
2814
|
+
const closeResp = await fetch(`${cdpBase}/json/close/${encodeURIComponent(target.id)}`, {
|
|
2815
|
+
signal: AbortSignal.timeout(3_000),
|
|
2816
|
+
});
|
|
2817
|
+
if (closeResp.ok) closed += 1;
|
|
2818
|
+
} catch {}
|
|
2819
|
+
}));
|
|
2820
|
+
if (closed > 0) {
|
|
2821
|
+
console.log(` [${agentId}] 🧹 Closed ${closed} stale verification tab(s) for ${stripAgentForgeCacheBust(effectiveRequestedUrl)}`);
|
|
2822
|
+
}
|
|
2823
|
+
};
|
|
2796
2824
|
|
|
2797
2825
|
const controlSurfaceUrl = effectiveRequestedUrl || page?.url || '';
|
|
2798
2826
|
if (!taskIsAboutAgentForge && isAgentForgeControlSurface(controlSurfaceUrl)) {
|
|
@@ -2811,6 +2839,7 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2811
2839
|
if (!newResp.ok) throw new Error(`CDP new page failed: HTTP ${newResp.status}`);
|
|
2812
2840
|
page = await newResp.json();
|
|
2813
2841
|
}
|
|
2842
|
+
await closeDuplicateVerificationTargets(page);
|
|
2814
2843
|
|
|
2815
2844
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
2816
2845
|
let seq = 0;
|
|
@@ -2892,7 +2921,14 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
2892
2921
|
}
|
|
2893
2922
|
return lastState;
|
|
2894
2923
|
};
|
|
2895
|
-
|
|
2924
|
+
const currentPageMatchesRequest = effectiveRequestedUrl && normalizeForNavigationCompare(page.url) === normalizeForNavigationCompare(effectiveRequestedUrl);
|
|
2925
|
+
const shouldNavigateForFreshVerification = !!(
|
|
2926
|
+
effectiveRequestedUrl &&
|
|
2927
|
+
options?.afterMutation &&
|
|
2928
|
+
navigationRequestedUrl &&
|
|
2929
|
+
navigationRequestedUrl !== effectiveRequestedUrl
|
|
2930
|
+
);
|
|
2931
|
+
if (effectiveRequestedUrl && (!currentPageMatchesRequest || shouldNavigateForFreshVerification)) {
|
|
2896
2932
|
await cdp('Page.navigate', { url: navigationRequestedUrl || effectiveRequestedUrl });
|
|
2897
2933
|
await waitForRequestedDocument(effectiveRequestedUrl);
|
|
2898
2934
|
} else if (effectiveRequestedUrl) {
|
|
@@ -4629,8 +4665,10 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4629
4665
|
recordDirectToolSummary('browser', result);
|
|
4630
4666
|
continue;
|
|
4631
4667
|
}
|
|
4632
|
-
|
|
4633
|
-
|
|
4668
|
+
const resultUrl = browserResultUrl(result);
|
|
4669
|
+
rememberLocalScopedUrl(resultUrl);
|
|
4670
|
+
const verifiedLocalUrl = isLocalUiUrl(resultUrl || url);
|
|
4671
|
+
if (isLocalUiVerificationResult(result) || verifiedLocalUrl) {
|
|
4634
4672
|
recordCleanLocalVerification(result, [slug]);
|
|
4635
4673
|
verifiedAny = true;
|
|
4636
4674
|
}
|
|
@@ -4766,6 +4804,13 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
4766
4804
|
return `I am checking the live browser${url} to judge the actual product surface before deciding the next change.`;
|
|
4767
4805
|
}
|
|
4768
4806
|
if (name === 'read_file' || name === 'read') {
|
|
4807
|
+
const scope = extractExplicitScope(task);
|
|
4808
|
+
const targetText = String(targetPath || base || '').toLowerCase();
|
|
4809
|
+
const looksLikePageSource = /\.(html?|css|s[ac]ss|jsx?|tsx?|vue|svelte|astro|mdx?)$/i.test(targetText);
|
|
4810
|
+
const isNamedTarget = scope.slugs.length > 0 && scopeSlugsMatchingText(targetText, scope.slugs).length > 0;
|
|
4811
|
+
if (base && looksLikePageSource && scope.slugs.length > 0 && !isNamedTarget) {
|
|
4812
|
+
return `I am reading ${base} as read-only project context, not as a target page.`;
|
|
4813
|
+
}
|
|
4769
4814
|
return base
|
|
4770
4815
|
? `I am reading ${base} to work from the current implementation instead of guessing.`
|
|
4771
4816
|
: `I am reading the current code before making the next change.`;
|
|
@@ -5097,6 +5142,10 @@ export class OpenClawCLI extends EventEmitter {
|
|
|
5097
5142
|
lastDirectVisualWarning = visualWarning;
|
|
5098
5143
|
} else if (!taskRequiresVisualVerification || isLocalUiVerificationResult(result)) {
|
|
5099
5144
|
recordCleanLocalVerification(result);
|
|
5145
|
+
} else {
|
|
5146
|
+
const verifiedUrl = resultUrl || args?.url || '';
|
|
5147
|
+
const verifiedSlugs = isLocalUiUrl(verifiedUrl) ? scopeSlugsInText(verifiedUrl) : [];
|
|
5148
|
+
if (verifiedSlugs.length > 0) recordCleanLocalVerification(result, verifiedSlugs);
|
|
5100
5149
|
}
|
|
5101
5150
|
}
|
|
5102
5151
|
}
|
package/src/browser.js
CHANGED
|
@@ -25,6 +25,43 @@ async function getBrowser() {
|
|
|
25
25
|
return _browser;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function stripLocalCacheBust(value) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = new URL(value);
|
|
31
|
+
parsed.searchParams.delete('__agentforge_verify');
|
|
32
|
+
return parsed.href.replace(/\/$/, '');
|
|
33
|
+
} catch {
|
|
34
|
+
return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sameBrowserTargetUrl(left, right) {
|
|
39
|
+
if (!left || !right) return false;
|
|
40
|
+
return stripLocalCacheBust(left) === stripLocalCacheBust(right);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isLocalVerificationUrl(value) {
|
|
44
|
+
return isLocalHttpUrl(value) && /[?&]__agentforge_verify=/.test(String(value || ''));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function closeStaleVerificationTabs(browser, keepPage, targetUrl) {
|
|
48
|
+
const expected = targetUrl ? stripLocalCacheBust(targetUrl) : '';
|
|
49
|
+
const pages = await browser.pages();
|
|
50
|
+
let closed = 0;
|
|
51
|
+
await Promise.all(pages.map(async (page) => {
|
|
52
|
+
if (!page || page.isClosed() || page === keepPage) return;
|
|
53
|
+
let url = '';
|
|
54
|
+
try { url = page.url(); } catch { return; }
|
|
55
|
+
if (!isLocalVerificationUrl(url)) return;
|
|
56
|
+
if (expected && stripLocalCacheBust(url) !== expected) return;
|
|
57
|
+
try {
|
|
58
|
+
await page.close();
|
|
59
|
+
closed += 1;
|
|
60
|
+
} catch {}
|
|
61
|
+
}));
|
|
62
|
+
if (closed > 0) console.log(`[browser.js] Closed ${closed} stale verification tab(s) for ${targetUrl || 'local browser'}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
// Return the active page for this agent. Creates a fresh tab on first use.
|
|
29
66
|
// preferUrl: hint — if the agent's tab already has a better match among all open
|
|
30
67
|
// tabs (e.g. a link opened a new tab), update the agent's pointer to it.
|
|
@@ -37,7 +74,7 @@ async function getPage(agentId, preferUrl) {
|
|
|
37
74
|
// If preferUrl given, check if any tab matches better (handles new-tab links)
|
|
38
75
|
if (preferUrl) {
|
|
39
76
|
const pages = await browser.pages();
|
|
40
|
-
const match = pages.find(p => !p.isClosed() && p.url()
|
|
77
|
+
const match = pages.find(p => !p.isClosed() && sameBrowserTargetUrl(p.url(), preferUrl));
|
|
41
78
|
if (match && match !== existing) {
|
|
42
79
|
_agentPages.set(agentId, match);
|
|
43
80
|
return match;
|
|
@@ -46,6 +83,15 @@ async function getPage(agentId, preferUrl) {
|
|
|
46
83
|
return existing;
|
|
47
84
|
}
|
|
48
85
|
|
|
86
|
+
if (preferUrl) {
|
|
87
|
+
const pages = await browser.pages();
|
|
88
|
+
const match = pages.find(p => !p.isClosed() && sameBrowserTargetUrl(p.url(), preferUrl));
|
|
89
|
+
if (match) {
|
|
90
|
+
_agentPages.set(agentId, match);
|
|
91
|
+
return match;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
49
95
|
// Agent has no page — open a fresh tab so it doesn't land on another agent's tab
|
|
50
96
|
const fresh = await browser.newPage();
|
|
51
97
|
_agentPages.set(agentId, fresh);
|
|
@@ -71,23 +117,14 @@ function withLocalCacheBust(value) {
|
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
async function waitForPageReady(page, expectedUrl) {
|
|
74
|
-
const
|
|
75
|
-
try {
|
|
76
|
-
const parsed = new URL(value);
|
|
77
|
-
parsed.searchParams.delete('__agentforge_verify');
|
|
78
|
-
return parsed.href.replace(/\/$/, '');
|
|
79
|
-
} catch {
|
|
80
|
-
return String(value || '').replace(/([?&])__agentforge_verify=[^&#]+&?/, '$1').replace(/[?&]$/, '').replace(/\/$/, '');
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
const expected = expectedUrl ? stripCacheBust(expectedUrl) : '';
|
|
120
|
+
const expected = expectedUrl ? stripLocalCacheBust(expectedUrl) : '';
|
|
84
121
|
for (let i = 0; i < 45; i++) {
|
|
85
122
|
try {
|
|
86
123
|
const state = await page.evaluate(() => ({
|
|
87
124
|
href: location.href,
|
|
88
125
|
readyState: document.readyState,
|
|
89
126
|
}));
|
|
90
|
-
if ((!expected ||
|
|
127
|
+
if ((!expected || stripLocalCacheBust(state.href) === expected) && state.readyState !== 'loading') return;
|
|
91
128
|
} catch {
|
|
92
129
|
// Page contexts can disappear while navigation commits.
|
|
93
130
|
}
|
|
@@ -200,7 +237,7 @@ async function _browserActionInner(input, agentId, browser) {
|
|
|
200
237
|
case 'navigate':
|
|
201
238
|
case 'open': {
|
|
202
239
|
const url = input.url || input.targetUrl;
|
|
203
|
-
const page = await getPage(agentId);
|
|
240
|
+
const page = await getPage(agentId, url);
|
|
204
241
|
await page.setCacheEnabled(false).catch(() => null);
|
|
205
242
|
const navigationUrl = withLocalCacheBust(url);
|
|
206
243
|
// Use Promise.race to enforce a hard 20s cap — page.goto timeout option can hang
|
|
@@ -215,6 +252,7 @@ async function _browserActionInner(input, agentId, browser) {
|
|
|
215
252
|
_agentPages.set(agentId, page);
|
|
216
253
|
// Brief pause for initial render, then snapshot
|
|
217
254
|
await waitForPageReady(page, url);
|
|
255
|
+
await closeStaleVerificationTabs(browser, page, url);
|
|
218
256
|
await _wait(400);
|
|
219
257
|
return await _snapshot(page, agentId);
|
|
220
258
|
}
|
package/src/selfUpdate.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* prevent infinite re-exec loops).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execSync, spawn } from 'child_process';
|
|
10
|
-
import { mkdirSync } from 'fs';
|
|
9
|
+
import { execFileSync, execSync, spawn } from 'child_process';
|
|
10
|
+
import { mkdirSync, rmSync } from 'fs';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
12
|
import path from 'path';
|
|
13
13
|
|
|
@@ -75,22 +75,58 @@ export async function checkAndUpdate(packageName, currentVersion) {
|
|
|
75
75
|
|
|
76
76
|
function installGlobalPackage(pkg, { force = false } = {}) {
|
|
77
77
|
const forceFlag = force ? ' --force' : '';
|
|
78
|
+
const preflightEnv = ensureWritableGlobalInstallEnv();
|
|
78
79
|
try {
|
|
79
|
-
execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit' });
|
|
80
|
+
execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env: preflightEnv });
|
|
80
81
|
return;
|
|
81
82
|
} catch (error) {
|
|
82
83
|
if (!looksLikePermissionFailure(error)) throw error;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
const env = configureUserOwnedNpmGlobalEnv();
|
|
87
|
+
execSync(`npm install -g${forceFlag} ${pkg}`, { stdio: 'inherit', env });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function npmConfigGet(args) {
|
|
91
|
+
try {
|
|
92
|
+
return execFileSync('npm', args, {
|
|
93
|
+
encoding: 'utf8',
|
|
94
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
95
|
+
}).trim();
|
|
96
|
+
} catch {
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function canWriteNpmRoot(rootDir) {
|
|
102
|
+
if (!rootDir) return false;
|
|
103
|
+
const testDir = path.join(rootDir, `.agentforge-write-test-${process.pid}`);
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(testDir, { recursive: true });
|
|
106
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureWritableGlobalInstallEnv() {
|
|
115
|
+
const root = npmConfigGet(['root', '-g']);
|
|
116
|
+
if (canWriteNpmRoot(root)) return process.env;
|
|
117
|
+
return configureUserOwnedNpmGlobalEnv();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function configureUserOwnedNpmGlobalEnv() {
|
|
85
121
|
const prefix = path.join(homedir(), '.npm-global');
|
|
86
122
|
mkdirSync(path.join(prefix, 'bin'), { recursive: true });
|
|
87
123
|
mkdirSync(path.join(prefix, 'lib', 'node_modules'), { recursive: true });
|
|
88
|
-
|
|
124
|
+
execFileSync('npm', ['config', 'set', 'prefix', prefix], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
89
125
|
const env = {
|
|
90
126
|
...process.env,
|
|
91
127
|
PATH: `${path.join(prefix, 'bin')}${path.delimiter}${process.env.PATH || ''}`,
|
|
92
128
|
};
|
|
93
|
-
|
|
129
|
+
return env;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
function looksLikePermissionFailure(error) {
|