@reticlehq/server 2.12.0 → 2.13.1
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/SKILL.md +67 -109
- package/dist/bridge/auth-failure-reason.d.ts +6 -2
- package/dist/bridge/auth-failure-reason.js +15 -8
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +247 -164
- package/dist/bridge/pairing-token.d.ts +5 -0
- package/dist/bridge/pairing-token.js +28 -0
- package/dist/bridge/protocol-skew.d.ts +37 -0
- package/dist/bridge/protocol-skew.js +45 -0
- package/dist/capsule/capsule-store.d.ts +6 -6
- package/dist/capsule/causal-summary.js +7 -2
- package/dist/cli/cli-doctor.js +31 -1
- package/dist/cli/cli-launch.d.ts +11 -1
- package/dist/cli/cli-launch.js +59 -10
- package/dist/cli/cli-parse.d.ts +15 -1
- package/dist/cli/cli-parse.js +212 -8
- package/dist/cli/cli-verify.d.ts +1 -0
- package/dist/cli/cli-verify.js +3 -2
- package/dist/cli/cloud-cli.js +428 -259
- package/dist/cli/cloud-keystore.d.ts +37 -0
- package/dist/cli/cloud-keystore.js +97 -0
- package/dist/cli/cloud-kit.d.ts +37 -0
- package/dist/cli/cloud-kit.js +121 -0
- package/dist/cli/cloud-login.d.ts +17 -0
- package/dist/cli/cloud-login.js +183 -0
- package/dist/cli/cloud-session.d.ts +48 -0
- package/dist/cli/cloud-session.js +53 -0
- package/dist/cli/config-discovery.js +1 -0
- package/dist/cli/daemon-start-options.d.ts +32 -0
- package/dist/cli/daemon-start-options.js +38 -0
- package/dist/cli/device-flow.d.ts +76 -0
- package/dist/cli/device-flow.js +56 -0
- package/dist/cli/doctor-rows.d.ts +9 -0
- package/dist/cli/doctor-rows.js +9 -0
- package/dist/cli/drive-attach.d.ts +13 -0
- package/dist/cli/drive-attach.js +18 -0
- package/dist/cli/drive-command.js +7 -3
- package/dist/cli/mcp-command.js +51 -20
- package/dist/cli/project-name.d.ts +30 -0
- package/dist/cli/project-name.js +44 -0
- package/dist/cli/status-next-action.d.ts +14 -0
- package/dist/cli/status-next-action.js +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -80
- package/dist/cloud/cloud-config.d.ts +22 -1
- package/dist/cloud/cloud-config.js +64 -5
- package/dist/cloud/cloud-sync.d.ts +5 -5
- package/dist/cloud/cloud-sync.js +33 -2
- package/dist/cloud/sync-cycle.d.ts +110 -0
- package/dist/cloud/sync-cycle.js +270 -0
- package/dist/cloud/sync-daemon.d.ts +69 -0
- package/dist/cloud/sync-daemon.js +260 -0
- package/dist/cloud/sync-disk.d.ts +9 -0
- package/dist/cloud/sync-disk.js +191 -0
- package/dist/cloud/sync-hash.d.ts +2 -0
- package/dist/cloud/sync-hash.js +35 -0
- package/dist/crawl/crawl-tools.js +5 -3
- package/dist/crawl/crawl.d.ts +3 -0
- package/dist/crawl/crawl.js +5 -0
- package/dist/daemon/daemon-resolve.d.ts +43 -0
- package/dist/daemon/daemon-resolve.js +69 -0
- package/dist/daemon/dev-servers.d.ts +15 -0
- package/dist/daemon/dev-servers.js +68 -0
- package/dist/daemon/wake-decision.d.ts +32 -0
- package/dist/daemon/wake-decision.js +38 -0
- package/dist/domain/domain-tools.js +2 -2
- package/dist/events/contradictions.d.ts +18 -0
- package/dist/events/contradictions.js +134 -21
- package/dist/events/declared.d.ts +7 -4
- package/dist/events/declared.js +15 -6
- package/dist/events/echo-mismatch.js +64 -14
- package/dist/events/event-filters.d.ts +8 -0
- package/dist/events/event-filters.js +19 -2
- package/dist/events/predicate-eval.js +53 -5
- package/dist/events/predicate-precheck.d.ts +8 -0
- package/dist/events/predicate-precheck.js +41 -0
- package/dist/events/predicate-route.d.ts +14 -0
- package/dist/events/predicate-route.js +36 -8
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +37 -5
- package/dist/events/split-text-miss.d.ts +30 -0
- package/dist/events/split-text-miss.js +44 -0
- package/dist/flows/flake.d.ts +6 -6
- package/dist/flows/flow-memory-consult.d.ts +55 -0
- package/dist/flows/flow-memory-consult.js +38 -0
- package/dist/flows/flow-replay-run.d.ts +18 -7
- package/dist/flows/flow-replay-run.js +239 -22
- package/dist/flows/flow-replay.js +11 -2
- package/dist/flows/flow-step-runners.d.ts +8 -0
- package/dist/flows/flow-step-runners.js +8 -2
- package/dist/flows/flow-store-for-session.d.ts +31 -0
- package/dist/flows/flow-store-for-session.js +33 -0
- package/dist/flows/flow-tools.js +58 -32
- package/dist/flows/flows.d.ts +8 -0
- package/dist/flows/flows.js +468 -2
- package/dist/flows/heal-run.js +5 -2
- package/dist/flows/predicate-to-expect.d.ts +17 -12
- package/dist/flows/predicate-to-expect.js +30 -12
- package/dist/flows/recordings.d.ts +13 -1
- package/dist/flows/recordings.js +17 -2
- package/dist/flows/replay.d.ts +22 -2
- package/dist/flows/replay.js +28 -2
- package/dist/flows/verify-change-tools.js +5 -2
- package/dist/honesty/blind-spots.d.ts +55 -2
- package/dist/honesty/blind-spots.js +63 -3
- package/dist/honesty/honesty.d.ts +24 -0
- package/dist/honesty/honesty.js +25 -1
- package/dist/honesty/instrumentation-gaps.d.ts +9 -0
- package/dist/honesty/instrumentation-gaps.js +29 -2
- package/dist/honesty/verified.d.ts +2 -1
- package/dist/honesty/verified.js +16 -1
- package/dist/http-server.js +12 -0
- package/dist/impact/impact-recorder.d.ts +31 -8
- package/dist/impact/impact-recorder.js +104 -21
- package/dist/impact/impact-store.d.ts +15 -1
- package/dist/impact/impact-store.js +43 -2
- package/dist/index.js +113 -7
- package/dist/init/agent-rules.d.ts +13 -6
- package/dist/init/agent-rules.js +23 -12
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +28 -0
- package/dist/init/capabilities.d.ts +4 -1
- package/dist/init/capabilities.js +22 -2
- package/dist/init/confirm.d.ts +31 -7
- package/dist/init/confirm.js +48 -8
- package/dist/init/cra.d.ts +20 -4
- package/dist/init/cra.js +57 -17
- package/dist/init/csp-check.js +17 -2
- package/dist/init/csp-doctor.d.ts +8 -1
- package/dist/init/csp-doctor.js +8 -3
- package/dist/init/dev-script.d.ts +3 -1
- package/dist/init/dev-script.js +8 -5
- package/dist/init/format-generated.d.ts +18 -0
- package/dist/init/format-generated.js +39 -0
- package/dist/init/install-hint.d.ts +9 -0
- package/dist/init/install-hint.js +29 -0
- package/dist/init/mcp-clients.d.ts +7 -0
- package/dist/init/mcp-clients.js +7 -0
- package/dist/init/mcp.d.ts +1 -1
- package/dist/init/mcp.js +1 -1
- package/dist/init/node-io.js +11 -1
- package/dist/init/plan-framework.js +81 -45
- package/dist/init/plan.d.ts +20 -7
- package/dist/init/plan.js +20 -45
- package/dist/init/preflight.d.ts +28 -0
- package/dist/init/preflight.js +38 -0
- package/dist/init/run.d.ts +32 -9
- package/dist/init/run.js +141 -119
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/snippets.js +41 -25
- package/dist/init/vite-config.d.ts +1 -1
- package/dist/init/vite-config.js +39 -25
- package/dist/init/workspace-apps.d.ts +17 -10
- package/dist/init/workspace-apps.js +110 -0
- package/dist/input/network-mock-tools.js +3 -6
- package/dist/input/real-input.js +25 -5
- package/dist/input/scroll-tools.js +4 -6
- package/dist/input/viewport-tools.js +5 -4
- package/dist/intent/inline-intent.d.ts +10 -19
- package/dist/intent/inline-intent.js +91 -1
- package/dist/intent/intent-shard-store.d.ts +72 -0
- package/dist/intent/intent-shard-store.js +203 -0
- package/dist/intent/intent-shard.d.ts +411 -0
- package/dist/intent/intent-shard.js +176 -0
- package/dist/intent/intent-store.d.ts +12 -0
- package/dist/intent/intent-store.js +21 -0
- package/dist/intent/intent-subject.d.ts +45 -0
- package/dist/intent/intent-subject.js +177 -0
- package/dist/intent/intent-tools.js +65 -2
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +15 -0
- package/dist/license/license-env.d.ts +29 -0
- package/dist/license/license-env.js +148 -0
- package/dist/loopback-agent.d.ts +31 -0
- package/dist/loopback-agent.js +57 -0
- package/dist/mcp/attach-memory.d.ts +18 -2
- package/dist/mcp/attach-memory.js +50 -7
- package/dist/mcp/client-identity.d.ts +31 -0
- package/dist/mcp/client-identity.js +51 -0
- package/dist/mcp/mcp-post-transport.d.ts +26 -0
- package/dist/mcp/mcp-post-transport.js +109 -0
- package/dist/mcp/mcp-proxy.d.ts +2 -1
- package/dist/mcp/mcp-proxy.js +52 -62
- package/dist/mcp/mcp.js +7 -1
- package/dist/mcp/proxy-backoff.d.ts +11 -0
- package/dist/mcp/proxy-backoff.js +13 -0
- package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
- package/dist/mcp/proxy-daemon-probe.js +7 -1
- package/dist/mcp/proxy-handshake.d.ts +23 -0
- package/dist/mcp/proxy-handshake.js +31 -0
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +12 -0
- package/dist/memory/memory-tools.d.ts +2 -0
- package/dist/memory/memory-tools.js +82 -0
- package/dist/memory/project-memory.d.ts +34 -0
- package/dist/memory/project-memory.js +96 -0
- package/dist/oracles/propose-consequences.js +12 -3
- package/dist/pool/browser-pool.d.ts +63 -4
- package/dist/pool/browser-pool.js +113 -4
- package/dist/pool/playwright-launcher.js +9 -0
- package/dist/project/project-tools.js +3 -3
- package/dist/project/reticle-dir.d.ts +15 -2
- package/dist/project/reticle-dir.js +21 -4
- package/dist/runs/run-store.d.ts +10 -0
- package/dist/runs/run-store.js +10 -0
- package/dist/runs/verification-sync.d.ts +0 -11
- package/dist/runs/verification-sync.js +22 -1
- package/dist/runs/verify-server.d.ts +8 -1
- package/dist/runs/verify-server.js +14 -2
- package/dist/session/command-timeout.d.ts +5 -2
- package/dist/session/command-timeout.js +5 -4
- package/dist/session/lease-visibility.d.ts +67 -0
- package/dist/session/lease-visibility.js +76 -0
- package/dist/session/no-session-diagnosis.d.ts +23 -0
- package/dist/session/no-session-diagnosis.js +58 -17
- package/dist/session/no-session-next-action.d.ts +7 -0
- package/dist/session/no-session-next-action.js +42 -3
- package/dist/session/no-session-watch.d.ts +10 -6
- package/dist/session/no-session-watch.js +51 -19
- package/dist/session/pending-commands.d.ts +11 -1
- package/dist/session/pending-commands.js +12 -2
- package/dist/session/resolve-within.d.ts +34 -0
- package/dist/session/resolve-within.js +53 -0
- package/dist/session/session-info.d.ts +18 -0
- package/dist/session/session-info.js +11 -0
- package/dist/session/session-manager.d.ts +14 -0
- package/dist/session/session-manager.js +61 -3
- package/dist/session/session-mirror.d.ts +16 -0
- package/dist/session/session-mirror.js +22 -0
- package/dist/session/session-recommendation.d.ts +19 -5
- package/dist/session/session-recommendation.js +25 -9
- package/dist/session/session-successor.js +36 -1
- package/dist/session/session-tools.js +2 -2
- package/dist/session/session.d.ts +22 -2
- package/dist/session/session.js +87 -7
- package/dist/session/wall-clock.d.ts +12 -0
- package/dist/session/wall-clock.js +14 -0
- package/dist/setup/agent-configs.d.ts +96 -0
- package/dist/setup/agent-configs.js +333 -0
- package/dist/setup/agent-io.d.ts +9 -0
- package/dist/setup/agent-io.js +16 -0
- package/dist/setup/agent-writer.d.ts +51 -0
- package/dist/setup/agent-writer.js +115 -0
- package/dist/setup/approval-migration.d.ts +47 -0
- package/dist/setup/approval-migration.js +68 -0
- package/dist/setup/auto-approve.d.ts +85 -0
- package/dist/setup/auto-approve.js +150 -0
- package/dist/setup/bridge-port.d.ts +15 -0
- package/dist/setup/bridge-port.js +24 -0
- package/dist/setup/desktop-shape.d.ts +57 -0
- package/dist/setup/desktop-shape.js +63 -0
- package/dist/setup/dev-server-wait.d.ts +68 -0
- package/dist/setup/dev-server-wait.js +67 -0
- package/dist/setup/drive-agent.d.ts +71 -0
- package/dist/setup/drive-agent.js +171 -0
- package/dist/setup/drive-plan.d.ts +68 -0
- package/dist/setup/drive-plan.js +130 -0
- package/dist/setup/ensure-daemon.d.ts +29 -0
- package/dist/setup/ensure-daemon.js +58 -0
- package/dist/setup/init-runtime.d.ts +47 -0
- package/dist/setup/init-runtime.js +160 -0
- package/dist/setup/interrupt.d.ts +25 -0
- package/dist/setup/interrupt.js +40 -0
- package/dist/setup/license-key.d.ts +39 -0
- package/dist/setup/license-key.js +72 -0
- package/dist/setup/listeners.d.ts +37 -0
- package/dist/setup/listeners.js +85 -0
- package/dist/setup/node-effects.d.ts +60 -0
- package/dist/setup/node-effects.js +210 -0
- package/dist/setup/page-probe.d.ts +39 -0
- package/dist/setup/page-probe.js +49 -0
- package/dist/setup/relaunch.d.ts +36 -0
- package/dist/setup/relaunch.js +57 -0
- package/dist/setup/remaining-steps.d.ts +19 -0
- package/dist/setup/remaining-steps.js +31 -0
- package/dist/setup/run-setup.d.ts +99 -0
- package/dist/setup/run-setup.js +203 -0
- package/dist/setup/session-pick.d.ts +47 -0
- package/dist/setup/session-pick.js +51 -0
- package/dist/setup/setup-command.d.ts +45 -0
- package/dist/setup/setup-command.js +199 -0
- package/dist/setup/setup-options.d.ts +74 -0
- package/dist/setup/setup-options.js +67 -0
- package/dist/setup/transcripts.d.ts +16 -0
- package/dist/setup/transcripts.js +43 -0
- package/dist/status-payload.d.ts +14 -1
- package/dist/status-payload.js +23 -1
- package/dist/telemetry/daemon-telemetry.d.ts +8 -1
- package/dist/telemetry/daemon-telemetry.js +52 -5
- package/dist/telemetry/error-class.js +1 -1
- package/dist/telemetry/feedback-context.d.ts +9 -1
- package/dist/telemetry/feedback-context.js +60 -29
- package/dist/telemetry/feedback-local.d.ts +15 -0
- package/dist/telemetry/feedback-local.js +56 -0
- package/dist/telemetry/feedback.d.ts +11 -0
- package/dist/telemetry/feedback.js +19 -6
- package/dist/telemetry/init-telemetry.d.ts +2 -0
- package/dist/telemetry/init-telemetry.js +2 -0
- package/dist/telemetry/install-source.d.ts +14 -0
- package/dist/telemetry/install-source.js +30 -0
- package/dist/telemetry/license-activation.d.ts +7 -1
- package/dist/telemetry/license-activation.js +9 -5
- package/dist/telemetry/mcp-connection.js +7 -0
- package/dist/telemetry/project-profile.d.ts +12 -1
- package/dist/telemetry/project-profile.js +11 -6
- package/dist/telemetry/proxy-telemetry.d.ts +3 -0
- package/dist/telemetry/proxy-telemetry.js +21 -0
- package/dist/telemetry/session-metrics.d.ts +12 -0
- package/dist/telemetry/session-metrics.js +35 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +3 -1
- package/dist/telemetry/tool-refused.js +34 -2
- package/dist/tools/act-capsule.d.ts +10 -0
- package/dist/tools/act-capsule.js +8 -2
- package/dist/tools/act-danger.d.ts +2 -1
- package/dist/tools/act-danger.js +11 -3
- package/dist/tools/act-observation.d.ts +41 -0
- package/dist/tools/act-observation.js +36 -0
- package/dist/tools/act-preflight.d.ts +20 -0
- package/dist/tools/act-preflight.js +98 -0
- package/dist/tools/act-sequence-retry.d.ts +45 -0
- package/dist/tools/act-sequence-retry.js +58 -0
- package/dist/tools/act-sequence-tool.d.ts +13 -0
- package/dist/tools/act-sequence-tool.js +135 -0
- package/dist/tools/act-target.d.ts +14 -0
- package/dist/tools/act-target.js +47 -0
- package/dist/tools/act-tools.d.ts +21 -1
- package/dist/tools/act-tools.js +100 -213
- package/dist/tools/assert-verdict.js +22 -16
- package/dist/tools/browser-tools.js +6 -2
- package/dist/tools/dynamic-tools.js +24 -5
- package/dist/tools/error-recovery.d.ts +1 -0
- package/dist/tools/error-recovery.js +8 -0
- package/dist/tools/invoke-tool.js +59 -9
- package/dist/tools/lease-tools.d.ts +30 -4
- package/dist/tools/lease-tools.js +169 -5
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +22 -0
- package/dist/tools/numeric-bounds.d.ts +100 -0
- package/dist/tools/numeric-bounds.js +125 -0
- package/dist/tools/observe-tools.js +47 -40
- package/dist/tools/read-tools.js +28 -7
- package/dist/tools/real-input-attempt.d.ts +10 -0
- package/dist/tools/real-input-attempt.js +59 -1
- package/dist/tools/reconcile-tools.js +4 -4
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +6 -1
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tools-helpers.d.ts +19 -0
- package/dist/tools/tools-helpers.js +46 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +56 -8
- package/dist/update/reticle-deps.d.ts +9 -0
- package/dist/update/reticle-deps.js +27 -0
- package/dist/update/sdk-sync.d.ts +1 -2
- package/dist/update/sdk-sync.js +1 -21
- package/dist/update/update-nudge.d.ts +18 -0
- package/dist/update/update-nudge.js +20 -0
- package/dist/version/peer-announce.js +4 -2
- package/dist/version/sdk-fix.d.ts +47 -0
- package/dist/version/sdk-fix.js +127 -0
- package/dist/version/version-nudge.d.ts +14 -0
- package/dist/version/version-nudge.js +21 -0
- package/dist/version/version-skew.d.ts +38 -3
- package/dist/version/version-skew.js +83 -7
- package/dist/visual/visual-store.d.ts +6 -6
- package/dist/visual/visual-store.js +18 -16
- package/dist/visual/visual-tools.js +57 -12
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/serve.mdx +4 -2
- package/docs/cli.mdx +7 -3
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +1 -1
- package/docs/getting-started.md +19 -3
- package/docs/index.mdx +9 -6
- package/docs/install-agentic.mdx +38 -12
- package/docs/install-manual.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/skill-file.mdx +3 -3
- package/docs/telemetry-contract.md +108 -4
- package/docs/tools-act-sequence.mdx +16 -0
- package/docs/usage.md +1 -1
- package/package.json +4 -3
- package/dist/init/cursor.d.ts +0 -24
- package/dist/init/cursor.js +0 -80
package/dist/bridge/bridge.js
CHANGED
|
@@ -9,6 +9,7 @@ import { log } from '../log.js';
|
|
|
9
9
|
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
10
10
|
import { describeSkew, sdkFix, SkewPair } from '../version/version-skew.js';
|
|
11
11
|
import { noteVersionSkew } from '../version/version-nudge.js';
|
|
12
|
+
import { protocolSkewReason } from './protocol-skew.js';
|
|
12
13
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
13
14
|
/**
|
|
14
15
|
* Take the mutable half of a session's identity from a repeat HELLO.
|
|
@@ -37,8 +38,19 @@ function refreshIdentity(session, hello) {
|
|
|
37
38
|
* surfacing them is strictly better than the generic answer.
|
|
38
39
|
*/
|
|
39
40
|
export const WS_CLOSE_REASON = {
|
|
40
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The generic form. The live path picks a DIRECTION-aware reason instead (see protocol-skew.ts);
|
|
43
|
+
* this stays as the close-code table's label and as the fallback when the skew cannot be read.
|
|
44
|
+
*/
|
|
45
|
+
PROTOCOL_MISMATCH: 'protocol version mismatch — @reticlehq/browser and server disagree',
|
|
41
46
|
AUTH_FAILED: 'authentication failed — reload the page to pick up the current pairing token',
|
|
47
|
+
/**
|
|
48
|
+
* The daemon threw while handling a message. Closing is deliberate: an exception leaves the
|
|
49
|
+
* session in a state neither side can describe, and a socket left OPEN and unresponsive is the
|
|
50
|
+
* worst of the outcomes — the agent's next tool call reports "no browser session connected",
|
|
51
|
+
* which reads as an app nobody started. Closing at least makes the SDK reconnect and say so.
|
|
52
|
+
*/
|
|
53
|
+
HANDLER_FAILED: 'the Reticle daemon failed handling a message — reload the page to reconnect',
|
|
42
54
|
/**
|
|
43
55
|
* The pool of half-open handshakes was full, so this dial was turned away before it could say
|
|
44
56
|
* anything. Every other refusal here records why and this one did not — it closed the socket and
|
|
@@ -78,13 +90,13 @@ const WS_CLOSE = {
|
|
|
78
90
|
HELLO_TIMEOUT: [1008, 'hello timeout'],
|
|
79
91
|
INVALID_MESSAGE: [1008, 'invalid message'],
|
|
80
92
|
HELLO_DUPLICATE: [1008, 'hello already received'],
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// Kept under the 123-byte WebSocket close-reason limit.
|
|
93
|
+
// AUTH_FAILED's code is 1008; the reason string is chosen at close time by authFailureReason
|
|
94
|
+
// (no token vs wrong token vs different project). A reload cannot mint a credential into a CDN
|
|
95
|
+
// snippet or a Next env frozen at config eval. Kept under the 123-byte close-reason limit.
|
|
85
96
|
AUTH_FAILED: [1008, WS_CLOSE_REASON.AUTH_FAILED],
|
|
86
97
|
SESSION_LIMIT: [1013, 'session limit reached'],
|
|
87
98
|
PROTOCOL_MISMATCH: [1008, WS_CLOSE_REASON.PROTOCOL_MISMATCH],
|
|
99
|
+
HANDLER_FAILED: [1011, WS_CLOSE_REASON.HANDLER_FAILED],
|
|
88
100
|
};
|
|
89
101
|
/** Parse a positive integer env override; anything else (unset, zero, junk) falls through to the default. */
|
|
90
102
|
function positiveIntFromEnv(raw) {
|
|
@@ -175,6 +187,7 @@ export class Bridge {
|
|
|
175
187
|
#maxSessions;
|
|
176
188
|
#maxPendingConnections;
|
|
177
189
|
#helloTimeoutMs;
|
|
190
|
+
#sdkFix;
|
|
178
191
|
#pendingConnections = 0;
|
|
179
192
|
#onReplay;
|
|
180
193
|
/**
|
|
@@ -188,7 +201,15 @@ export class Bridge {
|
|
|
188
201
|
* is the only thing that could have found it.
|
|
189
202
|
*/
|
|
190
203
|
#onSessionReady = [];
|
|
191
|
-
|
|
204
|
+
/*
|
|
205
|
+
* A LIST, not a single slot.
|
|
206
|
+
*
|
|
207
|
+
* It held one handler, so a second `attachSessionCreate` silently replaced the first — and the
|
|
208
|
+
* first is what stamps a session's artifactRoot, without which every verdict is recorded against
|
|
209
|
+
* wherever the daemon was started. A registration API whose failure mode is "the previous caller
|
|
210
|
+
* stops working, quietly" is a trap; anything that needs to run on a new session now simply runs.
|
|
211
|
+
*/
|
|
212
|
+
#onSessionCreate = [];
|
|
192
213
|
/** Fired when a session is removed — flushes its journal tail + persists what it learned. */
|
|
193
214
|
#onSessionEnd;
|
|
194
215
|
constructor(options) {
|
|
@@ -202,9 +223,24 @@ export class Bridge {
|
|
|
202
223
|
this.#clock = options.clock ?? (() => Date.now());
|
|
203
224
|
this.#token =
|
|
204
225
|
options.token !== undefined && options.token.length > 0 ? options.token : undefined;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
226
|
+
// An entry that fails to normalize — typically a scheme-less `myapp.test` — used to be
|
|
227
|
+
// filtered out with no trace: the allow-list LOOKED configured, every dial from that origin
|
|
228
|
+
// was refused at the gate, and the refusal read as a scheme mismatch in the page rather than
|
|
229
|
+
// as a config value that was silently discarded. Warn at construction, naming the entry and
|
|
230
|
+
// the accepted form, so the config error surfaces at startup instead of as a mystery 403.
|
|
231
|
+
this.#allowedOrigins = new Set();
|
|
232
|
+
for (const entry of options.allowedOrigins ?? []) {
|
|
233
|
+
const normalized = normalizeOrigin(entry);
|
|
234
|
+
if (null === normalized) {
|
|
235
|
+
log('allowed_origin_ignored', {
|
|
236
|
+
entry,
|
|
237
|
+
warning: `${ReticleEnv.ALLOWED_ORIGINS} entry "${entry}" is not a valid origin and was ` +
|
|
238
|
+
`IGNORED — an origin needs a scheme, e.g. "http://${entry}" (scheme://host[:port]).`,
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
this.#allowedOrigins.add(normalized);
|
|
243
|
+
}
|
|
208
244
|
// Binding beyond localhost means the browser dials in from a non-loopback Origin. With no
|
|
209
245
|
// allow-list, #originAllowed rejects every such Origin at the WS handshake, so the bridge comes
|
|
210
246
|
// up but accepts nothing — a silent, fail-closed footgun. Refuse to start with a clear message.
|
|
@@ -224,6 +260,7 @@ export class Bridge {
|
|
|
224
260
|
this.#maxPendingConnections =
|
|
225
261
|
options.maxPendingConnections ?? TRANSPORT_LIMITS.MAX_PENDING_CONNECTIONS;
|
|
226
262
|
this.#helloTimeoutMs = options.helloTimeoutMs ?? TRANSPORT_LIMITS.HELLO_TIMEOUT_MS;
|
|
263
|
+
this.#sdkFix = options.sdkFix ?? (() => sdkFix(SERVER_VERSION));
|
|
227
264
|
if (options.server !== undefined) {
|
|
228
265
|
const srv = options.server;
|
|
229
266
|
this.#wss = new WebSocketServer({
|
|
@@ -323,176 +360,221 @@ export class Bridge {
|
|
|
323
360
|
socket.close(...WS_CLOSE.HELLO_TIMEOUT);
|
|
324
361
|
}, this.#helloTimeoutMs);
|
|
325
362
|
let droppedByRate = 0;
|
|
363
|
+
/*
|
|
364
|
+
* Everything below runs inside a guard, because a throw here used to leave the socket OPEN and
|
|
365
|
+
* unresponsive. Every other refusal in this file is careful to say WHY it closed, precisely so a
|
|
366
|
+
* failure is not mistaken for an app nobody started — and an unhandled exception silently
|
|
367
|
+
* produced that exact indistinguishable state. Measured: one undefined import turned into a test
|
|
368
|
+
* that timed out waiting for a close, with no error surfacing anywhere.
|
|
369
|
+
*/
|
|
326
370
|
socket.on('message', (raw) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
messageWindowStartedAt
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
messagesInWindow += 1;
|
|
334
|
-
const overRate = messagesInWindow > this.#maxMessagesPerSecond;
|
|
335
|
-
const text = rawToString(raw);
|
|
336
|
-
const parsed = this.#parse(text);
|
|
337
|
-
if (null === parsed) {
|
|
338
|
-
const got = helloProtocolMismatch(text);
|
|
339
|
-
if (got !== null) {
|
|
340
|
-
log('protocol_version_mismatch', { got, expected: RETICLE_PROTOCOL_VERSION });
|
|
341
|
-
// Told to the AGENT too, not only to the SDK that is about to stop retrying. Without this
|
|
342
|
-
// the next tool call answers "no browser session connected", which reads as "no app is
|
|
343
|
-
// running" — so an SDK too old to connect is invisible.
|
|
344
|
-
this.sessions.noteClosure(WS_CLOSE_REASON.PROTOCOL_MISMATCH, this.#clock());
|
|
345
|
-
socket.close(...WS_CLOSE.PROTOCOL_MISMATCH);
|
|
346
|
-
return;
|
|
371
|
+
try {
|
|
372
|
+
const now = this.#clock();
|
|
373
|
+
if (now - messageWindowStartedAt >= 1000) {
|
|
374
|
+
messageWindowStartedAt = now;
|
|
375
|
+
messagesInWindow = 0;
|
|
376
|
+
highValueInWindow = 0;
|
|
347
377
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
378
|
+
messagesInWindow += 1;
|
|
379
|
+
const overRate = messagesInWindow > this.#maxMessagesPerSecond;
|
|
380
|
+
const text = rawToString(raw);
|
|
381
|
+
const parsed = this.#parse(text);
|
|
382
|
+
if (null === parsed) {
|
|
383
|
+
const got = helloProtocolMismatch(text);
|
|
384
|
+
if (got !== null) {
|
|
385
|
+
log('protocol_version_mismatch', { got, expected: RETICLE_PROTOCOL_VERSION });
|
|
386
|
+
/*
|
|
387
|
+
* Name the component that is actually stale.
|
|
388
|
+
*
|
|
389
|
+
* This used to send one fixed "upgrade @reticlehq/browser" whichever way the versions
|
|
390
|
+
* disagreed, and the skew seen in practice runs the other way — a current SDK in the app
|
|
391
|
+
* dialling a daemon npx served from cache. Advice naming the wrong component is worse than
|
|
392
|
+
* none: the user upgrades what was already current, sees no change, and blames the tool.
|
|
393
|
+
*/
|
|
394
|
+
const reason = protocolSkewReason(got, RETICLE_PROTOCOL_VERSION);
|
|
395
|
+
// Told to the AGENT too, not only to the SDK that is about to stop retrying. Without this
|
|
396
|
+
// the next tool call answers "no browser session connected", which reads as "no app is
|
|
397
|
+
// running" — so an SDK too old to connect is invisible.
|
|
398
|
+
this.sessions.noteClosure(reason, this.#clock());
|
|
399
|
+
socket.close(WS_CLOSE.PROTOCOL_MISMATCH[0], reason);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
socket.close(...WS_CLOSE.INVALID_MESSAGE);
|
|
373
403
|
return;
|
|
374
404
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
405
|
+
// Over the cap we SAMPLE — we never disconnect. Going blind is the one thing an observability
|
|
406
|
+
// layer must not do when it sees too much, and the close was a policy code the SDK correctly
|
|
407
|
+
// never retries: the app kept running and Reticle was blind from that instant, on exactly the
|
|
408
|
+
// busiest apps. Measured: each request emits two messages (pending + settled), so the cap binds
|
|
409
|
+
// at ~500 requests/second — a dashboard burst reaches it, a streaming app lives above it.
|
|
410
|
+
//
|
|
411
|
+
// Only EVENTS are dropped. A dropped hello would strand the session and a dropped
|
|
412
|
+
// command_result would hang the agent's in-flight call, so control traffic is always processed
|
|
413
|
+
// however fast it arrives — the cap exists to bound observation, not to break the protocol.
|
|
414
|
+
// Over the cap we drop by VALUE, not by arrival order. Volume is inversely correlated with
|
|
415
|
+
// value — a render-commit storm is thousands/second while the network call an assertion turns
|
|
416
|
+
// on is one — so first-come-first-dropped spends the budget on churn and loses the signal.
|
|
417
|
+
// High-value kinds get a bounded reserve on top of the cap; see event-priority.ts.
|
|
418
|
+
if (overRate && parsed.kind === MessageKind.EVENT) {
|
|
419
|
+
const withinReserve = isHighValueEvent(parsed.event.type) && highValueInWindow < highValueReserve;
|
|
420
|
+
if (withinReserve) {
|
|
421
|
+
highValueInWindow += 1;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
droppedByRate += 1;
|
|
425
|
+
if (session !== undefined)
|
|
426
|
+
session.noteRateLimited(droppedByRate);
|
|
385
427
|
return;
|
|
386
428
|
}
|
|
387
|
-
refreshIdentity(session, parsed);
|
|
388
|
-
return;
|
|
389
429
|
}
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
430
|
+
if (parsed.kind === MessageKind.HELLO) {
|
|
431
|
+
if (session !== undefined) {
|
|
432
|
+
// A repeat hello on the SAME socket for the SAME session is an identity refresh, not a
|
|
433
|
+
// violation: the SDK re-announces when the app registers capabilities, which happens after
|
|
434
|
+
// connect by design. Closing the socket on it made `hasCapabilities` permanently false for
|
|
435
|
+
// every app that declared anything. A hello bearing a DIFFERENT session id is still a
|
|
436
|
+
// protocol violation — that is the case this guard exists for.
|
|
437
|
+
if (parsed.sessionId !== session.id) {
|
|
438
|
+
socket.close(...WS_CLOSE.HELLO_DUPLICATE);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
refreshIdentity(session, parsed);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (this.#token !== undefined && !tokensMatch(this.#token, parsed.token)) {
|
|
445
|
+
// A daemon left running by ANOTHER project answers this app and rejects it on token. The
|
|
446
|
+
// token is not wrong, it is somebody else's — and "authentication failed" sends the user to
|
|
447
|
+
// check the one thing that is fine. See auth-failure-reason.
|
|
448
|
+
const reason = authFailureReason(this.#servedProjects, parsed.projectId, parsed.token);
|
|
449
|
+
log('authentication_failed', {
|
|
450
|
+
served: [...this.#servedProjects],
|
|
451
|
+
...(parsed.projectId === undefined ? {} : { helloProject: parsed.projectId }),
|
|
452
|
+
presented: parsed.token !== undefined && 0 < parsed.token.length,
|
|
453
|
+
});
|
|
454
|
+
this.sessions.noteClosure(WS_CLOSE_REASON.AUTH_FAILED, this.#clock());
|
|
455
|
+
socket.close(WS_CLOSE.AUTH_FAILED[0], reason);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const existing = this.sessions.get(parsed.sessionId);
|
|
459
|
+
if (existing === undefined && this.sessions.count() >= this.#maxSessions) {
|
|
460
|
+
socket.close(...WS_CLOSE.SESSION_LIMIT);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
clearTimeout(helloTimer);
|
|
464
|
+
releasePending();
|
|
465
|
+
session = new Session(parsed, socket, this.#clock);
|
|
466
|
+
// Recorded on ACCEPTANCE, so it is evidence of what this daemon really serves.
|
|
467
|
+
if (parsed.projectId !== undefined)
|
|
468
|
+
this.#servedProjects.add(parsed.projectId);
|
|
469
|
+
// Attach the durable journal (and anything else registered) before any events stream in.
|
|
470
|
+
// One throwing handler must not stop the others, or the order of registration would decide
|
|
471
|
+
// which features survive a bad frame.
|
|
472
|
+
for (const handler of this.#onSessionCreate) {
|
|
473
|
+
try {
|
|
474
|
+
handler(session);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
/* a handler that failed is not a reason to drop the session */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// A tab that has just connected has no impact record yet, so the report would read "nothing
|
|
481
|
+
// recorded" over a file with a month of history in it. Push what is already on disk as soon
|
|
482
|
+
// as there is somewhere to push it to; a session is also a thing that HAPPENED, so it counts.
|
|
483
|
+
// A reconnecting tab keeps its id, so counting every connect made a page reload look like
|
|
484
|
+
// a fresh session - the number climbed while nothing new happened.
|
|
485
|
+
if (!this.#countedSessions.has(session.id)) {
|
|
486
|
+
this.#countedSessions.add(session.id);
|
|
487
|
+
// Against this session's OWN project. Counted here rather than at the chokepoint because
|
|
488
|
+
// a connect is not a tool call, and a session that connects and is never driven is still
|
|
489
|
+
// that project's session — not the daemon's.
|
|
490
|
+
recordImpact({ sessions: 1 }, {}, session.artifactRoot);
|
|
491
|
+
}
|
|
492
|
+
// ...and the tab is shown its own project's numbers. Captured into a const so the closure
|
|
493
|
+
// cannot be read as capturing a reassignable binding that TS believes may be undefined.
|
|
494
|
+
const attached = session;
|
|
495
|
+
attached.pushImpact(() => impactSnapshot(attached.artifactRoot), true);
|
|
496
|
+
const replaced = this.sessions.add(session);
|
|
497
|
+
if (replaced !== undefined) {
|
|
498
|
+
// Name the newcomer. A field report had a live session vanish during `reticle_lease` and the
|
|
499
|
+
// only evidence was "session replaced by a newer connection" in the page console — which
|
|
500
|
+
// says a replacement happened but not BY WHAT, so the cause had to be inferred. A session
|
|
501
|
+
// is only ever replaced by one carrying the SAME id, and knowing which URL claimed it is
|
|
502
|
+
// the difference between a diagnosis and a guess.
|
|
503
|
+
log('session_replaced', {
|
|
504
|
+
sessionId: session.id,
|
|
505
|
+
byUrl: session.url,
|
|
506
|
+
previousUrl: replaced.url,
|
|
507
|
+
});
|
|
508
|
+
// Hand the displaced session its replacement BEFORE ending it, so a tool call still holding
|
|
509
|
+
// the old handle can finish against the live connection instead of returning an error whose
|
|
510
|
+
// only answer is to go and rediscover an id that has not changed. See Session.succeededBy.
|
|
511
|
+
replaced.succeededBy(session);
|
|
512
|
+
replaced.disconnect(`session replaced by a newer connection claiming the same id (${session.id}) from ${session.url}`, true);
|
|
513
|
+
}
|
|
514
|
+
// The daemon is the single judge of skew, and HELLO is where the page announces itself.
|
|
515
|
+
// Reported on the session (reticle_sessions) AND queued for the next tool result, because an
|
|
516
|
+
// agent driving a flow never calls reticle_sessions and would never learn.
|
|
517
|
+
const skew = describeSkew({
|
|
518
|
+
what: 'the page',
|
|
519
|
+
version: parsed.sdkVersion,
|
|
520
|
+
contract: parsed.contract,
|
|
521
|
+
fix: this.#sdkFix(),
|
|
522
|
+
}, { version: SERVER_VERSION, contract: CONTRACT_FINGERPRINT });
|
|
523
|
+
if (skew !== undefined) {
|
|
524
|
+
log('version_skew', {
|
|
525
|
+
sessionId: session.id,
|
|
526
|
+
sdk: parsed.sdkVersion,
|
|
527
|
+
daemon: SERVER_VERSION,
|
|
528
|
+
});
|
|
529
|
+
session.versionSkew = skew;
|
|
530
|
+
noteVersionSkew(SkewPair.SDK, skew);
|
|
531
|
+
}
|
|
532
|
+
log('session_connected', { sessionId: session.id, url: session.url });
|
|
533
|
+
// The one fact that separates a broken install from an unused one. Best-effort and wrapped:
|
|
534
|
+
// a metric must never affect whether an app can connect. See SessionSummary.appConnects.
|
|
535
|
+
try {
|
|
536
|
+
getSessionMetrics().recordAppConnected();
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
/* never let a counter interfere with an app connecting */
|
|
540
|
+
}
|
|
541
|
+
// Each handler is independent, so one throwing must not cost the others their turn.
|
|
542
|
+
for (const onReady of this.#onSessionReady) {
|
|
543
|
+
try {
|
|
544
|
+
onReady(session);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
/* a session-ready observer must never break the session it observes */
|
|
548
|
+
}
|
|
549
|
+
}
|
|
401
550
|
return;
|
|
402
551
|
}
|
|
403
|
-
|
|
404
|
-
if (existing === undefined && this.sessions.count() >= this.#maxSessions) {
|
|
405
|
-
socket.close(...WS_CLOSE.SESSION_LIMIT);
|
|
552
|
+
if (session === undefined)
|
|
406
553
|
return;
|
|
554
|
+
session.touch();
|
|
555
|
+
if (parsed.kind === MessageKind.EVENT) {
|
|
556
|
+
// A panel ▶ replay needs the daemon's flow store, which the Session can't reach — route it to
|
|
557
|
+
// the daemon-wired handler instead of the in-session control path. Everything else is normal.
|
|
558
|
+
const replay = replayRequest(parsed.event);
|
|
559
|
+
if (replay !== undefined)
|
|
560
|
+
this.#onReplay?.(session.id, replay);
|
|
561
|
+
// Pass the raw frame's byte length so the buffer doesn't re-serialize every event for accounting.
|
|
562
|
+
else
|
|
563
|
+
session.pushEvent(parsed.event, Buffer.byteLength(text, 'utf8'));
|
|
407
564
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
session = new Session(parsed, socket, this.#clock);
|
|
411
|
-
// Recorded on ACCEPTANCE, so it is evidence of what this daemon really serves.
|
|
412
|
-
if (parsed.projectId !== undefined)
|
|
413
|
-
this.#servedProjects.add(parsed.projectId);
|
|
414
|
-
this.#onSessionCreate?.(session); // attach the durable journal before any events stream in
|
|
415
|
-
// A tab that has just connected has no impact record yet, so the report would read "nothing
|
|
416
|
-
// recorded" over a file with a month of history in it. Push what is already on disk as soon
|
|
417
|
-
// as there is somewhere to push it to; a session is also a thing that HAPPENED, so it counts.
|
|
418
|
-
// A reconnecting tab keeps its id, so counting every connect made a page reload look like
|
|
419
|
-
// a fresh session - the number climbed while nothing new happened.
|
|
420
|
-
if (!this.#countedSessions.has(session.id)) {
|
|
421
|
-
this.#countedSessions.add(session.id);
|
|
422
|
-
recordImpact({ sessions: 1 });
|
|
423
|
-
}
|
|
424
|
-
session.pushImpact(impactSnapshot, true);
|
|
425
|
-
const replaced = this.sessions.add(session);
|
|
426
|
-
if (replaced !== undefined) {
|
|
427
|
-
// Name the newcomer. A field report had a live session vanish during `reticle_lease` and the
|
|
428
|
-
// only evidence was "session replaced by a newer connection" in the page console — which
|
|
429
|
-
// says a replacement happened but not BY WHAT, so the cause had to be inferred. A session
|
|
430
|
-
// is only ever replaced by one carrying the SAME id, and knowing which URL claimed it is
|
|
431
|
-
// the difference between a diagnosis and a guess.
|
|
432
|
-
log('session_replaced', {
|
|
433
|
-
sessionId: session.id,
|
|
434
|
-
byUrl: session.url,
|
|
435
|
-
previousUrl: replaced.url,
|
|
436
|
-
});
|
|
437
|
-
// Hand the displaced session its replacement BEFORE ending it, so a tool call still holding
|
|
438
|
-
// the old handle can finish against the live connection instead of returning an error whose
|
|
439
|
-
// only answer is to go and rediscover an id that has not changed. See Session.succeededBy.
|
|
440
|
-
replaced.succeededBy(session);
|
|
441
|
-
replaced.disconnect(`session replaced by a newer connection claiming the same id (${session.id}) from ${session.url}`);
|
|
565
|
+
else if (parsed.kind === MessageKind.COMMAND_RESULT) {
|
|
566
|
+
session.handleResult(parsed);
|
|
442
567
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
what: 'the page',
|
|
448
|
-
version: parsed.sdkVersion,
|
|
449
|
-
contract: parsed.contract,
|
|
450
|
-
fix: sdkFix(SERVER_VERSION),
|
|
451
|
-
}, { version: SERVER_VERSION, contract: CONTRACT_FINGERPRINT });
|
|
452
|
-
if (skew !== undefined) {
|
|
453
|
-
log('version_skew', {
|
|
454
|
-
sessionId: session.id,
|
|
455
|
-
sdk: parsed.sdkVersion,
|
|
456
|
-
daemon: SERVER_VERSION,
|
|
457
|
-
});
|
|
458
|
-
session.versionSkew = skew;
|
|
459
|
-
noteVersionSkew(SkewPair.SDK, skew);
|
|
460
|
-
}
|
|
461
|
-
log('session_connected', { sessionId: session.id, url: session.url });
|
|
462
|
-
// The one fact that separates a broken install from an unused one. Best-effort and wrapped:
|
|
463
|
-
// a metric must never affect whether an app can connect. See SessionSummary.appConnects.
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
log('message_handler_failed', { error: err instanceof Error ? err.message : String(err) });
|
|
571
|
+
// Never let the close path throw too — that would restore the hang this guard removes.
|
|
464
572
|
try {
|
|
465
|
-
|
|
573
|
+
socket.close(...WS_CLOSE.HANDLER_FAILED);
|
|
466
574
|
}
|
|
467
575
|
catch {
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
// Each handler is independent, so one throwing must not cost the others their turn.
|
|
471
|
-
for (const onReady of this.#onSessionReady) {
|
|
472
|
-
try {
|
|
473
|
-
onReady(session);
|
|
474
|
-
}
|
|
475
|
-
catch {
|
|
476
|
-
/* a session-ready observer must never break the session it observes */
|
|
477
|
-
}
|
|
576
|
+
socket.terminate();
|
|
478
577
|
}
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
if (session === undefined)
|
|
482
|
-
return;
|
|
483
|
-
session.touch();
|
|
484
|
-
if (parsed.kind === MessageKind.EVENT) {
|
|
485
|
-
// A panel ▶ replay needs the daemon's flow store, which the Session can't reach — route it to
|
|
486
|
-
// the daemon-wired handler instead of the in-session control path. Everything else is normal.
|
|
487
|
-
const replay = replayRequest(parsed.event);
|
|
488
|
-
if (replay !== undefined)
|
|
489
|
-
this.#onReplay?.(session.id, replay);
|
|
490
|
-
// Pass the raw frame's byte length so the buffer doesn't re-serialize every event for accounting.
|
|
491
|
-
else
|
|
492
|
-
session.pushEvent(parsed.event, Buffer.byteLength(text, 'utf8'));
|
|
493
|
-
}
|
|
494
|
-
else if (parsed.kind === MessageKind.COMMAND_RESULT) {
|
|
495
|
-
session.handleResult(parsed);
|
|
496
578
|
}
|
|
497
579
|
});
|
|
498
580
|
socket.on('close', () => {
|
|
@@ -593,8 +675,9 @@ export class Bridge {
|
|
|
593
675
|
attachSessionEnd(handler) {
|
|
594
676
|
this.#onSessionEnd = handler;
|
|
595
677
|
}
|
|
678
|
+
/** Register a handler to run when a session connects. Additive — every handler runs. */
|
|
596
679
|
attachSessionCreate(handler) {
|
|
597
|
-
this.#onSessionCreate
|
|
680
|
+
this.#onSessionCreate.push(handler);
|
|
598
681
|
}
|
|
599
682
|
close() {
|
|
600
683
|
return new Promise((resolve) => {
|
|
@@ -25,3 +25,8 @@ export declare function nodePairingTokenDeps(): PairingTokenDeps;
|
|
|
25
25
|
* fails, so the caller degrades to the prior tokenless behavior rather than bricking the daemon.
|
|
26
26
|
*/
|
|
27
27
|
export declare function readOrCreatePairingToken(dir: string, deps: PairingTokenDeps): Promise<string | undefined>;
|
|
28
|
+
/**
|
|
29
|
+
* Sync twin of `readOrCreatePairingToken`. `reticle init` and Next's `withReticle` are both
|
|
30
|
+
* synchronous, and a snippet printed with no token is a page that can never authenticate.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readOrCreatePairingTokenSync(dir: string): string | undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
@@ -59,3 +60,30 @@ export async function readOrCreatePairingToken(dir, deps) {
|
|
|
59
60
|
return undefined;
|
|
60
61
|
}
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Sync twin of `readOrCreatePairingToken`. `reticle init` and Next's `withReticle` are both
|
|
65
|
+
* synchronous, and a snippet printed with no token is a page that can never authenticate.
|
|
66
|
+
*/
|
|
67
|
+
export function readOrCreatePairingTokenSync(dir) {
|
|
68
|
+
const path = pairingTokenPath(dir);
|
|
69
|
+
try {
|
|
70
|
+
const existing = readFileSync(path, 'utf8').trim();
|
|
71
|
+
if (existing.length > 0)
|
|
72
|
+
return existing;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* missing or unreadable — fall through and create one */
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const token = randomBytes(TOKEN_BYTES).toString('hex');
|
|
79
|
+
if (0 === token.length)
|
|
80
|
+
return undefined;
|
|
81
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 });
|
|
83
|
+
chmodSync(path, 0o600);
|
|
84
|
+
return token;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which side of a protocol mismatch is the stale one, and therefore what to tell the user to do.
|
|
3
|
+
*
|
|
4
|
+
* The close reason was a single fixed string — "upgrade @reticlehq/browser" — sent whichever way the
|
|
5
|
+
* versions disagreed. That is correct only when the page is BEHIND the daemon, and the common real
|
|
6
|
+
* skew runs the other way: the app's package.json pulls a current `@reticlehq/browser` while the
|
|
7
|
+
* daemon is an older `@reticlehq/server` that npx resolved from cache. Observed in this repo's own
|
|
8
|
+
* logs as `got: 2, expected: 1` — a browser two steps ahead, told to upgrade itself.
|
|
9
|
+
*
|
|
10
|
+
* Advice that names the wrong component is worse than none: the user upgrades the thing that was
|
|
11
|
+
* already current, sees no change, and concludes the tool is broken.
|
|
12
|
+
*
|
|
13
|
+
* Kept short on purpose — a WebSocket close reason is capped at 123 BYTES, and a reason that
|
|
14
|
+
* overflows is dropped by the socket layer, which would take the diagnosis with it.
|
|
15
|
+
*
|
|
16
|
+
* NOT a duplicate of `version/version-skew.ts`, and the two should not be merged. That module
|
|
17
|
+
* describes peers which connected SUCCESSFULLY and then disagree about behaviour, so it has both
|
|
18
|
+
* versions and a wire-contract fingerprint to reason from and a whole sentence to say it in. This
|
|
19
|
+
* one runs where the hello was rejected outright: the connection never forms, a single integer is
|
|
20
|
+
* all that is known, and the answer has to fit in a close frame. The remedies are worded to match
|
|
21
|
+
* that module's, because a user who hits both should not be told two different things.
|
|
22
|
+
*/
|
|
23
|
+
/** The page is older than the daemon: its SDK cannot speak the protocol the daemon expects. */
|
|
24
|
+
export declare const BROWSER_IS_OLDER = "protocol mismatch \u2014 upgrade @reticlehq/browser in this app";
|
|
25
|
+
/** The daemon is older than the page. Usually an npx cache serving a stale @reticlehq/server. */
|
|
26
|
+
export declare const DAEMON_IS_OLDER = "protocol mismatch \u2014 the daemon is older; run `reticle stop`, then retry";
|
|
27
|
+
/** The versions disagree but not in a direction we can read. */
|
|
28
|
+
export declare const SKEW_UNKNOWN = "protocol version mismatch \u2014 @reticlehq/browser and server disagree";
|
|
29
|
+
/** The cap a WebSocket close reason must fit inside, in bytes. */
|
|
30
|
+
export declare const CLOSE_REASON_MAX_BYTES = 123;
|
|
31
|
+
/**
|
|
32
|
+
* Name the stale component, given what the page announced and what this daemon speaks.
|
|
33
|
+
*
|
|
34
|
+
* `got` is the page's protocol version, `expected` is the daemon's. A `got` we could not read at
|
|
35
|
+
* all falls back to naming both, which is vague but never actively misleading.
|
|
36
|
+
*/
|
|
37
|
+
export declare const protocolSkewReason: (got: number | null, expected: number) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which side of a protocol mismatch is the stale one, and therefore what to tell the user to do.
|
|
3
|
+
*
|
|
4
|
+
* The close reason was a single fixed string — "upgrade @reticlehq/browser" — sent whichever way the
|
|
5
|
+
* versions disagreed. That is correct only when the page is BEHIND the daemon, and the common real
|
|
6
|
+
* skew runs the other way: the app's package.json pulls a current `@reticlehq/browser` while the
|
|
7
|
+
* daemon is an older `@reticlehq/server` that npx resolved from cache. Observed in this repo's own
|
|
8
|
+
* logs as `got: 2, expected: 1` — a browser two steps ahead, told to upgrade itself.
|
|
9
|
+
*
|
|
10
|
+
* Advice that names the wrong component is worse than none: the user upgrades the thing that was
|
|
11
|
+
* already current, sees no change, and concludes the tool is broken.
|
|
12
|
+
*
|
|
13
|
+
* Kept short on purpose — a WebSocket close reason is capped at 123 BYTES, and a reason that
|
|
14
|
+
* overflows is dropped by the socket layer, which would take the diagnosis with it.
|
|
15
|
+
*
|
|
16
|
+
* NOT a duplicate of `version/version-skew.ts`, and the two should not be merged. That module
|
|
17
|
+
* describes peers which connected SUCCESSFULLY and then disagree about behaviour, so it has both
|
|
18
|
+
* versions and a wire-contract fingerprint to reason from and a whole sentence to say it in. This
|
|
19
|
+
* one runs where the hello was rejected outright: the connection never forms, a single integer is
|
|
20
|
+
* all that is known, and the answer has to fit in a close frame. The remedies are worded to match
|
|
21
|
+
* that module's, because a user who hits both should not be told two different things.
|
|
22
|
+
*/
|
|
23
|
+
/** The page is older than the daemon: its SDK cannot speak the protocol the daemon expects. */
|
|
24
|
+
export const BROWSER_IS_OLDER = 'protocol mismatch — upgrade @reticlehq/browser in this app';
|
|
25
|
+
/** The daemon is older than the page. Usually an npx cache serving a stale @reticlehq/server. */
|
|
26
|
+
export const DAEMON_IS_OLDER = 'protocol mismatch — the daemon is older; run `reticle stop`, then retry';
|
|
27
|
+
/** The versions disagree but not in a direction we can read. */
|
|
28
|
+
export const SKEW_UNKNOWN = 'protocol version mismatch — @reticlehq/browser and server disagree';
|
|
29
|
+
/** The cap a WebSocket close reason must fit inside, in bytes. */
|
|
30
|
+
export const CLOSE_REASON_MAX_BYTES = 123;
|
|
31
|
+
/**
|
|
32
|
+
* Name the stale component, given what the page announced and what this daemon speaks.
|
|
33
|
+
*
|
|
34
|
+
* `got` is the page's protocol version, `expected` is the daemon's. A `got` we could not read at
|
|
35
|
+
* all falls back to naming both, which is vague but never actively misleading.
|
|
36
|
+
*/
|
|
37
|
+
export const protocolSkewReason = (got, expected) => {
|
|
38
|
+
if (null === got || !Number.isFinite(got))
|
|
39
|
+
return SKEW_UNKNOWN;
|
|
40
|
+
if (got < expected)
|
|
41
|
+
return BROWSER_IS_OLDER;
|
|
42
|
+
if (got > expected)
|
|
43
|
+
return DAEMON_IS_OLDER;
|
|
44
|
+
return SKEW_UNKNOWN;
|
|
45
|
+
};
|