dsh-github-router 0.1.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/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +156 -0
- package/SECURITY.md +112 -0
- package/cordis.patch.yml +8 -0
- package/docs/design.md +198 -0
- package/lib/cache.js +91 -0
- package/lib/client.js +553 -0
- package/lib/config.js +109 -0
- package/lib/core/file.js +166 -0
- package/lib/core/issue.js +202 -0
- package/lib/core/pr.js +429 -0
- package/lib/core/probe.js +121 -0
- package/lib/core/runtime.js +89 -0
- package/lib/guidance.js +24 -0
- package/lib/index.js +52 -0
- package/lib/net.js +213 -0
- package/lib/remote.js +169 -0
- package/lib/render.js +154 -0
- package/lib/routes/api.js +136 -0
- package/lib/routes/gh.js +109 -0
- package/lib/routes/git.js +429 -0
- package/lib/routes/html.js +250 -0
- package/lib/routes/mirror.js +55 -0
- package/lib/skill.js +44 -0
- package/lib/tools/api.js +101 -0
- package/lib/tools/file.js +48 -0
- package/lib/tools/issue.js +46 -0
- package/lib/tools/pr.js +56 -0
- package/lib/tools/probe.js +42 -0
- package/lib/tunnel.js +247 -0
- package/lib/util.js +235 -0
- package/package.json +79 -0
package/lib/tunnel.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency proxy transport: HTTPS through an HTTP CONNECT tunnel
|
|
3
|
+
* (plus plain HTTP via absolute-URI proxying), built on node:http/tls.
|
|
4
|
+
*
|
|
5
|
+
* Why not global fetch + undici ProxyAgent: this plugin must install
|
|
6
|
+
* OFFLINE on machines whose package registry access is exactly what is
|
|
7
|
+
* broken. The tunnel client is small, carries its own cooperative timeout
|
|
8
|
+
* and caller abort, enforces the byte cap, and sends
|
|
9
|
+
* `accept-encoding: identity` so responses never need decompression.
|
|
10
|
+
* @module dsh-github-router/tunnel
|
|
11
|
+
*/
|
|
12
|
+
import http from 'node:http'
|
|
13
|
+
import tls from 'node:tls'
|
|
14
|
+
|
|
15
|
+
const MAX_REDIRECTS = 3
|
|
16
|
+
|
|
17
|
+
/** Basic auth header value for a proxy URL carrying credentials, else null. */
|
|
18
|
+
export function proxyAuthHeader(proxyUrl) {
|
|
19
|
+
if (proxyUrl.username === '' && proxyUrl.password === '') return null
|
|
20
|
+
const user = decodeURIComponent(proxyUrl.username)
|
|
21
|
+
const pass = decodeURIComponent(proxyUrl.password)
|
|
22
|
+
if (user === '' && pass === '') return null
|
|
23
|
+
return 'Basic ' + Buffer.from(`${user}:${pass}`, 'utf8').toString('base64')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The CONNECT authority line for a target URL. */
|
|
27
|
+
export function connectTarget(target) {
|
|
28
|
+
return `${target.hostname}:${target.port !== '' && target.port !== null ? target.port : 443}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Build the request-line + headers block written onto the tunnel socket. */
|
|
32
|
+
export function buildRequestHead(target, method, headers) {
|
|
33
|
+
const path = target.pathname + target.search
|
|
34
|
+
const lines = [`${method} ${path} HTTP/1.1`, `host: ${target.host}`]
|
|
35
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
36
|
+
if (value === undefined || value === null) continue
|
|
37
|
+
lines.push(`${key}: ${value}`)
|
|
38
|
+
}
|
|
39
|
+
lines.push('accept-encoding: identity', 'connection: close', '', '')
|
|
40
|
+
return lines.join('\r\n')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse an HTTP response head into { status, headers }. */
|
|
44
|
+
export function parseResponseHead(headText) {
|
|
45
|
+
const lines = String(headText).split('\r\n')
|
|
46
|
+
const match = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(lines[0] ?? '')
|
|
47
|
+
if (match === null) throw new Error(`bad HTTP status line: ${(lines[0] ?? '').slice(0, 60)}`)
|
|
48
|
+
const headers = {}
|
|
49
|
+
for (const line of lines.slice(1)) {
|
|
50
|
+
const colon = line.indexOf(':')
|
|
51
|
+
if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim()
|
|
52
|
+
}
|
|
53
|
+
return { status: Number(match[1]), headers }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function openTunnel(target, proxyUrl, timeoutMs, signal) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const auth = proxyAuthHeader(proxyUrl)
|
|
59
|
+
const request = http.request({
|
|
60
|
+
host: proxyUrl.hostname,
|
|
61
|
+
port: proxyUrl.port !== '' ? Number(proxyUrl.port) : 80,
|
|
62
|
+
method: 'CONNECT',
|
|
63
|
+
path: connectTarget(target),
|
|
64
|
+
headers: {
|
|
65
|
+
host: connectTarget(target),
|
|
66
|
+
...(auth !== null ? { 'proxy-authorization': auth } : {}),
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
const onAbort = () => request.destroy(new DOMException('aborted', 'AbortError'))
|
|
70
|
+
if (signal !== undefined && signal !== null) {
|
|
71
|
+
if (signal.aborted) onAbort()
|
|
72
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
73
|
+
}
|
|
74
|
+
const timer = setTimeout(() => request.destroy(new DOMException('timed out', 'TimeoutError')), timeoutMs)
|
|
75
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
76
|
+
request.on('connect', (response, socket) => {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
if (response.statusCode !== 200) {
|
|
79
|
+
socket.destroy()
|
|
80
|
+
reject(new Error(`proxy CONNECT failed: HTTP ${response.statusCode}`))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const tlsSocket = tls.connect({ socket, servername: target.hostname }, () => {
|
|
84
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
85
|
+
resolve(tlsSocket)
|
|
86
|
+
})
|
|
87
|
+
tlsSocket.on('error', reject)
|
|
88
|
+
})
|
|
89
|
+
request.on('error', (error) => {
|
|
90
|
+
clearTimeout(timer)
|
|
91
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
92
|
+
reject(error)
|
|
93
|
+
})
|
|
94
|
+
request.end()
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readResponse(socket, maxBytes) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const chunks = []
|
|
101
|
+
let total = 0
|
|
102
|
+
let headerBuf = Buffer.alloc(0)
|
|
103
|
+
let headerDone = false
|
|
104
|
+
let settled = false
|
|
105
|
+
let status = 0
|
|
106
|
+
let headers = {}
|
|
107
|
+
let truncated = false
|
|
108
|
+
const finish = (error) => {
|
|
109
|
+
if (settled) return
|
|
110
|
+
settled = true
|
|
111
|
+
socket.destroy()
|
|
112
|
+
if (error !== undefined) reject(error)
|
|
113
|
+
else resolve({ status, ok: status >= 200 && status < 300, body: Buffer.concat(chunks), truncated, headers })
|
|
114
|
+
}
|
|
115
|
+
const onBody = (buf) => {
|
|
116
|
+
total += buf.length
|
|
117
|
+
if (total > maxBytes) {
|
|
118
|
+
const keep = maxBytes - (total - buf.length)
|
|
119
|
+
if (keep > 0) chunks.push(buf.subarray(0, keep))
|
|
120
|
+
truncated = true
|
|
121
|
+
finish()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
chunks.push(buf)
|
|
125
|
+
}
|
|
126
|
+
socket.on('data', (buf) => {
|
|
127
|
+
if (!headerDone) {
|
|
128
|
+
headerBuf = Buffer.concat([headerBuf, buf])
|
|
129
|
+
const idx = headerBuf.indexOf('\r\n\r\n')
|
|
130
|
+
if (idx === -1) {
|
|
131
|
+
if (headerBuf.length > 65536) finish(new Error('response headers too large'))
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const head = parseResponseHead(headerBuf.subarray(0, idx).toString('latin1'))
|
|
136
|
+
status = head.status
|
|
137
|
+
headers = head.headers
|
|
138
|
+
} catch (error) {
|
|
139
|
+
finish(error)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
headerDone = true
|
|
143
|
+
const rest = headerBuf.subarray(idx + 4)
|
|
144
|
+
if (rest.length > 0) onBody(rest)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
onBody(buf)
|
|
148
|
+
})
|
|
149
|
+
socket.on('end', () => {
|
|
150
|
+
if (headerDone) finish()
|
|
151
|
+
else finish(new Error('connection closed before response headers'))
|
|
152
|
+
})
|
|
153
|
+
socket.on('error', (error) => finish(error))
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One proxied request: CONNECT tunnel for https targets, absolute-URI for
|
|
159
|
+
* http targets. Returns the same shape as the direct fetch path.
|
|
160
|
+
*/
|
|
161
|
+
export async function tunnelRequest(targetUrl, options) {
|
|
162
|
+
const { method = 'GET', headers = {}, timeoutMs = 15000, proxy, signal, maxBytes = 1048576 } = options
|
|
163
|
+
let target = new URL(targetUrl)
|
|
164
|
+
let proxyUrl
|
|
165
|
+
try {
|
|
166
|
+
proxyUrl = new URL(String(proxy))
|
|
167
|
+
} catch {
|
|
168
|
+
// Never echo credentials: display only scheme://host:port.
|
|
169
|
+
const display = String(proxy).replace(/\/\/([^/@]+)@/, '//')
|
|
170
|
+
throw new Error(`invalid proxy URL: ${display.slice(0, 120)}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
174
|
+
if (signal !== undefined && signal !== null && signal.aborted) throw new DOMException('aborted', 'AbortError')
|
|
175
|
+
|
|
176
|
+
let socket
|
|
177
|
+
if (target.protocol === 'https:') {
|
|
178
|
+
socket = await openTunnel(target, proxyUrl, timeoutMs, signal)
|
|
179
|
+
} else if (target.protocol === 'http:') {
|
|
180
|
+
const auth = proxyAuthHeader(proxyUrl)
|
|
181
|
+
socket = await new Promise((resolve, reject) => {
|
|
182
|
+
const request = http.request({
|
|
183
|
+
host: proxyUrl.hostname,
|
|
184
|
+
port: proxyUrl.port !== '' ? Number(proxyUrl.port) : 80,
|
|
185
|
+
method,
|
|
186
|
+
path: target.toString(),
|
|
187
|
+
headers: {
|
|
188
|
+
host: target.host,
|
|
189
|
+
...(auth !== null ? { 'proxy-authorization': auth } : {}),
|
|
190
|
+
...headers,
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
const onAbort = () => request.destroy(new DOMException('aborted', 'AbortError'))
|
|
194
|
+
if (signal !== undefined && signal !== null) {
|
|
195
|
+
if (signal.aborted) onAbort()
|
|
196
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
197
|
+
}
|
|
198
|
+
const timer = setTimeout(() => request.destroy(new DOMException('timed out', 'TimeoutError')), timeoutMs)
|
|
199
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
200
|
+
request.on('response', (response) => {
|
|
201
|
+
clearTimeout(timer)
|
|
202
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
203
|
+
resolve({ stream: response, status: response.statusCode ?? 0, headers: response.headers })
|
|
204
|
+
})
|
|
205
|
+
request.on('error', (error) => {
|
|
206
|
+
clearTimeout(timer)
|
|
207
|
+
if (signal !== undefined && signal !== null) signal.removeEventListener('abort', onAbort)
|
|
208
|
+
reject(error)
|
|
209
|
+
})
|
|
210
|
+
request.end()
|
|
211
|
+
}).then(({ stream, status, headers }) => {
|
|
212
|
+
// Collect the http-over-proxy body (rarely used by this plugin).
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const chunks = []
|
|
215
|
+
let total = 0
|
|
216
|
+
let truncated = false
|
|
217
|
+
stream.on('data', (buf) => {
|
|
218
|
+
total += buf.length
|
|
219
|
+
if (total > maxBytes) {
|
|
220
|
+
const keep = maxBytes - (total - buf.length)
|
|
221
|
+
if (keep > 0) chunks.push(buf.subarray(0, keep))
|
|
222
|
+
truncated = true
|
|
223
|
+
stream.destroy()
|
|
224
|
+
} else chunks.push(buf)
|
|
225
|
+
})
|
|
226
|
+
stream.on('end', () => resolve({ status, ok: status >= 200 && status < 300, body: Buffer.concat(chunks), truncated, headers }))
|
|
227
|
+
stream.on('error', reject)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
return socket
|
|
231
|
+
} else {
|
|
232
|
+
throw new Error(`unsupported target protocol: ${target.protocol}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (target.protocol === 'https:') {
|
|
236
|
+
socket.write(buildRequestHead(target, method, headers))
|
|
237
|
+
const response = await readResponse(socket, maxBytes)
|
|
238
|
+
const location = response.headers.location
|
|
239
|
+
if (location !== undefined && [301, 302, 303, 307, 308].includes(response.status) && hop < MAX_REDIRECTS) {
|
|
240
|
+
target = new URL(location, target)
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
return { status: response.status, ok: response.ok, body: response.body, truncated: response.truncated, headers: response.headers, url: target.toString() }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`too many redirects (${MAX_REDIRECTS})`)
|
|
247
|
+
}
|
package/lib/util.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers: session cwd, subprocess execution (argv arrays only),
|
|
3
|
+
* input validation guards, and text shaping.
|
|
4
|
+
* @module dsh-github-router/util
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
11
|
+
export const STORAGE_DIR = join(DSH_HOME, 'storages', 'dsh-github-router')
|
|
12
|
+
export const CACHE_DIR = join(STORAGE_DIR, 'cache')
|
|
13
|
+
export const GIT_CACHE_DIR = join(STORAGE_DIR, 'git')
|
|
14
|
+
|
|
15
|
+
/** Session workspace cwd, with a process-cwd fallback. */
|
|
16
|
+
export function sessionCwd(exec) {
|
|
17
|
+
const header = exec && exec.agent && exec.agent.session && exec.agent.session.header
|
|
18
|
+
if (header && typeof header.cwd === 'string' && header.cwd.length > 0) return header.cwd
|
|
19
|
+
return process.cwd()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sha1(text) {
|
|
23
|
+
return createHash('sha1').update(String(text), 'utf8').digest('hex')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function sleep(ms) {
|
|
27
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isAbortError(error) {
|
|
31
|
+
return (
|
|
32
|
+
(error instanceof DOMException && error.name === 'AbortError') ||
|
|
33
|
+
(error !== null && typeof error === 'object' && error.name === 'AbortError')
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ------------------------------------------------------------------ guards
|
|
38
|
+
|
|
39
|
+
/** GitHub owner/repo names: alphanumerics plus . _ -, not empty. */
|
|
40
|
+
const OWNER_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
41
|
+
|
|
42
|
+
export function guardOwnerRepo(value, label) {
|
|
43
|
+
const text = String(value ?? '')
|
|
44
|
+
if (text.length === 0 || text.length > 100 || !OWNER_REPO_RE.test(text)) {
|
|
45
|
+
throw new Error(`${label} must be a valid GitHub owner or repository name (got "${text}")`)
|
|
46
|
+
}
|
|
47
|
+
return text
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function guardPrNumber(value, label = 'number') {
|
|
51
|
+
const n = Number(value)
|
|
52
|
+
if (!Number.isInteger(n) || n < 1 || n > 100_000_000) {
|
|
53
|
+
throw new Error(`${label} must be a positive integer (got "${String(value)}")`)
|
|
54
|
+
}
|
|
55
|
+
return n
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* api.github.com relative paths only: starts with '/', no query/fragment,
|
|
60
|
+
* no control characters or spaces, no '..' segments. GET only — enforced
|
|
61
|
+
* by the client, which has no other verbs.
|
|
62
|
+
*/
|
|
63
|
+
const API_PATH_RE = /^\/[A-Za-z0-9._~%/-]*$/
|
|
64
|
+
|
|
65
|
+
export function guardApiPath(path) {
|
|
66
|
+
const text = String(path ?? '')
|
|
67
|
+
if (text.length === 0 || text.length > 2048 || !API_PATH_RE.test(text)) {
|
|
68
|
+
throw new Error(`path must be a plain api.github.com relative path starting with "/" (got "${text}")`)
|
|
69
|
+
}
|
|
70
|
+
if (text.split('/').includes('..') || text.includes('%2e%2e') || text.toLowerCase().includes('%2e%2e')) {
|
|
71
|
+
throw new Error(`path must not contain ".." segments (got "${text}")`)
|
|
72
|
+
}
|
|
73
|
+
return text
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Repo file path: relative, no leading slash, no ".." segments, no control
|
|
78
|
+
* characters, bounded length. It is passed to subprocesses as a single argv
|
|
79
|
+
* element (never interpolated into a shell command).
|
|
80
|
+
*/
|
|
81
|
+
const FILE_PATH_RE = /^[^\u0000-\u001f\u007f\\:*?"<>|]+$/
|
|
82
|
+
|
|
83
|
+
export function guardFilePath(path) {
|
|
84
|
+
const text = String(path ?? '')
|
|
85
|
+
if (text.length === 0 || text.length > 1024) {
|
|
86
|
+
throw new Error(`path must be a non-empty relative repository file path (got "${text}")`)
|
|
87
|
+
}
|
|
88
|
+
if (text.startsWith('/') || !FILE_PATH_RE.test(text)) {
|
|
89
|
+
throw new Error(`path must be a relative repository file path without control characters (got "${text}")`)
|
|
90
|
+
}
|
|
91
|
+
if (text.split('/').includes('..')) {
|
|
92
|
+
throw new Error(`path must not contain ".." segments (got "${text}")`)
|
|
93
|
+
}
|
|
94
|
+
return text
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function guardRef(value, label = 'ref') {
|
|
98
|
+
const text = String(value ?? '').trim()
|
|
99
|
+
if (text.length === 0 || text.length > 512 || text.startsWith('-') || /[\u0000-\u001f\u007f~^:?*[\\]/.test(text) || text.includes('..')) {
|
|
100
|
+
throw new Error(`${label} must be a plain git ref name (branch, tag, or full commit sha) (got "${text}")`)
|
|
101
|
+
}
|
|
102
|
+
return text
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function guardByteCount(value, label, fallback, min, max) {
|
|
106
|
+
if (value === undefined || value === null) return fallback
|
|
107
|
+
const n = Number(value)
|
|
108
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
109
|
+
throw new Error(`${label} must be an integer between ${min} and ${max} (got "${String(value)}")`)
|
|
110
|
+
}
|
|
111
|
+
return n
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// -------------------------------------------------------------- subprocess
|
|
115
|
+
|
|
116
|
+
const DEFAULT_STDOUT_CAP = 1_048_576
|
|
117
|
+
const DEFAULT_STDERR_CAP = 262_144
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run one subprocess to completion through the platform subprocess service
|
|
121
|
+
* (host token — the same channel the pyenv/graphlint plugins use), with
|
|
122
|
+
* argv arrays and no shell interpolation anywhere.
|
|
123
|
+
* @returns { exitCode, ok, timedOut, stdout: {text, truncated}, stderr: {...}, spawnError }
|
|
124
|
+
*/
|
|
125
|
+
export async function runCaptured(subprocess, spec) {
|
|
126
|
+
const {
|
|
127
|
+
argv,
|
|
128
|
+
cwd,
|
|
129
|
+
env,
|
|
130
|
+
graceMs = 15_000,
|
|
131
|
+
signal,
|
|
132
|
+
maxStdout = DEFAULT_STDOUT_CAP,
|
|
133
|
+
maxStderr = DEFAULT_STDERR_CAP,
|
|
134
|
+
onSpawn,
|
|
135
|
+
} = spec
|
|
136
|
+
let handle
|
|
137
|
+
try {
|
|
138
|
+
handle = subprocess.spawn({
|
|
139
|
+
argv,
|
|
140
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
141
|
+
...(env !== undefined ? { env } : {}),
|
|
142
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: maxStdout }, stderr: { maxBytes: maxStderr } },
|
|
143
|
+
graceMs,
|
|
144
|
+
...(signal !== undefined ? { signal } : {}),
|
|
145
|
+
})
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return { exitCode: null, ok: false, timedOut: false, spawnError: String(error && error.message ? error.message : error), stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }
|
|
148
|
+
}
|
|
149
|
+
if (onSpawn !== undefined) onSpawn(handle)
|
|
150
|
+
let outcome
|
|
151
|
+
try {
|
|
152
|
+
outcome = await handle.done
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { exitCode: null, ok: false, timedOut: false, spawnError: String(error && error.message ? error.message : error), stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false } }
|
|
155
|
+
}
|
|
156
|
+
const shape = (reader) => {
|
|
157
|
+
if (reader === undefined || reader === null) return { text: '', truncated: false }
|
|
158
|
+
const read = reader.readFrom(0)
|
|
159
|
+
return { text: typeof read.text === 'string' ? read.text : '', truncated: !!read.lossy }
|
|
160
|
+
}
|
|
161
|
+
const exitCode = outcome.exitCode === undefined || outcome.exitCode === null ? null : outcome.exitCode
|
|
162
|
+
return {
|
|
163
|
+
exitCode,
|
|
164
|
+
ok: exitCode === 0,
|
|
165
|
+
timedOut: outcome.exitCode === null || outcome.exitCode === undefined,
|
|
166
|
+
spawnError: null,
|
|
167
|
+
stdout: shape(handle.collected && handle.collected.stdout),
|
|
168
|
+
stderr: shape(handle.collected && handle.collected.stderr),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Env for read-only git/gh children: never prompts, no color, plus the ambient env. */
|
|
173
|
+
export function childEnv(extra) {
|
|
174
|
+
return {
|
|
175
|
+
...process.env,
|
|
176
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
177
|
+
GH_PROMPT_DISABLED: '1',
|
|
178
|
+
NO_COLOR: '1',
|
|
179
|
+
...extra,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------- shaping
|
|
184
|
+
|
|
185
|
+
/** Byte-bounded truncation with a marker; lossless for inputs under the cap. */
|
|
186
|
+
export function truncateText(text, maxBytes) {
|
|
187
|
+
const s = String(text ?? '')
|
|
188
|
+
const buf = Buffer.from(s, 'utf8')
|
|
189
|
+
if (buf.length <= maxBytes) return { text: s, truncated: false }
|
|
190
|
+
return { text: buf.subarray(0, maxBytes).toString('utf8'), truncated: true }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const HTML_ENTITIES = {
|
|
194
|
+
'&': '&',
|
|
195
|
+
'<': '<',
|
|
196
|
+
'>': '>',
|
|
197
|
+
'"': '"',
|
|
198
|
+
''': "'",
|
|
199
|
+
''': "'",
|
|
200
|
+
' ': ' ',
|
|
201
|
+
''': "'",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Crude tag-strip for bodies extracted from page payloads (display only). */
|
|
205
|
+
export function stripHtml(html) {
|
|
206
|
+
let text = String(html ?? '')
|
|
207
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
208
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
209
|
+
text = text.replace(/<br\s*\/?>/gi, '\n')
|
|
210
|
+
text = text.replace(/<\/p>/gi, '\n')
|
|
211
|
+
text = text.replace(/<li[^>]*>/gi, '\n- ')
|
|
212
|
+
text = text.replace(/<[^>]+>/g, '')
|
|
213
|
+
text = text.replace(/&(amp|lt|gt|quot|apos|nbsp|#39|#x27);/gi, (m) => HTML_ENTITIES[m.toLowerCase()] ?? m)
|
|
214
|
+
text = text.replace(/\u00a0/g, ' ')
|
|
215
|
+
text = text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n')
|
|
216
|
+
return text.trim()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --------------------------------------------------------------- git urls
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Extract { owner, repo } from common GitHub remote URL shapes.
|
|
223
|
+
* Returns null when the URL does not look like a GitHub repo remote.
|
|
224
|
+
*/
|
|
225
|
+
export function ownerRepoFromUrl(url) {
|
|
226
|
+
const text = String(url ?? '').trim().replace(/\\/g, '/')
|
|
227
|
+
let match
|
|
228
|
+
match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(text)
|
|
229
|
+
if (match) return { owner: match[1], repo: match[2] }
|
|
230
|
+
match = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/.exec(text)
|
|
231
|
+
if (match) return { owner: match[1], repo: match[2] }
|
|
232
|
+
match = /^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/.exec(text)
|
|
233
|
+
if (match) return { owner: match[1], repo: match[2] }
|
|
234
|
+
return null
|
|
235
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-github-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin: read-only GitHub access for agents (github_probe / github_pr / github_issue / github_file / github_api). Routes every request inside the tool — api.github.com (direct or proxy), gh CLI, git protocol (plugin-owned fetch cache plus read-only local repo reads), PR/issue page HTML with strict JSON embeddedData extraction, and optional user-configured raw mirrors — so agents never burn turns fighting sandbox TLS/proxy failures in a shell. No write/push capability exists anywhere in the plugin.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"dsh",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"github",
|
|
10
|
+
"pull-request",
|
|
11
|
+
"issue",
|
|
12
|
+
"read-only",
|
|
13
|
+
"proxy",
|
|
14
|
+
"router"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Yutong Zou <yutong.zou.24@alumni.ucl.ac.uk> (https://github.com/AngelosZou)",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/AngelosZou/dsh-github-router.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/AngelosZou/dsh-github-router/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/AngelosZou/dsh-github-router#readme",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "lib/index.js",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"default": "./lib/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./client": {
|
|
33
|
+
"default": "./lib/client.js"
|
|
34
|
+
},
|
|
35
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"lib",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"SECURITY.md",
|
|
42
|
+
"README.md",
|
|
43
|
+
"README.zh.md",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"CONTRIBUTING.md",
|
|
46
|
+
"docs"
|
|
47
|
+
],
|
|
48
|
+
"dsh": {
|
|
49
|
+
"bundle": {
|
|
50
|
+
"patch": "./cordis.patch.yml"
|
|
51
|
+
},
|
|
52
|
+
"client": {
|
|
53
|
+
"platform": "web",
|
|
54
|
+
"inject": [
|
|
55
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=20.18.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
64
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
65
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
66
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
67
|
+
"@deepseek-ai/schemastery": "^2.0.0"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
71
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
72
|
+
},
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"test": "node --test --test-isolation=none \"test/*.test.js\""
|
|
78
|
+
}
|
|
79
|
+
}
|