@lythos/cold-pool 0.17.11 → 0.18.0
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/README.md +2 -2
- package/package.json +2 -2
- package/src/git-io.ts +1 -1
- package/src/mirror.test.ts +188 -102
- package/src/mirror.ts +93 -27
package/README.md
CHANGED
|
@@ -15,10 +15,10 @@ deck / curator / arena consume it instead of running `git clone` themselves.
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
# Scan cold pool for unreferenced repos
|
|
18
|
-
bunx @lythos/cold-pool@0.
|
|
18
|
+
bunx @lythos/cold-pool@0.18.0 prune [--yes] [--dry-run]
|
|
19
19
|
|
|
20
20
|
# Validate lock file against cold pool
|
|
21
|
-
bunx @lythos/cold-pool@0.
|
|
21
|
+
bunx @lythos/cold-pool@0.18.0 validate [--lock ./skill-deck.lock]
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Architecture
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/cold-pool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Cold pool service layer — dedicated resource holder for skill repositories with intent/plan/execute primitives. Single owner of git side-effects; consumed by deck/curator/arena.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/lythos-labs/lythoskill/tree/main/packages/lythoskill-cold-pool#readme",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@lythos/infra": "^0.
|
|
45
|
+
"@lythos/infra": "^0.18.0",
|
|
46
46
|
"simple-git": "^3.36.0"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
package/src/git-io.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { dirname, resolve } from 'node:path'
|
|
|
15
15
|
export type GitExec = typeof execFileSync
|
|
16
16
|
|
|
17
17
|
/** Read LYTHOS_SOCKS_PROXY env var and return git-config args if present. */
|
|
18
|
-
function socksProxyArgs(): string[] {
|
|
18
|
+
export function socksProxyArgs(): string[] {
|
|
19
19
|
const proxy = process.env.LYTHOS_SOCKS_PROXY?.trim()
|
|
20
20
|
if (!proxy) return []
|
|
21
21
|
const url = proxy.startsWith('socks5://') ? proxy : `socks5://${proxy}`
|
package/src/mirror.test.ts
CHANGED
|
@@ -50,78 +50,168 @@ describe('probeConnectivity', () => {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
/** execFileSync fake: per-URL git ls-remote success/failure; curl never succeeds. */
|
|
54
|
+
function mockGitExec(okUrls: string[] = []) {
|
|
55
|
+
const calls: Array<{ file: string; args: string[] }> = []
|
|
56
|
+
const exec = (file: unknown, args: unknown) => {
|
|
57
|
+
const a = (args as string[]).map(String)
|
|
58
|
+
calls.push({ file: String(file), args: a })
|
|
59
|
+
if (String(file) === 'git') {
|
|
60
|
+
const url = a[a.length - 1]!
|
|
61
|
+
if (okUrls.includes(url)) return ''
|
|
62
|
+
throw new Error(`git ls-remote failed for ${url}`)
|
|
63
|
+
}
|
|
64
|
+
throw new Error('curl failed')
|
|
65
|
+
}
|
|
66
|
+
return { exec: exec as any, calls }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** execFileSync fake where git can never answer (e.g. binary missing). */
|
|
70
|
+
function gitUnavailable() {
|
|
71
|
+
return (() => {
|
|
72
|
+
throw new Error('spawnSync git ENOENT')
|
|
73
|
+
}) as any
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DIRECT = 'https://example.com/skill'
|
|
77
|
+
const MIRROR = 'https://my-mirror.example.com'
|
|
78
|
+
const MIRROR_URL = `${MIRROR}/${DIRECT}`
|
|
79
|
+
const infoRefs = (u: string) => `${u}/info/refs?service=git-upload-pack`
|
|
80
|
+
|
|
81
|
+
// ── Tier 1: git-verified ──────────────────────────────────────────
|
|
60
82
|
|
|
61
|
-
|
|
83
|
+
it('direct ls-remote ok → git-verified direct, fetch never called', async () => {
|
|
84
|
+
const { exec } = mockGitExec([DIRECT])
|
|
85
|
+
mockFetch(new Map([[infoRefs(DIRECT), new Response(null, { status: 200 })]]))
|
|
86
|
+
|
|
87
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: exec })
|
|
62
88
|
|
|
63
89
|
expect(result).toMatchObject({
|
|
64
90
|
path: 'direct',
|
|
65
|
-
url:
|
|
91
|
+
url: DIRECT,
|
|
66
92
|
latencyMs: expect.any(Number),
|
|
93
|
+
confidence: 'git-verified',
|
|
67
94
|
})
|
|
95
|
+
expect(fetchCalls.length).toBe(0)
|
|
68
96
|
})
|
|
69
97
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
mockFetch(
|
|
74
|
-
new Map([
|
|
75
|
-
['https://example.com/skill', new Response(null, { status: 500 })],
|
|
76
|
-
['https://my-mirror.example.com/https://example.com/skill', new Response(null, { status: 200 })],
|
|
77
|
-
]),
|
|
78
|
-
)
|
|
98
|
+
it('direct ls-remote fails, mirror ls-remote ok → git-verified mirror', async () => {
|
|
99
|
+
process.env.LYTHOS_GH_MIRROR = MIRROR
|
|
100
|
+
const { exec } = mockGitExec([MIRROR_URL])
|
|
101
|
+
mockFetch(new Map())
|
|
79
102
|
|
|
80
|
-
const result = await probeConnectivity(
|
|
103
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: exec })
|
|
81
104
|
|
|
82
105
|
expect(result).toMatchObject({
|
|
83
106
|
path: 'mirror',
|
|
84
|
-
url:
|
|
85
|
-
|
|
107
|
+
url: MIRROR_URL,
|
|
108
|
+
confidence: 'git-verified',
|
|
86
109
|
})
|
|
110
|
+
// direct failure recorded for diagnostics
|
|
111
|
+
expect(result?.failures?.some((f) => f.url === DIRECT)).toBe(true)
|
|
87
112
|
})
|
|
88
113
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
it('HEAD-blocking mirror is NOT rejected when ls-remote succeeds (the K3 false negative)', async () => {
|
|
115
|
+
// ghfast.top-style mirror: rejects HEAD/GET on bare URL with 403, but
|
|
116
|
+
// serves git smart-HTTP fine — ls-remote (clone's handshake) succeeds.
|
|
117
|
+
process.env.LYTHOS_GH_MIRROR = MIRROR
|
|
118
|
+
const { exec } = mockGitExec([MIRROR_URL])
|
|
119
|
+
globalThis.fetch = async () => new Response(null, { status: 403 })
|
|
92
120
|
|
|
93
|
-
const result = await probeConnectivity(
|
|
121
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: exec })
|
|
94
122
|
|
|
95
|
-
expect(result).
|
|
123
|
+
expect(result).toBeDefined()
|
|
124
|
+
expect(result?.path).toBe('mirror')
|
|
125
|
+
expect(result?.confidence).toBe('git-verified')
|
|
126
|
+
expect(result?.authRequired).toBeUndefined()
|
|
96
127
|
})
|
|
97
128
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
129
|
+
it('LYTHOS_SOCKS_PROXY set → ls-remote injects -c http.proxy flags (same as clone)', async () => {
|
|
130
|
+
process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
|
|
131
|
+
const { exec, calls } = mockGitExec([DIRECT])
|
|
132
|
+
|
|
133
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: exec })
|
|
134
|
+
|
|
135
|
+
expect(result?.confidence).toBe('git-verified')
|
|
136
|
+
const gitCall = calls.find((c) => c.file === 'git')!
|
|
137
|
+
expect(gitCall.args).toContain('http.proxy=socks5://127.0.0.1:1080')
|
|
138
|
+
expect(gitCall.args).toContain('https.proxy=socks5://127.0.0.1:1080')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ── Tier 2: http-signal (git cannot answer) ──────────────────────
|
|
142
|
+
|
|
143
|
+
it('git unavailable, GET info/refs 200 → http-signal direct', async () => {
|
|
144
|
+
mockFetch(new Map([[infoRefs(DIRECT), new Response(null, { status: 200 })]]))
|
|
145
|
+
|
|
146
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
147
|
+
|
|
148
|
+
expect(result).toMatchObject({
|
|
149
|
+
path: 'direct',
|
|
150
|
+
url: DIRECT,
|
|
151
|
+
confidence: 'http-signal',
|
|
152
|
+
})
|
|
153
|
+
expect(fetchCalls.length).toBe(1)
|
|
154
|
+
expect(fetchCalls[0].url).toBe(infoRefs(DIRECT))
|
|
155
|
+
expect(fetchCalls[0].options.method).toBe('GET')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('403 on GET info/refs → reachable with authRequired (not "blocked")', async () => {
|
|
159
|
+
mockFetch(new Map([[infoRefs(DIRECT), new Response(null, { status: 403 })]]))
|
|
160
|
+
|
|
161
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
162
|
+
|
|
163
|
+
expect(result).toMatchObject({ path: 'direct', confidence: 'http-signal', authRequired: true })
|
|
164
|
+
})
|
|
105
165
|
|
|
106
|
-
|
|
166
|
+
it('401 on GET info/refs → reachable with authRequired', async () => {
|
|
167
|
+
mockFetch(new Map([[infoRefs(DIRECT), new Response(null, { status: 401 })]]))
|
|
168
|
+
|
|
169
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
170
|
+
|
|
171
|
+
expect(result?.authRequired).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('404 on GET info/refs → reachable (host alive), no authRequired', async () => {
|
|
175
|
+
mockFetch(new Map([[infoRefs(DIRECT), new Response(null, { status: 404 })]]))
|
|
176
|
+
|
|
177
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
107
178
|
|
|
108
179
|
expect(result?.path).toBe('direct')
|
|
180
|
+
expect(result?.authRequired).toBeUndefined()
|
|
109
181
|
})
|
|
110
182
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
183
|
+
it('git unavailable, direct HTTP fails, mirror HTTP ok → http-signal mirror', async () => {
|
|
184
|
+
process.env.LYTHOS_GH_MIRROR = MIRROR
|
|
185
|
+
mockFetch(new Map([[infoRefs(MIRROR_URL), new Response(null, { status: 200 })]]))
|
|
186
|
+
|
|
187
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
188
|
+
|
|
189
|
+
expect(result).toMatchObject({ path: 'mirror', url: MIRROR_URL, confidence: 'http-signal' })
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ── Both tiers fail everywhere → undefined ───────────────────────
|
|
193
|
+
|
|
194
|
+
it('undefined only when both tiers fail on all URLs', async () => {
|
|
195
|
+
process.env.LYTHOS_GH_MIRROR = MIRROR
|
|
196
|
+
mockFetch(new Map()) // every fetch → ENOTFOUND
|
|
197
|
+
|
|
198
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
199
|
+
|
|
200
|
+
expect(result).toBeUndefined()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// ── Tier-2 racing behavior ───────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
it('tier-2 HTTP probes race concurrently, not sequentially', async () => {
|
|
206
|
+
process.env.LYTHOS_GH_MIRROR = MIRROR
|
|
114
207
|
const start = performance.now()
|
|
115
208
|
|
|
116
209
|
mockFetch(
|
|
117
|
-
new Map([
|
|
118
|
-
['https://example.com/skill', new Response(null, { status: 500 })],
|
|
119
|
-
['https://my-mirror.example.com/https://example.com/skill', new Response(null, { status: 200 })],
|
|
120
|
-
]),
|
|
210
|
+
new Map([[infoRefs(MIRROR_URL), new Response(null, { status: 200 })]]),
|
|
121
211
|
50, // each mock fetch takes 50ms
|
|
122
212
|
)
|
|
123
213
|
|
|
124
|
-
const result = await probeConnectivity(
|
|
214
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
125
215
|
|
|
126
216
|
const elapsed = performance.now() - start
|
|
127
217
|
|
|
@@ -130,23 +220,7 @@ describe('probeConnectivity', () => {
|
|
|
130
220
|
expect(elapsed).toBeLessThan(150)
|
|
131
221
|
})
|
|
132
222
|
|
|
133
|
-
|
|
134
|
-
it('without user mirror, only probes direct', async () => {
|
|
135
|
-
mockFetch(
|
|
136
|
-
new Map([
|
|
137
|
-
['https://example.com/skill', new Response(null, { status: 200 })],
|
|
138
|
-
]),
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
const result = await probeConnectivity('https://example.com/skill')
|
|
142
|
-
|
|
143
|
-
expect(result?.path).toBe('direct')
|
|
144
|
-
expect(fetchCalls.length).toBe(1)
|
|
145
|
-
expect(fetchCalls[0].url).toBe('https://example.com/skill')
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// ── Vertical Slice 7: Timeout honored ──
|
|
149
|
-
it('timeout aborts slow probes', async () => {
|
|
223
|
+
it('timeout aborts slow tier-2 probes', async () => {
|
|
150
224
|
globalThis.fetch = async (_input, init?) => {
|
|
151
225
|
return new Promise((_, reject) => {
|
|
152
226
|
const timer = setTimeout(
|
|
@@ -161,83 +235,96 @@ describe('probeConnectivity', () => {
|
|
|
161
235
|
}
|
|
162
236
|
|
|
163
237
|
const start = performance.now()
|
|
164
|
-
const result = await probeConnectivity(
|
|
238
|
+
const result = await probeConnectivity(DIRECT, 100, { execFileSync: gitUnavailable() })
|
|
165
239
|
const elapsed = performance.now() - start
|
|
166
240
|
|
|
167
241
|
expect(result).toBeUndefined()
|
|
168
242
|
expect(elapsed).toBeLessThan(500) // 100ms timeout + overhead
|
|
169
243
|
})
|
|
170
244
|
|
|
171
|
-
// ──
|
|
172
|
-
|
|
245
|
+
// ── Tier-2 SOCKS proxy routing (curl) ────────────────────────────
|
|
246
|
+
|
|
247
|
+
it('SOCKS proxy set, git unavailable, curl ok → http-signal direct via curl', async () => {
|
|
173
248
|
process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
|
|
174
249
|
const execCalls: Array<{ file: string; args: string[] }> = []
|
|
175
250
|
|
|
176
251
|
const mockExec = (file: unknown, args: unknown) => {
|
|
177
|
-
|
|
178
|
-
|
|
252
|
+
const a = (args as string[]).map(String)
|
|
253
|
+
execCalls.push({ file: String(file), args: a })
|
|
254
|
+
if (String(file) === 'curl') return '200'
|
|
255
|
+
throw new Error('spawnSync git ENOENT')
|
|
179
256
|
}
|
|
180
|
-
const result = await probeConnectivity(
|
|
181
|
-
execFileSync: mockExec as any,
|
|
182
|
-
})
|
|
257
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: mockExec as any })
|
|
183
258
|
|
|
184
259
|
expect(result).toMatchObject({
|
|
185
260
|
path: 'direct',
|
|
186
|
-
url:
|
|
261
|
+
url: DIRECT,
|
|
187
262
|
latencyMs: expect.any(Number),
|
|
263
|
+
confidence: 'http-signal',
|
|
188
264
|
})
|
|
189
|
-
|
|
190
|
-
expect(
|
|
191
|
-
expect(
|
|
192
|
-
expect(
|
|
265
|
+
const curlCalls = execCalls.filter((c) => c.file === 'curl')
|
|
266
|
+
expect(curlCalls.length).toBe(1)
|
|
267
|
+
expect(curlCalls[0].args).toContain('--proxy')
|
|
268
|
+
expect(curlCalls[0].args).toContain('socks5://127.0.0.1:1080')
|
|
269
|
+
expect(curlCalls[0].args[curlCalls[0].args.length - 1]).toBe(infoRefs(DIRECT))
|
|
193
270
|
})
|
|
194
271
|
|
|
195
|
-
// ── Vertical Slice 9: SOCKS proxy with socks5:// prefix ──
|
|
196
272
|
it('SOCKS proxy already has socks5:// prefix → does not double-prefix', async () => {
|
|
197
273
|
process.env.LYTHOS_SOCKS_PROXY = 'socks5://proxy.example.com:1080'
|
|
198
274
|
const execCalls: Array<{ file: string; args: string[] }> = []
|
|
199
275
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
276
|
+
const mockExec = (file: unknown, args: unknown) => {
|
|
277
|
+
const a = (args as string[]).map(String)
|
|
278
|
+
execCalls.push({ file: String(file), args: a })
|
|
279
|
+
if (String(file) === 'curl') return '200'
|
|
280
|
+
throw new Error('spawnSync git ENOENT')
|
|
281
|
+
}
|
|
282
|
+
await probeConnectivity(DIRECT, 3000, { execFileSync: mockExec as any })
|
|
283
|
+
|
|
284
|
+
const curlCall = execCalls.find((c) => c.file === 'curl')!
|
|
285
|
+
expect(curlCall.args).toContain('socks5://proxy.example.com:1080')
|
|
286
|
+
expect(curlCall.args).not.toContain('socks5://socks5://proxy.example.com:1080')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('SOCKS curl gets 403 → http-signal direct with authRequired', async () => {
|
|
290
|
+
process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
|
|
291
|
+
|
|
292
|
+
const mockExec = (file: unknown) => {
|
|
293
|
+
if (String(file) === 'curl') return '403'
|
|
294
|
+
throw new Error('spawnSync git ENOENT')
|
|
203
295
|
}
|
|
204
|
-
await probeConnectivity(
|
|
205
|
-
execFileSync: mockExec2 as any,
|
|
206
|
-
})
|
|
296
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: mockExec as any })
|
|
207
297
|
|
|
208
|
-
expect(
|
|
209
|
-
expect(execCalls[0].args).not.toContain('socks5://socks5://proxy.example.com:1080')
|
|
298
|
+
expect(result).toMatchObject({ path: 'direct', confidence: 'http-signal', authRequired: true })
|
|
210
299
|
})
|
|
211
300
|
|
|
212
|
-
|
|
213
|
-
it('SOCKS proxy fails → no automatic unproxied fallback', async () => {
|
|
301
|
+
it('SOCKS proxy fails (git + curl) → no automatic unproxied fallback', async () => {
|
|
214
302
|
process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
|
|
215
303
|
|
|
216
|
-
const result = await probeConnectivity(
|
|
217
|
-
execFileSync: () => {
|
|
304
|
+
const result = await probeConnectivity(DIRECT, 3000, {
|
|
305
|
+
execFileSync: (() => {
|
|
218
306
|
throw new Error('curl failed')
|
|
219
|
-
},
|
|
307
|
+
}) as any,
|
|
220
308
|
fetch: async () => new Response(null, { status: 200 }),
|
|
221
309
|
})
|
|
222
310
|
|
|
223
|
-
// Design choice: when SOCKS proxy is configured but
|
|
224
|
-
// probe fails. No automatic fallback to unproxied
|
|
225
|
-
// explicitly chose to route traffic through the
|
|
226
|
-
// choice rather than silently bypassing it.
|
|
311
|
+
// Design choice: when SOCKS proxy is configured but both git and curl
|
|
312
|
+
// fail, the direct probe fails. No automatic fallback to unproxied
|
|
313
|
+
// fetch — the user explicitly chose to route traffic through the
|
|
314
|
+
// proxy, so we honor that choice rather than silently bypassing it.
|
|
227
315
|
expect(result).toBeUndefined()
|
|
228
316
|
})
|
|
229
317
|
|
|
230
|
-
|
|
231
|
-
it('SOCKS proxy fails but mirror succeeds', async () => {
|
|
318
|
+
it('SOCKS proxy fails but mirror succeeds → mirror; only one curl call', async () => {
|
|
232
319
|
process.env.LYTHOS_SOCKS_PROXY = '127.0.0.1:1080'
|
|
233
|
-
process.env.LYTHOS_GH_MIRROR =
|
|
320
|
+
process.env.LYTHOS_GH_MIRROR = MIRROR
|
|
234
321
|
const execCalls: Array<{ file: string; args: string[] }> = []
|
|
235
322
|
|
|
236
|
-
const result = await probeConnectivity(
|
|
237
|
-
execFileSync: (file, args) => {
|
|
238
|
-
execCalls.push({ file: String(file), args: args as string[] })
|
|
239
|
-
throw new Error('
|
|
240
|
-
},
|
|
323
|
+
const result = await probeConnectivity(DIRECT, 3000, {
|
|
324
|
+
execFileSync: ((file: unknown, args: unknown) => {
|
|
325
|
+
execCalls.push({ file: String(file), args: (args as string[]).map(String) })
|
|
326
|
+
throw new Error('proxy unreachable')
|
|
327
|
+
}) as any,
|
|
241
328
|
fetch: async (input) => {
|
|
242
329
|
const url = String(input)
|
|
243
330
|
if (url.includes('my-mirror')) {
|
|
@@ -249,7 +336,7 @@ describe('probeConnectivity', () => {
|
|
|
249
336
|
|
|
250
337
|
// SOCKS proxy is only used for direct probes; mirror probes use native fetch
|
|
251
338
|
expect(result?.path).toBe('mirror')
|
|
252
|
-
expect(execCalls.length).toBe(1)
|
|
339
|
+
expect(execCalls.filter((c) => c.file === 'curl').length).toBe(1)
|
|
253
340
|
})
|
|
254
341
|
|
|
255
342
|
// ── Backward compat: legacy env var name still works ──
|
|
@@ -265,12 +352,11 @@ describe('probeConnectivity', () => {
|
|
|
265
352
|
|
|
266
353
|
mockFetch(
|
|
267
354
|
new Map([
|
|
268
|
-
[
|
|
269
|
-
['https://legacy-mirror.example.com/https://example.com/skill', new Response(null, { status: 200 })],
|
|
355
|
+
[infoRefs(`https://legacy-mirror.example.com/${DIRECT}`), new Response(null, { status: 200 })],
|
|
270
356
|
]),
|
|
271
357
|
)
|
|
272
358
|
|
|
273
|
-
const result = await probeConnectivity(
|
|
359
|
+
const result = await probeConnectivity(DIRECT, 3000, { execFileSync: gitUnavailable() })
|
|
274
360
|
|
|
275
361
|
console.warn = originalWarn
|
|
276
362
|
|
package/src/mirror.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { execFileSync } from 'node:child_process'
|
|
17
|
+
import { socksProxyArgs } from './git-io.js'
|
|
17
18
|
|
|
18
19
|
export function getMirror(): string | undefined {
|
|
19
20
|
let v = process.env.LYTHOS_GH_MIRROR?.trim()
|
|
@@ -52,6 +53,15 @@ export interface ProbeResult {
|
|
|
52
53
|
path: 'direct' | 'mirror'
|
|
53
54
|
url: string
|
|
54
55
|
latencyMs: number
|
|
56
|
+
/**
|
|
57
|
+
* Evidence tier. 'git-verified' = `git ls-remote` succeeded — the exact
|
|
58
|
+
* handshake a clone performs, on the same network stack. 'http-signal' =
|
|
59
|
+
* only an HTTP response was observed (fallback tier when git cannot
|
|
60
|
+
* answer); weaker evidence, so callers should treat it as advisory.
|
|
61
|
+
*/
|
|
62
|
+
confidence: 'git-verified' | 'http-signal'
|
|
63
|
+
/** Set when the HTTP tier got 401/403 — host reachable, credentials likely required. */
|
|
64
|
+
authRequired?: boolean
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
export interface ProbeFailure {
|
|
@@ -64,42 +74,88 @@ export interface ProbeDeps {
|
|
|
64
74
|
execFileSync?: typeof execFileSync
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
/** Short, single-line reason from an exec/fetch error (stderr first line wins). */
|
|
78
|
+
function errReason(err: unknown): string {
|
|
79
|
+
const stderr = (err as { stderr?: { toString: () => string } })?.stderr?.toString().trim()
|
|
80
|
+
if (stderr) return stderr.split('\n')[0]!
|
|
81
|
+
return err instanceof Error ? err.message : String(err)
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
/**
|
|
68
|
-
*
|
|
69
|
-
* Uses short timeout (default 3s) to fail fast instead of waiting for
|
|
70
|
-
* the full git clone / fetch timeout.
|
|
85
|
+
* Tiered connectivity probe: direct first, user mirror (if set) alongside.
|
|
71
86
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
87
|
+
* Tier 1 — `git ls-remote --heads <url>`: the same first handshake a clone
|
|
88
|
+
* performs, on the same network stack — it inherits the user's git config
|
|
89
|
+
* (http.proxy, url.insteadOf, http.sslVerify, credentials) and gets the same
|
|
90
|
+
* LYTHOS_SOCKS_PROXY `-c` flags that git-io's clone/pull inject. This is the
|
|
91
|
+
* only tier with real predictive power for clone success.
|
|
74
92
|
*
|
|
75
|
-
*
|
|
93
|
+
* Tier 2 (fallback when no ls-remote succeeds — e.g. git binary missing) —
|
|
94
|
+
* HTTP `GET <url>/info/refs?service=git-upload-pack`, the request a clone
|
|
95
|
+
* actually sends first (NOT a bare-URL HEAD, which git smart-HTTP reverse
|
|
96
|
+
* proxies like ghfast.top reject with 403/405). Any HTTP response proves the
|
|
97
|
+
* host is reachable (as opposed to DNS/TLS failure); 401/403 is classified
|
|
98
|
+
* as auth-required, not "blocked". The direct tier keeps the
|
|
99
|
+
* LYTHOS_SOCKS_PROXY curl route — Bun fetch cannot speak socks5://, and
|
|
100
|
+
* git ls-remote already inherits git proxy config, so SOCKS-via-curl is
|
|
101
|
+
* only an HTTP-tier concern.
|
|
102
|
+
*
|
|
103
|
+
* Returns the first success with per-URL failures recorded, or undefined
|
|
104
|
+
* only when BOTH tiers fail on ALL URLs.
|
|
76
105
|
*/
|
|
77
106
|
export async function probeConnectivity(
|
|
78
107
|
url: string,
|
|
79
108
|
timeoutMs = 3000,
|
|
80
109
|
deps?: ProbeDeps,
|
|
81
110
|
): Promise<ProbeResult & { failures?: ProbeFailure[] } | undefined> {
|
|
82
|
-
const start = performance.now()
|
|
83
111
|
const failures: ProbeFailure[] = []
|
|
84
112
|
const _fetch = deps?.fetch ?? globalThis.fetch
|
|
85
113
|
const _exec = deps?.execFileSync ?? execFileSync
|
|
114
|
+
const targets: Array<{ targetUrl: string; pathLabel: 'direct' | 'mirror' }> = [
|
|
115
|
+
{ targetUrl: url, pathLabel: 'direct' },
|
|
116
|
+
...mirrorUrls(url).map((m) => ({ targetUrl: m, pathLabel: 'mirror' as const })),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
// ── Tier 1: git ls-remote (ground truth) ───────────────────────────
|
|
120
|
+
// execFileSync is synchronous, so tier-1 probes run sequentially — the
|
|
121
|
+
// per-probe timeout bounds each attempt.
|
|
122
|
+
for (const { targetUrl, pathLabel } of targets) {
|
|
123
|
+
const t0 = performance.now()
|
|
124
|
+
try {
|
|
125
|
+
_exec('git', [...socksProxyArgs(), 'ls-remote', '--heads', targetUrl], {
|
|
126
|
+
encoding: 'utf-8',
|
|
127
|
+
timeout: timeoutMs,
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
})
|
|
130
|
+
return {
|
|
131
|
+
path: pathLabel,
|
|
132
|
+
url: targetUrl,
|
|
133
|
+
latencyMs: Math.round(performance.now() - t0),
|
|
134
|
+
confidence: 'git-verified',
|
|
135
|
+
failures,
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
failures.push({ url: targetUrl, reason: errReason(err) })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
86
141
|
|
|
87
|
-
|
|
142
|
+
// ── Tier 2: HTTP signal (degraded path when git cannot answer) ─────
|
|
143
|
+
async function tryHttp(
|
|
88
144
|
targetUrl: string,
|
|
89
145
|
pathLabel: 'direct' | 'mirror',
|
|
90
146
|
): Promise<ProbeResult | undefined> {
|
|
91
147
|
const t0 = performance.now()
|
|
148
|
+
const probeUrl = `${targetUrl.replace(/\/+$/, '')}/info/refs?service=git-upload-pack`
|
|
92
149
|
try {
|
|
93
150
|
// Route direct probes through SOCKS proxy when configured
|
|
94
151
|
if (pathLabel === 'direct') {
|
|
95
152
|
const socksProxy = process.env.LYTHOS_SOCKS_PROXY?.trim()
|
|
96
153
|
if (socksProxy) {
|
|
97
154
|
const proxyUrl = socksProxy.startsWith('socks5://') ? socksProxy : `socks5://${socksProxy}`
|
|
98
|
-
_exec(
|
|
155
|
+
const out = _exec(
|
|
99
156
|
'curl',
|
|
100
157
|
[
|
|
101
158
|
'--silent',
|
|
102
|
-
'--head',
|
|
103
159
|
'--location',
|
|
104
160
|
'--proxy',
|
|
105
161
|
proxyUrl,
|
|
@@ -107,38 +163,48 @@ export async function probeConnectivity(
|
|
|
107
163
|
String(Math.ceil(timeoutMs / 1000)),
|
|
108
164
|
'--max-time',
|
|
109
165
|
String(Math.ceil(timeoutMs / 1000)),
|
|
110
|
-
|
|
166
|
+
'--output',
|
|
167
|
+
'/dev/null',
|
|
168
|
+
'--write-out',
|
|
169
|
+
'%{http_code}',
|
|
170
|
+
probeUrl,
|
|
111
171
|
],
|
|
112
172
|
{ encoding: 'utf-8', timeout: timeoutMs + 500 },
|
|
113
173
|
)
|
|
114
|
-
|
|
174
|
+
const code = Number(String(out).trim())
|
|
175
|
+
return {
|
|
176
|
+
path: pathLabel,
|
|
177
|
+
url: targetUrl,
|
|
178
|
+
latencyMs: Math.round(performance.now() - t0),
|
|
179
|
+
confidence: 'http-signal',
|
|
180
|
+
authRequired: code === 401 || code === 403 || undefined,
|
|
181
|
+
}
|
|
115
182
|
}
|
|
116
183
|
}
|
|
117
184
|
|
|
118
|
-
const res = await _fetch(
|
|
119
|
-
method: '
|
|
185
|
+
const res = await _fetch(probeUrl, {
|
|
186
|
+
method: 'GET',
|
|
120
187
|
signal: AbortSignal.timeout(timeoutMs),
|
|
121
188
|
redirect: 'follow',
|
|
122
189
|
})
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
190
|
+
// Any HTTP response means the host is reachable — even a 4xx/5xx
|
|
191
|
+
// (a 4xx only says this URL/method is not accepted, unlike a
|
|
192
|
+
// DNS/TLS failure which means the host cannot be reached at all).
|
|
193
|
+
return {
|
|
194
|
+
path: pathLabel,
|
|
195
|
+
url: targetUrl,
|
|
196
|
+
latencyMs: Math.round(performance.now() - t0),
|
|
197
|
+
confidence: 'http-signal',
|
|
198
|
+
authRequired: res.status === 401 || res.status === 403 || undefined,
|
|
126
199
|
}
|
|
127
|
-
failures.push({ url: targetUrl, reason: `HTTP ${res.status}` })
|
|
128
200
|
} catch (err) {
|
|
129
|
-
failures.push({
|
|
130
|
-
url: targetUrl,
|
|
131
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
132
|
-
})
|
|
201
|
+
failures.push({ url: targetUrl, reason: errReason(err) })
|
|
133
202
|
}
|
|
134
203
|
return undefined
|
|
135
204
|
}
|
|
136
205
|
|
|
137
|
-
//
|
|
138
|
-
const probes =
|
|
139
|
-
for (const mirrorUrl of mirrorUrls(url)) {
|
|
140
|
-
probes.push(tryFetch(mirrorUrl, 'mirror'))
|
|
141
|
-
}
|
|
206
|
+
// Race direct + mirrors concurrently; first success wins
|
|
207
|
+
const probes = targets.map((t) => tryHttp(t.targetUrl, t.pathLabel))
|
|
142
208
|
|
|
143
209
|
let result: ProbeResult | undefined
|
|
144
210
|
let pending = probes.length
|