@riceawa/dsh-lan-gateway 0.3.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/LICENSE +20 -0
- package/README.md +221 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +733 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +71 -0
- package/lib/index.js +1310 -0
- package/package.json +86 -0
- package/skills/lan-gateway.md +56 -0
- package/src/auth.ts +182 -0
- package/src/client/index.ts +89 -0
- package/src/client/lan-gateway-card.tsx +603 -0
- package/src/gateway.ts +343 -0
- package/src/index.ts +498 -0
- package/src/login.ts +133 -0
- package/src/state.ts +93 -0
- package/src/tls.ts +164 -0
- package/src/tool.ts +82 -0
- package/src/x509.ts +314 -0
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@riceawa/dsh-lan-gateway",
|
|
3
|
+
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0, forwards to the loopback dsh web server with header rewrite to pass the /api trust fence. LAN sources are password-free; non-LAN sources get a login page + HMAC cookie. Optional TLS with auto-generated self-signed or user-supplied certificates, and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./client": {
|
|
16
|
+
"default": "./lib/client.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib",
|
|
21
|
+
"src",
|
|
22
|
+
"skills",
|
|
23
|
+
"cordis.patch.yml"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"build:client": "tsdown",
|
|
30
|
+
"prepack": "tsdown"
|
|
31
|
+
},
|
|
32
|
+
"dsh": {
|
|
33
|
+
"bundle": {
|
|
34
|
+
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"client": {
|
|
37
|
+
"inject": [
|
|
38
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
39
|
+
],
|
|
40
|
+
"platform": "web",
|
|
41
|
+
"immediately": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
46
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.0",
|
|
47
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.0",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
52
|
+
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
|
|
54
|
+
"@deepseek-ai/dsh-settings": "0.1.0-rc.6",
|
|
55
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
|
56
|
+
"@deepseek-ai/schemastery": "3.18.1",
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"@types/react": "~18.3.1",
|
|
59
|
+
"react": "^18.2.0",
|
|
60
|
+
"tsdown": "^0.22.2",
|
|
61
|
+
"tsx": "^4.23.12",
|
|
62
|
+
"typescript": "^5.9.3",
|
|
63
|
+
"unrun": "^0.3.1",
|
|
64
|
+
"vitest": "^4.1.1"
|
|
65
|
+
},
|
|
66
|
+
"license": "MIT",
|
|
67
|
+
"keywords": [
|
|
68
|
+
"dsh",
|
|
69
|
+
"deepseek-harness",
|
|
70
|
+
"plugin",
|
|
71
|
+
"lan",
|
|
72
|
+
"gateway",
|
|
73
|
+
"reverse-proxy",
|
|
74
|
+
"uuid",
|
|
75
|
+
"secure-context",
|
|
76
|
+
"shim"
|
|
77
|
+
],
|
|
78
|
+
"repository": {
|
|
79
|
+
"type": "git",
|
|
80
|
+
"url": "git+https://github.com/rice-awa/dsh-lan-gateway.git"
|
|
81
|
+
},
|
|
82
|
+
"bugs": {
|
|
83
|
+
"url": "https://github.com/rice-awa/dsh-lan-gateway/issues"
|
|
84
|
+
},
|
|
85
|
+
"homepage": "https://github.com/rice-awa/dsh-lan-gateway#readme"
|
|
86
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lan-gateway
|
|
3
|
+
description: Manage remote (non-LAN) access to the DeepSeek Harness web GUI through the dsh-lan-gateway plugin. Use when the user asks to open the web GUI to the LAN/internet, set or change the remote-access password, rotate the session secret, or check whether remote access is currently enabled and what it is listening on.
|
|
4
|
+
whenToUse: The user wants the web GUI reachable from their LAN or the wider internet, wants to enable/disable that, set the login password for remote visitors, rotate the session cookie secret, or learn the current gateway status (port, target, password state, trusted CIDRs).
|
|
5
|
+
user-invocable: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# dsh-lan-gateway
|
|
9
|
+
|
|
10
|
+
The `dsh-lan-gateway` plugin lets the DeepSeek Harness web GUI be reached from the
|
|
11
|
+
LAN and the wider internet. dsh itself binds only to loopback (the web CLI
|
|
12
|
+
hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on
|
|
13
|
+
`0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin so
|
|
14
|
+
the `/api` trust fence still passes.
|
|
15
|
+
|
|
16
|
+
## Access tiers
|
|
17
|
+
|
|
18
|
+
- **Loopback** (`127.0.0.1`, `::1`) — no password.
|
|
19
|
+
- **LAN** (RFC1918 `10/8`, `172.16/12`, `192.168/16`, link-local `169.254/16`, `fe80::/10`)
|
|
20
|
+
— no password.
|
|
21
|
+
- **Anything else** (the public internet, CGNAT, Tailscale) — must sign in at the
|
|
22
|
+
login page and receive the session cookie.
|
|
23
|
+
|
|
24
|
+
You can extend the trusted LAN ranges by editing the plugin's `lanCidrs` config.
|
|
25
|
+
|
|
26
|
+
## Drive it through the `lan_gateway` tool
|
|
27
|
+
|
|
28
|
+
Do not edit state files by hand — use the `lan_gateway` tool. The gateway refuses
|
|
29
|
+
to listen until a password is set (unless `authRequired` is explicitly false).
|
|
30
|
+
|
|
31
|
+
- `lan_gateway` with `command: "status"` — is it listening, on which port, toward
|
|
32
|
+
which dsh port, password set?, trusted CIDRs, cookie lifetime.
|
|
33
|
+
- `lan_gateway` with `command: "set-password"` and `password: "<new pass>"`
|
|
34
|
+
(min 8 chars) — set the remote-access password. Pass an empty password to clear.
|
|
35
|
+
- `lan_gateway` with `command: "enable"` — start listening on the gateway port.
|
|
36
|
+
- `lan_gateway` with `command: "disable"` — stop listening (dsh itself stays up).
|
|
37
|
+
- `lan_gateway` with `command: "rotate-secret"` — invalidate every issued login
|
|
38
|
+
cookie (users must sign in again).
|
|
39
|
+
|
|
40
|
+
## After enabling
|
|
41
|
+
|
|
42
|
+
Tell the user the gateway URL they can share:
|
|
43
|
+
|
|
44
|
+
- On the LAN: `http://<lan-ip>:<gatewayPort>/` (default port `3081`).
|
|
45
|
+
- From outside: the machine's public address or a Tailscale IP on port
|
|
46
|
+
`3081` — that path requires the password.
|
|
47
|
+
|
|
48
|
+
## Troubleshooting
|
|
49
|
+
|
|
50
|
+
- Gateway won't start: set a password first (`lan_gateway set-password`), check
|
|
51
|
+
the port is free, confirm `ctx.webServer.port` (default dsh web port `3080`)
|
|
52
|
+
is where dsh is actually listening.
|
|
53
|
+
- Remote visitors see a redirect loop or 403: check the login cookie
|
|
54
|
+
(`dsh_gw_auth`) and that their source is classified as `internet`. A `403` on
|
|
55
|
+
`/api` from a browser is the gateway's own origin/CSRF fence — a real page
|
|
56
|
+
load carries the right Origin; a hand-crafted cross-site request does not.
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication primitives for the LAN gateway: source-IP classification
|
|
3
|
+
* (loopback / lan / internet), HMAC-signed session cookies, and an in-memory
|
|
4
|
+
* per-source login rate limiter. Pure functions where possible so the tests
|
|
5
|
+
* can exercise them without a live server. No runtime dependencies beyond
|
|
6
|
+
* node:crypto.
|
|
7
|
+
*
|
|
8
|
+
* @module @riceawa/dsh-lan-gateway/auth
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createHmac,
|
|
13
|
+
timingSafeEqual,
|
|
14
|
+
} from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
/** The three trust tiers a request source can fall into. */
|
|
17
|
+
export type SourceClass = 'loopback' | 'lan' | 'internet'
|
|
18
|
+
|
|
19
|
+
/** One CIDR range: an IPv4 address and its prefix length. */
|
|
20
|
+
export interface Cidr {
|
|
21
|
+
addr: number
|
|
22
|
+
prefix: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_LAN_CIDRS: readonly string[] = [
|
|
26
|
+
'10.0.0.0/8',
|
|
27
|
+
'172.16.0.0/12',
|
|
28
|
+
'192.168.0.0/16',
|
|
29
|
+
'169.254.0.0/16', // link-local
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/** Default LAN CIDRs: RFC1918 + link-local, IPv4. */
|
|
33
|
+
export const DEFAULT_LAN_CIDR_STRINGS: readonly string[] = [...DEFAULT_LAN_CIDRS]
|
|
34
|
+
|
|
35
|
+
/** Parse a dotted-quad IPv4 string to its 32-bit integer, or undefined. */
|
|
36
|
+
export function parseIpv4(text: string): number | undefined {
|
|
37
|
+
const parts = text.split('.')
|
|
38
|
+
if (parts.length !== 4) return undefined
|
|
39
|
+
let out = 0
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
if (!/^\d{1,3}$/.test(part)) return undefined
|
|
42
|
+
const byte = Number(part)
|
|
43
|
+
if (byte > 255) return undefined
|
|
44
|
+
out = (out << 8) | byte
|
|
45
|
+
}
|
|
46
|
+
return out >>> 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Parse `a.b.c.d/len` into a {@link Cidr}, or undefined on malformed input. */
|
|
50
|
+
export function parseCidr(text: string): Cidr | undefined {
|
|
51
|
+
const slash = text.indexOf('/')
|
|
52
|
+
const addrText = slash === -1 ? text : text.slice(0, slash)
|
|
53
|
+
const prefixText = slash === -1 ? '32' : text.slice(slash + 1)
|
|
54
|
+
const addr = parseIpv4(addrText)
|
|
55
|
+
if (addr === undefined) return undefined
|
|
56
|
+
if (!/^\d{1,2}$/.test(prefixText)) return undefined
|
|
57
|
+
const prefix = Number(prefixText)
|
|
58
|
+
if (prefix < 0 || prefix > 32) return undefined
|
|
59
|
+
return { addr, prefix }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a 32-bit IPv4 address falls inside one CIDR range. */
|
|
63
|
+
export function inCidr(ip: number, cidr: Cidr): boolean {
|
|
64
|
+
if (cidr.prefix === 0) return true
|
|
65
|
+
const mask = cidr.prefix === 32 ? 0xffffffff : (0xffffffff << (32 - cidr.prefix)) >>> 0
|
|
66
|
+
return (ip & mask) === (cidr.addr & mask)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Normalize a raw socket address to a bare IPv4/6 string we classify on. */
|
|
70
|
+
function normalizeAddress(raw: string): string {
|
|
71
|
+
const value = raw.trim()
|
|
72
|
+
// IPv4-mapped IPv6: ::ffff:a.b.c.d
|
|
73
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value)
|
|
74
|
+
if (mapped !== null) return mapped[1]!
|
|
75
|
+
return value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Classify a source address string into one of the three trust tiers.
|
|
80
|
+
* @param remoteAddress - the raw value of `req.socket.remoteAddress`.
|
|
81
|
+
* @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
|
|
82
|
+
* @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
|
|
83
|
+
*/
|
|
84
|
+
export function classifySource(
|
|
85
|
+
remoteAddress: string | undefined,
|
|
86
|
+
lanCidrs: readonly string[] = DEFAULT_LAN_CIDR_STRINGS,
|
|
87
|
+
): SourceClass {
|
|
88
|
+
const address = normalizeAddress(remoteAddress ?? '')
|
|
89
|
+
if (address === '') return 'internet'
|
|
90
|
+
|
|
91
|
+
// Loopback: IPv4 127/8, ::1, or mapped 127.x.
|
|
92
|
+
const ipv4 = parseIpv4(address)
|
|
93
|
+
if (ipv4 !== undefined) {
|
|
94
|
+
if (ipv4 >>> 24 === 127) return 'loopback'
|
|
95
|
+
for (const cidrText of lanCidrs) {
|
|
96
|
+
const cidr = parseCidr(cidrText)
|
|
97
|
+
if (cidr !== undefined && inCidr(ipv4, cidr)) return 'lan'
|
|
98
|
+
}
|
|
99
|
+
return 'internet'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (address === '::1') return 'loopback'
|
|
103
|
+
// Link-local IPv6 fe80::/10.
|
|
104
|
+
if (address.toLowerCase().startsWith('fe80:')) return 'lan'
|
|
105
|
+
return 'internet'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Encode a byte buffer as URL-safe base64 without padding. */
|
|
109
|
+
function base64url(input: Buffer): string {
|
|
110
|
+
return input.toString('base64url')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Issue a signed session cookie value.
|
|
115
|
+
* @param secret - the HMAC signing secret (base64 string).
|
|
116
|
+
* @param expiresMs - epoch millis at which the session expires.
|
|
117
|
+
* @returns a `payload.signature` string suitable for the cookie value.
|
|
118
|
+
*/
|
|
119
|
+
export function signCookie(secret: string, expiresMs: number): string {
|
|
120
|
+
const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })))
|
|
121
|
+
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
122
|
+
return `${payload}.${sig}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether a cookie value is a valid, unexpired session signed with `secret`. */
|
|
126
|
+
export function verifyCookie(secret: string, value: string | undefined, now: number): boolean {
|
|
127
|
+
if (value === undefined) return false
|
|
128
|
+
const dot = value.indexOf('.')
|
|
129
|
+
if (dot === -1) return false
|
|
130
|
+
const payload = value.slice(0, dot)
|
|
131
|
+
const sig = value.slice(dot + 1)
|
|
132
|
+
const expected = createHmac('sha256', secret).update(payload).digest()
|
|
133
|
+
let actual: Buffer
|
|
134
|
+
try {
|
|
135
|
+
actual = Buffer.from(sig, 'base64url')
|
|
136
|
+
} catch {
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
if (expected.length !== actual.length) return false
|
|
140
|
+
if (!timingSafeEqual(expected, actual)) return false
|
|
141
|
+
try {
|
|
142
|
+
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown }
|
|
143
|
+
return typeof decoded.exp === 'number' && decoded.exp > now
|
|
144
|
+
} catch {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A token bucket limiter keyed by source address. */
|
|
150
|
+
export class RateLimiter {
|
|
151
|
+
private readonly buckets = new Map<string, { tokens: number; resetAt: number }>()
|
|
152
|
+
constructor(
|
|
153
|
+
private readonly maxTokens: number,
|
|
154
|
+
private readonly windowMs: number,
|
|
155
|
+
) {}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Attempt to consume one token for `key`.
|
|
159
|
+
* @returns true when the attempt is allowed, false when the source is
|
|
160
|
+
* temporarily rate-limited.
|
|
161
|
+
*/
|
|
162
|
+
allow(key: string): boolean {
|
|
163
|
+
const now = Date.now()
|
|
164
|
+
const bucket = this.buckets.get(key)
|
|
165
|
+
if (bucket === undefined || bucket.resetAt <= now) {
|
|
166
|
+
this.buckets.set(key, { tokens: this.maxTokens - 1, resetAt: now + this.windowMs })
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
if (bucket.tokens > 0) {
|
|
170
|
+
bucket.tokens -= 1
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Drop expired buckets to bound memory. */
|
|
177
|
+
prune(now: number = Date.now()): void {
|
|
178
|
+
for (const [key, bucket] of this.buckets) {
|
|
179
|
+
if (bucket.resetAt <= now) this.buckets.delete(key)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @riceawa/dsh-lan-gateway — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs:
|
|
5
|
+
* 1. Insecure-origin UUID shim: the gateway can serve the GUI over plain HTTP
|
|
6
|
+
* on LAN addresses, where browsers lack `crypto.randomUUID()`. This bundle
|
|
7
|
+
* installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
|
|
8
|
+
* module scope. With TLS enabled the origin is secure and the shim is a
|
|
9
|
+
* no-op.
|
|
10
|
+
* 2. Settings card: registers the LAN gateway card into the official
|
|
11
|
+
* Settings → Plugins page (`settings.plugin.item` slot), editing the
|
|
12
|
+
* `lan-gateway` settings namespace so port, CIDRs, auth, and TLS are
|
|
13
|
+
* adjustable from the GUI.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
|
|
17
|
+
function uuidFromRandomValues(): string {
|
|
18
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
|
|
19
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
20
|
+
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
|
|
21
|
+
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
|
|
22
|
+
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
23
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Install `randomUUID` on the browser Crypto prototype when the platform
|
|
28
|
+
* lacks it. Idempotent; re-checks every call.
|
|
29
|
+
* @returns `true` when the shim was installed by this call.
|
|
30
|
+
*/
|
|
31
|
+
export function installRandomUuidShim(): boolean {
|
|
32
|
+
const cryptoObj = globalThis.crypto
|
|
33
|
+
if (cryptoObj === undefined) return false
|
|
34
|
+
if (typeof cryptoObj.randomUUID === 'function') return false
|
|
35
|
+
if (typeof cryptoObj.getRandomValues !== 'function') return false
|
|
36
|
+
try {
|
|
37
|
+
// Browsers expose crypto.randomUUID through the Crypto prototype; patching
|
|
38
|
+
// the prototype (not the instance) covers `crypto.randomUUID()` everywhere.
|
|
39
|
+
const proto = Object.getPrototypeOf(cryptoObj) as { randomUUID?: unknown } | null
|
|
40
|
+
if (proto !== null && typeof proto.randomUUID !== 'function') {
|
|
41
|
+
Object.defineProperty(proto, 'randomUUID', {
|
|
42
|
+
value: uuidFromRandomValues,
|
|
43
|
+
writable: true,
|
|
44
|
+
configurable: true,
|
|
45
|
+
})
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
// Fallback: direct instance property (sandboxed/odd environments).
|
|
49
|
+
Object.defineProperty(cryptoObj, 'randomUUID', {
|
|
50
|
+
value: uuidFromRandomValues,
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
})
|
|
54
|
+
return true
|
|
55
|
+
} catch {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Module scope: the shim is live as soon as this bundle is evaluated, before
|
|
61
|
+
// any RPC/session code runs. apply() re-runs it as a belt-and-braces re-check.
|
|
62
|
+
installRandomUuidShim()
|
|
63
|
+
|
|
64
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
65
|
+
import { LanGatewayCard } from './lan-gateway-card.tsx'
|
|
66
|
+
|
|
67
|
+
export const name = 'dsh-lan-gateway'
|
|
68
|
+
|
|
69
|
+
/** Only the slots service: the card itself is self-loading (ModLens-style). */
|
|
70
|
+
export const inject = ['slots']
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Mount the settings card and the UUID shim.
|
|
74
|
+
* @param ctx - the browser plugin context.
|
|
75
|
+
*/
|
|
76
|
+
export function apply(ctx: ClientContext): void {
|
|
77
|
+
installRandomUuidShim()
|
|
78
|
+
|
|
79
|
+
// The card rides the official Plugins → Configurable tab. Like ModLens, it
|
|
80
|
+
// registers with no inject face and fetches its own loopback config route,
|
|
81
|
+
// so it has no settings/locale/connection service dependencies.
|
|
82
|
+
ctx.slots.inject('settings.plugin.item', function* () {
|
|
83
|
+
yield ctx.slots.register({
|
|
84
|
+
name: 'settings.plugin.item',
|
|
85
|
+
id: 'lan-gateway',
|
|
86
|
+
order: 30,
|
|
87
|
+
}, LanGatewayCard)
|
|
88
|
+
})
|
|
89
|
+
}
|