@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
|
@@ -2,6 +2,7 @@ import { randomUUID, createHash } from 'node:crypto'
|
|
|
2
2
|
import { mkdirSync } from 'node:fs'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
4
|
import { createDesktopSessionHost } from './desktop-sessions.mjs'
|
|
5
|
+
import { connectSecureTransport, normalizeSecureTransport } from '../../../modules/openmaus-protocol/transport.mjs'
|
|
5
6
|
|
|
6
7
|
export const REMOTE_TOOLS = Object.freeze(['machine_ping','fs_list','fs_read','fs_write','shell_exec','container_command_v1','cli_probe_v1','cli_task_v1',
|
|
7
8
|
'operation_status','operation_cancel','artifact_stat','artifact_read','worktree_create','worktree_diff','worktree_integrate',
|
|
@@ -21,16 +22,18 @@ const references = (values, allowed) => {
|
|
|
21
22
|
if (!Array.isArray(values) || !values.length || values.length > 64 || new Set(values).size !== values.length || values.some(v => !ref(v) || allowed && !allowed.includes(v))) fail('INVALID_MACHINE_SCOPE')
|
|
22
23
|
return [...values].sort()
|
|
23
24
|
}
|
|
24
|
-
export function normalizeMachineEndpoint(value, protectedTunnel = false) {
|
|
25
|
+
export function normalizeMachineEndpoint(value, protectedTunnel = false, transport) {
|
|
25
26
|
let url; try { url = new URL(value) } catch { fail('INVALID_MACHINE_ENDPOINT') }
|
|
26
27
|
if (!['https:','http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') fail('INVALID_MACHINE_ENDPOINT')
|
|
27
|
-
if (
|
|
28
|
+
if (transport !== undefined) normalizeSecureTransport(transport)
|
|
29
|
+
if (url.protocol === 'http:' && !['127.0.0.1','[::1]','localhost'].includes(url.hostname) && protectedTunnel !== true && transport?.kind !== 'noise-v1') fail('MACHINE_TLS_REQUIRED')
|
|
28
30
|
return url.origin
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** Host metadata only. Long-lived credentials stay in the existing DS secret UI. */
|
|
32
|
-
export function openRemoteMachines({ Database, file, resolveSecret, botExists, authority, cliProfiles = [], onChanged = () => {}, now = Date.now, fetchImpl = fetch }) {
|
|
34
|
+
export function openRemoteMachines({ Database, file, resolveSecret, botExists, mainBotIds = () => [], authority, desktopProxy, desktopHttp, cliProfiles = [], onChanged = () => {}, now = Date.now, fetchImpl = fetch }) {
|
|
33
35
|
if (typeof Database !== 'function' || typeof resolveSecret !== 'function' || typeof botExists !== 'function') throw new TypeError('REMOTE_MACHINE_SERVICES_REQUIRED')
|
|
36
|
+
if (typeof mainBotIds !== 'function') throw new TypeError('REMOTE_MACHINE_MAIN_ROLE_REQUIRED')
|
|
34
37
|
if (!Array.isArray(cliProfiles) || cliProfiles.length > 64 || new Set(cliProfiles.map(p=>`${p?.machineId}/${p?.rootId}`)).size !== cliProfiles.length
|
|
35
38
|
|| cliProfiles.some(p=>!p||!['machineId','rootId','profileId'].every(k=>ref(p[k]))||!Number.isSafeInteger(p.profileRevision)||p.profileRevision<1
|
|
36
39
|
||!/^[a-f0-9]{64}$/.test(p.entry?.sha256??'')||!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(p.entry?.version??''))) throw new TypeError('REMOTE_CLI_PROFILES_INVALID')
|
|
@@ -41,6 +44,7 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
41
44
|
db.exec(`CREATE TABLE IF NOT EXISTS remote_machine_component(component TEXT PRIMARY KEY,version INTEGER NOT NULL);
|
|
42
45
|
CREATE TABLE IF NOT EXISTS remote_machines(id TEXT PRIMARY KEY,revision INTEGER NOT NULL,config TEXT NOT NULL,revoked INTEGER NOT NULL,health TEXT);
|
|
43
46
|
CREATE TABLE IF NOT EXISTS remote_machine_bindings(machine_id TEXT NOT NULL,bot_id TEXT NOT NULL,revision INTEGER NOT NULL,scope TEXT NOT NULL,revoked INTEGER NOT NULL,PRIMARY KEY(machine_id,bot_id));
|
|
47
|
+
CREATE TABLE IF NOT EXISTS remote_machine_grant_subjects(machine_id TEXT NOT NULL,bot_id TEXT NOT NULL,PRIMARY KEY(machine_id,bot_id));
|
|
44
48
|
CREATE TABLE IF NOT EXISTS remote_machine_revocations(machine_id TEXT NOT NULL,bot_id TEXT NOT NULL,status TEXT NOT NULL,at_ms INTEGER NOT NULL,request_id TEXT NOT NULL,PRIMARY KEY(machine_id,bot_id));
|
|
45
49
|
CREATE TABLE IF NOT EXISTS remote_machine_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,machine_id TEXT NOT NULL,bot_id TEXT,kind TEXT NOT NULL,time INTEGER NOT NULL);`)
|
|
46
50
|
const version = db.prepare('SELECT version FROM remote_machine_component WHERE component=?').get('machines')
|
|
@@ -49,9 +53,28 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
49
53
|
if(!db.pragma('table_info(remote_machine_revocations)').some(column=>column.name==='request_id'))db.exec("ALTER TABLE remote_machine_revocations ADD COLUMN request_id TEXT NOT NULL DEFAULT ''")
|
|
50
54
|
let closed = false
|
|
51
55
|
const active = new Set()
|
|
56
|
+
const channels = new Map()
|
|
52
57
|
const alive = () => { if (closed) fail('MACHINE_DIRECTORY_CLOSED') }
|
|
53
58
|
const machine = id => { alive(); if (!ref(id)) fail('INVALID_MACHINE_ID'); const r = db.prepare('SELECT * FROM remote_machines WHERE id=?').get(id); if (!r || r.revoked) fail('MACHINE_NOT_PAIRED'); return { ...r, config: JSON.parse(r.config), health: r.health ? JSON.parse(r.health) : null } }
|
|
54
|
-
const binding = (machineId,botId) => {
|
|
59
|
+
const binding = (machineId,botId) => {
|
|
60
|
+
const row = db.prepare('SELECT * FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(machineId,botId)
|
|
61
|
+
if (!botExists(botId) || row?.revoked) fail('MACHINE_BOT_DENIED')
|
|
62
|
+
if (mainBotIds().includes(botId)) {
|
|
63
|
+
const record = machine(machineId)
|
|
64
|
+
// Main inherits only this paired client's verified ceiling. The remote
|
|
65
|
+
// authority still authenticates the client, Bot and every issued grant.
|
|
66
|
+
return { machine_id: record.id, bot_id: botId, revision: record.revision,
|
|
67
|
+
scope: { roots: [...record.config.roots], tools: [...record.config.tools], mode: 'workspace-write' } }
|
|
68
|
+
}
|
|
69
|
+
if (!row) fail('MACHINE_BOT_DENIED')
|
|
70
|
+
return { ...row, scope: JSON.parse(row.scope) }
|
|
71
|
+
}
|
|
72
|
+
const mainScopes = botId => db.prepare('SELECT * FROM remote_machines WHERE revoked=0').all().flatMap(row => {
|
|
73
|
+
if (db.prepare('SELECT revoked FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(row.id,botId)?.revoked) return []
|
|
74
|
+
const config=JSON.parse(row.config)
|
|
75
|
+
return [{machineId:row.id,revision:row.revision,roots:[...config.roots],tools:[...config.tools],mode:'workspace-write',inheritedMain:true}]
|
|
76
|
+
})
|
|
77
|
+
const inheritedMainScopes = botId => botExists(botId)&&mainBotIds().includes(botId) ? mainScopes(botId) : null
|
|
55
78
|
const event = (id,botId,kind) => db.prepare('INSERT INTO remote_machine_events(machine_id,bot_id,kind,time) VALUES(?,?,?,?)').run(id,botId,kind,now())
|
|
56
79
|
const requestRevocation = (id,botId) => {
|
|
57
80
|
const key=randomUUID()
|
|
@@ -75,7 +98,9 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
75
98
|
}
|
|
76
99
|
const request = async (config,path,token,body,signal) => {
|
|
77
100
|
try {
|
|
78
|
-
|
|
101
|
+
const channel = config.transport ? await secureChannel(config,signal) : null
|
|
102
|
+
const send = channel ? (url,options) => channel.fetch(path,options) : fetchImpl
|
|
103
|
+
return await responseJson(await send(`${config.endpoint}${path}`, { method: 'POST', redirect: 'error',
|
|
79
104
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' },
|
|
80
105
|
body: JSON.stringify(body), signal: AbortSignal.any([AbortSignal.timeout(35000), ...(signal ? [signal] : [])]) }))
|
|
81
106
|
} catch (error) { if (signal?.aborted) throw signal.reason; if (/^[A-Z][A-Z0-9_]{0,80}$/.test(error?.code)) throw error; fail('MACHINE_CONNECTION_FAILED') }
|
|
@@ -86,13 +111,36 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
86
111
|
if (typeof secret !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(secret)) fail('MACHINE_SECRET_UNAVAILABLE')
|
|
87
112
|
return secret
|
|
88
113
|
}
|
|
114
|
+
const secureChannel = async (config,signal) => {
|
|
115
|
+
alive()
|
|
116
|
+
const token=await secretFor({config})
|
|
117
|
+
const key=JSON.stringify([config.endpoint,config.transport.publicKey,config.secretRef,createHash('sha256').update(token).digest('hex')])
|
|
118
|
+
let entry=channels.get(key)
|
|
119
|
+
if(!entry){
|
|
120
|
+
if(channels.size>=32)fail('MACHINE_CONNECTION_LIMIT')
|
|
121
|
+
const pending=connectSecureTransport({endpoint:config.endpoint,transport:config.transport,token,signal})
|
|
122
|
+
entry={config,pending};channels.set(key,entry)
|
|
123
|
+
void pending.then(channel=>{if(closed){channel.close();return};void channel.closed.then(()=>{if(channels.get(key)===entry)channels.delete(key)})},()=>{if(channels.get(key)===entry)channels.delete(key)})
|
|
124
|
+
}
|
|
125
|
+
const channel=await entry.pending
|
|
126
|
+
signal?.throwIfAborted();alive()
|
|
127
|
+
if(channel.state!=='open')fail('MACHINE_CONNECTION_FAILED')
|
|
128
|
+
return channel
|
|
129
|
+
}
|
|
130
|
+
const closeChannels = config => {
|
|
131
|
+
for(const [key,entry] of channels)if(!config||entry.config.secretRef===config.secretRef&&entry.config.endpoint===config.endpoint){channels.delete(key);void entry.pending.then(channel=>channel.close(),()=>{})}
|
|
132
|
+
}
|
|
89
133
|
const versionUnsupported = (code,details) => { throw Object.assign(new Error(`${code}: require agentApiMajor=1 and MCP protocol 2025-11-25`), { code, details }) }
|
|
90
134
|
const issue = async (record,botId,scope,identity,signal) => {
|
|
135
|
+
// Persist the identity before any network await, including pair probes and
|
|
136
|
+
// inherited Main grants that have no per-Bot binding row. Whole-machine
|
|
137
|
+
// revocation must reach every identity for which this Host requested tickets.
|
|
138
|
+
db.prepare('INSERT OR IGNORE INTO remote_machine_grant_subjects VALUES(?,?)').run(record.id,botId)
|
|
91
139
|
const secret = await secretFor(record)
|
|
92
140
|
signal?.throwIfAborted()
|
|
93
141
|
const value = await request(record.config,'/control/v1/grants',secret,{ botId, taskId: identity.taskId, attemptId: identity.attemptId, roots: scope.roots, tools: scope.tools, ttlMs: 60000 },signal)
|
|
94
142
|
if (value.agentApiMajor !== 1) versionUnsupported('MACHINE_VERSION_UNSUPPORTED', { requiredAgentApiMajor: 1, actualAgentApiMajor: value.agentApiMajor ?? null })
|
|
95
|
-
if (value.machineId !== record.id || value.botId !== botId || value.taskId !== identity.taskId || value.attemptId !== identity.attemptId || typeof value.grantToken !== 'string') fail('MACHINE_IDENTITY_MISMATCH')
|
|
143
|
+
if (value.machineId !== record.id || record.config.clientId && value.clientId !== record.config.clientId || value.botId !== botId || value.taskId !== identity.taskId || value.attemptId !== identity.attemptId || typeof value.grantToken !== 'string') fail('MACHINE_IDENTITY_MISMATCH')
|
|
96
144
|
return value.grantToken
|
|
97
145
|
}
|
|
98
146
|
const invoke = async (record,token,name,args,operationId,signal) => {
|
|
@@ -115,24 +163,36 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
115
163
|
const compare = (old, expectedRevision) => { if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || (old?.revision ?? 0) !== expectedRevision) fail('MACHINE_CONFIG_CONFLICT') }
|
|
116
164
|
const publicMachine = row => ({ id: row.id, machineId: row.id, revision: row.revision, name: row.config.name, endpoint: row.config.endpoint,
|
|
117
165
|
secretRef: row.config.secretRef, roots: row.config.roots, tools: row.config.tools, protectedTunnel: row.config.protectedTunnel,
|
|
166
|
+
...(row.config.transport?{transport:row.config.transport}:{}),
|
|
118
167
|
status: row.revoked ? 'revoked' : row.health && now()-row.health.lastSeenAt<90000 ? 'online' : 'unverified', lastSeenAt: row.health?.lastSeenAt ?? null,
|
|
119
168
|
platform: row.health?.platform ?? null, version: row.health?.version ?? null, capabilities: row.health?.capabilities ?? [], desktop: row.health?.desktop ?? {status:'unverified'},
|
|
120
169
|
revocations:db.prepare('SELECT bot_id,status,at_ms FROM remote_machine_revocations WHERE machine_id=?').all(row.id).map(r=>({botId:r.bot_id,status:r.status,at:r.at_ms})),
|
|
121
170
|
bindings: db.prepare('SELECT bot_id,revision,scope,revoked FROM remote_machine_bindings WHERE machine_id=?').all(row.id).map(b => ({botId:b.bot_id,revision:b.revision,revoked:Boolean(b.revoked),...JSON.parse(b.scope)})) })
|
|
122
|
-
const desktopSessions = createDesktopSessionHost({ machine, binding, authority, issue, request, event, now
|
|
171
|
+
const desktopSessions = createDesktopSessionHost({ machine, binding, authority, issue, request, event, now, desktopProxy, desktopHttp,
|
|
172
|
+
cleanupRequest:async(record,input,signal)=>request(record.config,'/control/v1/desktop/close',await secretFor(record),input,signal),
|
|
173
|
+
connectDesktop: async (record,signal,origin,options={}) => {
|
|
174
|
+
if(machine(record.id).revision!==record.revision)fail('MACHINE_GRANT_CHANGED')
|
|
175
|
+
const channel=await secureChannel(record.config,signal)
|
|
176
|
+
if(machine(record.id).revision!==record.revision)fail('MACHINE_GRANT_CHANGED')
|
|
177
|
+
return channel.openWebSocket('/desktop/v1/socket',{origin,...options})
|
|
178
|
+
},
|
|
179
|
+
})
|
|
123
180
|
const store = {
|
|
124
181
|
schemaVersion:1, file,
|
|
125
182
|
desktopSession: (subject, input, context) => desktopSessions.open(subject, input, context),
|
|
183
|
+
routineDesktopSession: (subject,input,context) => desktopSessions.openInternal(subject,input,context),
|
|
126
184
|
renewDesktopSession: (subject, input, context) => desktopSessions.renew(subject, input, context),
|
|
127
185
|
closeDesktopSession: (subject, input, context) => desktopSessions.close(subject, input, context),
|
|
128
186
|
closeDesktopSessions: () => desktopSessions.closeAll(),
|
|
129
187
|
async pair(input,context) {
|
|
130
|
-
owner(context); alive(); keys(input,['machineId','name','endpoint','secretRef','protectedTunnel','roots','tools','probeBotId','expectedRevision'])
|
|
188
|
+
owner(context); alive(); keys(input,['machineId','clientId','name','endpoint','secretRef','protectedTunnel','transport','roots','tools','probeBotId','expectedRevision'])
|
|
131
189
|
if (!ref(input.machineId) || !ref(input.probeBotId) || !botExists(input.probeBotId)) fail('INVALID_MACHINE_ID')
|
|
132
190
|
if (typeof input.name !== 'string' || !input.name.trim() || input.name.length>128 || /[\x00-\x1f]/.test(input.name)) fail('INVALID_MACHINE_NAME')
|
|
133
191
|
if (typeof input.secretRef !== 'string' || !/^ds-secret\/[A-Za-z][A-Za-z0-9_]{0,127}$/.test(input.secretRef)) fail('INVALID_MACHINE_SECRET_REF')
|
|
134
192
|
if (input.protectedTunnel !== undefined && typeof input.protectedTunnel !== 'boolean') fail('INVALID_MACHINE_ENDPOINT')
|
|
135
|
-
|
|
193
|
+
if (input.clientId !== undefined && !ref(input.clientId)) fail('INVALID_MACHINE_ID')
|
|
194
|
+
const transport = input.transport === undefined ? undefined : normalizeSecureTransport(input.transport)
|
|
195
|
+
const config = { name:input.name.trim(), endpoint:normalizeMachineEndpoint(input.endpoint,input.protectedTunnel,transport), secretRef:input.secretRef, protectedTunnel:input.protectedTunnel===true, roots:references(input.roots), tools:references(input.tools,REMOTE_TOOLS), ...(transport?{transport}:{}), ...(input.clientId?{clientId:input.clientId}:{}) }
|
|
136
196
|
if (!config.tools.includes('machine_ping')) fail('MACHINE_PING_REQUIRED')
|
|
137
197
|
compare(db.prepare('SELECT revision FROM remote_machines WHERE id=?').get(input.machineId),input.expectedRevision)
|
|
138
198
|
const record = {id:input.machineId,config}, identity={taskId:`pair-${randomUUID()}`,attemptId:`attempt-${randomUUID()}`}
|
|
@@ -173,10 +233,15 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
173
233
|
owner(context); alive(); keys(input,['machineId','botId','expectedRevision'])
|
|
174
234
|
const record=machine(input.machineId), botId=input.botId??null
|
|
175
235
|
if(botId!==null&&!ref(botId)) fail('INVALID_MACHINE_ID')
|
|
176
|
-
const affected=botId?[botId]:db.prepare('SELECT bot_id FROM remote_machine_bindings WHERE machine_id=?
|
|
236
|
+
const affected=botId?[botId]:db.prepare('SELECT bot_id FROM remote_machine_bindings WHERE machine_id=? UNION SELECT bot_id FROM remote_machine_grant_subjects WHERE machine_id=?').all(record.id,record.id).map(r=>r.bot_id)
|
|
177
237
|
const requests=new Map()
|
|
178
238
|
db.transaction(()=>{
|
|
179
|
-
if(botId){
|
|
239
|
+
if(botId){
|
|
240
|
+
const previous=db.prepare('SELECT revision FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(record.id,botId)
|
|
241
|
+
compare(previous,input.expectedRevision)
|
|
242
|
+
if(previous)db.prepare('UPDATE remote_machine_bindings SET revoked=1,revision=revision+1 WHERE machine_id=? AND bot_id=?').run(record.id,botId)
|
|
243
|
+
else db.prepare('INSERT INTO remote_machine_bindings VALUES(?,?,1,?,1)').run(record.id,botId,JSON.stringify({roots:[],tools:[],mode:'read-only'}))
|
|
244
|
+
}
|
|
180
245
|
else{compare(record,input.expectedRevision);db.prepare('UPDATE remote_machines SET revoked=1,revision=revision+1 WHERE id=?').run(record.id);db.prepare('UPDATE remote_machine_bindings SET revoked=1,revision=revision+1 WHERE machine_id=?').run(record.id)}
|
|
181
246
|
event(record.id,botId,'revoked')
|
|
182
247
|
for(const id of affected)requests.set(id,requestRevocation(record.id,id))
|
|
@@ -293,7 +358,7 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
293
358
|
throw error
|
|
294
359
|
}
|
|
295
360
|
},
|
|
296
|
-
scopes(botId) { alive(); return db.prepare('SELECT machine_id,revision,scope FROM remote_machine_bindings WHERE bot_id=? AND revoked=0').all(botId).flatMap(r=>{try{machine(r.machine_id);return [{machineId:r.machine_id,revision:r.revision,...JSON.parse(r.scope)}]}catch{return []}}) },
|
|
361
|
+
scopes(botId) { alive(); return inheritedMainScopes(botId) ?? db.prepare('SELECT machine_id,revision,scope FROM remote_machine_bindings WHERE bot_id=? AND revoked=0').all(botId).flatMap(r=>{try{machine(r.machine_id);return [{machineId:r.machine_id,revision:r.revision,...JSON.parse(r.scope)}]}catch{return []}}) },
|
|
297
362
|
/**
|
|
298
363
|
* A Bot's grants, straight from committed state.
|
|
299
364
|
*
|
|
@@ -301,16 +366,19 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
|
|
|
301
366
|
* must not depend on `scopes()` (which needs the directory alive). This reads the same rows
|
|
302
367
|
* without the availability check: what was authorised stays authorised across a restart.
|
|
303
368
|
*/
|
|
304
|
-
bindingsFor(botId) {
|
|
369
|
+
bindingsFor(botId,{main=mainBotIds().includes(botId)}={}) {
|
|
305
370
|
alive()
|
|
306
371
|
if (typeof botId !== 'string' || !botId) return []
|
|
372
|
+
// Only Host grant compilation supplies this override, from the saved role
|
|
373
|
+
// being adopted. The currently committed role may still be the old one.
|
|
374
|
+
if(main)return mainScopes(botId)
|
|
307
375
|
return db.prepare('SELECT machine_id, revision, scope FROM remote_machine_bindings WHERE bot_id=? AND revoked=0').all(botId).flatMap(row => {
|
|
308
376
|
try { const scope = JSON.parse(row.scope); return [{ machineId: row.machine_id, revision: row.revision, roots: scope.roots ?? [], tools: scope.tools ?? [], mode: scope.mode ?? 'read-only' }] } catch { return [] }
|
|
309
377
|
})
|
|
310
378
|
},
|
|
311
379
|
|
|
312
380
|
readModel() { alive(); return {status:'available',items:db.prepare('SELECT * FROM remote_machines ORDER BY id').all().map(row=>publicMachine({...row,config:JSON.parse(row.config),health:row.health?JSON.parse(row.health):null}))} },
|
|
313
|
-
close() { if(!closed){for(const call of active)call.controller.abort(new Error('MACHINE_DIRECTORY_CLOSED'));closed=true;db.close()} },
|
|
381
|
+
close() { if(!closed){for(const call of active)call.controller.abort(new Error('MACHINE_DIRECTORY_CLOSED'));closed=true;closeChannels();db.close()} },
|
|
314
382
|
}
|
|
315
383
|
return Object.freeze(store)
|
|
316
384
|
}
|
|
@@ -8,6 +8,7 @@ const failureStatus = (failure, signal) => failure?.status === 'unknown' || fail
|
|
|
8
8
|
/** Legacy five-action routines remain usable on the acknowledged desktop session. */
|
|
9
9
|
export function routineStepInputs(step) {
|
|
10
10
|
if (step.action === 'input') return [normalizeRoutineInput(step.event)]
|
|
11
|
+
if (step.action === 'release-input') return []
|
|
11
12
|
if (step.action === 'wait') return []
|
|
12
13
|
if (step.action === 'click') return ['down', 'up'].map(action => normalizeRoutineInput({ type: 'pointer', action, x: step.x, y: step.y, button: step.button ?? 'left' }))
|
|
13
14
|
if (step.action === 'type') return [normalizeRoutineInput({ type: 'text', text: step.text })]
|
|
@@ -48,7 +49,7 @@ export function createRoutineExecutionService({ machines, store, botIdForSubject
|
|
|
48
49
|
const completed = [], cleanup = { inputReleased: false, controlReleased: false, stopConfirmed: false }
|
|
49
50
|
try {
|
|
50
51
|
if (controller.signal.aborted) fail('ROUTINE_CANCELLED', { status: 'cancelled' })
|
|
51
|
-
ticket = await machines.desktopSession(subject, { machineId, rootId, mode: 'watch', fps: 5,
|
|
52
|
+
ticket = await (machines.routineDesktopSession??machines.desktopSession).call(machines,subject, { machineId, rootId, mode: 'watch', fps: 5,
|
|
52
53
|
...(routine.recording ? { displayId: routine.recording.display.displayId } : {}) }, runContext)
|
|
53
54
|
store.updateRun(botId, row.runId, { sessionId: ticket.sessionId })
|
|
54
55
|
session = createRoutineDesktopSession({ ticket, WebSocketImpl, signal: controller.signal, now, ackTimeoutMs, renewEveryMs,
|
|
@@ -66,10 +67,11 @@ export function createRoutineExecutionService({ machines, store, botIdForSubject
|
|
|
66
67
|
await session.acquire()
|
|
67
68
|
for (currentStep = 0; currentStep < routine.steps.length; currentStep++) {
|
|
68
69
|
const step = routine.steps[currentStep]
|
|
69
|
-
await routineDelay(step.action === 'wait' ? step.ms : step.action
|
|
70
|
+
await routineDelay(step.action === 'wait' ? step.ms : ['input','release-input'].includes(step.action) ? step.delayMs : 0, controller.signal)
|
|
70
71
|
const currentDisplay = normalizeRoutineDisplay(session.metadata)
|
|
71
72
|
if (['displayId', 'width', 'height', 'coordinateSpace'].some(field => currentDisplay[field] !== display[field])) fail('ROUTINE_DISPLAY_CHANGED')
|
|
72
|
-
|
|
73
|
+
if (step.action === 'release-input') await session.releaseInput()
|
|
74
|
+
else for (const event of inputs[currentStep]) await session.input(event, { stepIndex: currentStep })
|
|
73
75
|
completed.push(currentStep)
|
|
74
76
|
store.updateRun(botId, row.runId, { steps: completed, stoppedAt: currentStep + 1 })
|
|
75
77
|
await onStep?.(step, currentStep, { runId: row.runId, taskId: row.taskId, attemptId: row.attemptId })
|
|
@@ -12,10 +12,9 @@ const deferred = () => {
|
|
|
12
12
|
export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis.WebSocket, renewTicket, closeTicket,
|
|
13
13
|
signal, ackTimeoutMs = 5_000, renewEveryMs = 45_000, now = Date.now,
|
|
14
14
|
onFrame, onMetadata, onState, onBeforeInput, onAcknowledgedInput, onInputFailure } = {}) {
|
|
15
|
-
if (!ticket?.sessionId || !ticket?.token || !ticket
|
|
15
|
+
if (!ticket?.sessionId || !ticket?.token || (typeof ticket.openWebSocket!=='function'&&(!ticket.websocketUrl||typeof WebSocketImpl!=='function'))) throw error('ROUTINE_DESKTOP_UNAVAILABLE')
|
|
16
16
|
if (!Number.isSafeInteger(ackTimeoutMs) || ackTimeoutMs < 1 || ackTimeoutMs > 60_000) throw error('ROUTINE_ACK_TIMEOUT_INVALID')
|
|
17
|
-
|
|
18
|
-
socket.binaryType = 'arraybuffer'
|
|
17
|
+
let socket
|
|
19
18
|
const ready = deferred(), pending = new Map()
|
|
20
19
|
let mode = 'watch', metadata = null, nextId = 0, queue = Promise.resolve(), closed = false, fatal = null
|
|
21
20
|
let renewTimer, currentTicket = ticket
|
|
@@ -31,12 +30,12 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
|
|
|
31
30
|
emit({ status: 'error', mode, code })
|
|
32
31
|
}
|
|
33
32
|
const send = value => {
|
|
34
|
-
if (closed || socket
|
|
33
|
+
if (closed || socket?.readyState !== 1) throw error('ROUTINE_DESKTOP_DISCONNECTED')
|
|
35
34
|
socket.send(JSON.stringify(value))
|
|
36
35
|
}
|
|
37
36
|
const request = (packet, type, id, operationSignal) => {
|
|
38
37
|
if (operationSignal?.aborted) return Promise.reject(error('ROUTINE_CANCELLED', 'cancelled'))
|
|
39
|
-
if (closed || socket
|
|
38
|
+
if (closed || socket?.readyState !== 1) return Promise.reject(error('ROUTINE_DESKTOP_DISCONNECTED'))
|
|
40
39
|
const key = `${type}:${id ?? ''}`, result = deferred()
|
|
41
40
|
const waiter = { ...result, type, id, sent: false, fail: null, finish: null }
|
|
42
41
|
const clear = () => { clearTimeout(timer); operationSignal?.removeEventListener?.('abort', abort); pending.delete(key) }
|
|
@@ -58,17 +57,17 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
|
|
|
58
57
|
if (closed) return
|
|
59
58
|
if (!renewed || renewed.sessionId !== ticket.sessionId || !Number.isFinite(renewed.expiresAt) || renewed.expiresAt <= now()) throw error('ROUTINE_RENEW_INVALID')
|
|
60
59
|
currentTicket = renewed; scheduleRenew()
|
|
61
|
-
} catch { breakConnection('ROUTINE_AUTHORIZATION_LOST'); try { socket
|
|
60
|
+
} catch { breakConnection('ROUTINE_AUTHORIZATION_LOST'); try { socket?.close() } catch {} }
|
|
62
61
|
}, Math.max(1, Math.min(renewEveryMs, untilExpiry)))
|
|
63
62
|
renewTimer.unref?.()
|
|
64
63
|
}
|
|
65
|
-
|
|
64
|
+
const onOpen = () => {
|
|
66
65
|
try { send({ type: 'auth', sessionId: ticket.sessionId, token: ticket.token }); emit({ status: 'authenticating', mode }) }
|
|
67
66
|
catch (failure) { breakConnection(failure.code ?? 'ROUTINE_AUTH_FAILED') }
|
|
68
67
|
}
|
|
69
|
-
const readyTimer = setTimeout(() => { breakConnection('ROUTINE_READY_TIMEOUT'); try { socket
|
|
68
|
+
const readyTimer = setTimeout(() => { breakConnection('ROUTINE_READY_TIMEOUT'); try { socket?.close() } catch {} }, ackTimeoutMs)
|
|
70
69
|
ready.promise.finally(() => clearTimeout(readyTimer)).catch(() => {})
|
|
71
|
-
|
|
70
|
+
const onMessage = message => {
|
|
72
71
|
if (closed) return
|
|
73
72
|
if (typeof message.data !== 'string') { onFrame?.(message.data); return }
|
|
74
73
|
let value
|
|
@@ -101,8 +100,15 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
|
|
|
101
100
|
}
|
|
102
101
|
if (value.type === 'error') breakConnection(value.code ?? 'ROUTINE_DESKTOP_ERROR')
|
|
103
102
|
}
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
const attach = value => {
|
|
104
|
+
value.onerror=()=>breakConnection('ROUTINE_DESKTOP_DISCONNECTED')
|
|
105
|
+
if(closed||fatal){value.close();return}
|
|
106
|
+
socket=value;socket.binaryType='arraybuffer';socket.onopen=onOpen;socket.onmessage=onMessage
|
|
107
|
+
socket.onclose=()=>breakConnection('ROUTINE_DESKTOP_DISCONNECTED')
|
|
108
|
+
if(socket.readyState===1)onOpen()
|
|
109
|
+
}
|
|
110
|
+
if(typeof ticket.openWebSocket==='function')Promise.resolve().then(()=>ticket.openWebSocket()).then(attach,()=>breakConnection('ROUTINE_DESKTOP_UNAVAILABLE'))
|
|
111
|
+
else attach(new WebSocketImpl(ticket.websocketUrl))
|
|
106
112
|
const serialize = (work, cleanup = false) => {
|
|
107
113
|
const result = queue.then(async () => {
|
|
108
114
|
if (!cleanup && fatal) throw fatal
|
|
@@ -148,9 +154,9 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
|
|
|
148
154
|
async close() {
|
|
149
155
|
if (closed) return { stopConfirmed: false, code: 'ROUTINE_ALREADY_CLOSED' }
|
|
150
156
|
clearTimeout(renewTimer)
|
|
151
|
-
try { if (socket
|
|
157
|
+
try { if (socket?.readyState === 1) send({ type: 'close' }) } catch {}
|
|
152
158
|
closed = true; mode = 'watch'; stopPending(error('ROUTINE_DESKTOP_CLOSED'))
|
|
153
|
-
try { socket
|
|
159
|
+
try { socket?.close() } catch {}
|
|
154
160
|
try { return await closeTicket?.(ticket.sessionId) ?? { stopConfirmed: false } }
|
|
155
161
|
catch (failure) { return { stopConfirmed: false, code: failure.code ?? 'ROUTINE_STOP_UNCONFIRMED' } }
|
|
156
162
|
},
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { parseSecureInvite } from './pairing-invite.mjs'
|
|
3
|
+
import { connectSecureTransport, normalizeSecureTransport } from '../../../modules/openmaus-protocol/transport.mjs'
|
|
4
|
+
import { REMOTE_TOOLS } from './remote-machines.mjs'
|
|
5
|
+
|
|
6
|
+
const fail = code => { throw Object.assign(new Error(code), { code }) }
|
|
7
|
+
const hash = value => createHash('sha256').update(value).digest('hex')
|
|
8
|
+
// SHA256(token) is also the Noise credential hash. A recovery check must not
|
|
9
|
+
// persist that reusable authentication material in the Host intent journal.
|
|
10
|
+
const credentialDigest = token => hash(`dsh-pairing-recovery-v1\0${token}`)
|
|
11
|
+
const sameScope = (a,b) => Array.isArray(a) && Array.isArray(b) && JSON.stringify([...a].sort()) === JSON.stringify([...b].sort())
|
|
12
|
+
|
|
13
|
+
/** Durable intent/result only. Raw invite proofs remain in memory; device credentials go straight to Host secrets. */
|
|
14
|
+
export function openSecurePairingHost({ Database, file, saveSecret, resolveSecret, machines, pair, botExists, now = Date.now }) {
|
|
15
|
+
const db = new Database(file)
|
|
16
|
+
db.pragma('busy_timeout=5000'); db.pragma('journal_mode=WAL'); db.pragma('synchronous=FULL')
|
|
17
|
+
db.exec(`CREATE TABLE IF NOT EXISTS secure_pairing_requests (
|
|
18
|
+
request_id TEXT PRIMARY KEY,intent_hash TEXT NOT NULL,machine_id TEXT NOT NULL,client_id TEXT NOT NULL,
|
|
19
|
+
config TEXT NOT NULL,status TEXT NOT NULL,result TEXT,created_at INTEGER NOT NULL
|
|
20
|
+
); CREATE UNIQUE INDEX IF NOT EXISTS secure_pairing_intent ON secure_pairing_requests(intent_hash)`)
|
|
21
|
+
if (!db.pragma('table_info(secure_pairing_requests)').some(column=>column.name==='credential_digest')) db.exec('ALTER TABLE secure_pairing_requests ADD COLUMN credential_digest TEXT')
|
|
22
|
+
const inFlight = new Set()
|
|
23
|
+
let closed = false
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
async redeem(input,context) {
|
|
26
|
+
if (context?.kind !== 'owner') fail('REMOTE_OWNER_REQUIRED')
|
|
27
|
+
if (closed) fail('PAIRING_SERVICE_CLOSED')
|
|
28
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !['invite','code','requestId','probeBotId','expectedRevision'].includes(key))
|
|
29
|
+
|| typeof input.requestId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(input.requestId)
|
|
30
|
+
|| typeof input.code !== 'string' || !/^\d{6}$/.test(input.code) || !botExists(input.probeBotId)
|
|
31
|
+
|| !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) fail('PAIRING_REQUEST_INVALID')
|
|
32
|
+
const invite = parseSecureInvite(input.invite), transport = normalizeSecureTransport(invite.transport)
|
|
33
|
+
if (invite.ceiling.tools.some(tool => !REMOTE_TOOLS.includes(tool))) fail('PAIRING_TOOLS_UNSUPPORTED')
|
|
34
|
+
if (!invite.ceiling.hostManagedBots && !invite.ceiling.botIds.includes(input.probeBotId)) fail('MACHINE_BOT_DENIED')
|
|
35
|
+
const { code: _printedCode, token, ...publicInvite } = invite
|
|
36
|
+
const intent = hash(JSON.stringify({ invite: publicInvite, token: hash(token), code: hash(input.code), probeBotId: input.probeBotId, expectedRevision: input.expectedRevision }))
|
|
37
|
+
const secretName = `REMOTE_AGENT_${hash(JSON.stringify([invite.machineId,invite.clientId])).slice(0,40).toUpperCase()}`
|
|
38
|
+
const config = { machineId: invite.machineId, clientId: invite.clientId, name: invite.name, endpoint: invite.endpoint,
|
|
39
|
+
transport, roots: invite.ceiling.roots, tools: invite.ceiling.tools, secretRef: `ds-secret/${secretName}`,
|
|
40
|
+
probeBotId: input.probeBotId, expectedRevision: input.expectedRevision }
|
|
41
|
+
let row = db.prepare('SELECT * FROM secure_pairing_requests WHERE request_id=?').get(input.requestId)
|
|
42
|
+
if (row && row.intent_hash !== intent) fail('PAIRING_REQUEST_CONFLICT')
|
|
43
|
+
if (!row) row = db.prepare('SELECT * FROM secure_pairing_requests WHERE intent_hash=?').get(intent)
|
|
44
|
+
const requestId = row?.request_id ?? input.requestId
|
|
45
|
+
if (row?.status === 'complete') {
|
|
46
|
+
const result=JSON.parse(row.result), live=machines.readModel().items.find(item=>item.id===invite.machineId)
|
|
47
|
+
if(!live||live.status==='revoked'||live.revision!==result.revision||live.transport?.publicKey!==transport.publicKey)fail('PAIRING_CONFIGURATION_CHANGED')
|
|
48
|
+
return { ...result, recovered: true }
|
|
49
|
+
}
|
|
50
|
+
if (inFlight.has(requestId)) fail('PAIRING_REQUEST_PENDING')
|
|
51
|
+
const existing = () => machines.readModel().items.find(item => item.id === invite.machineId)
|
|
52
|
+
const current = existing()
|
|
53
|
+
if (!row && (current?.revision ?? 0) !== input.expectedRevision) fail('MACHINE_CONFIG_CONFLICT')
|
|
54
|
+
if (!row) {
|
|
55
|
+
if (invite.expiresAt <= now() || invite.expiresAt > now() + 180_000) fail('PAIRING_WINDOW_CLOSED')
|
|
56
|
+
db.prepare("INSERT INTO secure_pairing_requests(request_id,intent_hash,machine_id,client_id,config,status,result,created_at) VALUES(?,?,?,?,?,'pending',NULL,?)").run(requestId,intent,invite.machineId,invite.clientId,JSON.stringify(config),now())
|
|
57
|
+
row = { status: 'pending' }
|
|
58
|
+
}
|
|
59
|
+
inFlight.add(requestId)
|
|
60
|
+
let channel
|
|
61
|
+
try {
|
|
62
|
+
// The Secret service and SQLite cannot share a transaction. Persist the
|
|
63
|
+
// verified credential digest first, then reconcile an already committed
|
|
64
|
+
// Secret after a crash without redeeming or minting another device.
|
|
65
|
+
if (row.status !== 'secret-saved' && /^[a-f0-9]{64}$/.test(row.credential_digest??'') && typeof resolveSecret==='function') {
|
|
66
|
+
const saved=await resolveSecret(config.secretRef)
|
|
67
|
+
if (saved!==null && saved!==undefined) {
|
|
68
|
+
if (typeof saved!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(saved)||credentialDigest(saved)!==row.credential_digest) fail('PAIRING_CREDENTIAL_CHANGED')
|
|
69
|
+
db.prepare("UPDATE secure_pairing_requests SET status='secret-saved' WHERE request_id=?").run(requestId)
|
|
70
|
+
row.status='secret-saved'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (row.status !== 'secret-saved') {
|
|
74
|
+
if (invite.expiresAt <= now()) fail('PAIRING_RESULT_UNRECOVERABLE')
|
|
75
|
+
context.signal?.throwIfAborted()
|
|
76
|
+
try { channel = await connectSecureTransport({ endpoint: invite.endpoint, transport, token, signal: context.signal }) }
|
|
77
|
+
catch { fail('PAIRING_PEER_UNVERIFIED') }
|
|
78
|
+
let response, result
|
|
79
|
+
try {
|
|
80
|
+
response = await channel.fetch('/pair/v2/redeem', { headers: { 'content-type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ requestId, code: input.code }), signal: AbortSignal.any([AbortSignal.timeout(15_000),...(context.signal?[context.signal]:[])]) })
|
|
82
|
+
result = await response.json()
|
|
83
|
+
} catch { fail('PAIRING_RESULT_UNKNOWN') }
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
if (result?.error?.code==='PAIRING_ALREADY_REDEEMED') fail('PAIRING_DEVICE_ALREADY_COMMITTED')
|
|
86
|
+
fail(/^[A-Z][A-Z0-9_]{0,80}$/.test(result?.error?.code) ? result.error.code : 'PAIRING_REDEEM_FAILED')
|
|
87
|
+
}
|
|
88
|
+
if (result.machineId !== invite.machineId || result.clientId !== invite.clientId || typeof result.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(result.token)
|
|
89
|
+
|| normalizeSecureTransport(result.transport).publicKey !== transport.publicKey || !result.ceiling
|
|
90
|
+
|| !sameScope(result.ceiling.roots,invite.ceiling.roots) || !sameScope(result.ceiling.tools,invite.ceiling.tools)
|
|
91
|
+
|| !sameScope(result.ceiling.botIds??[],invite.ceiling.botIds) || (result.ceiling.hostManagedBots===true)!==(invite.ceiling.hostManagedBots===true)) fail('PAIRING_IDENTITY_MISMATCH')
|
|
92
|
+
if (row.credential_digest && row.credential_digest!==credentialDigest(result.token)) fail('PAIRING_CREDENTIAL_CHANGED')
|
|
93
|
+
db.prepare("UPDATE secure_pairing_requests SET status='secret-saving',credential_digest=? WHERE request_id=?").run(credentialDigest(result.token),requestId)
|
|
94
|
+
await saveSecret(secretName,result.token)
|
|
95
|
+
db.prepare("UPDATE secure_pairing_requests SET status='secret-saved' WHERE request_id=?").run(requestId)
|
|
96
|
+
}
|
|
97
|
+
context.signal?.throwIfAborted()
|
|
98
|
+
const live = existing()
|
|
99
|
+
let result
|
|
100
|
+
if (live && live.revision === config.expectedRevision + 1 && live.secretRef === config.secretRef
|
|
101
|
+
&& live.endpoint === config.endpoint && live.transport?.publicKey === config.transport.publicKey
|
|
102
|
+
&& sameScope(live.roots,config.roots) && sameScope(live.tools,config.tools)) {
|
|
103
|
+
result = { ...live, mainBinding: { status: 'blocked', code: 'PAIRING_BINDING_REVIEW_REQUIRED' } }
|
|
104
|
+
} else result = await pair(config,context)
|
|
105
|
+
if (closed) fail('PAIRING_SERVICE_CLOSED')
|
|
106
|
+
db.prepare("UPDATE secure_pairing_requests SET status='complete',result=? WHERE request_id=?").run(JSON.stringify(result),requestId)
|
|
107
|
+
return result
|
|
108
|
+
} finally { channel?.close(); inFlight.delete(requestId) }
|
|
109
|
+
},
|
|
110
|
+
close() { if (!closed) { closed = true; db.close() } },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
@@ -647,6 +647,35 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
|
|
|
647
647
|
updated_ms DESC,id LIMIT ?`).all(JSON.stringify(ids),limit))
|
|
648
648
|
return {items,total,truncated:total>items.length}
|
|
649
649
|
},
|
|
650
|
+
// A sanitized cross-task event feed for the notification center: it names the
|
|
651
|
+
// safe structured fields only (seq/task/attempt/kind/at plus a validated
|
|
652
|
+
// outcome and code). Raw event detail — run ids, reference/evidence hashes,
|
|
653
|
+
// operation ids, gates — never leaves the journal, so a person-facing
|
|
654
|
+
// notification is built from structured state instead of a leak. Keyset
|
|
655
|
+
// pagination walks the monotonic seq; `maxSeq` is the feed watermark.
|
|
656
|
+
notificationEvents(owners,kinds,{before=null,after=0,limit=64}={}) {
|
|
657
|
+
const ids=[...new Set((Array.isArray(owners)?owners:[]).map(taskRef))]
|
|
658
|
+
const wanted=[...new Set((Array.isArray(kinds)?kinds:[]).filter(kind=>typeof kind==='string'))]
|
|
659
|
+
if(!ids.length||!wanted.length||!Number.isSafeInteger(limit)||limit<1||limit>512)return {items:[],maxSeq:0}
|
|
660
|
+
const bound=value=>Number.isSafeInteger(value)&&value>=0?value:0
|
|
661
|
+
const floor=bound(after),ceil=before===null?null:bound(before)
|
|
662
|
+
const range=ceil===null?'e.seq>?':'e.seq>? AND e.seq<?',params=ceil===null?[floor]:[floor,ceil]
|
|
663
|
+
const ownerJson=JSON.stringify(ids),kindJson=JSON.stringify(wanted)
|
|
664
|
+
const items=stmt(`SELECT e.seq,e.task_id,e.attempt_id,e.kind,e.at_ms,e.detail,t.owner_bot
|
|
665
|
+
FROM task_events e JOIN tasks t ON t.id=e.task_id
|
|
666
|
+
WHERE t.owner_bot IN (SELECT value FROM json_each(?)) AND e.kind IN (SELECT value FROM json_each(?)) AND ${range}
|
|
667
|
+
ORDER BY e.seq DESC LIMIT ?`).all(ownerJson,kindJson,...params,limit).map(row=>{
|
|
668
|
+
let outcome=null,notifyCode=null
|
|
669
|
+
try{const detail=JSON.parse(row.detail)
|
|
670
|
+
outcome=['succeeded','failed','cancelled','skipped','blocked','unknown','waiting-budget','needs-reconciliation','ready','running','admitting','cancelling'].includes(detail?.outcome)?detail.outcome:null
|
|
671
|
+
notifyCode=typeof detail?.code==='string'&&/^[A-Z0-9_:-]{1,128}$/.test(detail.code)?detail.code:null
|
|
672
|
+
}catch{}
|
|
673
|
+
return {seq:row.seq,taskId:row.task_id,ownerBotId:row.owner_bot,attemptId:row.attempt_id,kind:row.kind,at:row.at_ms,outcome,code:notifyCode}
|
|
674
|
+
})
|
|
675
|
+
const maxSeq=stmt(`SELECT COALESCE(MAX(e.seq),0) AS m FROM task_events e JOIN tasks t ON t.id=e.task_id
|
|
676
|
+
WHERE t.owner_bot IN (SELECT value FROM json_each(?)) AND e.kind IN (SELECT value FROM json_each(?))`).get(ownerJson,kindJson).m
|
|
677
|
+
return {items,maxSeq}
|
|
678
|
+
},
|
|
650
679
|
// One Bot's durable tasks, oldest first; the read model never invents a task.
|
|
651
680
|
list(owner) {taskRef(owner);return viewsOf(stmt('SELECT * FROM tasks WHERE owner_bot=? ORDER BY created_ms,id LIMIT 128').all(owner))},
|
|
652
681
|
close() {if(!closed){closed=true;if(borrowed){try{db.pragma(`busy_timeout=${priorTimeout}`)}catch{}}else db.close();onClose()}},
|
|
@@ -36,6 +36,23 @@ export function projectProductTask(task) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Bounded owner navigation data; never reads funding receipts or backend inputs. */
|
|
40
|
+
export async function readProductAttention({ runtime, journal, limit = 512 }, payload = {}) {
|
|
41
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) {
|
|
42
|
+
throw Object.assign(new Error('INVALID_ATTENTION_REQUEST'), { code: 'INVALID_ATTENTION_REQUEST' })
|
|
43
|
+
}
|
|
44
|
+
await runtime.load()
|
|
45
|
+
const loaded = journal.productList(Object.values(runtime.state?.bots ?? {}).map(bot => bot.id), { limit })
|
|
46
|
+
const items = loaded.items.map(task => {
|
|
47
|
+
const view = projectProductTask(task)
|
|
48
|
+
const remote = view.result?.remote
|
|
49
|
+
return { botId: view.botId, taskId: view.taskId, state: view.state,
|
|
50
|
+
...(remote ? { result: { remote: { stopConfirmed: remote.stopConfirmed, possiblyApplied: remote.possiblyApplied,
|
|
51
|
+
...(remote.notStarted === true ? { notStarted: true } : {}) } } } : {}) }
|
|
52
|
+
})
|
|
53
|
+
return { schemaVersion: 1, tasks: { status: 'available', items, total: loaded.total, truncated: loaded.truncated } }
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
/** The service checks the Bot's committed task/read grant and the task's owner first. */
|
|
40
57
|
export function inspectProductTask({ service, journal, subject, taskId }) {
|
|
41
58
|
const task = service.get(subject, { taskId })
|