@ljwei-stak/dsh-model-route-bot 0.66.4 → 0.67.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/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +18 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- package/remote-computer-agent/src/transport.ts +39 -15
|
@@ -3,7 +3,7 @@ import {readFile} from 'node:fs/promises'
|
|
|
3
3
|
import {EventEmitter} from 'node:events'
|
|
4
4
|
import {randomUUID} from 'node:crypto'
|
|
5
5
|
import type {WebSocket} from 'ws'
|
|
6
|
-
import {AgentError,canonical,fail,integer,matches,secret,sha,type Context} from './protocol.js'
|
|
6
|
+
import {AgentError,canonical,fail,identifier,integer,matches,secret,sha,type Context} from './protocol.js'
|
|
7
7
|
|
|
8
8
|
type Config={pipeName:string;secretFile:string}
|
|
9
9
|
type Ack={ok:boolean;code?:string;possiblyApplied?:boolean;stopConfirmed?:boolean;mode?:'watch'|'control';expiresAt?:number}
|
|
@@ -113,6 +113,13 @@ export class DesktopSessions {
|
|
|
113
113
|
try{this.check(c);const ack=await row.native.request({t:'renew',expiresAt:c.expiresAt});if(ack.ok!==true)fail(ack.code??'DESKTOP_RENEW_FAILED',409);this.check(c);if(row.closed)fail('DESKTOP_SESSION_CLOSED',409);row.context=structuredClone(c);this.audit(row,'renewed');return this.ticket(row)}catch(e){await this.stop(row,'DESKTOP_RENEW_FAILED');throw e}finally{row.renewing=false}
|
|
114
114
|
}
|
|
115
115
|
async closeSession(c:Context,request:any){const row=this.own(c,request?.sessionId);return this.stop(row,'SESSION_CLOSED')}
|
|
116
|
+
async closeClientSession(clientId:string,request:any){
|
|
117
|
+
if(!request||typeof request!=='object'||Array.isArray(request)||Object.keys(request).some(key=>!['sessionId','botId','taskId','attemptId'].includes(key)))fail('INVALID_ARGUMENT')
|
|
118
|
+
for(const key of ['sessionId','botId','taskId','attemptId'])identifier(request[key])
|
|
119
|
+
const row=this.sessions.get(request.sessionId)
|
|
120
|
+
if(!row||row.context.clientId!==clientId||row.context.botId!==request.botId||row.context.taskId!==request.taskId||row.context.attemptId!==request.attemptId)fail('SCOPE_DENIED',403)
|
|
121
|
+
return this.stop(row,'SESSION_CLOSED')
|
|
122
|
+
}
|
|
116
123
|
hasOrigin(origin:string){return [...this.sessions.values()].some(row=>!row.closed&&row.origin===origin&&row.context.expiresAt>Date.now())}
|
|
117
124
|
private send(row:Session,message:any){if(row.socket?.readyState===1)row.socket.send(JSON.stringify(message))}
|
|
118
125
|
private nextFrame(row:Session,delay=0){if(row.closed||row.frameRequested||row.pendingFrame||!row.socket||row.socket.readyState!==1)return;clearTimeout(row.frameTimer);row.frameRequested=true;row.frameTimer=setTimeout(()=>void (async()=>{if(row.closed)return;this.check(row.context);if(row.profile!==row.appliedProfile){const ack=await row.native.request({t:'video',...videoProfiles[row.profile]});if(!ack.ok)throw new AgentError(ack.code??'DESKTOP_VIDEO_FAILED');row.appliedProfile=row.profile}if(!row.closed)row.native.frame(this.forceKeyframes.delete(row))})().catch(()=>this.stop(row,'DESKTOP_VIDEO_FAILED')),delay);row.frameTimer.unref()}
|
|
@@ -156,7 +163,7 @@ export class DesktopSessions {
|
|
|
156
163
|
fail('INVALID_ARGUMENT')
|
|
157
164
|
}
|
|
158
165
|
/** Called only after the HTTP server validates Host/network/TLS on upgrade. */
|
|
159
|
-
accept(socket:WebSocket,origin?:string,allowedOrigins:string[]=[]){
|
|
166
|
+
accept(socket:WebSocket,origin?:string,allowedOrigins:string[]=[],peerClientId?:string){
|
|
160
167
|
this.sockets.add(socket);let row:Session|undefined,authenticating=false,alive=true
|
|
161
168
|
const authTimer=setTimeout(()=>socket.close(1008,'Desktop authentication required'),this.options.authTimeoutMs??5000);authTimer.unref()
|
|
162
169
|
const heartbeat=setInterval(()=>{if(!alive){socket.terminate();return}alive=false;socket.ping()},15000);heartbeat.unref();socket.on('pong',data=>{
|
|
@@ -181,6 +188,7 @@ export class DesktopSessions {
|
|
|
181
188
|
try{
|
|
182
189
|
const candidate=this.sessions.get(message.sessionId)
|
|
183
190
|
if(message.type!=='auth'||typeof message.token!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(message.token)||!candidate||!matches(message.token,candidate.digest))fail('AUTH_INVALID',401)
|
|
191
|
+
if(peerClientId!==undefined&&candidate.context.clientId!==peerClientId)fail('AUTH_INVALID',401)
|
|
184
192
|
if(candidate.closed||candidate.used)fail('DESKTOP_SESSION_CLOSED',409)
|
|
185
193
|
this.check(candidate.context)
|
|
186
194
|
if(origin!==undefined&&(candidate.origin?origin!==candidate.origin:!allowedOrigins.includes(origin)))fail('ORIGIN_DENIED',403)
|
|
@@ -221,7 +229,7 @@ export class DesktopSessions {
|
|
|
221
229
|
}
|
|
222
230
|
private stop(row:Session,reason:string):Promise<{stopConfirmed:boolean}>{
|
|
223
231
|
this.forceKeyframes.delete(row)
|
|
224
|
-
if(row.stop)return row.stop
|
|
232
|
+
if(row.stop)return row.stopped?Promise.resolve({stopConfirmed:true}):row.stop
|
|
225
233
|
row.closed=true;row.closedAt=Date.now();clearTimeout(row.frameTimer);clearTimeout(row.ackTimer);row.pendingFrame=undefined
|
|
226
234
|
row.stop=(async()=>{const result=await row.native.close().catch(()=>({stopConfirmed:false}));row.stopped=result.stopConfirmed===true;row.mode='watch';this.send(row,{type:'status',status:'disconnected',mode:'watch',code:reason,stopConfirmed:row.stopped});row.socket?.close(1000,'Desktop session closed');this.audit(row,'closed',{reason,stopConfirmed:row.stopped});return {stopConfirmed:row.stopped}})()
|
|
227
235
|
return row.stop
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { duplexPair } from 'node:stream'
|
|
3
|
+
import { WebSocketServer } from 'ws'
|
|
4
|
+
import { E2ESession, TRANSPORT_DIRECT, DIRECT_PROTOCOL, SEND_BUFFER_HIGH } from '../../modules/openmaus-protocol/index.mjs'
|
|
5
|
+
import { fail } from './protocol.js'
|
|
6
|
+
import type { SecurePairing } from './secure-pairing.js'
|
|
7
|
+
|
|
8
|
+
type Peer = Parameters<SecurePairing['validPeer']>[0]
|
|
9
|
+
|
|
10
|
+
/** Inner requests arrive only on a successfully authenticated Noise stream, never a public TCP port. */
|
|
11
|
+
export function secureTransport(pairing: SecurePairing,
|
|
12
|
+
handler: (req: http.IncomingMessage, res: http.ServerResponse, peer: Peer) => void,
|
|
13
|
+
upgrade: (req: http.IncomingMessage, socket: any, head: Buffer, peer: Peer) => void) {
|
|
14
|
+
const sockets = new WebSocketServer({ noServer: true, maxPayload: 65535, perMessageDeflate: false,
|
|
15
|
+
handleProtocols: protocols => protocols.has(DIRECT_PROTOCOL) ? DIRECT_PROTOCOL : false })
|
|
16
|
+
const sessions = new Set<any>(), attempts = new Map<string, { start: number; count: number }>()
|
|
17
|
+
let windowStart = 0, windowCount = 0, closing = false
|
|
18
|
+
const sweep = setInterval(() => {
|
|
19
|
+
for (const session of sessions) if (session.peer && !pairing.validPeer(session.peer)) session.remove()
|
|
20
|
+
}, 500)
|
|
21
|
+
sweep.unref()
|
|
22
|
+
return {
|
|
23
|
+
accept(req: http.IncomingMessage, socket: any, head: Buffer) {
|
|
24
|
+
if (closing || sockets.clients.size >= 16) fail('E2E_CONNECTION_LIMIT', 429)
|
|
25
|
+
if (req.headers.origin !== undefined || req.headers['sec-websocket-protocol'] !== DIRECT_PROTOCOL) fail('E2E_CLIENT_REQUIRED', 403)
|
|
26
|
+
const now = Date.now(), source = req.socket.remoteAddress ?? 'unknown'
|
|
27
|
+
if (now - windowStart >= 60_000 || now < windowStart) { windowStart = now; windowCount = 0 }
|
|
28
|
+
const previous = attempts.get(source), row = previous && now - previous.start < 60_000 && now >= previous.start ? previous : { start: now, count: 0 }
|
|
29
|
+
if (++row.count > 30 || ++windowCount > 120) fail('E2E_CONNECTION_RATE_LIMIT', 429)
|
|
30
|
+
attempts.delete(source); attempts.set(source, row)
|
|
31
|
+
while (attempts.size > 256) attempts.delete(attempts.keys().next().value!)
|
|
32
|
+
sockets.handleUpgrade(req, socket, head, ws => {
|
|
33
|
+
sockets.emit('connection', ws, req)
|
|
34
|
+
const streams = new Set<any>()
|
|
35
|
+
let session: any
|
|
36
|
+
const origin = http.createServer((request, response) => {
|
|
37
|
+
if (!session.peer || !pairing.validPeer(session.peer)) { response.writeHead(403); response.end(); return }
|
|
38
|
+
handler(request, response, session.peer)
|
|
39
|
+
})
|
|
40
|
+
origin.requestTimeout = 30_000; origin.headersTimeout = 10_000
|
|
41
|
+
origin.on('upgrade', (request, connection, bytes) => {
|
|
42
|
+
if (!session.peer || !pairing.validPeer(session.peer)) return connection.destroy()
|
|
43
|
+
upgrade(request, connection, bytes, session.peer)
|
|
44
|
+
})
|
|
45
|
+
const transport = {
|
|
46
|
+
send: (message: Uint8Array) => { if (ws.readyState === 1) ws.send(message) },
|
|
47
|
+
close: (code: number) => { if (ws.readyState < 2) ws.close(code === 1000 || code >= 3000 && code <= 4999 ? code : 1000) },
|
|
48
|
+
get bufferedAmount() { return ws.bufferedAmount },
|
|
49
|
+
onceDrained(listener: () => void) {
|
|
50
|
+
const check = () => { if (ws.readyState !== 1 || ws.bufferedAmount <= SEND_BUFFER_HIGH / 4) listener(); else { const timer = setTimeout(check, 5); timer.unref() } }
|
|
51
|
+
check()
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
session = E2ESession.computer({ transport, transportByte: TRANSPORT_DIRECT, staticKey: pairing.transportKey,
|
|
55
|
+
candidates: () => pairing.candidates(),
|
|
56
|
+
async connectOrigin() {
|
|
57
|
+
if (!session.peer || !pairing.validPeer(session.peer)) throw new Error('Peer removed')
|
|
58
|
+
const [client, server] = duplexPair()
|
|
59
|
+
Object.assign(server, { setTimeout() { return server }, setNoDelay() { return server }, setKeepAlive() { return server }, remoteAddress: source })
|
|
60
|
+
client.on('error', () => {}); server.on('error', () => {})
|
|
61
|
+
client.once('close', () => server.destroy()); server.once('close', () => client.destroy())
|
|
62
|
+
streams.add(server); server.once('close', () => streams.delete(server))
|
|
63
|
+
origin.emit('connection', server)
|
|
64
|
+
return client
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
sessions.add(session)
|
|
68
|
+
ws.on('message', (data, binary) => { try { session.receive(Buffer.from(data as Buffer), binary) } catch { session.close(4000) } })
|
|
69
|
+
ws.on('error', () => session.close(4000))
|
|
70
|
+
ws.once('close', code => session.transportClosed(code))
|
|
71
|
+
void session.closed.then(() => { sessions.delete(session); for (const stream of streams) stream.destroy(); origin.close() })
|
|
72
|
+
})
|
|
73
|
+
},
|
|
74
|
+
async close() {
|
|
75
|
+
closing = true; clearInterval(sweep)
|
|
76
|
+
for (const session of sessions) session.shutdown()
|
|
77
|
+
for (const socket of sockets.clients) socket.terminate()
|
|
78
|
+
await new Promise<void>(resolve => sockets.close(() => resolve()))
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import QRCode from 'qrcode'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { mkdir, writeFile, lstat, rename, rm } from 'node:fs/promises'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
import { fail } from './protocol.js'
|
|
6
|
+
|
|
7
|
+
const escape=(value:unknown)=>String(value).replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!))
|
|
8
|
+
|
|
9
|
+
export async function pairingPage(invite:any){
|
|
10
|
+
const {code,...scannable}=invite
|
|
11
|
+
const payload=JSON.stringify(scannable),nonce=randomBytes(18).toString('base64'),fingerprint=invite.transport.fingerprint.match(/.{1,8}/g).join(' ')
|
|
12
|
+
let qr=''
|
|
13
|
+
try{qr=await QRCode.toString(payload,{type:'svg',errorCorrectionLevel:'M',margin:4,width:320})}catch{}
|
|
14
|
+
const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none';style-src 'unsafe-inline';script-src 'nonce-${nonce}';base-uri 'none';form-action 'none'"><title>安全配对 · DSH 电脑</title><style>
|
|
15
|
+
:root{font-family:system-ui,"Microsoft YaHei",sans-serif;color:#25342f;background:#f5f7f6;color-scheme:light}*{box-sizing:border-box}body{margin:0;padding:24px}.card{max-width:760px;margin:32px auto;padding:32px;background:white;border:1px solid #dfe7e2;border-radius:22px;box-shadow:0 16px 50px #142d2110}h1{margin:8px 0;font-size:25px}p{line-height:1.65;color:#5f6e65}.badge{font-size:12px;color:#317453}.grid{display:grid;grid-template-columns:300px minmax(0,1fr);gap:28px;align-items:center}.qr svg{display:block;width:100%;height:auto}.code{letter-spacing:.18em;font-size:36px;color:#244f3d;font-weight:650}.timer{font-weight:600;color:#317453}.expired{color:#a83a39}.fingerprint{display:block;font-size:12px;line-height:1.8;overflow-wrap:anywhere}textarea{width:100%;min-height:94px;padding:12px;border:1px solid #dfe7e2;border-radius:10px;font:11px monospace;resize:vertical;background:#f8faf8;color:#45584d}button{border:0;border-radius:9px;background:#2d654a;color:white;font:inherit;padding:10px 18px;cursor:pointer}button:disabled{opacity:.5}details{margin-top:20px}summary{cursor:pointer}.footer{font-size:12px;margin-bottom:0}@media(max-width:620px){body{padding:12px}.card{margin:8px auto;padding:22px}.grid{grid-template-columns:1fr;gap:8px}.qr{max-width:300px;margin:auto}}
|
|
16
|
+
</style></head><body><main class="card"><span class="badge">你的电脑 · 端到端加密</span><h1>把这台电脑连接到 Bot</h1><p>在已登录的插件中打开“安全配对电脑”,扫描或粘贴下方邀请码,再核对目标电脑的确认码。</p><div class="grid"><div class="qr">${qr||'<p>这份邀请包含较大的授权范围,请复制下方邀请码。</p>'}</div><section><strong>${escape(invite.name)}</strong><p>${escape(invite.endpoint)}</p><div aria-label="六位确认码" class="code">${escape(code)}</div><p id="timer" class="timer" role="timer">邀请码有效期为两分钟</p><strong>设备指纹</strong><code class="fingerprint">${escape(fingerprint)}</code></section></div><p>共享目录:${escape(invite.ceiling.roots.join('、'))};允许 ${invite.ceiling.tools.length} 项能力。请在接收端核对同一范围。</p><label for="invite">设备邀请码(不含确认码)</label><textarea id="invite" readonly spellcheck="false">${escape(payload)}</textarea><p><button id="copy" type="button">复制邀请码</button> <span id="feedback" role="status"></span></p><details><summary>查看能力范围</summary><p>${escape(invite.ceiling.tools.join('、'))}</p></details><p class="footer">此页面在本机生成,不加载远程资源。邀请码过期后运行 pair-device.cmd 重新生成;已配对的设备需在目标电脑核对后管理。</p></main><script nonce="${nonce}">
|
|
17
|
+
const expiresAt=${invite.expiresAt};const timer=document.getElementById('timer');const update=()=>{const remaining=Math.max(0,Math.ceil((expiresAt-Date.now())/1000));timer.textContent=remaining?'邀请码剩余 '+remaining+' 秒':'邀请码已过期,请重新生成';timer.classList.toggle('expired',!remaining);document.getElementById('copy').disabled=!remaining};update();setInterval(update,1000);
|
|
18
|
+
document.getElementById('copy').onclick=async()=>{const field=document.getElementById('invite');try{await navigator.clipboard.writeText(field.value);document.getElementById('feedback').textContent='已复制'}catch{field.focus();field.select();document.getElementById('feedback').textContent='请按 Ctrl+C 复制选中的邀请码'}};
|
|
19
|
+
</script></body></html>`
|
|
20
|
+
return {html,qr:!!qr,payload}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Replacing an invitation replaces only the explicitly named regular file; no credential is printed. */
|
|
24
|
+
export async function writePairingPage(invite:any,destination:string){
|
|
25
|
+
if(!destination)fail('PAIRING_OUTPUT_REQUIRED')
|
|
26
|
+
const page=resolve(destination),json=`${page}.private.json`
|
|
27
|
+
for(const path of [page,json]){const old=await lstat(path).catch(error=>{if(error.code==='ENOENT')return null;throw error});if(old&&(!old.isFile()||old.isSymbolicLink()||old.nlink!==1))fail('PAIRING_OUTPUT_INVALID')}
|
|
28
|
+
await mkdir(dirname(page),{recursive:true,mode:0o700})
|
|
29
|
+
const result=await pairingPage(invite)
|
|
30
|
+
for(const [path,text]of [[json,JSON.stringify(JSON.parse(result.payload),null,2)+'\n'],[page,result.html]]){
|
|
31
|
+
const temporary=`${path}.${randomBytes(8).toString('hex')}.tmp`
|
|
32
|
+
try{await writeFile(temporary,text,{flag:'wx',mode:0o600});await rename(temporary,path)}finally{await rm(temporary,{force:true})}
|
|
33
|
+
}
|
|
34
|
+
return {machineId:invite.machineId,clientId:invite.clientId,inviteId:invite.inviteId,expiresAt:invite.expiresAt,transport:invite.transport,pairingFile:json,pageFile:page,qr:result.qr}
|
|
35
|
+
}
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
-
export const AGENT_VERSION = '0.1.
|
|
3
|
-
export const AGENT_API_MAJOR = 1
|
|
4
|
-
export class AgentError extends Error { constructor(public code:string, public status=400) { super(code) } }
|
|
5
|
-
export const fail = (code:string,status=400):never => { throw new AgentError(code,status) }
|
|
6
|
-
export const secret = () => randomBytes(32).toString('base64url')
|
|
7
|
-
export const sha = (value:string|Buffer) => createHash('sha256').update(value).digest('hex')
|
|
8
|
-
export const matches = (value:string, digest:string) => { const a=Buffer.from(sha(value),'hex'),b=Buffer.from(digest,'hex'); return a.length===b.length && timingSafeEqual(a,b) }
|
|
9
|
-
export function canonical(v:any):string { if(v===null||typeof v!=='object')return JSON.stringify(v);if(Array.isArray(v))return `[${v.map(canonical).join(',')}]`;return `{${Object.keys(v).sort().map(k=>`${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}` }
|
|
10
|
-
export function identifier(v:any):string { if(typeof v!=='string'||!/^[-a-zA-Z0-9_.]{1,128}$/.test(v))fail('INVALID_ID');return v }
|
|
11
|
-
export function integer(v:any,min:number,max:number,defaultValue?:number) { const n=v??defaultValue;if(!Number.isSafeInteger(n)||n<min||n>max)fail('INVALID_ARGUMENT');return n }
|
|
12
|
-
export type Context = {clientId:string;botId:string;taskId:string;attemptId:string;roots:string[];tools:string[];expiresAt:number;revision:number;grantId:string}
|
|
13
|
-
export const CATALOG = Object.freeze([
|
|
14
|
-
['machine_ping',false],['fs_list',false],['fs_read',false],['fs_write',true],['shell_exec',true],
|
|
15
|
-
// Versioned container-command Task backend. A distinct name means an older
|
|
16
|
-
// Agent answers before any work instead of silently running a native shell.
|
|
17
|
-
['container_command_v1',true],
|
|
18
|
-
['cli_probe_v1',false],['cli_task_v1',true],
|
|
19
|
-
['operation_status',false],['operation_cancel',true],['artifact_stat',false],['artifact_read',false],
|
|
20
|
-
['worktree_create',true],['worktree_diff',false],['worktree_integrate',true],
|
|
21
|
-
['browser_open',true],['browser_snapshot',false],['browser_click',true],['browser_type',true],['browser_script',true],['browser_upload',true],['browser_download',true],['browser_close',true],
|
|
22
|
-
['desktop_screenshot',true],['desktop_click',true],['desktop_type',true],['desktop_key',true],['desktop_scroll',true],
|
|
23
|
-
].map(([name,mutating])=>({name:String(name),mutating:Boolean(mutating),inputSchema:{type:'object',additionalProperties:true}})))
|
|
24
|
-
export const TOOL_NAMES = CATALOG.map(x=>x.name)
|
|
25
|
-
export const isMutating = (name:string) => CATALOG.find(x=>x.name===name)?.mutating??false
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
export const AGENT_VERSION = '0.1.2'
|
|
3
|
+
export const AGENT_API_MAJOR = 1
|
|
4
|
+
export class AgentError extends Error { constructor(public code:string, public status=400) { super(code) } }
|
|
5
|
+
export const fail = (code:string,status=400):never => { throw new AgentError(code,status) }
|
|
6
|
+
export const secret = () => randomBytes(32).toString('base64url')
|
|
7
|
+
export const sha = (value:string|Buffer) => createHash('sha256').update(value).digest('hex')
|
|
8
|
+
export const matches = (value:string, digest:string) => { const a=Buffer.from(sha(value),'hex'),b=Buffer.from(digest,'hex'); return a.length===b.length && timingSafeEqual(a,b) }
|
|
9
|
+
export function canonical(v:any):string { if(v===null||typeof v!=='object')return JSON.stringify(v);if(Array.isArray(v))return `[${v.map(canonical).join(',')}]`;return `{${Object.keys(v).sort().map(k=>`${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}` }
|
|
10
|
+
export function identifier(v:any):string { if(typeof v!=='string'||!/^[-a-zA-Z0-9_.]{1,128}$/.test(v))fail('INVALID_ID');return v }
|
|
11
|
+
export function integer(v:any,min:number,max:number,defaultValue?:number) { const n=v??defaultValue;if(!Number.isSafeInteger(n)||n<min||n>max)fail('INVALID_ARGUMENT');return n }
|
|
12
|
+
export type Context = {clientId:string;botId:string;taskId:string;attemptId:string;roots:string[];tools:string[];expiresAt:number;revision:number;grantId:string}
|
|
13
|
+
export const CATALOG = Object.freeze([
|
|
14
|
+
['machine_ping',false],['fs_list',false],['fs_read',false],['fs_write',true],['shell_exec',true],
|
|
15
|
+
// Versioned container-command Task backend. A distinct name means an older
|
|
16
|
+
// Agent answers before any work instead of silently running a native shell.
|
|
17
|
+
['container_command_v1',true],
|
|
18
|
+
['cli_probe_v1',false],['cli_task_v1',true],
|
|
19
|
+
['operation_status',false],['operation_cancel',true],['artifact_stat',false],['artifact_read',false],
|
|
20
|
+
['worktree_create',true],['worktree_diff',false],['worktree_integrate',true],
|
|
21
|
+
['browser_open',true],['browser_snapshot',false],['browser_click',true],['browser_type',true],['browser_script',true],['browser_upload',true],['browser_download',true],['browser_close',true],
|
|
22
|
+
['desktop_screenshot',true],['desktop_click',true],['desktop_type',true],['desktop_key',true],['desktop_scroll',true],
|
|
23
|
+
].map(([name,mutating])=>({name:String(name),mutating:Boolean(mutating),inputSchema:{type:'object',additionalProperties:true}})))
|
|
24
|
+
export const TOOL_NAMES = CATALOG.map(x=>x.name)
|
|
25
|
+
export const isMutating = (name:string) => CATALOG.find(x=>x.name===name)?.mutating??false
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { generateKeyPair, keyPairFromPrivate, keyId, psk, b64u, credentialHashFromHex } from '../../modules/openmaus-protocol/index.mjs'
|
|
3
|
+
import { canonical, fail, identifier, secret, sha } from './protocol.js'
|
|
4
|
+
|
|
5
|
+
export const PAIRING_TTL_MS = 120_000
|
|
6
|
+
export const PAIRING_ATTEMPTS = 5
|
|
7
|
+
export const MAX_PAIRED_CLIENTS = 128
|
|
8
|
+
const MAX_INVITES = 20
|
|
9
|
+
const digestEqual = (a: string, b: string) => /^[0-9a-f]{64}$/.test(a) && /^[0-9a-f]{64}$/.test(b) && timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
|
|
10
|
+
const nameOf = (value: unknown) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 60) || 'DSH Host'
|
|
11
|
+
type Approval = { botIds: string[]; roots: string[]; tools: string[]; hostManagedBots?: boolean }
|
|
12
|
+
type Peer = { kind: 'invite'; id: string; digest: string } | { kind: 'client'; id: string; digest: string; revision: number }
|
|
13
|
+
type Outcome = { clientId: string; token: string; machineId: string; secretRef: string; hostManagedBots: boolean }
|
|
14
|
+
|
|
15
|
+
/** An invite fixes the remote owner's ceiling; redemption never accepts replacement roots or tools. */
|
|
16
|
+
export class SecurePairing {
|
|
17
|
+
private replay = new Map<string, { proof: string; requestId: string; expiresAt: number; result: Outcome }>()
|
|
18
|
+
readonly transportKey: { privateKey: Buffer; publicKey: Buffer }
|
|
19
|
+
constructor(private db: any, private machineId: string, private commitClient: (id: string, ceiling: Approval) => Outcome, private now = Date.now) {
|
|
20
|
+
db.exec(`CREATE TABLE IF NOT EXISTS pairing_invites (
|
|
21
|
+
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, token_digest TEXT NOT NULL, code_digest TEXT NOT NULL,
|
|
22
|
+
expires_at INTEGER NOT NULL, attempts_left INTEGER NOT NULL, ceiling TEXT NOT NULL, name TEXT NOT NULL,
|
|
23
|
+
consumed_request TEXT, consumed_at INTEGER
|
|
24
|
+
); CREATE INDEX IF NOT EXISTS pairing_invite_expiry ON pairing_invites(expires_at);`)
|
|
25
|
+
const generated = generateKeyPair()
|
|
26
|
+
db.prepare('INSERT OR IGNORE INTO meta VALUES (?,?)').run('e2eTransportPrivateKey', b64u(generated.privateKey))
|
|
27
|
+
const stored = db.prepare('SELECT value FROM meta WHERE key=?').get('e2eTransportPrivateKey')?.value
|
|
28
|
+
if (typeof stored !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(stored)) fail('PAIRING_IDENTITY_INVALID')
|
|
29
|
+
try { this.transportKey = keyPairFromPrivate(Buffer.from(stored, 'base64url')) } catch { fail('PAIRING_IDENTITY_INVALID') }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
identity() { return { kind: 'noise-v1', publicKey: b64u(this.transportKey.publicKey), fingerprint: sha(this.transportKey.publicKey) } }
|
|
33
|
+
|
|
34
|
+
create(clientId: string, ceiling: Approval, options: { endpoint: string; name?: string }) {
|
|
35
|
+
identifier(clientId)
|
|
36
|
+
let endpoint: URL
|
|
37
|
+
try { endpoint = new URL(options.endpoint) } catch { fail('PAIRING_ENDPOINT_INVALID') }
|
|
38
|
+
if (!['http:', 'https:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.pathname !== '/' || endpoint.search || endpoint.hash || endpoint.href.length > 1024) fail('PAIRING_ENDPOINT_INVALID')
|
|
39
|
+
const now = this.now(), token = `dsh_pair_${secret()}`, code = String(randomInt(1_000_000)).padStart(6, '0'), id = `invite-${randomUUID()}`
|
|
40
|
+
return this.db.transaction(() => {
|
|
41
|
+
this.db.prepare('DELETE FROM pairing_invites WHERE expires_at<=?').run(now - PAIRING_TTL_MS)
|
|
42
|
+
if (this.db.prepare('SELECT 1 FROM clients WHERE id=? AND revoked=0').get(clientId)) fail('CLIENT_ALREADY_PAIRED', 409)
|
|
43
|
+
if (this.db.prepare('SELECT count(*) AS n FROM clients WHERE revoked=0').get().n >= MAX_PAIRED_CLIENTS) fail('PAIRING_DEVICE_LIMIT', 429)
|
|
44
|
+
this.db.prepare('UPDATE pairing_invites SET attempts_left=0 WHERE client_id=? AND consumed_request IS NULL').run(clientId)
|
|
45
|
+
if (this.db.prepare('SELECT count(*) AS n FROM pairing_invites WHERE expires_at>? AND attempts_left>0 AND consumed_request IS NULL').get(now).n >= MAX_INVITES) fail('PAIRING_WINDOW_LIMIT', 429)
|
|
46
|
+
this.db.prepare('INSERT INTO pairing_invites VALUES (?,?,?,?,?,?,?,?,NULL,NULL)').run(id, clientId, sha(token), sha(code), now + PAIRING_TTL_MS, PAIRING_ATTEMPTS, canonical(ceiling), nameOf(options.name))
|
|
47
|
+
return { format: 'dsh-pairing-invite', version: 2, inviteId: id, machineId: this.machineId, clientId, name: nameOf(options.name),
|
|
48
|
+
endpoint: endpoint.origin, expiresAt: now + PAIRING_TTL_MS, token, code, transport: this.identity(),
|
|
49
|
+
ceiling: structuredClone(ceiling) }
|
|
50
|
+
}).immediate()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
candidates() {
|
|
54
|
+
const now = this.now(), transportId = keyId(this.transportKey.publicKey)
|
|
55
|
+
for (const [id, row] of this.replay) if (row.expiresAt <= now) this.replay.delete(id)
|
|
56
|
+
const invites = this.db.prepare('SELECT id,token_digest FROM pairing_invites WHERE expires_at>? AND attempts_left>0').all(now)
|
|
57
|
+
.map((row: any) => ({ id: { kind: 'invite', id: row.id, digest: row.token_digest } as Peer, hash: row.token_digest }))
|
|
58
|
+
const clients = this.db.prepare('SELECT id,digest,revision FROM clients WHERE revoked=0').all()
|
|
59
|
+
.map((row: any) => ({ id: { kind: 'client', id: row.id, digest: row.digest, revision: row.revision } as Peer, hash: row.digest }))
|
|
60
|
+
if (clients.length > MAX_PAIRED_CLIENTS) fail('PAIRING_DEVICE_LIMIT', 429)
|
|
61
|
+
return [...invites, ...clients].map(row => ({ id: row.id, psk: psk(credentialHashFromHex(row.hash)!, transportId) }))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
validPeer(peer: Peer) {
|
|
65
|
+
if (peer.kind === 'client') {
|
|
66
|
+
const client = this.db.prepare('SELECT digest,revision,revoked FROM clients WHERE id=?').get(peer.id)
|
|
67
|
+
return Boolean(client && !client.revoked && client.revision === peer.revision && digestEqual(client.digest, peer.digest))
|
|
68
|
+
}
|
|
69
|
+
const invite = this.db.prepare('SELECT token_digest,expires_at,attempts_left FROM pairing_invites WHERE id=?').get(peer.id)
|
|
70
|
+
return Boolean(invite && invite.expires_at > this.now() && invite.attempts_left > 0 && digestEqual(invite.token_digest, peer.digest))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
redeem(peer: Peer, input: any) {
|
|
74
|
+
if (peer.kind !== 'invite' || !input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !['requestId', 'code'].includes(key))
|
|
75
|
+
|| typeof input.requestId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(input.requestId) || typeof input.code !== 'string' || !/^\d{6}$/.test(input.code)) fail('PAIRING_REQUEST_INVALID')
|
|
76
|
+
const now = this.now()
|
|
77
|
+
const decision = this.db.transaction(() => {
|
|
78
|
+
const row = this.db.prepare('SELECT * FROM pairing_invites WHERE id=?').get(peer.id)
|
|
79
|
+
if (!row || !digestEqual(row.token_digest, peer.digest) || row.expires_at <= now || row.attempts_left <= 0) return { error: 'PAIRING_WINDOW_CLOSED' }
|
|
80
|
+
if (!digestEqual(row.code_digest, sha(input.code))) {
|
|
81
|
+
this.db.prepare('UPDATE pairing_invites SET attempts_left=attempts_left-1 WHERE id=?').run(peer.id)
|
|
82
|
+
return { error: 'PAIRING_CODE_INVALID' }
|
|
83
|
+
}
|
|
84
|
+
if (row.consumed_request !== null) {
|
|
85
|
+
const cached = this.replay.get(row.id), client = this.db.prepare('SELECT digest,revoked FROM clients WHERE id=?').get(row.client_id)
|
|
86
|
+
if (row.consumed_request !== input.requestId || !cached || cached.expiresAt <= now || cached.proof !== peer.digest || cached.requestId !== input.requestId) return { error: 'PAIRING_ALREADY_REDEEMED' }
|
|
87
|
+
if (!client || client.revoked || !digestEqual(client.digest, sha(cached.result.token))) return { error: 'PAIRING_DEVICE_CHANGED' }
|
|
88
|
+
return { result: structuredClone(cached.result), ceiling: JSON.parse(row.ceiling), recovered: true }
|
|
89
|
+
}
|
|
90
|
+
const result = this.commitClient(row.client_id, JSON.parse(row.ceiling))
|
|
91
|
+
this.db.prepare('UPDATE pairing_invites SET consumed_request=?,consumed_at=? WHERE id=?').run(input.requestId, now, row.id)
|
|
92
|
+
this.replay.set(row.id, { proof: peer.digest, requestId: input.requestId, expiresAt: row.expires_at, result: structuredClone(result) })
|
|
93
|
+
return { result, ceiling: JSON.parse(row.ceiling), recovered: false }
|
|
94
|
+
}).immediate()
|
|
95
|
+
if (decision.error) fail(decision.error, 403)
|
|
96
|
+
return { ...decision.result, transport: this.identity(), ceiling: decision.ceiling, recovered: decision.recovered }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
invalidate(clientId: string) {
|
|
100
|
+
this.db.prepare('UPDATE pairing_invites SET attempts_left=0 WHERE client_id=?').run(clientId)
|
|
101
|
+
for (const [id, row] of this.replay) if (row.result.clientId === clientId) this.replay.delete(id)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
close() { this.replay.clear() }
|
|
105
|
+
}
|
|
@@ -3,7 +3,9 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { createRequire } from 'node:module'
|
|
4
4
|
import { randomUUID } from 'node:crypto'
|
|
5
5
|
import { canonical, fail, identifier, matches, secret, sha, TOOL_NAMES, type Context } from './protocol.js'
|
|
6
|
+
import { SecurePairing, MAX_PAIRED_CLIENTS } from './secure-pairing.js'
|
|
6
7
|
export class Store {
|
|
8
|
+
pairing: SecurePairing
|
|
7
9
|
db:any;machineId:string;private lockFd:number|undefined;private lockPath:string;private controllerGuard:any;private closed=false;private admin=false
|
|
8
10
|
constructor(public dataDir:string,Database?:any,options:{admin?:boolean}={}) {
|
|
9
11
|
this.admin=options.admin===true
|
|
@@ -38,6 +40,7 @@ export class Store {
|
|
|
38
40
|
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY,time INTEGER NOT NULL,code TEXT NOT NULL,actor TEXT,operation TEXT);`)
|
|
39
41
|
this.db.prepare('INSERT OR IGNORE INTO meta VALUES (?,?)').run('machineId',`machine-${randomUUID()}`)
|
|
40
42
|
this.machineId=this.db.prepare('SELECT value FROM meta WHERE key=?').get('machineId').value
|
|
43
|
+
this.pairing=new SecurePairing(this.db,this.machineId,(id,ceiling)=>this.commitClient(id,ceiling))
|
|
41
44
|
if(!options.admin)this.db.prepare("UPDATE operations SET status='unknown',error='OUTCOME_UNKNOWN' WHERE status IN ('admitted','running','cancel_pending')").run()
|
|
42
45
|
// Tickets are process-lifetime credentials; a restart requires explicit reissuance.
|
|
43
46
|
if(!options.admin)this.db.prepare('DELETE FROM grants').run()
|
|
@@ -52,7 +55,7 @@ export class Store {
|
|
|
52
55
|
}
|
|
53
56
|
}finally{if(this.controllerGuard){try{this.controllerGuard.exec('ROLLBACK')}catch{};this.controllerGuard.close();this.controllerGuard=undefined}}
|
|
54
57
|
}
|
|
55
|
-
|
|
58
|
+
private approvedCeiling(id:string,ceiling:{botIds?:string[];hostManagedBots?:boolean;roots:string[];tools:string[]}) {
|
|
56
59
|
identifier(id)
|
|
57
60
|
if(!ceiling||ceiling.hostManagedBots!==undefined&&typeof ceiling.hostManagedBots!=='boolean'||!Array.isArray(ceiling.botIds??[])||!Array.isArray(ceiling.roots)||!ceiling.roots.length||!Array.isArray(ceiling.tools)||!ceiling.tools.length||ceiling.tools.some(x=>!TOOL_NAMES.includes(x)))fail('INVALID_CEILING')
|
|
58
61
|
const managed=ceiling.hostManagedBots===true,botIds=ceiling.botIds??[]
|
|
@@ -60,11 +63,23 @@ export class Store {
|
|
|
60
63
|
if(!managed&&!botIds.length)fail('INVALID_CEILING')
|
|
61
64
|
botIds.forEach(identifier);ceiling.roots.forEach(identifier)
|
|
62
65
|
const saved={botIds,roots:ceiling.roots,tools:ceiling.tools,...(managed?{hostManagedBots:true}:{})}
|
|
63
|
-
|
|
66
|
+
return saved
|
|
64
67
|
}
|
|
68
|
+
private commitClient(id:string,ceiling:{botIds:string[];hostManagedBots?:boolean;roots:string[];tools:string[]}) { return this.db.transaction(()=>{
|
|
69
|
+
const previous=this.db.prepare('SELECT revoked FROM clients WHERE id=?').get(id);if(previous&&!previous.revoked)fail('CLIENT_ALREADY_PAIRED',409)
|
|
70
|
+
if(this.db.prepare('SELECT count(*) AS n FROM clients WHERE revoked=0').get().n>=MAX_PAIRED_CLIENTS)fail('PAIRING_DEVICE_LIMIT',429)
|
|
71
|
+
const token=secret();if(previous)this.db.prepare('UPDATE clients SET digest=?,revision=revision+1,revoked=0,ceiling=? WHERE id=? AND revoked=1').run(sha(token),canonical(ceiling),id)
|
|
72
|
+
else this.db.prepare('INSERT INTO clients VALUES (?,?,1,0,?)').run(id,sha(token),canonical(ceiling));return {clientId:id,token,machineId:this.machineId,secretRef:`remote-agent/${this.machineId}/${id}`,hostManagedBots:ceiling.hostManagedBots===true}
|
|
73
|
+
}).immediate() }
|
|
74
|
+
pair(id:string,ceiling:{botIds?:string[];hostManagedBots?:boolean;roots:string[];tools:string[]}) { return this.commitClient(id,this.approvedCeiling(id,ceiling)) }
|
|
75
|
+
invite(id:string,ceiling:{botIds?:string[];hostManagedBots?:boolean;roots:string[];tools:string[]},options:{endpoint:string;name?:string}) { if(!this.admin)fail('ADMIN_REQUIRED',403);return this.pairing.create(id,this.approvedCeiling(id,ceiling),options) }
|
|
65
76
|
client(token:string) {if(typeof token!=='string'||token.length>256)fail('AUTH_INVALID',401);const row=this.db.prepare('SELECT * FROM clients WHERE digest=?').get(sha(token));const verified=matches(token,row?.digest??'0'.repeat(64));if(!verified||!row||row.revoked)fail('AUTH_INVALID',401);return row}
|
|
66
|
-
rotate(id:string) {
|
|
67
|
-
|
|
77
|
+
rotate(id:string) {return this.db.transaction(()=>{
|
|
78
|
+
identifier(id);const row=this.db.prepare('SELECT revoked FROM clients WHERE id=?').get(id);if(!row)fail('AUTH_INVALID',401)
|
|
79
|
+
if(row.revoked&&this.db.prepare('SELECT count(*) AS n FROM clients WHERE revoked=0').get().n>=MAX_PAIRED_CLIENTS)fail('PAIRING_DEVICE_LIMIT',429)
|
|
80
|
+
const token=secret();this.db.prepare('UPDATE clients SET digest=?,revision=revision+1,revoked=0 WHERE id=?').run(sha(token),id);this.pairing.invalidate(id);return {clientId:id,token}
|
|
81
|
+
}).immediate() }
|
|
82
|
+
revoke(id:string) {this.db.prepare('UPDATE clients SET revoked=1,revision=revision+1 WHERE id=?').run(identifier(id));this.pairing.invalidate(id)}
|
|
68
83
|
private currentClient(client:any){const row=this.db.prepare('SELECT * FROM clients WHERE id=?').get(client?.id);if(!row||row.revoked||row.revision!==client.revision||row.digest!==client.digest)fail('AUTH_INVALID',401);return row}
|
|
69
84
|
issue(client:any,request:any) {
|
|
70
85
|
client=this.currentClient(client)
|
|
@@ -97,5 +112,5 @@ export class Store {
|
|
|
97
112
|
finish(id:string,status:string,result:any=null,error:string|null=null) {this.db.prepare('UPDATE operations SET status=?,result=?,error=?,finished=? WHERE id=?').run(status,result===null?null:canonical(result),error,Date.now(),id)}
|
|
98
113
|
running(id:string) {this.db.prepare("UPDATE operations SET status='running' WHERE id=? AND status='admitted'").run(id)}
|
|
99
114
|
audit(code:string,actor:string|null=null,operation:string|null=null){this.db.prepare('INSERT INTO audit(time,code,actor,operation) VALUES (?,?,?,?)').run(Date.now(),code,actor,operation);this.db.prepare('DELETE FROM audit WHERE id < (SELECT max(id)-10000 FROM audit)').run()}
|
|
100
|
-
close(){if(this.closed)return;this.closed=true;try{this.db.close()}finally{this.releaseController()}}
|
|
115
|
+
close(){if(this.closed)return;this.closed=true;this.pairing?.close();try{this.db.close()}finally{this.releaseController()}}
|
|
101
116
|
}
|
|
@@ -8,7 +8,8 @@ import { AgentError, AGENT_VERSION, CATALOG, fail } from './protocol.js'
|
|
|
8
8
|
import type { Agent } from './agent.js'
|
|
9
9
|
import {AuthThrottle,validateAuthLimits,type AuthLimits} from './auth-throttle.js'
|
|
10
10
|
import {WebSocketServer} from 'ws'
|
|
11
|
-
|
|
11
|
+
import {secureTransport} from './e2e-server.js'
|
|
12
|
+
export type NetworkConfig={mode?:'loopback'|'wireguard'|'tailscale'|'cloudflare-tunnel'|'public-tls'|'e2e';host?:string;port?:number;protectedTunnel?:boolean;allowedHosts?:string[];allowedOrigins?:string[];trustedProxies?:string[];authLimits?:AuthLimits;tls?:{keyFile:string;certFile:string;caFile?:string;requireClientCertificate?:boolean}}
|
|
12
13
|
const loopback=(host:string)=>['127.0.0.1','::1','::ffff:127.0.0.1'].includes(host)
|
|
13
14
|
export function validateNetwork(c:NetworkConfig){const host=c.host??'127.0.0.1',mode=c.mode??'loopback';if(['0.0.0.0','::','localhost'].includes(host))fail('BIND_ADDRESS_REQUIRED')
|
|
14
15
|
if(!isIP(host)||c.port!==undefined&&(!Number.isSafeInteger(c.port)||c.port<0||c.port>65535))fail('INVALID_NETWORK_CONFIG')
|
|
@@ -22,31 +23,42 @@ export function validateNetwork(c:NetworkConfig){const host=c.host??'127.0.0.1',
|
|
|
22
23
|
if(['wireguard','tailscale'].includes(mode)){if(!Object.values(networkInterfaces()).flat().some(x=>x?.address===host))fail('BIND_INTERFACE_NOT_FOUND');if(!c.tls&&!c.protectedTunnel)fail('TUNNEL_ATTESTATION_REQUIRED')}
|
|
23
24
|
if(mode==='cloudflare-tunnel'&&(!loopback(host)||!c.protectedTunnel))fail('TUNNEL_BACKEND_MUST_BE_LOOPBACK')
|
|
24
25
|
if(mode==='public-tls'&&!c.tls&&(!loopback(host)||!c.trustedProxies?.length))fail('TLS_REQUIRED')
|
|
25
|
-
if(!c.tls&&!loopback(host)&&!c.protectedTunnel)fail('TLS_REQUIRED')
|
|
26
|
-
if(!['loopback','wireguard','tailscale','cloudflare-tunnel','public-tls'].includes(mode))fail('INVALID_NETWORK_MODE')
|
|
26
|
+
if(!c.tls&&!loopback(host)&&!c.protectedTunnel&&mode!=='e2e')fail('TLS_REQUIRED')
|
|
27
|
+
if(!['loopback','wireguard','tailscale','cloudflare-tunnel','public-tls','e2e'].includes(mode))fail('INVALID_NETWORK_MODE')
|
|
27
28
|
}
|
|
28
29
|
export async function serve(agent:Agent,c:NetworkConfig={}){
|
|
29
30
|
c=structuredClone(c);validateNetwork(c);const throttle=new AuthThrottle(c.authLimits),host=c.host??'127.0.0.1'
|
|
30
|
-
|
|
31
|
+
if(c.mode==='e2e'&&!agent.store.pairing)fail('PAIRING_SERVICE_REQUIRED')
|
|
32
|
+
const requestBoundary=(req:http.IncomingMessage,upgrade=false,encrypted=false)=>{
|
|
31
33
|
const address=server.address() as any,defaultHost=`${host.includes(':')?`[${host}]`:host}:${address.port}`
|
|
32
34
|
if(!(c.allowedHosts??[defaultHost]).includes(req.headers.host??''))fail('HOST_DENIED',403)
|
|
33
35
|
const origin=req.headers.origin
|
|
34
36
|
if(origin!==undefined&&!(c.allowedOrigins??[]).includes(origin)&&!(upgrade&&agent.desktopSessions.hasOrigin(origin)))fail('ORIGIN_DENIED',403)
|
|
35
|
-
if(c.mode==='public-tls'&&!c.tls&&(!(c.trustedProxies??[]).includes(req.socket.remoteAddress??'unknown')||req.headers['x-forwarded-proto']!=='https'))fail('TLS_REQUIRED',403)
|
|
37
|
+
if(!encrypted&&c.mode==='public-tls'&&!c.tls&&(!(c.trustedProxies??[]).includes(req.socket.remoteAddress??'unknown')||req.headers['x-forwarded-proto']!=='https'))fail('TLS_REQUIRED',403)
|
|
36
38
|
return defaultHost
|
|
37
39
|
}
|
|
38
|
-
const handler=async(req:http.IncomingMessage,res:http.ServerResponse)=>{
|
|
40
|
+
const handler=async(req:http.IncomingMessage,res:http.ServerResponse,peer?:Parameters<typeof agent.store.pairing.validPeer>[0])=>{
|
|
39
41
|
let context:any;const source=req.socket.remoteAddress??'unknown';const json=(status:number,value:any)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value))}
|
|
40
42
|
try{
|
|
41
|
-
const defaultHost=requestBoundary(req)
|
|
43
|
+
const defaultHost=requestBoundary(req,false,!!peer)
|
|
44
|
+
if(c.mode==='e2e'&&!peer)fail('E2E_REQUIRED',426)
|
|
45
|
+
const url=new URL(req.url??'/',`http://${defaultHost}`)
|
|
46
|
+
if(peer&&!agent.store.pairing.validPeer(peer))fail('AUTH_INVALID',401)
|
|
47
|
+
if(url.pathname==='/pair/v2/redeem'){
|
|
48
|
+
if(!peer||peer.kind!=='invite'||req.method!=='POST'||url.search)fail('PAIRING_CHANNEL_REQUIRED',403)
|
|
49
|
+
let body=Buffer.alloc(0);for await(const chunk of req){body=Buffer.concat([body,chunk]);if(body.length>2048)fail('REQUEST_TOO_LARGE',413)}
|
|
50
|
+
let input:any;try{input=JSON.parse(body.toString('utf8'))}catch{fail('INVALID_JSON')}
|
|
51
|
+
json(200,agent.store.pairing.redeem(peer,input));return
|
|
52
|
+
}
|
|
53
|
+
if(peer?.kind==='invite')fail('PAIRING_CHANNEL_ONLY',403)
|
|
42
54
|
const reject=(decision:ReturnType<AuthThrottle['failure']>)=>{
|
|
43
55
|
if(decision.audit)agent.store.audit(decision.code)
|
|
44
56
|
if(decision.retryAfterMs)res.setHeader('retry-after',String(Math.max(1,Math.ceil(decision.retryAfterMs/1000))))
|
|
45
57
|
fail(decision.code,decision.status)
|
|
46
58
|
}
|
|
47
59
|
const auth=req.headers.authorization??'';if(!/^Bearer [A-Za-z0-9_-]{43}$/.test(auth)){reject(throttle.failure(source))}
|
|
48
|
-
|
|
49
|
-
const authenticate=()=>{try{if(url.pathname.startsWith('/control/v1/'))client=agent.store.client(auth.slice(7));else context=agent.store.grant(auth.slice(7))}catch{reject(throttle.failure(source))}}
|
|
60
|
+
let client:any
|
|
61
|
+
const authenticate=()=>{try{if(peer&&!agent.store.pairing.validPeer(peer))fail('AUTH_INVALID',401);if(url.pathname.startsWith('/control/v1/'))client=agent.store.client(auth.slice(7));else context=agent.store.grant(auth.slice(7));if(peer&&(client?.id??context?.clientId)!==peer.id)fail('AUTH_INVALID',401)}catch{reject(throttle.failure(source))}}
|
|
50
62
|
authenticate()
|
|
51
63
|
// Valid credentials bypass anonymous source cooldown, including shared proxies.
|
|
52
64
|
const limited=throttle.authenticated(client?.id??context.clientId,client?.revision??context.revision)
|
|
@@ -59,6 +71,7 @@ export async function serve(agent:Agent,c:NetworkConfig={}){
|
|
|
59
71
|
authenticate()
|
|
60
72
|
if(url.pathname==='/control/v1/grants'){json(200,agent.store.issue(client,parsed));return}
|
|
61
73
|
if(url.pathname==='/control/v1/grants/revoke'){const result=agent.store.revokeGrants(client,parsed);await agent.desktopSessions.sweep();json(200,result);return}
|
|
74
|
+
if(url.pathname==='/control/v1/desktop/close'){json(200,await agent.desktopSessions.closeClientSession(client.id,parsed));return}
|
|
62
75
|
if(url.pathname==='/desktop/v1/session'){json(200,await agent.desktopSessions.open(context,parsed));return}
|
|
63
76
|
if(url.pathname==='/desktop/v1/session/renew'){json(200,await agent.desktopSessions.renew(context,parsed));return}
|
|
64
77
|
if(url.pathname==='/desktop/v1/session/close'){json(200,await agent.desktopSessions.closeSession(context,parsed));return}
|
|
@@ -74,16 +87,27 @@ export async function serve(agent:Agent,c:NetworkConfig={}){
|
|
|
74
87
|
}catch(e){json(e instanceof AgentError?e.status:500,{error:{code:e instanceof AgentError?e.code:'AGENT_INTERNAL'}})}
|
|
75
88
|
}
|
|
76
89
|
const tls=c.tls?{key:readFileSync(c.tls.keyFile),cert:readFileSync(c.tls.certFile),ca:c.tls.caFile?readFileSync(c.tls.caFile):undefined,requestCert:!!c.tls.requireClientCertificate,rejectUnauthorized:!!c.tls.requireClientCertificate}:null
|
|
77
|
-
const
|
|
90
|
+
const publicHandler=(req:http.IncomingMessage,res:http.ServerResponse)=>void handler(req,res)
|
|
91
|
+
const server=tls?https.createServer(tls,publicHandler):http.createServer(publicHandler);server.requestTimeout=30000;server.headersTimeout=10000;server.maxConnections=128
|
|
78
92
|
const websocket=new WebSocketServer({noServer:true,clientTracking:true,maxPayload:65536,perMessageDeflate:false})
|
|
79
|
-
|
|
93
|
+
const desktopUpgrade=(req:http.IncomingMessage,socket:any,head:Buffer,peer?:Parameters<typeof agent.store.pairing.validPeer>[0])=>{
|
|
80
94
|
try{
|
|
81
|
-
const defaultHost=requestBoundary(req,true),url=new URL(req.url??'/',`http://${defaultHost}`)
|
|
95
|
+
const defaultHost=requestBoundary(req,true,!!peer),url=new URL(req.url??'/',`http://${defaultHost}`)
|
|
96
|
+
if(c.mode==='e2e'&&!peer)fail('E2E_REQUIRED',426)
|
|
97
|
+
if(peer&&(peer.kind!=='client'||!agent.store.pairing.validPeer(peer)))fail('AUTH_INVALID',401)
|
|
82
98
|
if(url.pathname!=='/desktop/v1/socket'||url.search)fail('NOT_FOUND',404)
|
|
83
99
|
if(websocket.clients.size>=16)fail('DESKTOP_CONNECTION_LIMIT',429)
|
|
84
|
-
websocket.handleUpgrade(req,socket,head,ws=>{websocket.emit('connection',ws,req);agent.desktopSessions.accept(ws,req.headers.origin,c.allowedOrigins??[])})
|
|
100
|
+
websocket.handleUpgrade(req,socket,head,ws=>{websocket.emit('connection',ws,req);agent.desktopSessions.accept(ws,req.headers.origin,c.allowedOrigins??[],peer?.id)})
|
|
101
|
+
}catch(e){const status=e instanceof AgentError?e.status:500;socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)}
|
|
102
|
+
}
|
|
103
|
+
const encrypted=agent.store.pairing?secureTransport(agent.store.pairing,(req,res,peer)=>void handler(req,res,peer),desktopUpgrade):null
|
|
104
|
+
server.on('upgrade',(req,socket,head)=>{
|
|
105
|
+
try{
|
|
106
|
+
const defaultHost=requestBoundary(req,true),url=new URL(req.url??'/',`http://${defaultHost}`)
|
|
107
|
+
if(url.pathname==='/v1/e2e'&&!url.search){if(!encrypted)fail('NOT_FOUND',404);encrypted.accept(req,socket,head);return}
|
|
108
|
+
desktopUpgrade(req,socket,head)
|
|
85
109
|
}catch(e){const status=e instanceof AgentError?e.status:500;socket.end(`HTTP/1.1 ${status} Rejected\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)}
|
|
86
110
|
})
|
|
87
|
-
await new Promise<void>((resolve,reject)=>{server.once('error',reject);server.listen(c.port??9701,host,()=>resolve())})
|
|
88
|
-
return {server,address:server.address(),supportedMcpVersions:SUPPORTED_PROTOCOL_VERSIONS,close:async()=>{await agent.desktopSessions.close();for(const client of websocket.clients)client.terminate();await new Promise<void>(resolve=>websocket.close(()=>resolve()));await new Promise<void>((resolve,reject)=>server.close(e=>e?reject(e):resolve()))}}
|
|
111
|
+
try{await new Promise<void>((resolve,reject)=>{server.once('error',reject);server.listen(c.port??9701,host,()=>resolve())})}catch(error){await encrypted?.close();throw error}
|
|
112
|
+
return {server,address:server.address(),supportedMcpVersions:SUPPORTED_PROTOCOL_VERSIONS,close:async()=>{await agent.desktopSessions.close();for(const client of websocket.clients)client.terminate();await new Promise<void>(resolve=>websocket.close(()=>resolve()));await encrypted?.close();await new Promise<void>((resolve,reject)=>server.close(e=>e?reject(e):resolve()))}}
|
|
89
113
|
}
|