@ljwei-stak/dsh-model-route-bot 0.66.3 → 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 +20 -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/README.md +3 -3
- 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,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable Host service behind the private MCP browser and desktop tool families.
|
|
3
|
+
*
|
|
4
|
+
* One Bot conversation reaches a paired remote computer through this module and nothing else:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* ctx.provide('botNativeRemoteActions', openNativeRemoteActions({
|
|
8
|
+
* Database, file, directory, authority, machines: () => machines, now,
|
|
9
|
+
* }))
|
|
10
|
+
* const view = actions.forConversation(botId, conversationId)
|
|
11
|
+
* view.schemas() // what this conversation may see now
|
|
12
|
+
* await view.call(name, args, { turnId, callId, signal }) // one durable operation
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Six rules are load-bearing, and each one is a rule the model cannot argue with:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Every identity is Host-owned.** The machine, the root, the Agent `taskId`/`attemptId`, the
|
|
18
|
+
* `operationId` and the request key are derived from the Bot, the conversation and the turn/call
|
|
19
|
+
* identity the loop hands over. A reply may name one of its own bound machines or roots, and may
|
|
20
|
+
* reference a browser session, a screenshot or an operation the Host already issued; it can never
|
|
21
|
+
* mint one, and it is never accepted as an actor, an endpoint or a credential.
|
|
22
|
+
* 2. **A request key is reserved before a byte leaves the process.** The reservation is one
|
|
23
|
+
* synchronous immediate transaction that also carries the input digest, so a repeated call reads
|
|
24
|
+
* its original record instead of dispatching a second effect, and the same key with different
|
|
25
|
+
* content is a refusal rather than a silent overwrite.
|
|
26
|
+
* 3. **An unknown outcome is reconciled, never replayed.** A mutation whose dispatch may have
|
|
27
|
+
* happened answers `unknown` with its original operation identity and a next action; only an
|
|
28
|
+
* actual Agent operation record settles it. A pending or unknown mutation fences every further
|
|
29
|
+
* mutation of the same Bot, conversation, machine and root until it is reconciled.
|
|
30
|
+
* 4. **A browser session and a screenshot belong to the context that produced them.** The Agent
|
|
31
|
+
* owns single-use and expiry of both; this module only records that a session or a capture was
|
|
32
|
+
* really issued to this Bot, conversation and target, and refuses anything else before dispatch.
|
|
33
|
+
* It never invents acceptance and never bypasses the Agent's own freshness checks.
|
|
34
|
+
* 5. **Authority is re-decided, not cached.** Committed grants and paired scopes are read live
|
|
35
|
+
* before the reservation and again after every await, so a grant withdrawn mid-call cannot be
|
|
36
|
+
* turned into a delivered result.
|
|
37
|
+
* 6. **No second ledger.** There is no money accounting and no Task submission here: the paired
|
|
38
|
+
* machine directory, the capability authority and the Agent's own operation journal are the only
|
|
39
|
+
* authorities, and a screenshot's pixels are delivered once and never persisted.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createHash } from 'node:crypto'
|
|
43
|
+
import { mkdirSync } from 'node:fs'
|
|
44
|
+
import { dirname, resolve } from 'node:path'
|
|
45
|
+
|
|
46
|
+
const SCHEMA_VERSION = 1
|
|
47
|
+
/** The largest screenshot this Host hands back, whatever the remote artifact claims. */
|
|
48
|
+
const IMAGE_LIMIT_BYTES = 6 * 1024 * 1024
|
|
49
|
+
/** Ephemeral registries: a browser session and a desktop capture are per-conversation lookups. */
|
|
50
|
+
const SESSION_LIMIT = 4096
|
|
51
|
+
const CAPTURE_LIMIT = 4096
|
|
52
|
+
const ID_LIMIT_BYTES = 256
|
|
53
|
+
|
|
54
|
+
const fail = (code, extra) => { throw Object.assign(new Error(code), { code, ...(extra ?? {}) }) }
|
|
55
|
+
const plain = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
56
|
+
const ref = value => typeof value === 'string' && /^[-A-Za-z0-9_.]{1,128}$/.test(value)
|
|
57
|
+
const hex = value => createHash('sha256').update(value).digest('hex')
|
|
58
|
+
const derived = (prefix, parts) => `${prefix}-${hex(JSON.stringify(parts)).slice(0, 32)}`
|
|
59
|
+
|
|
60
|
+
/** What the Agent's own journal says. A status outside this set is not a status. */
|
|
61
|
+
const AGENT_STATUS = new Set(['admitted', 'running', 'cancel_pending', 'succeeded', 'failed', 'cancelled', 'unknown'])
|
|
62
|
+
/** Local, pre-dispatch states: nothing has been proven either way yet. */
|
|
63
|
+
const PENDING_STATUS = new Set(['reserved', 'dispatched'])
|
|
64
|
+
/** Everything that fences a later mutation of the same Bot, conversation, machine and root. */
|
|
65
|
+
const BLOCKING_STATUS = new Set(['reserved', 'dispatched', 'admitted', 'running', 'cancel_pending', 'unknown'])
|
|
66
|
+
const TERMINAL_STATUS = new Set(['succeeded', 'failed', 'cancelled'])
|
|
67
|
+
|
|
68
|
+
const NEXT_ACTION = 'Reconcile this original operation with bot_remote_operation_status using the returned operationId before taking any further action. An unknown remote effect is never replayed.'
|
|
69
|
+
|
|
70
|
+
const familyOf = entry => entry.machineTool.startsWith('browser_') ? 'browser'
|
|
71
|
+
: entry.machineTool.startsWith('desktop_') ? 'desktop' : 'operation'
|
|
72
|
+
const isObservation = entry => entry.machineTool === 'operation_status' || entry.machineTool === 'operation_cancel'
|
|
73
|
+
/**
|
|
74
|
+
* A desktop action also needs the artifact and operation tools: the screenshot is delivered through
|
|
75
|
+
* `machines.download`, and an unknown desktop effect can only be reconciled through
|
|
76
|
+
* `operation_status`/`operation_cancel`. A pairing without them cannot run the family at all, so the
|
|
77
|
+
* tools are not offered rather than offered and failed.
|
|
78
|
+
*/
|
|
79
|
+
const ARTIFACT_TOOLS = Object.freeze(['artifact_stat', 'artifact_read', 'operation_status', 'operation_cancel'])
|
|
80
|
+
const requirementsOf = entry => familyOf(entry) === 'desktop'
|
|
81
|
+
? Object.freeze([entry.capability, ...ARTIFACT_TOOLS.map(tool => `remote/${tool}`)])
|
|
82
|
+
: Object.freeze([entry.capability])
|
|
83
|
+
const pairedToolsOf = entry => familyOf(entry) === 'desktop'
|
|
84
|
+
? Object.freeze([entry.machineTool, ...ARTIFACT_TOOLS])
|
|
85
|
+
: Object.freeze([entry.machineTool])
|
|
86
|
+
/** A cancel is a stop request, not a workspace effect: a read-only pairing may hold it. */
|
|
87
|
+
const requiresWrite = entry => entry.mutating && !isObservation(entry)
|
|
88
|
+
|
|
89
|
+
const TARGET_KEYS = Object.freeze({
|
|
90
|
+
machineId: { type: 'string', description: 'One of this Bot\'s paired machines; omit to let the Host choose.' },
|
|
91
|
+
rootId: { type: 'string', description: 'One of the roots granted to this Bot on that machine; omit to let the Host choose.' },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const objectSchema = (properties, required = []) => Object.freeze({
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: Object.freeze(properties),
|
|
97
|
+
required: Object.freeze(required),
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const text = description => ({ type: 'string', description })
|
|
102
|
+
const count = description => ({ type: 'integer', description })
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The offered surface. `capability` is the exact authority name a call needs; `parameters` is a flat
|
|
106
|
+
* JSON object schema with `additionalProperties:false`, so a model cannot smuggle an identity, an
|
|
107
|
+
* endpoint or a credential past the parser.
|
|
108
|
+
*/
|
|
109
|
+
const CATALOG = Object.freeze([
|
|
110
|
+
Object.freeze({
|
|
111
|
+
name: 'bot_browser_open', machineTool: 'browser_open', capability: 'remote/browser_open', mutating: true,
|
|
112
|
+
description: 'Open a page in this Bot\'s granted remote browser. The browser session stays owned by this Bot conversation; later browser calls reuse it and never name a task id.',
|
|
113
|
+
parameters: objectSchema({ ...TARGET_KEYS, url: text('Absolute http(s) URL inside the origins this Bot is granted to reach.'), profileId: text('Optional Host-declared persistent browser profile on that machine.') }, ['url']),
|
|
114
|
+
}),
|
|
115
|
+
Object.freeze({
|
|
116
|
+
name: 'bot_browser_snapshot', machineTool: 'browser_snapshot', capability: 'remote/browser_snapshot', mutating: false,
|
|
117
|
+
description: 'Read the current page of this Bot\'s browser session: its URL, its text and the interactive nodes with the ref ids later clicks and typing use. Read-only, so it carries no operation envelope.',
|
|
118
|
+
parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.') }, ['browserSessionId']),
|
|
119
|
+
}),
|
|
120
|
+
Object.freeze({
|
|
121
|
+
name: 'bot_browser_click', machineTool: 'browser_click', capability: 'remote/browser_click', mutating: true,
|
|
122
|
+
description: 'Click one node of the current snapshot in this Bot\'s browser session. The snapshotId and ref must come from the most recent bot_browser_snapshot.',
|
|
123
|
+
parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.'), snapshotId: text('The snapshotId the most recent bot_browser_snapshot returned.'), ref: text('The ref of one node in that snapshot.') }, ['browserSessionId', 'snapshotId', 'ref']),
|
|
124
|
+
}),
|
|
125
|
+
Object.freeze({
|
|
126
|
+
name: 'bot_browser_type', machineTool: 'browser_type', capability: 'remote/browser_type', mutating: true,
|
|
127
|
+
description: 'Type text into one node of the current snapshot in this Bot\'s browser session. Unicode is sent directly, never through the clipboard.',
|
|
128
|
+
parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.'), snapshotId: text('The snapshotId the most recent bot_browser_snapshot returned.'), ref: text('The ref of one node in that snapshot.'), text: text('The text to enter into that node.') }, ['browserSessionId', 'snapshotId', 'ref', 'text']),
|
|
129
|
+
}),
|
|
130
|
+
Object.freeze({
|
|
131
|
+
name: 'bot_browser_close', machineTool: 'browser_close', capability: 'remote/browser_close', mutating: true,
|
|
132
|
+
description: 'Close this Bot\'s browser session. It cannot be used afterwards; take a new bot_browser_open instead.',
|
|
133
|
+
parameters: objectSchema({ ...TARGET_KEYS, browserSessionId: text('The browserSessionId a successful bot_browser_open returned.') }, ['browserSessionId']),
|
|
134
|
+
}),
|
|
135
|
+
Object.freeze({
|
|
136
|
+
name: 'bot_desktop_screenshot', machineTool: 'desktop_screenshot', capability: 'remote/desktop_screenshot', mutating: true,
|
|
137
|
+
description: 'Capture the granted remote desktop and return it as an image. The screenshotId is single-use and expires 15 seconds after the capture; take a new screenshot after any use, any failure and any window change.',
|
|
138
|
+
parameters: objectSchema({ ...TARGET_KEYS }),
|
|
139
|
+
}),
|
|
140
|
+
Object.freeze({
|
|
141
|
+
name: 'bot_desktop_click', machineTool: 'desktop_click', capability: 'remote/desktop_click', mutating: true,
|
|
142
|
+
description: 'Click on the granted remote desktop. x and y are pixels of the screenshot named by screenshotId, never scaled by DPI.',
|
|
143
|
+
parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), x: count('Horizontal pixel inside that screenshot.'), y: count('Vertical pixel inside that screenshot.'), button: text('Which mouse button to press.'), clickCount: count('How many clicks to send, 1 to 3.') }, ['screenshotId', 'x', 'y']),
|
|
144
|
+
}),
|
|
145
|
+
Object.freeze({
|
|
146
|
+
name: 'bot_desktop_type', machineTool: 'desktop_type', capability: 'remote/desktop_type', mutating: true,
|
|
147
|
+
description: 'Type text into the granted remote desktop. Unicode is sent directly, never through the clipboard; newline and tab become Enter and Tab.',
|
|
148
|
+
parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), text: text('The text to type.') }, ['screenshotId', 'text']),
|
|
149
|
+
}),
|
|
150
|
+
Object.freeze({
|
|
151
|
+
name: 'bot_desktop_key', machineTool: 'desktop_key', capability: 'remote/desktop_key', mutating: true,
|
|
152
|
+
description: 'Press one key on the granted remote desktop, optionally with modifiers held.',
|
|
153
|
+
parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), key: text('Which key to press.'), modifiers: { type: 'array', items: { type: 'string' }, description: 'Modifier keys to hold while the key is pressed.' } }, ['screenshotId', 'key']),
|
|
154
|
+
}),
|
|
155
|
+
Object.freeze({
|
|
156
|
+
name: 'bot_desktop_scroll', machineTool: 'desktop_scroll', capability: 'remote/desktop_scroll', mutating: true,
|
|
157
|
+
description: 'Scroll the granted remote desktop. 120 is one notch and positive scrolls up; x and y move the pointer onto the target first.',
|
|
158
|
+
parameters: objectSchema({ ...TARGET_KEYS, screenshotId: text('The screenshotId the most recent bot_desktop_screenshot returned.'), delta: count('Wheel delta in notches of 120; positive scrolls up.'), x: count('Optional horizontal pixel to move the pointer to first.'), y: count('Optional vertical pixel to move the pointer to first.') }, ['screenshotId', 'delta']),
|
|
159
|
+
}),
|
|
160
|
+
Object.freeze({
|
|
161
|
+
name: 'bot_remote_operation_status', machineTool: 'operation_status', capability: 'remote/operation_status', mutating: false,
|
|
162
|
+
description: 'Read the current state of one operation this Host issued for this Bot conversation. The operationId must come from an earlier tool result; an unknown or still running operation is reported as such and never as a success.',
|
|
163
|
+
parameters: objectSchema({ operationId: text('The operationId an earlier tool result returned.') }, ['operationId']),
|
|
164
|
+
}),
|
|
165
|
+
Object.freeze({
|
|
166
|
+
name: 'bot_remote_operation_cancel', machineTool: 'operation_cancel', capability: 'remote/operation_cancel', mutating: true,
|
|
167
|
+
description: 'Ask to stop one operation this Host issued for this Bot conversation. This is a stop request, not a confirmation: an already applied effect is not rolled back, and only stopConfirmed:true proves the action stopped.',
|
|
168
|
+
parameters: objectSchema({ operationId: text('The operationId an earlier tool result returned.') }, ['operationId']),
|
|
169
|
+
}),
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
export const NATIVE_REMOTE_TOOLS = CATALOG
|
|
173
|
+
|
|
174
|
+
const BY_NAME = new Map(CATALOG.map(entry => [entry.name, entry]))
|
|
175
|
+
|
|
176
|
+
/** One offered entry, or null. Frozen, so a caller cannot widen a schema at runtime. */
|
|
177
|
+
export const nativeRemoteToolEntry = name => BY_NAME.get(name) ?? null
|
|
178
|
+
|
|
179
|
+
const KEYS = Object.freeze(['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'])
|
|
180
|
+
const BUTTONS = Object.freeze(['left', 'right', 'middle'])
|
|
181
|
+
const MODIFIERS = Object.freeze(['ctrl', 'alt', 'shift'])
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Bounds the schema deliberately does not carry. The installed SDK's schema validator is narrower
|
|
185
|
+
* than JSON Schema, so numeric ranges stay here instead of in `parameters`, and this parser is the
|
|
186
|
+
* only place a business argument is accepted.
|
|
187
|
+
*/
|
|
188
|
+
const LIMITS = Object.freeze({
|
|
189
|
+
url: { maxLength: 2048, control: false },
|
|
190
|
+
profileId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
191
|
+
browserSessionId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
192
|
+
snapshotId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
193
|
+
ref: { pattern: /^\d{1,3}$/ },
|
|
194
|
+
screenshotId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
195
|
+
operationId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
196
|
+
machineId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
197
|
+
rootId: { pattern: /^[-A-Za-z0-9_.]{1,128}$/ },
|
|
198
|
+
text: { maxLength: 65536, control: true },
|
|
199
|
+
x: { min: 0, max: 100000 },
|
|
200
|
+
y: { min: 0, max: 100000 },
|
|
201
|
+
delta: { min: -1200, max: 1200 },
|
|
202
|
+
clickCount: { min: 1, max: 3 },
|
|
203
|
+
button: { enum: BUTTONS },
|
|
204
|
+
key: { enum: KEYS },
|
|
205
|
+
modifiers: { maxItems: 3, unique: true, items: { enum: MODIFIERS } },
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/** Strict, offline argument parsing: no unknown key, no missing required key, no loose type. */
|
|
209
|
+
function parseToolArgs(entry, args) {
|
|
210
|
+
const refuse = () => fail('INVALID_TOOL_ARGS', { tool: entry.name })
|
|
211
|
+
if (!plain(args)) refuse()
|
|
212
|
+
const { properties, required } = entry.parameters
|
|
213
|
+
if (Object.keys(args).some(key => !Object.hasOwn(properties, key))) refuse()
|
|
214
|
+
for (const key of required) if (!Object.hasOwn(args, key)) refuse()
|
|
215
|
+
for (const [key, value] of Object.entries(args)) {
|
|
216
|
+
const schema = properties[key], limit = LIMITS[key] ?? {}
|
|
217
|
+
const types = [schema.type].flat()
|
|
218
|
+
const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
|
|
219
|
+
const integer = types.includes('integer') && Number.isSafeInteger(value)
|
|
220
|
+
if (!types.includes(kind) && !integer) refuse()
|
|
221
|
+
if (limit.enum && !limit.enum.includes(value)) refuse()
|
|
222
|
+
if (typeof value === 'string') {
|
|
223
|
+
if (limit.pattern && !limit.pattern.test(value)) refuse()
|
|
224
|
+
if (limit.maxLength !== undefined && Buffer.byteLength(value) > limit.maxLength) refuse()
|
|
225
|
+
// Control characters are only ever business content inside typed text.
|
|
226
|
+
if (limit.control !== true && /[\u0000-\u001f\u007f]/.test(value)) refuse()
|
|
227
|
+
}
|
|
228
|
+
if (integer && (limit.min !== undefined && value < limit.min || limit.max !== undefined && value > limit.max)) refuse()
|
|
229
|
+
if (schema.items) {
|
|
230
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== schema.items.type)) refuse()
|
|
231
|
+
if (limit.maxItems !== undefined && value.length > limit.maxItems) refuse()
|
|
232
|
+
if (limit.unique === true && new Set(value).size !== value.length) refuse()
|
|
233
|
+
if (limit.items?.enum && value.some(item => !limit.items.enum.includes(item))) refuse()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return args
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const boundedId = (value, code) => {
|
|
240
|
+
if (typeof value !== 'string' || !value.length || Buffer.byteLength(value) > ID_LIMIT_BYTES || /[\u0000-\u001f\u007f]/.test(value)) fail(code)
|
|
241
|
+
return value
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param {{
|
|
246
|
+
* Database: Function,
|
|
247
|
+
* file: string,
|
|
248
|
+
* directory: { forBot: (botId: string) => unknown },
|
|
249
|
+
* authority: { assertTool: (subject: unknown, name: string) => void },
|
|
250
|
+
* machines: object | (() => object | null),
|
|
251
|
+
* now?: () => number,
|
|
252
|
+
* }} options
|
|
253
|
+
*/
|
|
254
|
+
export function openNativeRemoteActions({ Database, file, directory, authority, machines, now = Date.now } = {}) {
|
|
255
|
+
if (typeof Database !== 'function' || typeof file !== 'string' || !file) throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
|
|
256
|
+
if (!directory || typeof directory.forBot !== 'function') throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
|
|
257
|
+
if (!authority || typeof authority.assertTool !== 'function') throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
|
|
258
|
+
if (machines === undefined || machines === null) throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
|
|
259
|
+
if (typeof now !== 'function') throw new TypeError('NATIVE_REMOTE_SERVICES_REQUIRED')
|
|
260
|
+
|
|
261
|
+
// The same orchestration SQLite file every other component uses, under this module's own prefix.
|
|
262
|
+
const path = file === ':memory:' ? file : resolve(file)
|
|
263
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
|
|
264
|
+
const db = new Database(path)
|
|
265
|
+
db.pragma('busy_timeout=5000')
|
|
266
|
+
// WAL is a property of the file, already set by whoever opened it first; this connection only
|
|
267
|
+
// refuses to run where it is unavailable, and keeps its own commits durable.
|
|
268
|
+
if (path !== ':memory:' && db.pragma('journal_mode=WAL', { simple: true }) !== 'wal') { db.close(); fail('NATIVE_REMOTE_WAL_REQUIRED') }
|
|
269
|
+
db.pragma('synchronous=FULL')
|
|
270
|
+
try {
|
|
271
|
+
db.transaction(() => {
|
|
272
|
+
db.exec(`CREATE TABLE IF NOT EXISTS native_remote_component(component TEXT PRIMARY KEY,version INTEGER NOT NULL);
|
|
273
|
+
CREATE TABLE IF NOT EXISTS native_remote_requests(request_key TEXT PRIMARY KEY,bot_id TEXT NOT NULL,conversation_id TEXT NOT NULL,turn_id TEXT NOT NULL,call_id TEXT NOT NULL,
|
|
274
|
+
role TEXT NOT NULL,name TEXT NOT NULL,input_digest TEXT NOT NULL,machine_tool TEXT NOT NULL,operation_id TEXT,
|
|
275
|
+
task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,machine_id TEXT NOT NULL,root_id TEXT NOT NULL,status TEXT NOT NULL,value TEXT,error TEXT,artifact_id TEXT,created_ms INTEGER NOT NULL,updated_ms INTEGER NOT NULL);
|
|
276
|
+
CREATE TABLE IF NOT EXISTS native_remote_browser_sessions(browser_session_id TEXT PRIMARY KEY,bot_id TEXT NOT NULL,conversation_id TEXT NOT NULL,machine_id TEXT NOT NULL,root_id TEXT NOT NULL,
|
|
277
|
+
task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,operation_id TEXT,request_key TEXT NOT NULL,status TEXT NOT NULL,created_ms INTEGER NOT NULL,updated_ms INTEGER NOT NULL);
|
|
278
|
+
CREATE TABLE IF NOT EXISTS native_remote_desktop_captures(screenshot_id TEXT PRIMARY KEY,bot_id TEXT NOT NULL,conversation_id TEXT NOT NULL,machine_id TEXT NOT NULL,root_id TEXT NOT NULL,
|
|
279
|
+
task_id TEXT NOT NULL,attempt_id TEXT NOT NULL,operation_id TEXT,artifact_id TEXT NOT NULL,sha256 TEXT,size INTEGER,media_type TEXT,request_key TEXT NOT NULL,created_ms INTEGER NOT NULL);
|
|
280
|
+
CREATE TABLE IF NOT EXISTS native_remote_request_refs(request_key TEXT PRIMARY KEY,browser_session_id TEXT NOT NULL);
|
|
281
|
+
CREATE INDEX IF NOT EXISTS native_remote_requests_scope ON native_remote_requests(bot_id,conversation_id,machine_id,root_id,status,created_ms);
|
|
282
|
+
CREATE INDEX IF NOT EXISTS native_remote_requests_operation ON native_remote_requests(operation_id,role);
|
|
283
|
+
CREATE INDEX IF NOT EXISTS native_remote_browser_sessions_owner ON native_remote_browser_sessions(bot_id,conversation_id,created_ms);
|
|
284
|
+
CREATE INDEX IF NOT EXISTS native_remote_desktop_captures_owner ON native_remote_desktop_captures(bot_id,conversation_id,created_ms);`)
|
|
285
|
+
const row = db.prepare('SELECT version FROM native_remote_component WHERE component=?').get('native-remote-actions')
|
|
286
|
+
if (row && row.version !== SCHEMA_VERSION) fail('NATIVE_REMOTE_SCHEMA_UNSUPPORTED')
|
|
287
|
+
db.prepare('INSERT OR IGNORE INTO native_remote_component VALUES(?,?)').run('native-remote-actions', SCHEMA_VERSION)
|
|
288
|
+
}).immediate()
|
|
289
|
+
} catch (error) { db.close(); throw error }
|
|
290
|
+
|
|
291
|
+
let closed = false
|
|
292
|
+
const alive = () => { if (closed) fail('NATIVE_REMOTE_JOURNAL_CLOSED') }
|
|
293
|
+
const stmt = sql => { alive(); return db.prepare(sql) }
|
|
294
|
+
const fenceStatuses = [...BLOCKING_STATUS]
|
|
295
|
+
const q = {
|
|
296
|
+
getRequest: stmt('SELECT * FROM native_remote_requests WHERE request_key=?'),
|
|
297
|
+
findOperation: stmt("SELECT * FROM native_remote_requests WHERE operation_id=? AND bot_id=? AND conversation_id=? AND role='action'"),
|
|
298
|
+
reserve: stmt(`INSERT INTO native_remote_requests(request_key,bot_id,conversation_id,turn_id,call_id,role,name,input_digest,machine_tool,operation_id,
|
|
299
|
+
task_id,attempt_id,machine_id,root_id,status,value,error,artifact_id,created_ms,updated_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'reserved',NULL,NULL,NULL,?,?)`),
|
|
300
|
+
dispatched: stmt("UPDATE native_remote_requests SET status='dispatched',updated_ms=? WHERE request_key=? AND status='reserved'"),
|
|
301
|
+
settle: stmt('UPDATE native_remote_requests SET status=?,value=?,error=?,updated_ms=? WHERE request_key=?'),
|
|
302
|
+
settleArtifact: stmt('UPDATE native_remote_requests SET status=?,value=?,error=?,artifact_id=?,updated_ms=? WHERE request_key=?'),
|
|
303
|
+
fence: stmt(`SELECT operation_id,status,name,machine_tool FROM native_remote_requests WHERE bot_id=? AND conversation_id=? AND machine_id=? AND root_id=? AND role='action'
|
|
304
|
+
AND operation_id IS NOT NULL AND request_key<>? AND status IN (${fenceStatuses.map(() => '?').join(',')}) ORDER BY created_ms,rowid LIMIT 1`),
|
|
305
|
+
requestRef: stmt('SELECT browser_session_id FROM native_remote_request_refs WHERE request_key=?'),
|
|
306
|
+
reserveRef: stmt('INSERT INTO native_remote_request_refs VALUES(?,?)'),
|
|
307
|
+
session: stmt("SELECT * FROM native_remote_browser_sessions WHERE browser_session_id=? AND status='open'"),
|
|
308
|
+
anySession: stmt('SELECT * FROM native_remote_browser_sessions WHERE browser_session_id=?'),
|
|
309
|
+
openSession: stmt('INSERT INTO native_remote_browser_sessions(browser_session_id,bot_id,conversation_id,machine_id,root_id,task_id,attempt_id,operation_id,request_key,status,created_ms,updated_ms) VALUES(?,?,?,?,?,?,?,?,?,\'open\',?,?)'),
|
|
310
|
+
closeSession: stmt("UPDATE native_remote_browser_sessions SET status='closed',updated_ms=? WHERE browser_session_id=? AND status='open'"),
|
|
311
|
+
capture: stmt('SELECT * FROM native_remote_desktop_captures WHERE screenshot_id=?'),
|
|
312
|
+
openCapture: stmt('INSERT INTO native_remote_desktop_captures(screenshot_id,bot_id,conversation_id,machine_id,root_id,task_id,attempt_id,operation_id,artifact_id,sha256,size,media_type,request_key,created_ms) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
|
|
313
|
+
sessionCount: stmt('SELECT count(*) AS total FROM native_remote_browser_sessions'),
|
|
314
|
+
captureCount: stmt('SELECT count(*) AS total FROM native_remote_desktop_captures'),
|
|
315
|
+
sweepSessions: stmt(`DELETE FROM native_remote_browser_sessions WHERE browser_session_id NOT IN (SELECT browser_session_id FROM native_remote_browser_sessions ORDER BY created_ms DESC LIMIT ${SESSION_LIMIT})`),
|
|
316
|
+
sweepCaptures: stmt(`DELETE FROM native_remote_desktop_captures WHERE screenshot_id NOT IN (SELECT screenshot_id FROM native_remote_desktop_captures ORDER BY created_ms DESC LIMIT ${CAPTURE_LIMIT})`),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** In-process dispatches, so a concurrent duplicate waits for the original instead of repeating it. */
|
|
320
|
+
const inflight = new Map()
|
|
321
|
+
|
|
322
|
+
/** The machine directory, resolved per call: a Host may publish it late or replace it. */
|
|
323
|
+
const machineStore = () => {
|
|
324
|
+
const value = typeof machines === 'function' ? machines() : machines
|
|
325
|
+
if (!value || typeof value.call !== 'function') fail('MACHINE_DIRECTORY_UNAVAILABLE')
|
|
326
|
+
return value
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const statusOf = value => value && typeof value.status === 'string' && AGENT_STATUS.has(value.status) ? value.status : null
|
|
330
|
+
const codeOf = error => { const code = error?.code; return typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,80}$/.test(code) ? code : 'NATIVE_REMOTE_TOOL_FAILED' }
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* One Agent answer, normalised. A mutating tool answers with its operation record; a read-only one
|
|
334
|
+
* answers with the value itself, and that is not an operation envelope.
|
|
335
|
+
*/
|
|
336
|
+
const normalizeRecord = (entry, record, target, operationId, original) => {
|
|
337
|
+
const invalid = { status: 'unknown', value: null, result: null, error: 'NATIVE_REMOTE_PROTOCOL_INVALID' }
|
|
338
|
+
if (!plain(record)) return invalid
|
|
339
|
+
if (!entry.mutating && !isObservation(entry)) return { status: 'succeeded', value: record, result: record, error: null }
|
|
340
|
+
const operation = entry.machineTool === 'operation_cancel' ? record.operation : record
|
|
341
|
+
const expectedTool = original?.machine_tool ?? entry.machineTool
|
|
342
|
+
if (!plain(operation) || operation.schemaVersion !== 1 || operation.machineId !== target.machineId
|
|
343
|
+
|| operation.operationId !== operationId || operation.tool !== expectedTool || !statusOf(operation)) return invalid
|
|
344
|
+
return { status: operation.status, result: operation.result ?? null,
|
|
345
|
+
value: entry.machineTool === 'operation_cancel' ? record : operation.result ?? null, error: operation.error?.code ?? null }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const validateImage = image => {
|
|
349
|
+
if (!plain(image) || typeof image.content !== 'string' || !image.content) fail('NATIVE_REMOTE_IMAGE_INVALID')
|
|
350
|
+
if (image.mediaType !== 'image/png') fail('NATIVE_REMOTE_IMAGE_INVALID', { mediaType: image.mediaType ?? null })
|
|
351
|
+
const bytes = Buffer.from(image.content, 'base64')
|
|
352
|
+
if (!bytes.length || bytes.toString('base64') !== image.content) fail('NATIVE_REMOTE_IMAGE_INVALID')
|
|
353
|
+
if (bytes.length > IMAGE_LIMIT_BYTES) fail('NATIVE_REMOTE_IMAGE_LIMIT', { size: bytes.length })
|
|
354
|
+
if (bytes.length < 8 || !bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) fail('NATIVE_REMOTE_IMAGE_INVALID')
|
|
355
|
+
return Object.freeze({ mimeType: 'image/png', data: image.content })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const view = (botId, conversationId) => {
|
|
359
|
+
/** Live committed bindings for one Bot, straight from the machine directory. */
|
|
360
|
+
const scopesFor = () => {
|
|
361
|
+
const store = machineStore()
|
|
362
|
+
const rows = typeof store.scopes === 'function' ? store.scopes(botId)
|
|
363
|
+
: typeof store.bindingsFor === 'function' ? store.bindingsFor(botId) : []
|
|
364
|
+
if (!Array.isArray(rows)) fail('MACHINE_DIRECTORY_UNREADABLE')
|
|
365
|
+
return rows.filter(scope => scope && typeof scope.machineId === 'string' && Array.isArray(scope.tools) && Array.isArray(scope.roots))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The one live binding that can still run this entry against this exact machine and root. */
|
|
369
|
+
const liveScopeFor = (entry, target) => scopesFor().some(scope => scope.machineId === target.machineId
|
|
370
|
+
&& scope.roots.includes(target.rootId)
|
|
371
|
+
&& pairedToolsOf(entry).every(tool => scope.tools.includes(tool))
|
|
372
|
+
&& (!requiresWrite(entry) || scope.mode === 'workspace-write'))
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Live authority and live scope, both re-read. `soft` turns a refusal into `false` for the
|
|
376
|
+
* post-await re-check, where the effect is already durable and must not be invented away.
|
|
377
|
+
*/
|
|
378
|
+
const assertLive = (entry, target, { soft = false } = {}) => {
|
|
379
|
+
try {
|
|
380
|
+
const subject = directory.forBot(botId)
|
|
381
|
+
for (const capability of requirementsOf(entry)) authority.assertTool(subject, capability)
|
|
382
|
+
if (!liveScopeFor(entry, target)) fail('MACHINE_BOT_DENIED')
|
|
383
|
+
return true
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (soft) return false
|
|
386
|
+
throw error
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The target this call runs on: an explicitly named, still granted one, or the first live match. */
|
|
391
|
+
const resolveTarget = (entry, args) => {
|
|
392
|
+
const scopes = scopesFor()
|
|
393
|
+
const usable = scopes.filter(scope => scope.roots.length && pairedToolsOf(entry).every(tool => scope.tools.includes(tool))
|
|
394
|
+
&& (!requiresWrite(entry) || scope.mode === 'workspace-write'))
|
|
395
|
+
let chosen = usable
|
|
396
|
+
if (args.machineId !== undefined) {
|
|
397
|
+
if (!scopes.some(scope => scope.machineId === args.machineId)) fail('MACHINE_NOT_BOUND', { tool: entry.name })
|
|
398
|
+
chosen = usable.filter(scope => scope.machineId === args.machineId)
|
|
399
|
+
if (!chosen.length) fail('MACHINE_BOT_DENIED', { tool: entry.name, machineTool: entry.machineTool })
|
|
400
|
+
}
|
|
401
|
+
const scope = chosen[0]
|
|
402
|
+
if (!scope) fail('MACHINE_NOT_BOUND', { tool: entry.name })
|
|
403
|
+
if (args.rootId !== undefined && (typeof args.rootId !== 'string' || !scope.roots.includes(args.rootId))) fail('MACHINE_ROOT_NOT_BOUND', { tool: entry.name })
|
|
404
|
+
const rootId = args.rootId ?? scope.roots[0]
|
|
405
|
+
if (typeof rootId !== 'string' || !rootId) fail('MACHINE_ROOT_NOT_BOUND', { tool: entry.name })
|
|
406
|
+
return { machineId: scope.machineId, rootId }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Only declared business keys cross the boundary; identity keys never do. */
|
|
410
|
+
const businessArgs = (entry, parsed) => {
|
|
411
|
+
const business = {}
|
|
412
|
+
for (const key of Object.keys(entry.parameters.properties)) {
|
|
413
|
+
if (key === 'machineId' || key === 'rootId') continue
|
|
414
|
+
if (Object.hasOwn(parsed, key)) business[key] = parsed[key]
|
|
415
|
+
}
|
|
416
|
+
return business
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A continuation that names a target must name the one its own session or capture belongs to. A
|
|
421
|
+
* machine this Bot was never paired with is refused as such; a granted one that disagrees with the
|
|
422
|
+
* session is a mismatch, because the Agent bound that session to one task, attempt and root.
|
|
423
|
+
*/
|
|
424
|
+
const namedTarget = (entry, parsed, target) => {
|
|
425
|
+
if (parsed.machineId !== undefined && parsed.machineId !== target.machineId) {
|
|
426
|
+
if (!scopesFor().some(scope => scope.machineId === parsed.machineId)) fail('MACHINE_NOT_BOUND', { tool: entry.name })
|
|
427
|
+
fail('NATIVE_REMOTE_TARGET_MISMATCH', { machineId: parsed.machineId, bound: target.machineId })
|
|
428
|
+
}
|
|
429
|
+
if (parsed.rootId !== undefined && parsed.rootId !== target.rootId) fail('NATIVE_REMOTE_TARGET_MISMATCH', { rootId: parsed.rootId, bound: target.rootId })
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const recordOf = row => ({
|
|
433
|
+
operationId: row.operation_id ?? null,
|
|
434
|
+
taskId: row.task_id,
|
|
435
|
+
attemptId: row.attempt_id,
|
|
436
|
+
status: row.status,
|
|
437
|
+
value: row.value === null || row.value === undefined ? null : JSON.parse(row.value),
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
/** A dispatch this Host can no longer prove under the current grant: the record stays, the value does not travel. */
|
|
441
|
+
const undelivered = (row, error) => ({
|
|
442
|
+
operationId: row.operation_id ?? null, taskId: row.task_id, attemptId: row.attempt_id,
|
|
443
|
+
status: row.status, value: null, error, nextAction: NEXT_ACTION,
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
const persist = (requestKey, status, value, error, artifactId) => {
|
|
447
|
+
const encoded = value === null || value === undefined ? null : JSON.stringify(value)
|
|
448
|
+
try {
|
|
449
|
+
if (artifactId === undefined) q.settle.run(status, encoded, error ?? null, now(), requestKey)
|
|
450
|
+
else q.settleArtifact.run(status, encoded, error ?? null, artifactId, now(), requestKey)
|
|
451
|
+
} catch { /* a closed journal keeps its reservation; the Host reconciles it */ }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A successful open or capture is the only thing that may authorise a later continuation. */
|
|
455
|
+
const register = (entry, requestKey, target, taskId, attemptId, operationId, value, sessionId) => {
|
|
456
|
+
if (entry.machineTool === 'browser_open') {
|
|
457
|
+
if (!ref(value?.browserSessionId)) fail('NATIVE_REMOTE_PROTOCOL_INVALID', { tool: entry.name })
|
|
458
|
+
const existing = q.anySession.get(value.browserSessionId)
|
|
459
|
+
if (existing) {
|
|
460
|
+
if (existing.bot_id !== botId || existing.conversation_id !== conversationId || existing.request_key !== requestKey) fail('NATIVE_REMOTE_PROTOCOL_INVALID')
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
q.openSession.run(value.browserSessionId, botId, conversationId, target.machineId, target.rootId, taskId, attemptId, operationId, requestKey, now(), now())
|
|
464
|
+
if (q.sessionCount.get().total > SESSION_LIMIT) q.sweepSessions.run()
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
if (entry.machineTool === 'browser_close') {
|
|
468
|
+
if (ref(sessionId)) q.closeSession.run(now(), sessionId)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
if (entry.machineTool === 'desktop_screenshot') {
|
|
472
|
+
if (!ref(value?.screenshotId) || !ref(value?.artifactId)) fail('NATIVE_REMOTE_PROTOCOL_INVALID', { tool: entry.name })
|
|
473
|
+
const existing = q.capture.get(value.screenshotId)
|
|
474
|
+
if (existing) {
|
|
475
|
+
if (existing.bot_id !== botId || existing.conversation_id !== conversationId || existing.request_key !== requestKey || existing.artifact_id !== value.artifactId) fail('NATIVE_REMOTE_PROTOCOL_INVALID')
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
q.openCapture.run(value.screenshotId, botId, conversationId, target.machineId, target.rootId, taskId, attemptId, operationId,
|
|
479
|
+
value.artifactId, value.sha256 ?? null, Number.isSafeInteger(value.size) ? value.size : null, value.mediaType ?? null, requestKey, now())
|
|
480
|
+
if (q.captureCount.get().total > CAPTURE_LIMIT) q.sweepCaptures.run()
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The original artifact of a completed capture, over the context it was captured under. */
|
|
485
|
+
const downloadImage = async (row, signal) => {
|
|
486
|
+
const downloaded = await machineStore().download(directory.forBot(botId), {
|
|
487
|
+
machineId: row.machine_id, rootId: row.root_id, taskId: row.task_id, attemptId: row.attempt_id,
|
|
488
|
+
operationId: row.operation_id, artifactId: row.artifact_id,
|
|
489
|
+
}, { signal })
|
|
490
|
+
const image = validateImage(downloaded)
|
|
491
|
+
if (downloaded.artifactId !== row.artifact_id) fail('NATIVE_REMOTE_IMAGE_INVALID')
|
|
492
|
+
if (row.sha256 && downloaded.sha256 !== row.sha256) fail('NATIVE_REMOTE_IMAGE_INVALID')
|
|
493
|
+
if (row.size !== null && row.size !== undefined && downloaded.size !== row.size) fail('NATIVE_REMOTE_IMAGE_INVALID')
|
|
494
|
+
return image
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* What a finished row delivers now. A completed screenshot may download its original artifact
|
|
499
|
+
* again; it is never recaptured, and the pixels are never persisted.
|
|
500
|
+
*/
|
|
501
|
+
const deliver = async (row, signal) => {
|
|
502
|
+
const entry = nativeRemoteToolEntry(row.name)
|
|
503
|
+
const target = { machineId: row.machine_id, rootId: row.root_id }
|
|
504
|
+
if (!assertLive(entry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
|
|
505
|
+
const record = recordOf(row)
|
|
506
|
+
if (record.status !== 'succeeded') {
|
|
507
|
+
return { ...record, ...(row.error ? { error: row.error } : {}), ...(BLOCKING_STATUS.has(record.status) ? { nextAction: NEXT_ACTION } : {}) }
|
|
508
|
+
}
|
|
509
|
+
if (entry.machineTool === 'desktop_screenshot' && row.artifact_id) {
|
|
510
|
+
const image = await downloadImage(row, signal)
|
|
511
|
+
if (!assertLive(entry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
|
|
512
|
+
return { ...record, image }
|
|
513
|
+
}
|
|
514
|
+
return record
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* One tool call. Pre-dispatch refusals throw with the operation identity attached; anything that
|
|
519
|
+
* may already have been dispatched is returned as a record, never as a success it is not.
|
|
520
|
+
*/
|
|
521
|
+
const call = async (name, args = {}, context = {}) => {
|
|
522
|
+
alive()
|
|
523
|
+
const entry = nativeRemoteToolEntry(name)
|
|
524
|
+
if (!entry) fail('TOOL_NOT_GRANTED', { tool: String(name) })
|
|
525
|
+
const parsed = parseToolArgs(entry, args)
|
|
526
|
+
const turnId = boundedId(context.turnId, 'NATIVE_REMOTE_TURN_REQUIRED')
|
|
527
|
+
const callId = boundedId(context.callId, 'NATIVE_REMOTE_TURN_REQUIRED')
|
|
528
|
+
const signal = context.signal ?? null
|
|
529
|
+
try { signal?.throwIfAborted() } catch { fail('NATIVE_REMOTE_CANCELLED') }
|
|
530
|
+
// The Bot itself is a Host fact, resolved here rather than inside the dispatch, so an unknown
|
|
531
|
+
// or retired Bot is refused before a request key is reserved at all.
|
|
532
|
+
const subject = directory.forBot(botId)
|
|
533
|
+
const business = businessArgs(entry, parsed)
|
|
534
|
+
const observation = isObservation(entry)
|
|
535
|
+
const family = familyOf(entry)
|
|
536
|
+
|
|
537
|
+
// The target, and with it the Agent identity this call runs under. A browser continuation or a
|
|
538
|
+
// desktop action runs on the target its own session or capture was issued on; anything else
|
|
539
|
+
// chooses from the live bindings. A named machine or root that disagrees is a refusal, not a
|
|
540
|
+
// silent retarget.
|
|
541
|
+
let original = null, target, taskId, attemptId, operationId = null, role = 'action'
|
|
542
|
+
if (observation) {
|
|
543
|
+
original = q.findOperation.get(parsed.operationId, botId, conversationId)
|
|
544
|
+
if (!original) fail('NATIVE_REMOTE_OPERATION_UNKNOWN', { operationId: parsed.operationId })
|
|
545
|
+
target = { machineId: original.machine_id, rootId: original.root_id }
|
|
546
|
+
taskId = original.task_id
|
|
547
|
+
attemptId = original.attempt_id
|
|
548
|
+
operationId = original.operation_id
|
|
549
|
+
role = 'observation'
|
|
550
|
+
assertLive(entry, target)
|
|
551
|
+
} else if (family === 'browser' && entry.machineTool !== 'browser_open') {
|
|
552
|
+
const session = q.session.get(parsed.browserSessionId)
|
|
553
|
+
if (!session || session.bot_id !== botId || session.conversation_id !== conversationId) {
|
|
554
|
+
fail('NATIVE_REMOTE_BROWSER_SESSION_UNKNOWN', { browserSessionId: parsed.browserSessionId })
|
|
555
|
+
}
|
|
556
|
+
target = { machineId: session.machine_id, rootId: session.root_id }
|
|
557
|
+
namedTarget(entry, parsed, target)
|
|
558
|
+
taskId = session.task_id
|
|
559
|
+
attemptId = session.attempt_id
|
|
560
|
+
if (entry.mutating) operationId = derived('nro', [botId, conversationId, turnId, callId])
|
|
561
|
+
assertLive(entry, target)
|
|
562
|
+
} else if (family === 'desktop' && entry.machineTool !== 'desktop_screenshot') {
|
|
563
|
+
const capture = q.capture.get(parsed.screenshotId)
|
|
564
|
+
if (!capture || capture.bot_id !== botId || capture.conversation_id !== conversationId) {
|
|
565
|
+
fail('NATIVE_REMOTE_SCREENSHOT_UNKNOWN', { screenshotId: parsed.screenshotId })
|
|
566
|
+
}
|
|
567
|
+
target = { machineId: capture.machine_id, rootId: capture.root_id }
|
|
568
|
+
namedTarget(entry, parsed, target)
|
|
569
|
+
taskId = capture.task_id
|
|
570
|
+
attemptId = capture.attempt_id
|
|
571
|
+
if (entry.mutating) operationId = derived('nro', [botId, conversationId, turnId, callId])
|
|
572
|
+
assertLive(entry, target)
|
|
573
|
+
} else {
|
|
574
|
+
target = resolveTarget(entry, parsed)
|
|
575
|
+
taskId = derived('nrt', [botId, conversationId, target.machineId, target.rootId])
|
|
576
|
+
attemptId = derived('nra', [botId, conversationId, target.machineId, target.rootId])
|
|
577
|
+
// A read-only snapshot of a page carries no Agent operation envelope at all.
|
|
578
|
+
if (entry.mutating) operationId = derived('nro', [botId, conversationId, turnId, callId])
|
|
579
|
+
}
|
|
580
|
+
assertLive(entry, target)
|
|
581
|
+
|
|
582
|
+
const requestKey = derived('nrq', [botId, conversationId, turnId, callId])
|
|
583
|
+
const digest = hex(JSON.stringify([entry.name, target.machineId, target.rootId, business]))
|
|
584
|
+
|
|
585
|
+
// An unsettled mutation of this very context fences the next one: the Agent, not this Host,
|
|
586
|
+
// decides what happened to the first.
|
|
587
|
+
const reserved = db.transaction(() => {
|
|
588
|
+
const existing = q.getRequest.get(requestKey)
|
|
589
|
+
if (existing) return existing
|
|
590
|
+
if (requiresWrite(entry)) {
|
|
591
|
+
const fenced = q.fence.get(botId, conversationId, target.machineId, target.rootId, requestKey, ...fenceStatuses)
|
|
592
|
+
if (fenced) fail('NATIVE_REMOTE_OPERATION_PENDING', {
|
|
593
|
+
operationId: fenced.operation_id, status: fenced.status, tool: fenced.name, machineTool: fenced.machine_tool, nextAction: NEXT_ACTION,
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
q.reserve.run(requestKey, botId, conversationId, turnId, callId, role, entry.name, digest, entry.machineTool, operationId,
|
|
597
|
+
taskId, attemptId, target.machineId, target.rootId, now(), now())
|
|
598
|
+
if (parsed.browserSessionId) q.reserveRef.run(requestKey, parsed.browserSessionId)
|
|
599
|
+
return null
|
|
600
|
+
}).immediate()
|
|
601
|
+
|
|
602
|
+
if (reserved) {
|
|
603
|
+
if (reserved.name !== entry.name || reserved.input_digest !== digest) {
|
|
604
|
+
fail('NATIVE_REMOTE_REQUEST_CONFLICT', { requestKey, name: reserved.name, tool: entry.name })
|
|
605
|
+
}
|
|
606
|
+
if (reserved.role === 'observation') {
|
|
607
|
+
const record = recordOf(reserved)
|
|
608
|
+
return { ...record, ...(reserved.error ? { error: reserved.error } : {}), ...(BLOCKING_STATUS.has(record.status) ? { nextAction: NEXT_ACTION } : {}) }
|
|
609
|
+
}
|
|
610
|
+
// Identical request: the original record is the answer, and nothing is dispatched again.
|
|
611
|
+
if (TERMINAL_STATUS.has(reserved.status)) return await deliver(reserved, signal)
|
|
612
|
+
const running = inflight.get(reserved.request_key)
|
|
613
|
+
if (running) return await running
|
|
614
|
+
const record = recordOf(reserved)
|
|
615
|
+
const unsettled = PENDING_STATUS.has(record.status)
|
|
616
|
+
return {
|
|
617
|
+
...record, status: unsettled ? 'unknown' : record.status, value: unsettled ? null : record.value,
|
|
618
|
+
error: reserved.error ?? 'NATIVE_REMOTE_OUTCOME_UNKNOWN', nextAction: NEXT_ACTION,
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const work = async () => {
|
|
623
|
+
let record = null, thrown = null
|
|
624
|
+
// The directory is resolved before anything is marked dispatched, so a Host that lost it is a
|
|
625
|
+
// pre-dispatch refusal rather than an unknown outcome.
|
|
626
|
+
const store = machineStore()
|
|
627
|
+
try {
|
|
628
|
+
q.dispatched.run(now(), requestKey)
|
|
629
|
+
// A turn cancelled before dispatch never reached the machine: that is provable here, in
|
|
630
|
+
// this process, and it is the only kind of failure that may be called failed.
|
|
631
|
+
try { signal?.throwIfAborted() } catch (error) {
|
|
632
|
+
const code = codeOf(error)
|
|
633
|
+
persist(requestKey, 'failed', null, code, undefined)
|
|
634
|
+
return { operationId, taskId, attemptId, status: 'failed', value: null, error: code }
|
|
635
|
+
}
|
|
636
|
+
record = await store.call(subject, {
|
|
637
|
+
machineId: target.machineId, rootId: target.rootId, taskId, attemptId, tool: entry.machineTool, args: business,
|
|
638
|
+
...(operationId ? { operationId } : {}),
|
|
639
|
+
}, { signal })
|
|
640
|
+
} catch (error) { thrown = error }
|
|
641
|
+
if (thrown) {
|
|
642
|
+
const notSent = thrown?.remoteDispatchNotSent === true
|
|
643
|
+
const code = codeOf(thrown)
|
|
644
|
+
const settled = notSent || !operationId
|
|
645
|
+
const status = settled ? 'failed' : 'unknown'
|
|
646
|
+
persist(requestKey, status, null, code, undefined)
|
|
647
|
+
return { operationId, taskId, attemptId, status, value: null, error: code, ...(settled ? {} : { nextAction: NEXT_ACTION }) }
|
|
648
|
+
}
|
|
649
|
+
const outcome = normalizeRecord(entry, record, target, operationId, original)
|
|
650
|
+
const issuedKey = observation ? original.request_key : requestKey
|
|
651
|
+
const issuedEntry = observation ? nativeRemoteToolEntry(original.name) : entry
|
|
652
|
+
if (outcome.status === 'succeeded') {
|
|
653
|
+
try {
|
|
654
|
+
register(issuedEntry, issuedKey, target, taskId, attemptId, operationId, outcome.result,
|
|
655
|
+
observation ? q.requestRef.get(issuedKey)?.browser_session_id : parsed.browserSessionId)
|
|
656
|
+
} catch {
|
|
657
|
+
outcome.status = 'unknown'; outcome.value = outcome.result = null; outcome.error = 'NATIVE_REMOTE_PROTOCOL_INVALID'
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
persist(issuedKey, outcome.status, outcome.result,
|
|
661
|
+
outcome.status === 'succeeded' ? null : outcome.error, outcome.result?.artifactId)
|
|
662
|
+
if (observation) persist(requestKey, outcome.status, outcome.value, outcome.status === 'succeeded' ? null : outcome.error, undefined)
|
|
663
|
+
const row = q.getRequest.get(observation ? original.request_key : requestKey)
|
|
664
|
+
if (!assertLive(entry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
|
|
665
|
+
const base = {
|
|
666
|
+
operationId, taskId, attemptId, status: outcome.status, value: outcome.value,
|
|
667
|
+
...(outcome.status === 'succeeded' ? {} : { error: outcome.error ?? 'NATIVE_REMOTE_TOOL_FAILED' }),
|
|
668
|
+
}
|
|
669
|
+
// A status the Agent itself reports as unsettled is not a terminal answer: the operation keeps
|
|
670
|
+
// its identity and the caller is told to reconcile it rather than to repeat it.
|
|
671
|
+
if (BLOCKING_STATUS.has(outcome.status)) base.nextAction = NEXT_ACTION
|
|
672
|
+
if (issuedEntry.machineTool === 'desktop_screenshot' && outcome.status === 'succeeded' && row.artifact_id) {
|
|
673
|
+
if (!assertLive(issuedEntry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
|
|
674
|
+
const image = await downloadImage(row, signal)
|
|
675
|
+
if (!assertLive(issuedEntry, target, { soft: true })) return undelivered(row, 'NATIVE_REMOTE_AUTHORITY_LOST')
|
|
676
|
+
return { ...base, image }
|
|
677
|
+
}
|
|
678
|
+
return base
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const pending = work()
|
|
682
|
+
inflight.set(requestKey, pending)
|
|
683
|
+
try { return await pending } finally { inflight.delete(requestKey) }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return Object.freeze({
|
|
687
|
+
botId,
|
|
688
|
+
conversationId,
|
|
689
|
+
/** The schemas this conversation may see right now, re-decided from committed state. */
|
|
690
|
+
schemas() {
|
|
691
|
+
alive()
|
|
692
|
+
// A grant the authority no longer holds removes the tool from the request, so a withdrawn
|
|
693
|
+
// capability is never something the model discovers one refusal at a time.
|
|
694
|
+
const granted = entry => {
|
|
695
|
+
let subject = null
|
|
696
|
+
try { subject = directory.forBot(botId) } catch { return false }
|
|
697
|
+
return requirementsOf(entry).every(capability => {
|
|
698
|
+
try { authority.assertTool(subject, capability); return true } catch { return false }
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
const offered = []
|
|
702
|
+
for (const entry of CATALOG) {
|
|
703
|
+
const scopes = scopesFor()
|
|
704
|
+
if (isObservation(entry)) {
|
|
705
|
+
// Reconciliation is mounted from committed grants alone. It has to be available before
|
|
706
|
+
// the first operation is issued, or a model could not read the one it is about to create.
|
|
707
|
+
if (!scopes.some(scope => scope.tools.includes(entry.machineTool)) || !granted(entry)) continue
|
|
708
|
+
offered.push(entry)
|
|
709
|
+
continue
|
|
710
|
+
}
|
|
711
|
+
if (!scopes.some(scope => scope.roots.length && pairedToolsOf(entry).every(tool => scope.tools.includes(tool))
|
|
712
|
+
&& (!requiresWrite(entry) || scope.mode === 'workspace-write')) || !granted(entry)) continue
|
|
713
|
+
offered.push(entry)
|
|
714
|
+
}
|
|
715
|
+
return offered.map(entry => Object.freeze({ name: entry.name, description: entry.description, parameters: entry.parameters }))
|
|
716
|
+
},
|
|
717
|
+
call,
|
|
718
|
+
})
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return Object.freeze({
|
|
722
|
+
/**
|
|
723
|
+
* One conversation's live surface. Both methods re-read committed grants, so a cached view can
|
|
724
|
+
* never freeze an older grant into a turn.
|
|
725
|
+
*/
|
|
726
|
+
forConversation(botId, conversationId) {
|
|
727
|
+
const bot = boundedId(botId, 'NATIVE_REMOTE_BOT_REQUIRED')
|
|
728
|
+
const conversation = boundedId(conversationId, 'NATIVE_REMOTE_CONVERSATION_REQUIRED')
|
|
729
|
+
return view(bot, conversation)
|
|
730
|
+
},
|
|
731
|
+
/**
|
|
732
|
+
* Dispose this service. Nothing remote is stopped and nothing is replayed: an operation still in
|
|
733
|
+
* flight keeps its identity and its evidence, and the Host reconciles it.
|
|
734
|
+
*/
|
|
735
|
+
close() {
|
|
736
|
+
if (closed) return
|
|
737
|
+
closed = true
|
|
738
|
+
inflight.clear()
|
|
739
|
+
try { db.close() } catch { /* already closed */ }
|
|
740
|
+
},
|
|
741
|
+
})
|
|
742
|
+
}
|