@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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Pure request projection and chunk mapping for the Claude retained-session LLM route.
|
|
2
|
+
//
|
|
3
|
+
// This module holds no process, no session and no money. It answers three questions the
|
|
4
|
+
// bridge in `claude-llm-adapter.mjs` must answer before it may touch a native CLI:
|
|
5
|
+
//
|
|
6
|
+
// 1. Is this request one this route can serve at all? (`assertRouteRequest`)
|
|
7
|
+
// The route is a text-only Claude subscription conversation session: it accepts one
|
|
8
|
+
// new user text per call and nothing else. A request naming tools, an output bound,
|
|
9
|
+
// a temperature, stop sequences, an auxiliary purpose, an unknown field, or a
|
|
10
|
+
// non-text block is refused with the offending names — never silently ignored.
|
|
11
|
+
// 2. What is the NEW input for the retained session? (`projectRetainedTurn`)
|
|
12
|
+
// A retained CLI session already holds its own context. Re-sending the whole
|
|
13
|
+
// transcript would duplicate it, so only the trailing new user message is dispatched.
|
|
14
|
+
// Everything already delivered is verified by durable message id and by the SDK's own
|
|
15
|
+
// model provenance; a changed prefix, an unseen history, a different Bot, a different
|
|
16
|
+
// model, a changed persona or a repeated dispatch are refusals, not a quiet drop.
|
|
17
|
+
// 3. How does a native turn become SDK chunks? (`usageChunkFromTurn`,
|
|
18
|
+
// `finishReasonFromTurn`) A turn the CLI did not report usage for emits no usage
|
|
19
|
+
// chunk — unknown stays unknown. Exactly one finish is derived per turn, and a
|
|
20
|
+
// missing, unknown or cancelled outcome is never mapped onto success.
|
|
21
|
+
//
|
|
22
|
+
// Errors are thrown as `{code, message}` errors; the adapter turns them into terminal
|
|
23
|
+
// `finish` chunks so the SDK's own consumer contract sees one honest outcome.
|
|
24
|
+
|
|
25
|
+
import { validTurnText } from './claude-protocol.mjs'
|
|
26
|
+
|
|
27
|
+
/** What this route accepts, stated so a caller can read it instead of guessing. */
|
|
28
|
+
export const CLAUDE_LLM_CAPABILITIES = Object.freeze({
|
|
29
|
+
input: Object.freeze(['one new user text message per call']),
|
|
30
|
+
output: Object.freeze(['assistant text', 'reasoning deltas surfaced as reasoning chunks']),
|
|
31
|
+
accepts: Object.freeze(['provider', 'model', 'messages', 'system', 'signal', 'sessionId']),
|
|
32
|
+
refuses: Object.freeze(['tools', 'toolHistory', 'maxTokens', 'reasoningEffort', 'temperature', 'stop', 'purpose']),
|
|
33
|
+
unsupportedContent: Object.freeze(['image', 'file', 'tool-call', 'tool-addition', 'tool-removal']),
|
|
34
|
+
funding: Object.freeze(['subscription']),
|
|
35
|
+
notes: Object.freeze([
|
|
36
|
+
'one retained native CLI session per Bot conversation, serial turns',
|
|
37
|
+
'usage the CLI does not report stays unknown; no cost is estimated or invented here',
|
|
38
|
+
'the model provenance of a reply proves the CLI-configured init, not the response backend',
|
|
39
|
+
]),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** Request fields this route refuses outright: it cannot honour them, so it will not pretend to. */
|
|
43
|
+
const REFUSED_FIELDS = Object.freeze(['tools', 'toolHistory', 'maxTokens', 'reasoningEffort', 'temperature', 'stop', 'purpose'])
|
|
44
|
+
/** The closed GenerateOptions field set; anything else is a trust-boundary violation. */
|
|
45
|
+
const ALLOWED_FIELDS = Object.freeze(['provider', 'model', 'messages', 'system', 'signal', 'sessionId'])
|
|
46
|
+
|
|
47
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }) }
|
|
48
|
+
|
|
49
|
+
const idOf = message => (message && typeof message.id === 'string' && message.id ? message.id : null)
|
|
50
|
+
|
|
51
|
+
/** Text of a text-only content value, or null when it carries anything else. */
|
|
52
|
+
function textOf(content) {
|
|
53
|
+
if (typeof content === 'string') return content
|
|
54
|
+
if (!Array.isArray(content)) return null
|
|
55
|
+
let text = ''
|
|
56
|
+
for (const block of content) {
|
|
57
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') return null
|
|
58
|
+
text += block.text
|
|
59
|
+
}
|
|
60
|
+
return text
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The block types a content array carries, for a refusal that names what it saw. */
|
|
64
|
+
function blockTypes(content) {
|
|
65
|
+
if (!Array.isArray(content)) return []
|
|
66
|
+
return content.map(block => (block && typeof block.type === 'string' ? block.type : 'unknown'))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True for an assistant message this very route produced (SDK model provenance). */
|
|
70
|
+
const ownReply = (message, provider, model) => message?.role === 'assistant'
|
|
71
|
+
&& message.source?.kind === 'model'
|
|
72
|
+
&& message.source.provider === provider
|
|
73
|
+
&& message.source.model === model
|
|
74
|
+
|
|
75
|
+
/** One assistant history entry this route must be able to account for: text only. */
|
|
76
|
+
const assertAssistantHistory = (message, provider, model) => {
|
|
77
|
+
if (!ownReply(message, provider, model)) {
|
|
78
|
+
fail('CLAUDE_SESSION_HISTORY_UNSEEN',
|
|
79
|
+
`这条会话里有不是本路由产生的历史(${message.source?.provider ?? 'unknown'}/${message.source?.model ?? 'unknown'}),保留会话无法安全继续,请为本路由开启新会话。`)
|
|
80
|
+
}
|
|
81
|
+
if (textOf(message.content) === null) {
|
|
82
|
+
fail('CLAUDE_SESSION_HISTORY_UNSUPPORTED',
|
|
83
|
+
`本路由的历史回复只能承载文本,实际带有:${blockTypes(message.content).join(', ')}。`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Refuse a request this route cannot serve, before any session or process exists.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} options a GenerateOptions as the SDK passes it to an adapter
|
|
91
|
+
* @param {{provider: string, model: string, persona: string|null}} route the captured route snapshot
|
|
92
|
+
* @returns {{persona: string|null, messages: object[]}} the persona this call confirmed and the conversation messages
|
|
93
|
+
*/
|
|
94
|
+
export function assertRouteRequest(options, route) {
|
|
95
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) fail('CLAUDE_TURN_REQUEST_REQUIRED', '请求缺失。')
|
|
96
|
+
const refused = REFUSED_FIELDS.filter(field => options[field] !== undefined)
|
|
97
|
+
if (refused.length) {
|
|
98
|
+
fail('CLAUDE_ROUTE_CAPABILITY_REFUSED',
|
|
99
|
+
`本路由无法兑现这些能力,已拒绝而不是忽略:${refused.join(', ')}。接受的能力:${CLAUDE_LLM_CAPABILITIES.input.join('; ')}。`)
|
|
100
|
+
}
|
|
101
|
+
const unknown = Object.keys(options).filter(key => !ALLOWED_FIELDS.includes(key))
|
|
102
|
+
if (unknown.length) {
|
|
103
|
+
fail('CLAUDE_ROUTE_UNKNOWN_REQUEST_FIELD',
|
|
104
|
+
`请求带有本路由不接受的身份、路径、命令、凭据或引擎配置字段:${unknown.join(', ')}。`)
|
|
105
|
+
}
|
|
106
|
+
if (typeof options.provider !== 'string' || options.provider !== route.provider) fail('CLAUDE_ROUTE_PROVIDER_MISMATCH', 'provider 与已登记路由不一致。')
|
|
107
|
+
if (typeof options.model !== 'string' || options.model !== route.model) fail('CLAUDE_ROUTE_MODEL_MISMATCH', 'model 与已提交路由不一致。')
|
|
108
|
+
if (options.sessionId !== undefined && (typeof options.sessionId !== 'string' || !options.sessionId)) fail('CLAUDE_SESSION_ID_INVALID', 'sessionId 必须是非空字符串。')
|
|
109
|
+
|
|
110
|
+
// The persona seam: a leading system message is the loop's own projection of the
|
|
111
|
+
// Host-confirmed persona. It is compared, never invented, and never re-sent as a
|
|
112
|
+
// fake system authority — the engine has no system slot in this port.
|
|
113
|
+
let persona = null
|
|
114
|
+
let messages = options.messages
|
|
115
|
+
if (!Array.isArray(messages)) fail('CLAUDE_TURN_MESSAGES_REQUIRED', 'messages 缺失。')
|
|
116
|
+
if (messages.length && messages[0]?.role === 'system') {
|
|
117
|
+
persona = textOf(messages[0].content)
|
|
118
|
+
if (persona === null) fail('CLAUDE_PERSONA_UNSUPPORTED', `system 消息只能承载文本,实际带有:${blockTypes(messages[0].content).join(', ')}。`)
|
|
119
|
+
if (route.persona === null || route.persona === undefined) {
|
|
120
|
+
fail('CLAUDE_PERSONA_UNCONFIRMED', '宿主未确认本路由的人格设定,拒绝把请求里的 system 消息当作权威。')
|
|
121
|
+
}
|
|
122
|
+
if (persona !== route.persona) fail('CLAUDE_PERSONA_MISMATCH', '请求中的人格设定与宿主确认的不一致。')
|
|
123
|
+
messages = messages.slice(1)
|
|
124
|
+
}
|
|
125
|
+
if (options.system !== undefined) {
|
|
126
|
+
if (typeof options.system !== 'string') fail('CLAUDE_PERSONA_UNSUPPORTED', 'system 只能是字符串。')
|
|
127
|
+
if (route.persona === null || route.persona === undefined) fail('CLAUDE_PERSONA_UNCONFIRMED', '宿主未确认本路由的人格设定。')
|
|
128
|
+
if (options.system !== route.persona) fail('CLAUDE_PERSONA_MISMATCH', '请求中的 system 与宿主确认的人格设定不一致。')
|
|
129
|
+
persona = options.system
|
|
130
|
+
}
|
|
131
|
+
for (const message of messages) {
|
|
132
|
+
if (message?.role !== 'user' && message?.role !== 'assistant') {
|
|
133
|
+
fail('CLAUDE_ROUTE_CAPABILITY_REFUSED', `本路由不接受 ${String(message?.role)} 角色消息(工具更新等开发者消息无法兑现)。`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { persona, messages }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The empty projection state of a session that has dispatched nothing yet. */
|
|
140
|
+
export const emptyTurnState = () => Object.freeze({ sent: Object.freeze([]) })
|
|
141
|
+
|
|
142
|
+
/** The exact Host prefix carried in a new process's private reference context. */
|
|
143
|
+
export function seedTurnState(messages, prefixLength) {
|
|
144
|
+
if (!Number.isSafeInteger(prefixLength) || prefixLength < 1 || prefixLength >= messages.length) fail('CLAUDE_HOST_ROUTE_CONTEXT_INVALID', '宿主历史范围无效。')
|
|
145
|
+
return { sent: messages.slice(0, prefixLength).map(message => {
|
|
146
|
+
const id = idOf(message), text = textOf(message.content)
|
|
147
|
+
if (!id || text === null || !['user', 'assistant'].includes(message.role)) fail('CLAUDE_HOST_ROUTE_CONTEXT_INVALID', '原生切换只支持已确认的文本历史。')
|
|
148
|
+
return { id, text: message.role === 'user' ? text : null }
|
|
149
|
+
}) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What the retained session has NOT already seen: only the trailing new user text.
|
|
154
|
+
*
|
|
155
|
+
* Continuity is proved per delivered message: the durable id, and for a dispatched user
|
|
156
|
+
* message also its exact text. An id that survives an edit, a shortened history or a
|
|
157
|
+
* message this route never produced are refusals — a retained CLI session must never be
|
|
158
|
+
* continued on a history it did not actually see.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} input
|
|
161
|
+
* @param {object[]} input.messages conversation messages (the persona system message already removed)
|
|
162
|
+
* @param {string} input.provider this route's provider id
|
|
163
|
+
* @param {string} input.model this route's committed model id
|
|
164
|
+
* @param {{sent: readonly {id: string|null, text: string|null}[]}} input.state what this session already delivered
|
|
165
|
+
* @returns {{text: string, state: {sent: {id: string|null, text: string|null}[]}}}
|
|
166
|
+
*/
|
|
167
|
+
export function projectRetainedTurn({ messages, provider, model, state }) {
|
|
168
|
+
if (!Array.isArray(messages)) fail('CLAUDE_TURN_MESSAGES_REQUIRED', 'messages 缺失。')
|
|
169
|
+
const previous = state ?? emptyTurnState()
|
|
170
|
+
if (messages.length < previous.sent.length) {
|
|
171
|
+
fail('CLAUDE_SESSION_HISTORY_CHANGED', '会话历史比本会话已交付的更短:历史被改写,拒绝继续而不是重放。')
|
|
172
|
+
}
|
|
173
|
+
for (let index = 0; index < previous.sent.length; index++) {
|
|
174
|
+
// Durable ids are the only honest continuity proof; a positional hole is a change.
|
|
175
|
+
if (idOf(messages[index]) !== previous.sent[index].id) {
|
|
176
|
+
fail('CLAUDE_SESSION_HISTORY_CHANGED', `会话历史第 ${index + 1} 条与已交付的不一致:历史被改写,拒绝继续而不是重放。`)
|
|
177
|
+
}
|
|
178
|
+
// A user message we dispatched verbatim must still carry the text we dispatched.
|
|
179
|
+
if (previous.sent[index].text !== null && textOf(messages[index].content) !== previous.sent[index].text) {
|
|
180
|
+
fail('CLAUDE_SESSION_HISTORY_CHANGED', `会话历史第 ${index + 1} 条的用户文本已被改写,保留会话里的原文与它不再一致,拒绝继续而不是重放。`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let cursor = previous.sent.length
|
|
184
|
+
while (cursor < messages.length && messages[cursor]?.role === 'assistant') {
|
|
185
|
+
assertAssistantHistory(messages[cursor], provider, model)
|
|
186
|
+
cursor++
|
|
187
|
+
}
|
|
188
|
+
const fresh = messages.slice(cursor)
|
|
189
|
+
if (fresh.length === 0) {
|
|
190
|
+
fail('CLAUDE_TURN_ALREADY_DISPATCHED', '这次提交没有新的用户输入:同一请求不重复投递给保留会话。')
|
|
191
|
+
}
|
|
192
|
+
if (fresh.length > 1) {
|
|
193
|
+
// Extra entries this session never delivered are unseen history, not a multi-message
|
|
194
|
+
// turn: the retained CLI cannot be told about them and must not be replayed into.
|
|
195
|
+
if (fresh.slice(0, -1).some(message => message.role !== 'user')) {
|
|
196
|
+
fail('CLAUDE_SESSION_HISTORY_UNSEEN',
|
|
197
|
+
'这条会话里有本路由没有交付过的历史,保留会话无法安全继续,请为本路由开启新会话。')
|
|
198
|
+
}
|
|
199
|
+
fail('CLAUDE_TURN_INPUT_UNSUPPORTED', `一次调用只能携带一条新的用户消息,实际有 ${fresh.length} 条。`)
|
|
200
|
+
}
|
|
201
|
+
const incoming = fresh[0]
|
|
202
|
+
if (incoming.role !== 'user') {
|
|
203
|
+
fail('CLAUDE_TURN_INPUT_UNSUPPORTED', `新的输入必须是 user 消息,实际是 ${String(incoming.role)}。`)
|
|
204
|
+
}
|
|
205
|
+
const text = textOf(incoming.content)
|
|
206
|
+
if (text === null) {
|
|
207
|
+
fail('CLAUDE_TURN_INPUT_UNSUPPORTED', `本路由只接受文本输入,实际带有:${blockTypes(incoming.content).join(', ')}。`)
|
|
208
|
+
}
|
|
209
|
+
if (!validTurnText(text)) fail('CLAUDE_TURN_TEXT_INVALID', '输入文本为空、含 NUL 或超过 64000 字节。')
|
|
210
|
+
return {
|
|
211
|
+
text,
|
|
212
|
+
state: {
|
|
213
|
+
sent: messages.map(message => (message.role === 'user'
|
|
214
|
+
? { id: idOf(message), text: textOf(message.content) }
|
|
215
|
+
: { id: idOf(message), text: null })),
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The usage chunk one native turn earns. A turn the CLI reported no usage for earns
|
|
222
|
+
* none: the SDK consumer then records an incomplete usage instead of a fabricated one.
|
|
223
|
+
*/
|
|
224
|
+
export function usageChunkFromTurn(result) {
|
|
225
|
+
const usage = result?.usage
|
|
226
|
+
if (!usage || typeof usage.input !== 'number' || typeof usage.output !== 'number') return null
|
|
227
|
+
return {
|
|
228
|
+
inputTokens: usage.input,
|
|
229
|
+
outputTokens: usage.output,
|
|
230
|
+
...(typeof usage.cachedInput === 'number' ? { cacheReadTokens: usage.cachedInput } : {}),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Native stop reasons mapped onto stable, provider-neutral refusal codes. */
|
|
235
|
+
const STOP_REASON_CODES = Object.freeze({
|
|
236
|
+
'resume-refused': 'CLAUDE_SESSION_RESUME_REFUSED',
|
|
237
|
+
'model-identity-mismatch': 'CLAUDE_MODEL_IDENTITY_MISMATCH',
|
|
238
|
+
'grant-revoked': 'CLAUDE_GRANT_REVOKED',
|
|
239
|
+
'exit-before-result': 'CLAUDE_PROCESS_EXIT_BEFORE_RESULT',
|
|
240
|
+
'spawn-failed': 'CLAUDE_LAUNCH_FAILED',
|
|
241
|
+
'launch-failed': 'CLAUDE_LAUNCH_FAILED',
|
|
242
|
+
'stdin-write-failed': 'CLAUDE_DISPATCH_WRITE_FAILED',
|
|
243
|
+
'turn-timeout': 'CLAUDE_TURN_TIMEOUT',
|
|
244
|
+
'engine-stopped': 'CLAUDE_ENGINE_STOPPED',
|
|
245
|
+
cancelled: 'CLAUDE_TURN_CANCELLED',
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Exactly one finish reason per native turn.
|
|
250
|
+
*
|
|
251
|
+
* A turn is a success only when the CLI completed it, the session identity was
|
|
252
|
+
* confirmed by the init handshake, and the turn actually produced assistant text.
|
|
253
|
+
* An `unknown` outcome (the CLI may have run), a cancelled turn, a turn with no
|
|
254
|
+
* output at all and any failure are refusals — never a success with a missing answer.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} result the engine's settled turn result
|
|
257
|
+
* @param {{textChars?: number}} [observed] what the turn actually streamed
|
|
258
|
+
*/
|
|
259
|
+
export function finishReasonFromTurn(result, observed = {}) {
|
|
260
|
+
if (result?.cancelled === true) {
|
|
261
|
+
return { kind: 'aborted', failure: { code: 'CLAUDE_TURN_CANCELLED', message: '本轮在原生会话中被取消。' } }
|
|
262
|
+
}
|
|
263
|
+
if (!result || result.ok !== true) {
|
|
264
|
+
const code = STOP_REASON_CODES[result?.stopReason] ?? 'CLAUDE_TURN_FAILED'
|
|
265
|
+
if (result?.outcome === 'unknown') {
|
|
266
|
+
return { kind: 'error', failure: { code: 'CLAUDE_TURN_OUTCOME_UNKNOWN', message: `原生会话结果未知(${String(result.stopReason)}),不当作成功。` } }
|
|
267
|
+
}
|
|
268
|
+
return { kind: 'error', failure: { code, message: result?.errorText ?? String(result?.stopReason ?? 'native turn failed') } }
|
|
269
|
+
}
|
|
270
|
+
if (result.outcome !== 'succeeded') {
|
|
271
|
+
return { kind: 'error', failure: { code: 'CLAUDE_SESSION_UNCONFIRMED', message: '原生会话未通过 init 握手确认,结果不当作成功。' } }
|
|
272
|
+
}
|
|
273
|
+
if (!(typeof observed.textChars === 'number' && observed.textChars > 0)) {
|
|
274
|
+
return { kind: 'error', failure: { code: 'CLAUDE_TURN_NO_OUTPUT', message: '原生会话本轮没有给出任何文本,不当作成功。' } }
|
|
275
|
+
}
|
|
276
|
+
return { kind: 'stop' }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Turn facts a bridge records for the Host; never a monetary amount. */
|
|
280
|
+
export function turnFacts(result) {
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
turnId: result?.turnId ?? null,
|
|
283
|
+
nativeOutcome: result?.outcome ?? null,
|
|
284
|
+
nativeStopReason: result?.stopReason ?? null,
|
|
285
|
+
usageStatus: result?.usageStatus ?? 'unknown',
|
|
286
|
+
costStatus: 'unknown',
|
|
287
|
+
note: 'usage and cost come from the native result only; this bridge holds no second ledger and issues no receipt',
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The session key: one retained native CLI session per Bot conversation. */
|
|
292
|
+
export const sessionKeyOf = (botId, sessionId) => `${botId}\u0000${typeof sessionId === 'string' && sessionId ? sessionId : '-'}`
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Host-side permission/question broker for the Claude conversation engine.
|
|
2
|
+
//
|
|
3
|
+
// Adapted from OpenMausBot server/drivers/claude.ts createPermissionBroker at pin
|
|
4
|
+
// 1bc4749aa8307d7d6998bf86de4ef6478460dd93 (Apache-2.0); see licenses/NOTICE.md.
|
|
5
|
+
// Wire protocol (newline-delimited JSON on a local pipe, engine side is the server):
|
|
6
|
+
// child -> {t:'ask', id, kind:'permission'|'question', tool, input}
|
|
7
|
+
// host -> {t:'answer', id, behavior:'allow'|'deny'|'answer', message, source, always?}
|
|
8
|
+
// Duplicate ask ids, asks after close and unanswered permission asks all fail CLOSED.
|
|
9
|
+
|
|
10
|
+
import { createServer } from 'node:net'
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { chmod, unlink } from 'node:fs/promises'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { parseBrokerAsk, brokerAnswer } from './claude-protocol.mjs'
|
|
15
|
+
|
|
16
|
+
const DENY_TIMEOUT_NOTE = 'Host: nobody answered this permission request in time. Skip this action and finish what you can without it.'
|
|
17
|
+
const DUPLICATE_ASK_ID_NOTE = 'Host: duplicate ask id — skipping this request.'
|
|
18
|
+
const SYSTEM_ENDED_QUESTION = 'Host: the turn is ending — wrap up.'
|
|
19
|
+
const SYSTEM_ENDED_DENY = 'Host: the turn ended'
|
|
20
|
+
|
|
21
|
+
export function brokerPipeCandidates(home) {
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
const base = `\\\\.\\pipe\\dsh-claude-${randomBytes(9).toString('hex')}`
|
|
24
|
+
return [base, `${base}-r`]
|
|
25
|
+
}
|
|
26
|
+
return [join(home, 'tmp', `broker-${randomBytes(6).toString('hex')}.sock`)]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {string[]} options.pipeCandidates bind order
|
|
32
|
+
* @param {(ask: {id, kind, tool, input}) => void} options.onAsk
|
|
33
|
+
* @param {(resolved: {id, kind, tool, behavior, source}) => void} options.onResolve
|
|
34
|
+
* @param {() => boolean} [options.isActive] late asks fail closed when false
|
|
35
|
+
* @param {number} [options.timeoutMs] unanswered permission asks deny after this
|
|
36
|
+
*/
|
|
37
|
+
export async function createPermissionBroker({ pipeCandidates, onAsk, onResolve, isActive, timeoutMs = 15 * 60_000 }) {
|
|
38
|
+
if (!Array.isArray(pipeCandidates) || !pipeCandidates.length) throw new TypeError('BROKER_PIPES_REQUIRED')
|
|
39
|
+
const pending = new Map()
|
|
40
|
+
let closed = false
|
|
41
|
+
let boundPath = null
|
|
42
|
+
let server = null
|
|
43
|
+
const connections = new Set()
|
|
44
|
+
|
|
45
|
+
const connectionHandler = connection => {
|
|
46
|
+
connections.add(connection)
|
|
47
|
+
connection.on('error', () => {})
|
|
48
|
+
connection.setEncoding('utf8')
|
|
49
|
+
let buffer = ''
|
|
50
|
+
connection.on('data', chunk => {
|
|
51
|
+
buffer += chunk
|
|
52
|
+
let end
|
|
53
|
+
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
54
|
+
const line = buffer.slice(0, end)
|
|
55
|
+
buffer = buffer.slice(end + 1)
|
|
56
|
+
let message
|
|
57
|
+
try { message = JSON.parse(line) } catch { continue }
|
|
58
|
+
const ask = parseBrokerAsk(message)
|
|
59
|
+
if (!ask) continue
|
|
60
|
+
const reply = value => { try { connection.write(`${JSON.stringify(value)}\n`) } catch {} }
|
|
61
|
+
if (closed) { reply(brokerAnswer(ask.id, ask.kind === 'question' ? 'answer' : 'deny', ask.kind === 'question' ? SYSTEM_ENDED_QUESTION : SYSTEM_ENDED_DENY, 'system')); continue }
|
|
62
|
+
if (isActive && !isActive()) { reply(brokerAnswer(ask.id, ask.kind === 'question' ? 'answer' : 'deny', ask.kind === 'question' ? SYSTEM_ENDED_QUESTION : SYSTEM_ENDED_DENY, 'system')); continue }
|
|
63
|
+
if (pending.has(ask.id)) { reply(brokerAnswer(ask.id, 'deny', DUPLICATE_ASK_ID_NOTE, 'system')); continue }
|
|
64
|
+
const finish = (behavior, message, source, always = false) => {
|
|
65
|
+
if (!pending.delete(ask.id)) return
|
|
66
|
+
if (timer) clearTimeout(timer)
|
|
67
|
+
reply(brokerAnswer(ask.id, behavior, message, source, always))
|
|
68
|
+
onResolve({ ...ask, behavior, source })
|
|
69
|
+
}
|
|
70
|
+
const timer = ask.kind === 'question' ? null : setTimeout(() => finish('deny', DENY_TIMEOUT_NOTE, 'timeout'), timeoutMs)
|
|
71
|
+
timer?.unref()
|
|
72
|
+
pending.set(ask.id, { ask, finish })
|
|
73
|
+
onAsk(ask)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
connection.on('close', () => connections.delete(connection))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const candidate of pipeCandidates) {
|
|
80
|
+
const attempt = createServer(connectionHandler)
|
|
81
|
+
await unlink(candidate).catch(() => {})
|
|
82
|
+
const outcome = await new Promise(resolve => {
|
|
83
|
+
attempt.once('listening', () => resolve('listening'))
|
|
84
|
+
attempt.once('error', error => resolve(error))
|
|
85
|
+
attempt.listen(candidate)
|
|
86
|
+
})
|
|
87
|
+
if (outcome === 'listening' && process.platform !== 'win32') {
|
|
88
|
+
try { await chmod(candidate, 0o600) } catch (error) {
|
|
89
|
+
attempt.close()
|
|
90
|
+
await unlink(candidate).catch(() => {})
|
|
91
|
+
if (candidate === pipeCandidates[pipeCandidates.length - 1]) { server = null; boundPath = null; break }
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (outcome === 'listening') { server = attempt; boundPath = candidate; break }
|
|
96
|
+
if (candidate === pipeCandidates[pipeCandidates.length - 1]) break
|
|
97
|
+
}
|
|
98
|
+
// Fail closed: an unbound broker must never look usable.
|
|
99
|
+
if (!server) throw new Error('PERMISSION_BROKER_UNBOUND')
|
|
100
|
+
|
|
101
|
+
const drain = () => {
|
|
102
|
+
for (const value of Array.from(pending.values())) {
|
|
103
|
+
const { ask } = value
|
|
104
|
+
value.finish(ask.kind === 'question' ? 'answer' : 'deny', ask.kind === 'question' ? SYSTEM_ENDED_QUESTION : SYSTEM_ENDED_DENY, 'system')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
pipePath: boundPath,
|
|
110
|
+
/** Host-side decision. Returns false when the ask is unknown or the behavior does not fit its kind. */
|
|
111
|
+
answer(askId, behavior, message, always = false) {
|
|
112
|
+
const value = pending.get(askId)
|
|
113
|
+
if (!value) return false
|
|
114
|
+
if (value.ask.kind === 'question' ? behavior !== 'answer' : behavior === 'answer') return false
|
|
115
|
+
value.finish(behavior, message, 'user', always && behavior === 'allow')
|
|
116
|
+
return true
|
|
117
|
+
},
|
|
118
|
+
pause() { drain() },
|
|
119
|
+
close() {
|
|
120
|
+
if (closed) return
|
|
121
|
+
closed = true
|
|
122
|
+
drain()
|
|
123
|
+
for (const connection of connections) connection.destroy()
|
|
124
|
+
try { server.close() } catch {}
|
|
125
|
+
if (process.platform !== 'win32') void unlink(boundPath).catch(() => {})
|
|
126
|
+
},
|
|
127
|
+
get pendingCount() { return pending.size },
|
|
128
|
+
get closed() { return closed },
|
|
129
|
+
})
|
|
130
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP stdio permission proxy for the Claude conversation engine.
|
|
3
|
+
//
|
|
4
|
+
// Claude Code is launched with --permission-prompt-tool mcp__dsh__approve and an
|
|
5
|
+
// --mcp-config that runs this script. It implements the minimum MCP surface Claude needs
|
|
6
|
+
// (initialize / notifications/initialized / tools/list / tools/call) and forwards each
|
|
7
|
+
// approve call to the engine's permission broker pipe as newline-delimited JSON:
|
|
8
|
+
// -> {t:'ask', id, kind, tool, input}
|
|
9
|
+
// <- {t:'answer', id, behavior, message, source, always?}
|
|
10
|
+
//
|
|
11
|
+
// Adapted from the OpenMausBot permission-proxy concept (server/drivers/claude.ts,
|
|
12
|
+
// --permission-prompt-tool mcp__ogb__approve) at pin
|
|
13
|
+
// 1bc4749aa8307d7d6998bf86de4ef6478460dd93 (Apache-2.0); see licenses/NOTICE.md.
|
|
14
|
+
//
|
|
15
|
+
// Usage: node claude-permission-proxy.mjs <brokerPipePath>
|
|
16
|
+
|
|
17
|
+
import { connect } from 'node:net'
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { createInterface } from 'node:readline'
|
|
20
|
+
|
|
21
|
+
const APPROVE_TOOL = 'approve'
|
|
22
|
+
const ASK_TIMEOUT_MS = Number(process.env.DSH_APPROVE_TIMEOUT_MS ?? 15 * 60_000)
|
|
23
|
+
|
|
24
|
+
const pipePath = process.argv[2]
|
|
25
|
+
if (!pipePath || pipePath.startsWith('-')) {
|
|
26
|
+
process.stderr.write('claude-permission-proxy: broker pipe path argument required\n')
|
|
27
|
+
process.exit(2)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const send = message => process.stdout.write(`${JSON.stringify(message)}\n`)
|
|
31
|
+
const rpcResult = (id, result) => send({ jsonrpc: '2.0', id, result })
|
|
32
|
+
const rpcError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } })
|
|
33
|
+
|
|
34
|
+
function askBroker({ kind, tool, input }) {
|
|
35
|
+
return new Promise(resolve => {
|
|
36
|
+
const id = randomBytes(12).toString('hex')
|
|
37
|
+
let socket
|
|
38
|
+
const finish = value => {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
try { socket?.destroy() } catch {}
|
|
41
|
+
resolve(value)
|
|
42
|
+
}
|
|
43
|
+
const timer = setTimeout(() => finish({ behavior: 'deny', message: 'Host: permission broker timed out.', source: 'timeout' }), ASK_TIMEOUT_MS)
|
|
44
|
+
try { socket = connect(pipePath) } catch (error) { finish({ behavior: 'deny', message: `Host: broker unreachable (${error.message}).`, source: 'system' }); return }
|
|
45
|
+
socket.on('error', error => finish({ behavior: 'deny', message: `Host: broker unreachable (${error.message}).`, source: 'system' }))
|
|
46
|
+
socket.on('connect', () => {
|
|
47
|
+
socket.write(`${JSON.stringify({ t: 'ask', id, kind, tool, input })}\n`)
|
|
48
|
+
const lines = createInterface({ input: socket })
|
|
49
|
+
lines.on('line', line => {
|
|
50
|
+
try {
|
|
51
|
+
const message = JSON.parse(line)
|
|
52
|
+
if (message.t === 'answer' && message.id === id) finish(message)
|
|
53
|
+
} catch { /* not for us */ }
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const input = createInterface({ input: process.stdin })
|
|
60
|
+
input.on('line', line => {
|
|
61
|
+
let request
|
|
62
|
+
try { request = JSON.parse(line) } catch { return }
|
|
63
|
+
if (request.method === 'initialize') {
|
|
64
|
+
rpcResult(request.id, { protocolVersion: '2024-11-05', serverInfo: { name: 'dsh-permission-proxy', version: '1.0.0' }, capabilities: { tools: {} } })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (request.method === 'notifications/initialized') return
|
|
68
|
+
if (request.method === 'tools/list') {
|
|
69
|
+
rpcResult(request.id, { tools: [{ name: APPROVE_TOOL, description: 'Ask the DSH host whether a tool use is allowed.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['permission', 'question'] }, tool: { type: 'string' }, input: { type: 'object' } }, required: ['kind', 'tool'] } }] })
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (request.method === 'tools/call') {
|
|
73
|
+
if (request.params?.name !== APPROVE_TOOL) { rpcError(request.id, -32602, `Unknown tool ${request.params?.name}`); return }
|
|
74
|
+
const args = request.params.arguments ?? {}
|
|
75
|
+
void askBroker({ kind: args.kind === 'question' ? 'question' : 'permission', tool: typeof args.tool === 'string' ? args.tool : 'tool', input: args.input && typeof args.input === 'object' ? args.input : {} })
|
|
76
|
+
.then(answer => {
|
|
77
|
+
// The tool result is what Claude turns into its allow/deny decision; a refused
|
|
78
|
+
// ask must read as a denial, never as an error Claude might retry.
|
|
79
|
+
rpcResult(request.id, { content: [{ type: 'text', text: JSON.stringify({ behavior: answer.behavior, message: typeof answer.message === 'string' ? answer.message : '', source: answer.source, always: answer.always === true }) }], isError: false })
|
|
80
|
+
})
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (request.id !== undefined) rpcError(request.id, -32601, `Method not found: ${request.method}`)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
process.stdin.on('close', () => process.exit(0))
|
|
87
|
+
process.stdin.on('end', () => process.exit(0))
|