@shogo-ai/worker 1.0.10
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/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- package/src/lib/user-agent.ts +28 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Greenfield sweep for three never-loaded shogo-worker libs:
|
|
5
|
+
* api-discovery.ts, process-manager.ts, preflight.ts
|
|
6
|
+
* Shared mocks: node:fs (controllable), node:child_process (fake spawn),
|
|
7
|
+
* ./paths, ./transport.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, beforeEach, afterEach, mock, afterAll } from 'bun:test'
|
|
10
|
+
import { EventEmitter } from 'node:events'
|
|
11
|
+
|
|
12
|
+
// ── controllable node:fs ──────────────────────────────────────────────────────
|
|
13
|
+
let existsMap: Record<string, boolean> = {}
|
|
14
|
+
let existsPredicate: ((p: string) => boolean) | null = null
|
|
15
|
+
let pidFileContent: string | null = null
|
|
16
|
+
const fsWrites: Array<{ path: string; data: string }> = []
|
|
17
|
+
const fsUnlinks: string[] = []
|
|
18
|
+
let unlinkThrows = false
|
|
19
|
+
|
|
20
|
+
mock.module('node:fs', () => ({
|
|
21
|
+
existsSync: (p: string) => {
|
|
22
|
+
const s = String(p)
|
|
23
|
+
if (existsPredicate) return existsPredicate(s)
|
|
24
|
+
if (s in existsMap) return existsMap[s]
|
|
25
|
+
// PID file existence keyed off pidFileContent
|
|
26
|
+
if (s.endsWith('worker.pid')) return pidFileContent !== null
|
|
27
|
+
return false
|
|
28
|
+
},
|
|
29
|
+
readFileSync: (p: string) => {
|
|
30
|
+
if (String(p).endsWith('worker.pid')) return pidFileContent ?? ''
|
|
31
|
+
return ''
|
|
32
|
+
},
|
|
33
|
+
writeFileSync: (p: string, data: string) => { fsWrites.push({ path: String(p), data: String(data) }) },
|
|
34
|
+
unlinkSync: (p: string) => { if (unlinkThrows) throw new Error('ENOENT'); fsUnlinks.push(String(p)) },
|
|
35
|
+
openSync: () => 7,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
// ── fake child_process.spawn ──────────────────────────────────────────────────
|
|
39
|
+
let spawnPid: number | undefined = 4242
|
|
40
|
+
let lastSpawn: { cmd: string; args: string[]; opts: any; child: any } | null = null
|
|
41
|
+
class FakeChild extends EventEmitter {
|
|
42
|
+
pid: number | undefined
|
|
43
|
+
killed: string | null = null
|
|
44
|
+
unrefed = false
|
|
45
|
+
constructor(pid: number | undefined) { super(); this.pid = pid }
|
|
46
|
+
kill(sig: string) { this.killed = sig }
|
|
47
|
+
unref() { this.unrefed = true }
|
|
48
|
+
}
|
|
49
|
+
mock.module('node:child_process', () => ({
|
|
50
|
+
spawn: (cmd: string, args: string[], opts: any) => {
|
|
51
|
+
const child = new FakeChild(spawnPid)
|
|
52
|
+
lastSpawn = { cmd, args, opts, child }
|
|
53
|
+
return child
|
|
54
|
+
},
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
// ── ./paths ───────────────────────────────────────────────────────────────────
|
|
58
|
+
let ensureHomeCalls = 0
|
|
59
|
+
mock.module('../paths.ts', () => ({
|
|
60
|
+
PID_FILE: '/home/.shogo/worker.pid',
|
|
61
|
+
WORKER_LOG: '/home/.shogo/logs/worker.log',
|
|
62
|
+
WORKER_ERR: '/home/.shogo/logs/worker.err.log',
|
|
63
|
+
ensureHome: () => { ensureHomeCalls++ },
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
// ── ./transport (for preflight) ────────────────────────────────────────────────
|
|
67
|
+
// Delegate to the REAL transport by default so sibling files that import the
|
|
68
|
+
// genuine module (e.g. worker-transport-greenfield.test.ts) are not shadowed by
|
|
69
|
+
// this file's process-global mock. Individual tests below override the impls.
|
|
70
|
+
const _realTransport = require('../transport')
|
|
71
|
+
const _defaultAllowlist = _realTransport.deriveAllowlist
|
|
72
|
+
const _defaultProbeProxy = _realTransport.probeProxy
|
|
73
|
+
let allowlistImpl: (cloudUrl: string) => any[] = _defaultAllowlist
|
|
74
|
+
let probeProxyImpl: (...a: any[]) => Promise<{ ok: boolean; detail?: string }> = _defaultProbeProxy
|
|
75
|
+
mock.module('../transport.ts', () => ({
|
|
76
|
+
..._realTransport,
|
|
77
|
+
deriveAllowlist: (u: string) => allowlistImpl(u),
|
|
78
|
+
probeProxy: (...a: any[]) => probeProxyImpl(...a),
|
|
79
|
+
}))
|
|
80
|
+
|
|
81
|
+
import { findApiEntry } from '../api-discovery'
|
|
82
|
+
import { readPid, isRunning, clearPid, spawnWorker, installShutdownHooks, stopWorker } from '../process-manager'
|
|
83
|
+
import { makeChecks, runPreflight, type Check } from '../preflight'
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
existsMap = {}; pidFileContent = null; unlinkThrows = false
|
|
87
|
+
fsWrites.length = 0; fsUnlinks.length = 0
|
|
88
|
+
spawnPid = 4242; lastSpawn = null; ensureHomeCalls = 0
|
|
89
|
+
existsPredicate = null
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
function mockExists(fn: (p: string) => boolean) { existsPredicate = fn }
|
|
93
|
+
|
|
94
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
95
|
+
describe('api-discovery.findApiEntry', () => {
|
|
96
|
+
test('prefers bundled dist entry (node runner) when present', () => {
|
|
97
|
+
mockExists((p) => p.endsWith('entry.js'))
|
|
98
|
+
const r = findApiEntry()
|
|
99
|
+
expect(r.mode).toBe('bundled')
|
|
100
|
+
expect(r.runner).toBe('node')
|
|
101
|
+
expect(r.entry).toContain('entry.js')
|
|
102
|
+
})
|
|
103
|
+
test('falls back to monorepo entry (bun runner)', () => {
|
|
104
|
+
mockExists((p) => p.endsWith('entry.ts'))
|
|
105
|
+
const r = findApiEntry()
|
|
106
|
+
expect(r.mode).toBe('monorepo')
|
|
107
|
+
expect(r.runner).toBe('bun')
|
|
108
|
+
expect(r.entry).toContain('entry.ts')
|
|
109
|
+
})
|
|
110
|
+
test('throws when neither entry exists', () => {
|
|
111
|
+
mockExists(() => false)
|
|
112
|
+
expect(() => findApiEntry()).toThrow('Cannot locate apps/api entry')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
117
|
+
describe('process-manager', () => {
|
|
118
|
+
test('readPid returns null when no pid file, parses int when present', () => {
|
|
119
|
+
pidFileContent = null
|
|
120
|
+
expect(readPid()).toBe(null)
|
|
121
|
+
pidFileContent = ' 12345 \n'
|
|
122
|
+
expect(readPid()).toBe(12345)
|
|
123
|
+
pidFileContent = 'not-a-number'
|
|
124
|
+
expect(readPid()).toBe(null)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('isRunning reflects process.kill(pid,0)', () => {
|
|
128
|
+
const realKill = process.kill
|
|
129
|
+
;(process as any).kill = (_pid: number, _sig: number) => true
|
|
130
|
+
expect(isRunning(999)).toBe(true)
|
|
131
|
+
;(process as any).kill = () => { throw new Error('ESRCH') }
|
|
132
|
+
expect(isRunning(999)).toBe(false)
|
|
133
|
+
process.kill = realKill
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('clearPid swallows unlink errors', () => {
|
|
137
|
+
unlinkThrows = true
|
|
138
|
+
expect(() => clearPid()).not.toThrow()
|
|
139
|
+
unlinkThrows = false
|
|
140
|
+
clearPid()
|
|
141
|
+
expect(fsUnlinks).toContain('/home/.shogo/worker.pid')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('spawnWorker spawns, writes pid file, returns pid', () => {
|
|
145
|
+
const realKill = process.kill
|
|
146
|
+
;(process as any).kill = () => { throw new Error('ESRCH') } // no existing process
|
|
147
|
+
pidFileContent = null
|
|
148
|
+
const { pid, child } = spawnWorker({ entry: '/e.ts', runner: 'bun', env: {}, cwd: '/w' })
|
|
149
|
+
expect(pid).toBe(4242)
|
|
150
|
+
expect(ensureHomeCalls).toBe(1)
|
|
151
|
+
expect(lastSpawn!.cmd).toBe('bun')
|
|
152
|
+
expect(fsWrites.some((w) => w.path.endsWith('worker.pid') && w.data === '4242')).toBe(true)
|
|
153
|
+
expect((child as any).unrefed).toBe(false)
|
|
154
|
+
process.kill = realKill
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('spawnWorker detached unrefs the child + inheritStdio path', () => {
|
|
158
|
+
const realKill = process.kill
|
|
159
|
+
;(process as any).kill = () => { throw new Error('ESRCH') }
|
|
160
|
+
pidFileContent = null
|
|
161
|
+
const { child } = spawnWorker({ entry: '/e', runner: 'node', env: {}, cwd: '/w', detach: true, inheritStdio: true })
|
|
162
|
+
expect((child as any).unrefed).toBe(true)
|
|
163
|
+
expect(lastSpawn!.opts.detached).toBe(true)
|
|
164
|
+
process.kill = realKill
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('spawnWorker throws when a live worker already holds the pid file', () => {
|
|
168
|
+
const realKill = process.kill
|
|
169
|
+
pidFileContent = '777'
|
|
170
|
+
;(process as any).kill = () => true // existing pid is alive
|
|
171
|
+
expect(() => spawnWorker({ entry: '/e', runner: 'bun', env: {}, cwd: '/w' })).toThrow('already running')
|
|
172
|
+
process.kill = realKill
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('spawnWorker clears a stale pid file then spawns', () => {
|
|
176
|
+
const realKill = process.kill
|
|
177
|
+
pidFileContent = '888'
|
|
178
|
+
let calls = 0
|
|
179
|
+
;(process as any).kill = () => { calls++; throw new Error('ESRCH') } // stale
|
|
180
|
+
const { pid } = spawnWorker({ entry: '/e', runner: 'bun', env: {}, cwd: '/w' })
|
|
181
|
+
expect(pid).toBe(4242)
|
|
182
|
+
expect(fsUnlinks).toContain('/home/.shogo/worker.pid') // stale cleared
|
|
183
|
+
process.kill = realKill
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('spawnWorker throws when spawn yields no pid', () => {
|
|
187
|
+
const realKill = process.kill
|
|
188
|
+
;(process as any).kill = () => { throw new Error('ESRCH') }
|
|
189
|
+
spawnPid = undefined
|
|
190
|
+
pidFileContent = null
|
|
191
|
+
expect(() => spawnWorker({ entry: '/e', runner: 'bun', env: {}, cwd: '/w' })).toThrow('Failed to spawn')
|
|
192
|
+
process.kill = realKill
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('stopWorker: null when no pid; clears stale; kills live', () => {
|
|
196
|
+
const realKill = process.kill
|
|
197
|
+
pidFileContent = null
|
|
198
|
+
expect(stopWorker().killedPid).toBe(null)
|
|
199
|
+
// stale
|
|
200
|
+
pidFileContent = '321'
|
|
201
|
+
;(process as any).kill = (_p: number, sig: any) => { if (sig === 0) throw new Error('ESRCH'); return true }
|
|
202
|
+
expect(stopWorker().killedPid).toBe(null)
|
|
203
|
+
expect(fsUnlinks).toContain('/home/.shogo/worker.pid')
|
|
204
|
+
// live
|
|
205
|
+
pidFileContent = '654'
|
|
206
|
+
;(process as any).kill = () => true
|
|
207
|
+
expect(stopWorker('SIGKILL').killedPid).toBe(654)
|
|
208
|
+
process.kill = realKill
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('installShutdownHooks forwards signal to child + clears pid; child exit triggers process.exit', () => {
|
|
212
|
+
const realKill = process.kill
|
|
213
|
+
const realExit = process.exit
|
|
214
|
+
const onceHandlers: Record<string, Function> = {}
|
|
215
|
+
const realOnce = process.once
|
|
216
|
+
;(process as any).once = (evt: string, fn: Function) => { onceHandlers[evt] = fn; return process }
|
|
217
|
+
let exitCode: number | undefined
|
|
218
|
+
;(process as any).exit = (c?: number) => { exitCode = c; throw new Error('__exit__') }
|
|
219
|
+
|
|
220
|
+
const child = new FakeChild(111)
|
|
221
|
+
installShutdownHooks(child as any)
|
|
222
|
+
// trigger SIGINT
|
|
223
|
+
onceHandlers['SIGINT']?.()
|
|
224
|
+
expect(child.killed).toBe('SIGINT')
|
|
225
|
+
expect(fsUnlinks).toContain('/home/.shogo/worker.pid')
|
|
226
|
+
// second invocation is a no-op (shutdownStarted guard)
|
|
227
|
+
onceHandlers['SIGTERM']?.()
|
|
228
|
+
// child 'exit' with a code → process.exit(code)
|
|
229
|
+
expect(() => child.emit('exit', 3, null)).toThrow('__exit__')
|
|
230
|
+
expect(exitCode).toBe(3)
|
|
231
|
+
|
|
232
|
+
process.once = realOnce; process.exit = realExit; process.kill = realKill
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('installShutdownHooks exit handler with signal maps to 128+n', () => {
|
|
236
|
+
const realExit = process.exit
|
|
237
|
+
const onceHandlers: Record<string, Function> = {}
|
|
238
|
+
const realOnce = process.once
|
|
239
|
+
;(process as any).once = (evt: string, fn: Function) => { onceHandlers[evt] = fn; return process }
|
|
240
|
+
let exitCode: number | undefined
|
|
241
|
+
;(process as any).exit = (c?: number) => { exitCode = c; throw new Error('__exit__') }
|
|
242
|
+
const child = new FakeChild(222)
|
|
243
|
+
installShutdownHooks(child as any)
|
|
244
|
+
expect(() => child.emit('exit', null, 'SIGTERM')).toThrow('__exit__')
|
|
245
|
+
expect(exitCode).toBe(128 + 15)
|
|
246
|
+
// 'exit' process handler clears pid when no shutdown started
|
|
247
|
+
onceHandlers['exit']?.()
|
|
248
|
+
process.once = realOnce; process.exit = realExit
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
253
|
+
describe('preflight', () => {
|
|
254
|
+
const baseOpts = { cloudUrl: 'https://api.shogo.dev', apiKey: 'K', workerDir: '/worker' }
|
|
255
|
+
const ORIGINAL_FETCH = globalThis.fetch
|
|
256
|
+
afterEach(() => { globalThis.fetch = ORIGINAL_FETCH })
|
|
257
|
+
|
|
258
|
+
test('makeChecks: node-version + worker-dir + allowlist + api-key checks; proxy added when set', async () => {
|
|
259
|
+
existsMap = { '/worker': true }
|
|
260
|
+
const checks = makeChecks(baseOpts)
|
|
261
|
+
const names = checks.map((c) => c.name)
|
|
262
|
+
expect(names[0]).toContain('Runtime')
|
|
263
|
+
expect(names.some((n) => n.includes('Worker directory'))).toBe(true)
|
|
264
|
+
expect(names.some((n) => n.includes('Reach api.shogo.dev'))).toBe(true)
|
|
265
|
+
expect(names.some((n) => n.includes('API key valid'))).toBe(true)
|
|
266
|
+
expect(names.some((n) => n.includes('Proxy'))).toBe(false)
|
|
267
|
+
|
|
268
|
+
const withProxy = makeChecks({ ...baseOpts, proxy: { url: 'http://proxy:3128' } as any })
|
|
269
|
+
expect(withProxy.some((c) => c.name.includes('Proxy reachable (proxy:3128)'))).toBe(true)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('node-version check passes on >=20, worker-dir reflects existsSync', async () => {
|
|
273
|
+
existsMap = { '/worker': true }
|
|
274
|
+
const checks = makeChecks(baseOpts)
|
|
275
|
+
const node = await checks[0].run()
|
|
276
|
+
expect(node.ok).toBe(true) // test runner is node>=20
|
|
277
|
+
const dirOk = await checks[1].run()
|
|
278
|
+
expect(dirOk.ok).toBe(true)
|
|
279
|
+
existsMap = { '/worker': false }
|
|
280
|
+
const dirBad = await makeChecks(baseOpts)[1].run()
|
|
281
|
+
expect(dirBad.ok).toBe(false)
|
|
282
|
+
expect(dirBad.detail).toContain('does not exist')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('allowlist health probe: ok, no-response, and abort/error paths', async () => {
|
|
286
|
+
existsMap = { '/worker': true }
|
|
287
|
+
// ok
|
|
288
|
+
globalThis.fetch = (async () => ({ status: 200 })) as any
|
|
289
|
+
let checks = makeChecks(baseOpts)
|
|
290
|
+
const reach = checks.find((c) => c.name.includes('Reach api.shogo.dev'))!
|
|
291
|
+
expect(await reach.run()).toMatchObject({ ok: true, detail: 'HTTP 200' })
|
|
292
|
+
// no response (fetch resolves null via .catch)
|
|
293
|
+
globalThis.fetch = (async () => { throw new Error('conn refused') }) as any
|
|
294
|
+
checks = makeChecks(baseOpts)
|
|
295
|
+
const reach2 = checks.find((c) => c.name.includes('Reach api.shogo.dev'))!
|
|
296
|
+
expect((await reach2.run()).ok).toBe(false)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('api-key check: 401 rejected, ok, and thrown error', async () => {
|
|
300
|
+
existsMap = { '/worker': true }
|
|
301
|
+
globalThis.fetch = (async () => ({ status: 401, ok: false })) as any
|
|
302
|
+
let key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
|
|
303
|
+
expect(await key.run()).toMatchObject({ ok: false })
|
|
304
|
+
globalThis.fetch = (async () => ({ status: 200, ok: true })) as any
|
|
305
|
+
key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
|
|
306
|
+
expect(await key.run()).toMatchObject({ ok: true })
|
|
307
|
+
globalThis.fetch = (async () => ({ status: 500, ok: false })) as any
|
|
308
|
+
key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
|
|
309
|
+
expect((await key.run()).ok).toBe(false)
|
|
310
|
+
globalThis.fetch = (async () => { throw new Error('network') }) as any
|
|
311
|
+
key = makeChecks(baseOpts).find((c) => c.name.includes('API key'))!
|
|
312
|
+
expect(await key.run()).toMatchObject({ ok: false, detail: 'network' })
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
test('proxy check delegates to probeProxy', async () => {
|
|
316
|
+
existsMap = { '/worker': true }
|
|
317
|
+
probeProxyImpl = async () => ({ ok: false, detail: 'proxy down' })
|
|
318
|
+
const checks = makeChecks({ ...baseOpts, proxy: { url: 'http://p:1' } as any })
|
|
319
|
+
const proxy = checks.find((c) => c.name.includes('Proxy'))!
|
|
320
|
+
expect(await proxy.run()).toMatchObject({ ok: false, detail: 'proxy down' })
|
|
321
|
+
probeProxyImpl = _defaultProbeProxy
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test('makeChecks safeHost falls back to raw on bad proxy URL', () => {
|
|
325
|
+
const checks = makeChecks({ ...baseOpts, proxy: { url: 'not a url' } as any })
|
|
326
|
+
expect(checks.some((c) => c.name.includes('not a url'))).toBe(true)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('runPreflight: all-pass returns true', async () => {
|
|
330
|
+
const checks: Check[] = [
|
|
331
|
+
{ name: 'a', criticality: 'fatal', run: async () => ({ ok: true, detail: 'd' }) },
|
|
332
|
+
{ name: 'b', criticality: 'graceful', run: async () => ({ ok: true }) },
|
|
333
|
+
]
|
|
334
|
+
expect(await runPreflight(checks)).toBe(true)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test('runPreflight: graceful failure still returns true', async () => {
|
|
338
|
+
const checks: Check[] = [
|
|
339
|
+
{ name: 'a', criticality: 'fatal', run: async () => ({ ok: true }) },
|
|
340
|
+
{ name: 'b', criticality: 'graceful', run: async () => ({ ok: false, detail: 'optional down' }) },
|
|
341
|
+
]
|
|
342
|
+
expect(await runPreflight(checks)).toBe(true)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('runPreflight: fatal failure returns false', async () => {
|
|
346
|
+
const checks: Check[] = [
|
|
347
|
+
{ name: 'a', criticality: 'fatal', run: async () => ({ ok: false, detail: 'boom' }) },
|
|
348
|
+
]
|
|
349
|
+
expect(await runPreflight(checks)).toBe(false)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
afterAll(() => {
|
|
354
|
+
mock.restore()
|
|
355
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Greenfield coverage for lib/transport.ts — corporate proxy resolution,
|
|
5
|
+
* env injection, CONNECT-based reachability probe, and allowlist derivation.
|
|
6
|
+
* node:http is mocked with a scriptable fake CONNECT request emitter.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll, mock } from 'bun:test'
|
|
9
|
+
import { EventEmitter } from 'node:events'
|
|
10
|
+
|
|
11
|
+
// ── scriptable node:http request mock ─────────────────────────────────────────
|
|
12
|
+
type ConnectScript =
|
|
13
|
+
| { kind: 'connect'; statusCode: number }
|
|
14
|
+
| { kind: 'timeout' }
|
|
15
|
+
| { kind: 'error'; code?: string; message?: string }
|
|
16
|
+
| { kind: 'throw'; message: string }
|
|
17
|
+
let connectScript: ConnectScript = { kind: 'connect', statusCode: 200 }
|
|
18
|
+
let lastRequestOpts: any = null
|
|
19
|
+
const _realHttp = require('node:http')
|
|
20
|
+
|
|
21
|
+
class FakeClientRequest extends EventEmitter {
|
|
22
|
+
destroyed = false
|
|
23
|
+
destroy() { this.destroyed = true }
|
|
24
|
+
end() {
|
|
25
|
+
queueMicrotask(() => {
|
|
26
|
+
if (connectScript.kind === 'connect') {
|
|
27
|
+
this.emit('connect', { statusCode: connectScript.statusCode }, {}, Buffer.from(''))
|
|
28
|
+
} else if (connectScript.kind === 'timeout') {
|
|
29
|
+
this.emit('timeout')
|
|
30
|
+
} else if (connectScript.kind === 'error') {
|
|
31
|
+
const e: NodeJS.ErrnoException = new Error(connectScript.message ?? 'socket error')
|
|
32
|
+
if (connectScript.code) e.code = connectScript.code
|
|
33
|
+
this.emit('error', e)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
mock.module('node:http', () => ({
|
|
39
|
+
..._realHttp,
|
|
40
|
+
request: (opts: any) => {
|
|
41
|
+
lastRequestOpts = opts
|
|
42
|
+
if (connectScript.kind === 'throw') throw new Error(connectScript.message)
|
|
43
|
+
return new FakeClientRequest()
|
|
44
|
+
},
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
import { resolveProxy, applyProxyToEnv, probeProxy, deriveAllowlist } from '../transport'
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
connectScript = { kind: 'connect', statusCode: 200 }
|
|
51
|
+
lastRequestOpts = null
|
|
52
|
+
})
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
mock.module('node:http', () => _realHttp)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
describe('resolveProxy', () => {
|
|
59
|
+
test('flag wins over every env var', () => {
|
|
60
|
+
const r = resolveProxy('proxy.corp:8080', { HTTPS_PROXY: 'http://env:1' })
|
|
61
|
+
expect(r).toEqual({ url: 'http://proxy.corp:8080', source: 'flag' })
|
|
62
|
+
})
|
|
63
|
+
test('already-schemed flag is preserved', () => {
|
|
64
|
+
expect(resolveProxy('https://p.corp:443')?.url).toBe('https://p.corp:443')
|
|
65
|
+
})
|
|
66
|
+
test('env precedence: HTTPS_PROXY > https_proxy > HTTP_PROXY > http_proxy', () => {
|
|
67
|
+
expect(resolveProxy(undefined, { HTTPS_PROXY: 'a:1', https_proxy: 'b:2' })?.source).toBe('HTTPS_PROXY')
|
|
68
|
+
expect(resolveProxy(undefined, { https_proxy: 'b:2', HTTP_PROXY: 'c:3' })?.source).toBe('https_proxy')
|
|
69
|
+
expect(resolveProxy(undefined, { HTTP_PROXY: 'c:3', http_proxy: 'd:4' })?.source).toBe('HTTP_PROXY')
|
|
70
|
+
expect(resolveProxy(undefined, { http_proxy: 'd:4' })?.source).toBe('http_proxy')
|
|
71
|
+
})
|
|
72
|
+
test('normalizes bare host:port to http://', () => {
|
|
73
|
+
expect(resolveProxy(undefined, { HTTPS_PROXY: 'host:3128' })?.url).toBe('http://host:3128')
|
|
74
|
+
})
|
|
75
|
+
test('whitespace-only and empty values are ignored', () => {
|
|
76
|
+
expect(resolveProxy(' ', { HTTPS_PROXY: ' ', http_proxy: 'real:9' })?.source).toBe('http_proxy')
|
|
77
|
+
})
|
|
78
|
+
test('returns null when nothing is set', () => {
|
|
79
|
+
expect(resolveProxy(undefined, {})).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('applyProxyToEnv', () => {
|
|
84
|
+
test('returns env unchanged when proxy is null', () => {
|
|
85
|
+
const env = { FOO: 'bar' }
|
|
86
|
+
expect(applyProxyToEnv(env, null)).toBe(env)
|
|
87
|
+
})
|
|
88
|
+
test('injects all four variants when unset', () => {
|
|
89
|
+
const out = applyProxyToEnv({ PATH: '/x' }, { url: 'http://p:8080', source: 'flag' })
|
|
90
|
+
expect(out.HTTPS_PROXY).toBe('http://p:8080')
|
|
91
|
+
expect(out.https_proxy).toBe('http://p:8080')
|
|
92
|
+
expect(out.HTTP_PROXY).toBe('http://p:8080')
|
|
93
|
+
expect(out.http_proxy).toBe('http://p:8080')
|
|
94
|
+
expect(out.PATH).toBe('/x')
|
|
95
|
+
})
|
|
96
|
+
test('does not overwrite pre-existing proxy vars', () => {
|
|
97
|
+
const out = applyProxyToEnv({ HTTPS_PROXY: 'http://keep:1' }, { url: 'http://new:2', source: 'flag' })
|
|
98
|
+
expect(out.HTTPS_PROXY).toBe('http://keep:1')
|
|
99
|
+
expect(out.http_proxy).toBe('http://new:2')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('probeProxy', () => {
|
|
104
|
+
const proxy = { url: 'http://proxy.corp:3128', source: 'flag' as const }
|
|
105
|
+
|
|
106
|
+
test('CONNECT 200 → ok with detail', async () => {
|
|
107
|
+
connectScript = { kind: 'connect', statusCode: 200 }
|
|
108
|
+
const r = await probeProxy(proxy, 'api.shogo.ai')
|
|
109
|
+
expect(r.ok).toBe(true)
|
|
110
|
+
expect(r.detail).toContain('200')
|
|
111
|
+
expect(lastRequestOpts.method).toBe('CONNECT')
|
|
112
|
+
expect(lastRequestOpts.path).toBe('api.shogo.ai:443')
|
|
113
|
+
})
|
|
114
|
+
test('407 → not ok, surfaces auth hint with source', async () => {
|
|
115
|
+
connectScript = { kind: 'connect', statusCode: 407 }
|
|
116
|
+
const r = await probeProxy(proxy)
|
|
117
|
+
expect(r.ok).toBe(false)
|
|
118
|
+
expect(r.detail).toContain('407')
|
|
119
|
+
expect(r.detail).toContain('flag')
|
|
120
|
+
})
|
|
121
|
+
test('other status → not ok', async () => {
|
|
122
|
+
connectScript = { kind: 'connect', statusCode: 502 }
|
|
123
|
+
const r = await probeProxy(proxy)
|
|
124
|
+
expect(r.ok).toBe(false)
|
|
125
|
+
expect(r.detail).toContain('HTTP 502')
|
|
126
|
+
})
|
|
127
|
+
test('timeout → not ok', async () => {
|
|
128
|
+
connectScript = { kind: 'timeout' }
|
|
129
|
+
const r = await probeProxy(proxy, 'api.shogo.ai', 1234)
|
|
130
|
+
expect(r.ok).toBe(false)
|
|
131
|
+
expect(r.detail).toContain('1234ms')
|
|
132
|
+
})
|
|
133
|
+
test('socket error with code → not ok with code', async () => {
|
|
134
|
+
connectScript = { kind: 'error', code: 'ECONNREFUSED', message: 'refused' }
|
|
135
|
+
const r = await probeProxy(proxy)
|
|
136
|
+
expect(r.ok).toBe(false)
|
|
137
|
+
expect(r.detail).toContain('ECONNREFUSED')
|
|
138
|
+
})
|
|
139
|
+
test('https proxy url with no port defaults to 443', async () => {
|
|
140
|
+
connectScript = { kind: 'connect', statusCode: 200 }
|
|
141
|
+
await probeProxy({ url: 'https://secure.proxy', source: 'HTTPS_PROXY' })
|
|
142
|
+
expect(lastRequestOpts.port).toBe(443)
|
|
143
|
+
})
|
|
144
|
+
test('http proxy url with no port defaults to 80', async () => {
|
|
145
|
+
await probeProxy({ url: 'http://plain.proxy', source: 'HTTP_PROXY' })
|
|
146
|
+
expect(lastRequestOpts.port).toBe(80)
|
|
147
|
+
})
|
|
148
|
+
test('synchronous throw (bad URL) is caught', async () => {
|
|
149
|
+
connectScript = { kind: 'throw', message: 'boom' }
|
|
150
|
+
const r = await probeProxy(proxy)
|
|
151
|
+
expect(r.ok).toBe(false)
|
|
152
|
+
expect(r.detail).toBe('boom')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('deriveAllowlist', () => {
|
|
157
|
+
test('three-label host → control + tunnel + artifacts', () => {
|
|
158
|
+
const list = deriveAllowlist('https://studio.shogo.ai')
|
|
159
|
+
expect(list.map((h) => h.purpose)).toEqual(['control', 'tunnel-direct', 'artifacts'])
|
|
160
|
+
expect(list[0]).toMatchObject({ host: 'studio.shogo.ai', criticality: 'fatal' })
|
|
161
|
+
expect(list[1].host).toBe('api-direct.shogo.ai')
|
|
162
|
+
expect(list[2].host).toBe('artifacts.shogo.ai')
|
|
163
|
+
})
|
|
164
|
+
test('two-label root domain kept as-is', () => {
|
|
165
|
+
const list = deriveAllowlist('https://shogo.ai')
|
|
166
|
+
expect(list[1].host).toBe('api-direct.shogo.ai')
|
|
167
|
+
})
|
|
168
|
+
test('preserves non-https scheme', () => {
|
|
169
|
+
const list = deriveAllowlist('http://eu.shogo.dev:8443')
|
|
170
|
+
expect(list[0].url).toBe('http://eu.shogo.dev:8443')
|
|
171
|
+
expect(list[2].url).toBe('http://artifacts.shogo.dev')
|
|
172
|
+
})
|
|
173
|
+
test('invalid URL → empty list', () => {
|
|
174
|
+
expect(deriveAllowlist('not a url')).toEqual([])
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Finds the apps/api entry to spawn as the tunnel host.
|
|
5
|
+
*
|
|
6
|
+
* In the monorepo (dev), we resolve the workspace sibling.
|
|
7
|
+
* When published, the worker ships a bundled apps/api copy at ./dist/api/entry.js.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
|
|
15
|
+
interface Resolved {
|
|
16
|
+
entry: string;
|
|
17
|
+
runner: 'bun' | 'node';
|
|
18
|
+
mode: 'monorepo' | 'bundled';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function findApiEntry(): Resolved {
|
|
22
|
+
const packageRoot = resolve(__dirname, '..', '..');
|
|
23
|
+
const monorepoRoot = resolve(packageRoot, '..', '..');
|
|
24
|
+
const monorepoEntry = join(monorepoRoot, 'apps', 'api', 'src', 'entry.ts');
|
|
25
|
+
const bundledEntry = join(packageRoot, 'dist', 'api', 'entry.js');
|
|
26
|
+
|
|
27
|
+
if (existsSync(bundledEntry)) {
|
|
28
|
+
return { entry: bundledEntry, runner: 'node', mode: 'bundled' };
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(monorepoEntry)) {
|
|
31
|
+
return { entry: monorepoEntry, runner: 'bun', mode: 'monorepo' };
|
|
32
|
+
}
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Cannot locate apps/api entry. Looked in:\n ${bundledEntry}\n ${monorepoEntry}`,
|
|
35
|
+
);
|
|
36
|
+
}
|