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/docs/design.md ADDED
@@ -0,0 +1,198 @@
1
+ # Design
2
+
3
+ This document describes the architecture of dsh-github-router: the route
4
+ ladder, the per-route semantics, the caching and confinement model, the
5
+ tool contract, and the known limitations.
6
+
7
+ ## Goals
8
+
9
+ 1. **Read GitHub without shell retries.** The DSH shell sandbox frequently
10
+ breaks GitHub access (TLS credential resets, proxy misrouting); agents
11
+ burn many turns retrying `curl`/`gh`/`git` variants. Every GitHub read
12
+ should be one tool call with a structured outcome.
13
+ 2. **Route inside the tool.** The healthy path varies over time and per
14
+ endpoint — a typical hostile-network matrix has `api.github.com`
15
+ reachable directly while `github.com` page fetches time out direct and
16
+ only work through a proxy. The plugin must try routes in a fixed,
17
+ documented order and report which one served each part.
18
+ 3. **Strictly read-only.** No write, push, comment, or mutation verb
19
+ exists. This is a security property, not a roadmap item.
20
+ 4. **Installable offline.** Zero runtime dependencies: peers resolve from
21
+ the DSH profile, and proxied requests use a self-contained CONNECT
22
+ tunnel instead of a third-party HTTP stack.
23
+
24
+ ## Layers
25
+
26
+ ```
27
+ lib/tools/* defineTool wrappers: validation → core aggregate → render
28
+ lib/core/* per-call runtime (cache, token, transport) + aggregators
29
+ lib/routes/* transport adapters (api, gh, git, html, mirror)
30
+ lib/net.js direct fetch + retry/backoff + error taxonomy
31
+ lib/tunnel.js zero-dependency CONNECT proxy client
32
+ lib/cache.js TTL JSON cache (atomic writes, tolerant reads)
33
+ ```
34
+
35
+ Tools never speak HTTP; routes never speak `defineTool`. The core
36
+ aggregators own the ladder and the per-part attribution.
37
+
38
+ ## Route ladder
39
+
40
+ Order is fixed per part type and documented in the tool descriptions:
41
+
42
+ | Part | Ladder |
43
+ | --- | --- |
44
+ | PR/issue metadata, reviews, files, comments | api → gh → html |
45
+ | PR commits / diff | api → gh → git (local clone, then fetch cache) |
46
+ | Discussion (timeline items) | api → gh → html |
47
+ | File content | api contents → raw → mirrors → git |
48
+ | Diagnostics | `github_probe` runs every route once, in parallel |
49
+
50
+ `attemptLadder` walks direct → proxy for api/raw and proxy → direct for
51
+ page HTML. The inversion for HTML is empirical: machines whose direct TLS
52
+ to `github.com` is reset usually still reach pages through a working proxy,
53
+ while `api.github.com` is more often reachable directly. `effectiveProxy`
54
+ resolves the explicit setting, then the ambient `HTTP(S)_PROXY`
55
+ environment, then none.
56
+
57
+ ### api route
58
+
59
+ GET-only client over `api.github.com`. Paths, query keys, and values are
60
+ validated before URL construction; the token attaches only here. Non-2xx
61
+ responses map to stable codes (`AUTH_REQUIRED`, `RATE_LIMITED`,
62
+ `NOT_FOUND`, `HTTP`). Caching keys on the canonical URL with per-kind TTLs
63
+ (metadata 300 s, content 86400 s); `forceRefresh` bypasses reads and
64
+ refreshes writes.
65
+
66
+ ### gh route
67
+
68
+ Used only when `gh --version` and `gh auth status` both succeed. The only
69
+ subcommands ever constructed are `gh api <path>` (GET) and the `--json`
70
+ viewers (`pr view`, `issue view`, `repo view`) plus `pr diff`; JSON field
71
+ lists are constants, so user input reaches argv only as validated
72
+ owner/repo/number/path values. `gh` uses its own stored credentials — the
73
+ plugin token is never passed to it.
74
+
75
+ ### git route
76
+
77
+ Two strictly separated modes:
78
+
79
+ - **Fetch cache** — a plugin-owned repo under
80
+ `<DSH_HOME>/storages/dsh-github-router/git/` fetches
81
+ `refs/pull/N/{head,merge}` (shallow), plus branch/HEAD/sha fetches for
82
+ the file tool. `merge^1..head` yields commits and the PR diff; a missing
83
+ merge ref degrades to head-only history with a note. Fetches to one repo
84
+ are serialized by an in-process mutex.
85
+ - **Local reads** — user-granted repos (the `repos` setting, an explicit
86
+ `localRepo` argument, or the session cwd when its origin matches the
87
+ requested owner/repo) are read with `rev-parse`/`log`/`merge-base`/
88
+ `diff`/`show` **only**. The plugin never fetches into, checks out,
89
+ resets, or pushes to a user repository. PR refs are looked up as
90
+ `refs/pull/N/{merge,head}`, then `refs/remotes/origin/{pull/N/*, pr/N}`.
91
+
92
+ ### html route
93
+
94
+ Fetches the PR/issue page and extracts only
95
+ `<script type="application/json" data-target="react-app.embeddedData">`
96
+ islands via `JSON.parse` — never evaluated. A bounded BFS (depth 10,
97
+ ≤ 40k nodes) collects whitelisted shapes: a PR/issue root
98
+ (number + title + state + body), and `timelineItems`/`reviewThreads`
99
+ nodes mapped to comment/review/review-comment entries. Bodies are
100
+ tag-stripped for display and byte-capped; CSRF tokens, sessions, and the
101
+ raw payload never leave this module.
102
+
103
+ ### mirror route
104
+
105
+ Off by default: mirrors are third parties that see requested paths and can
106
+ substitute bytes. When enabled, each configured base yields two candidates
107
+ per file (raw passthrough and the `github.com` `/raw/` route), tried in
108
+ order.
109
+
110
+ ## Settings page
111
+
112
+ The Host registers the `dsh-github-router` settings namespace on the
113
+ official settings seam (durable document, schema validation, revision
114
+ fencing); the browser half (`lib/client.js`, a hand-written ModuleLoader
115
+ factory bundle with no build step) registers an INDEPENDENT settings nav
116
+ entry into the `settings.section` slot — the same mechanism
117
+ dsh-notification uses.
118
+
119
+ ### Framework limitation: the settings exposure allowlist
120
+
121
+ The framework's api-proxy serves settings namespaces to configuration
122
+ clients ONLY through its hardcoded allowlist (`WEB_SETTINGS_NAMESPACES` /
123
+ `PRODUCT_SETTINGS_NAMESPACES` / model-provider namespaces in
124
+ `dsh-host-apiproxy`) — a third-party namespace answers
125
+ `settings-not-exposed` on both reads and writes even when registered, and
126
+ the browser `settingsScope` therefore reports it `unavailable`. Exposing a
127
+ namespace from `settings.register()` is explicitly deferred framework
128
+ work.
129
+
130
+ ### Plugin-owned configuration routes
131
+
132
+ The channel is the dsh-market pattern: the Host mounts its own routes on
133
+ the shared web server (`ctx.webServer.register`, prefix
134
+ `/dsh-github-router`) and the browser page talks to them with plain
135
+ same-origin fetch — no typert, no gateway, no allowlist. The routes are
136
+ backed by the SAME settings seam (`lib/remote.js`): GET returns a redacted
137
+ view (`value`/`base`/`user`/`revision`/`writable`, secrets stripped); POST
138
+ applies single-field `set`/`unset` ops with revision fencing (conflicts
139
+ answer 409 and the client reloads the view); writes require same-origin
140
+ POSTs and are body-capped. The page's row activates on the
141
+ notification-proven service set (`slots`, `locale`).
142
+
143
+ Because the routes write **scalar fields by name**, the settings schema
144
+ is deliberately flat (`routesApi`, `cacheTtlMeta`, …); the Host projects
145
+ it into the nested runtime shape (`routes.api`, `cacheTtlSeconds.meta`) in
146
+ `resolveOptions`. The composition layer can still carry the same flat
147
+ keys. The page shows the common fields up top (token, proxy, main route
148
+ switches) and the long tail (timeouts, retries, cache TTLs, mirrors,
149
+ repos, git cache dir) in a collapsed "Advanced settings" disclosure.
150
+
151
+ ## Cache
152
+
153
+ `TtlCache` stores `{v, ts, ttlMs, value}` per canonical URL under
154
+ `<DSH_HOME>/storages/dsh-github-router/cache/`. Writes are tmp+rename
155
+ (atomic); corrupt or expired entries degrade to misses. Cache entries carry
156
+ rate-limit headers so successful reads surface remaining quota without an
157
+ extra request. An in-memory layer avoids disk churn for repeated hits
158
+ within one host process.
159
+
160
+ ## Confinement
161
+
162
+ - **Host-side execution** is the point of the plugin (the sandbox's TLS
163
+ failures do not apply), and is compensated by construction: no write
164
+ verb, whitelist parsing, byte caps, argv-only subprocesses.
165
+ - **Disk writes** happen only under `<DSH_HOME>/storages/dsh-github-router/`
166
+ (response cache + fetch cache). User repositories are read-only targets
167
+ and require explicit grant.
168
+ - **Secrets** — the token attaches only to api.github.com; the settings
169
+ schema marks it `role('secret')` (redacted on every wire boundary,
170
+ write-only input); proxy credentials are stripped from error text.
171
+
172
+ ## Tool contract
173
+
174
+ - Execute returns lossless JSON: no `undefined`-valued fields, and arrays
175
+ carry no enumerable side properties (this bit `github_api` list
176
+ endpoints in 0.1.0 and is covered by the `JSON.parse(JSON.stringify(...))`
177
+ round-trip in the tool body).
178
+ - Every tool declares `isConcurrencySafe: () => true` (pure reads; cache
179
+ writes commute) and a cooperative `timeoutMs`; execute forwards
180
+ `exec.signal` to fetches and subprocesses.
181
+ - Renders are compact text with per-part route attribution; list caps
182
+ append "showing N of M" notes instead of silently dropping entries.
183
+
184
+ ## Known limitations
185
+
186
+ - **Fallback routes are unexercised while the API is healthy.** The ladder
187
+ is failover, not fan-out: when api succeeds, gh/html/git are not called
188
+ (saving quota). Forcing a specific route per call is not yet supported.
189
+ - **Anonymous quota is shared per IP** (60 req/h). A PR aggregation costs
190
+ up to six calls. Token configuration is the intended mitigation; the
191
+ cache is the second line.
192
+ - **The HTML route depends on GitHub's page layout.** The extraction is
193
+ shape-based (whitelisted fields, not fixed paths), but a major page
194
+ redesign may require updating the matchers. It is a fallback, never the
195
+ primary route.
196
+ - **Rate-limit notes on list endpoints.** Array responses carry no side
197
+ metadata (lossless-JSON requirement), so `github_api` list calls do not
198
+ surface remaining quota; object endpoints do.
package/lib/cache.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Tiny TTL JSON cache in the host-owned storage dir. Writes are atomic
3
+ * (tmp + rename), reads tolerate corruption and expiry by discarding the
4
+ * entry. Concurrent writes commute: last writer wins, and a torn file is
5
+ * simply treated as a miss.
6
+ * @module dsh-github-router/cache
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { CACHE_DIR, sha1 } from './util.js'
11
+
12
+ export class TtlCache {
13
+ constructor(dir = CACHE_DIR) {
14
+ this.dir = dir
15
+ this.mem = new Map()
16
+ }
17
+
18
+ keyFor(url) {
19
+ return sha1(String(url))
20
+ }
21
+
22
+ fileFor(key) {
23
+ return join(this.dir, key + '.json')
24
+ }
25
+
26
+ /** Return a cached value when present and unexpired; expired/corrupt entries are dropped. */
27
+ get(url, ttlMs, now = Date.now()) {
28
+ if (!(ttlMs > 0)) return undefined
29
+ const key = this.keyFor(url)
30
+ const hit = this.mem.get(key)
31
+ if (hit !== undefined) {
32
+ if (hit.expiresAt > now) return hit.value
33
+ this.mem.delete(key)
34
+ }
35
+ const file = this.fileFor(key)
36
+ if (!existsSync(file)) return undefined
37
+ try {
38
+ const entry = JSON.parse(readFileSync(file, 'utf8'))
39
+ if (entry === null || typeof entry !== 'object' || !Number.isFinite(entry.ts) || entry.v !== 1) {
40
+ this.drop(key)
41
+ return undefined
42
+ }
43
+ if (entry.ts + (Number.isFinite(entry.ttlMs) ? entry.ttlMs : 0) <= now) {
44
+ this.drop(key)
45
+ return undefined
46
+ }
47
+ this.mem.set(key, { value: entry.value, expiresAt: entry.ts + entry.ttlMs })
48
+ return entry.value
49
+ } catch {
50
+ this.drop(key)
51
+ return undefined
52
+ }
53
+ }
54
+
55
+ set(url, value, ttlMs, now = Date.now()) {
56
+ if (!(ttlMs > 0)) return
57
+ const key = this.keyFor(url)
58
+ this.mem.set(key, { value, expiresAt: now + ttlMs })
59
+ const file = this.fileFor(key)
60
+ const tmp = file + '.tmp'
61
+ try {
62
+ mkdirSync(this.dir, { recursive: true })
63
+ writeFileSync(tmp, JSON.stringify({ v: 1, ts: now, ttlMs, value }), 'utf8')
64
+ renameSync(tmp, file)
65
+ } catch {
66
+ try { unlinkSync(tmp) } catch { /* best effort */ }
67
+ }
68
+ }
69
+
70
+ drop(url) {
71
+ const key = this.keyFor(url)
72
+ this.mem.delete(key)
73
+ try { unlinkSync(this.fileFor(key)) } catch { /* already gone */ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Cached-JSON helper shared by the core aggregators: a cache hit skips the
79
+ * network entirely; a successful fetch is stored with its per-kind TTL.
80
+ */
81
+ export async function cachedJson(cache, url, ttlMs, forceRefresh, fetcher) {
82
+ if (!forceRefresh) {
83
+ const hit = cache.get(url, ttlMs)
84
+ if (hit !== undefined) return { ...hit, cached: true }
85
+ }
86
+ const fresh = await fetcher()
87
+ if (fresh.ok && fresh.json !== undefined) {
88
+ cache.set(url, { json: fresh.json, headers: fresh.headers, status: fresh.status }, ttlMs)
89
+ }
90
+ return { ...fresh, cached: false }
91
+ }