@reticlehq/server 3.8.1 → 3.9.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.
Files changed (231) hide show
  1. package/LICENSE +201 -104
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/bin/launch.js +13 -0
  5. package/bin/package.json +3 -0
  6. package/bin/reticle.js +66 -8
  7. package/dist/chromium-launch-options.d.ts +25 -0
  8. package/dist/chromium-launch-options.js +21 -1
  9. package/dist/command/cli/adhoc-verdict.js +34 -7
  10. package/dist/command/cli/answers/gate-exit.d.ts +6 -2
  11. package/dist/command/cli/answers/gate-exit.js +12 -2
  12. package/dist/command/cli/ci-hint.d.ts +44 -0
  13. package/dist/command/cli/ci-hint.js +72 -0
  14. package/dist/command/cli/cli-doctor.d.ts +6 -1
  15. package/dist/command/cli/cli-doctor.js +45 -4
  16. package/dist/command/cli/cli-flow-commands.d.ts +7 -1
  17. package/dist/command/cli/cli-flow-commands.js +39 -17
  18. package/dist/command/cli/cli-kill.d.ts +21 -0
  19. package/dist/command/cli/cli-kill.js +46 -3
  20. package/dist/command/cli/cli-parse.d.ts +5 -2
  21. package/dist/command/cli/cli-parse.js +10 -5
  22. package/dist/command/cli/cli-usage.js +3 -2
  23. package/dist/command/cli/cli-verify.d.ts +6 -1
  24. package/dist/command/cli/cli-verify.js +32 -13
  25. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
  26. package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
  27. package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
  28. package/dist/command/cli/doctor/doctor-fix.js +89 -0
  29. package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
  30. package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
  31. package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
  32. package/dist/command/cli/doctor/doctor-rows.js +4 -0
  33. package/dist/command/cli/doctor-repairs.d.ts +64 -0
  34. package/dist/command/cli/doctor-repairs.js +214 -0
  35. package/dist/command/cli/drive/drive-command.js +3 -7
  36. package/dist/command/cli/first-use-checks.d.ts +41 -0
  37. package/dist/command/cli/first-use-checks.js +127 -0
  38. package/dist/command/cli/first-verdict-node.d.ts +3 -0
  39. package/dist/command/cli/first-verdict-node.js +189 -0
  40. package/dist/command/cli/first-verdict.d.ts +126 -0
  41. package/dist/command/cli/first-verdict.js +267 -0
  42. package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
  43. package/dist/command/cli/launch/sdk-version-match.js +3 -2
  44. package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
  45. package/dist/command/cli/mcp-command.js +13 -4
  46. package/dist/command/cli/ports/port-holder.d.ts +2 -0
  47. package/dist/command/cli/ports/port-holder.js +11 -3
  48. package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
  49. package/dist/command/cli/setup-mcp-cli.js +98 -23
  50. package/dist/command/cli/status/status-lines.d.ts +1 -0
  51. package/dist/command/cli/status/status-lines.js +50 -17
  52. package/dist/command/cli/status-fields.js +17 -3
  53. package/dist/command/cli/try-command.d.ts +7 -0
  54. package/dist/command/cli/try-command.js +11 -3
  55. package/dist/command/cli.js +70 -33
  56. package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
  57. package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
  58. package/dist/command/daemon/daemon-argv.d.ts +9 -0
  59. package/dist/command/daemon/daemon-argv.js +11 -0
  60. package/dist/command/daemon/daemon.d.ts +10 -3
  61. package/dist/command/daemon/daemon.js +17 -6
  62. package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
  63. package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
  64. package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
  65. package/dist/command/daemon/lifetime/death-cause.js +130 -0
  66. package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
  67. package/dist/command/daemon/lifetime/heartbeat.js +4 -13
  68. package/dist/command/daemon/port-state.d.ts +14 -0
  69. package/dist/command/daemon/port-state.js +31 -0
  70. package/dist/command/setup/agent-configs.d.ts +2 -0
  71. package/dist/command/setup/agent-configs.js +42 -15
  72. package/dist/command/setup/agent-next-steps.d.ts +77 -0
  73. package/dist/command/setup/agent-next-steps.js +284 -0
  74. package/dist/command/setup/agent-writer.js +11 -6
  75. package/dist/command/setup/init/init-host.js +2 -0
  76. package/dist/command/setup/init/init-runtime.d.ts +6 -1
  77. package/dist/command/setup/init/init-runtime.js +57 -21
  78. package/dist/command/setup/mcp-launcher.d.ts +3 -0
  79. package/dist/command/setup/mcp-launcher.js +11 -0
  80. package/dist/command/setup/setup-command.js +30 -12
  81. package/dist/command/setup/setup-mcp.d.ts +8 -0
  82. package/dist/command/setup/setup-mcp.js +47 -20
  83. package/dist/command/setup/start-dev-server.d.ts +8 -0
  84. package/dist/command/setup/start-dev-server.js +43 -0
  85. package/dist/command/version/identity/server-version.d.ts +2 -0
  86. package/dist/command/version/identity/server-version.js +2 -0
  87. package/dist/command/version/version-skew.d.ts +7 -0
  88. package/dist/command/version/version-skew.js +13 -2
  89. package/dist/features/license/license.d.ts +11 -71
  90. package/dist/features/license/license.js +43 -136
  91. package/dist/features/visual/desktop-capture.d.ts +33 -0
  92. package/dist/features/visual/desktop-capture.js +94 -0
  93. package/dist/features/visual/step-shots.js +139 -35
  94. package/dist/features/visual/visual-tools.d.ts +0 -18
  95. package/dist/features/visual/visual-tools.js +2 -81
  96. package/dist/import-graph.js +5 -6
  97. package/dist/index.d.ts +3 -1
  98. package/dist/index.js +14 -5
  99. package/dist/judgement/runs/run-ledger.d.ts +7 -5
  100. package/dist/judgement/runs/run-ledger.js +98 -69
  101. package/dist/judgement/runs/verification-sync.d.ts +6 -3
  102. package/dist/judgement/runs/verification-sync.js +21 -10
  103. package/dist/language/flows/flow-store-for-session.js +7 -5
  104. package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
  105. package/dist/language/flows/recording/tape/recordings.js +41 -44
  106. package/dist/language/flows/replay.d.ts +17 -7
  107. package/dist/language/flows/replay.js +95 -44
  108. package/dist/launch-chromium.d.ts +1 -0
  109. package/dist/launch-chromium.js +49 -12
  110. package/dist/memory/cloud/sync-screenshots.js +6 -1
  111. package/dist/portal/bridge/bridge.d.ts +13 -4
  112. package/dist/portal/bridge/bridge.js +10 -9
  113. package/dist/portal/input/drive-error.d.ts +6 -0
  114. package/dist/portal/input/drive-error.js +9 -0
  115. package/dist/portal/input/network-mock.js +3 -2
  116. package/dist/portal/input/real-input-press.d.ts +34 -0
  117. package/dist/portal/input/real-input-press.js +150 -0
  118. package/dist/portal/input/real-input.d.ts +21 -23
  119. package/dist/portal/input/real-input.js +153 -194
  120. package/dist/portal/input/viewport-tools.js +6 -3
  121. package/dist/portal/pool/browser-pool.d.ts +5 -1
  122. package/dist/portal/pool/browser-pool.js +32 -38
  123. package/dist/portal/pool/context-permissions.d.ts +20 -17
  124. package/dist/portal/pool/context-permissions.js +63 -1
  125. package/dist/portal/pool/csp-widen.d.ts +62 -0
  126. package/dist/portal/pool/csp-widen.js +132 -0
  127. package/dist/portal/pool/lease-page-error.d.ts +29 -0
  128. package/dist/portal/pool/lease-page-error.js +18 -0
  129. package/dist/portal/pool/page-error-journal.d.ts +57 -0
  130. package/dist/portal/pool/page-error-journal.js +181 -0
  131. package/dist/portal/pool/playwright-launcher.js +21 -8
  132. package/dist/portal/pool/pool-contract.d.ts +22 -0
  133. package/dist/portal/pool/pool-lease.d.ts +35 -2
  134. package/dist/portal/pool/pool-lease.js +23 -0
  135. package/dist/portal/pool/zero-install.js +69 -1
  136. package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
  137. package/dist/portal/session/dev-server/dev-command.js +56 -8
  138. package/dist/portal/session/no-session-diagnosis.js +10 -3
  139. package/dist/portal/session/no-session-next-action.d.ts +12 -0
  140. package/dist/portal/session/no-session-next-action.js +44 -4
  141. package/dist/portal/session/no-session-watch.js +3 -1
  142. package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
  143. package/dist/portal/session/page-commands/undeclared-command.js +16 -7
  144. package/dist/portal/session/recorded-gaps.d.ts +1 -1
  145. package/dist/portal/session/recorded-gaps.js +5 -2
  146. package/dist/portal/session/session-manager.js +40 -21
  147. package/dist/portal/session/session.js +4 -3
  148. package/dist/portal/session/zero-install-first-call.d.ts +20 -1
  149. package/dist/portal/session/zero-install-first-call.js +34 -2
  150. package/dist/start-wiring.d.ts +29 -2
  151. package/dist/start-wiring.js +73 -9
  152. package/dist/surface/mcp/mcp-proxy.js +10 -2
  153. package/dist/surface/mcp/mcp.js +5 -3
  154. package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
  155. package/dist/surface/mcp/peer/agents-connected.js +58 -0
  156. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
  157. package/dist/surface/tools/act/act-preflight.js +37 -17
  158. package/dist/surface/tools/act/act-target.js +3 -1
  159. package/dist/surface/tools/act/after-match.d.ts +7 -0
  160. package/dist/surface/tools/act/after-match.js +33 -0
  161. package/dist/surface/tools/act/resolve-target.d.ts +2 -0
  162. package/dist/surface/tools/act/resolve-target.js +35 -8
  163. package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
  164. package/dist/surface/tools/act/sequence-grade.js +8 -1
  165. package/dist/surface/tools/act-sequence-tool.js +3 -0
  166. package/dist/surface/tools/act-tools.js +12 -12
  167. package/dist/surface/tools/assert/assert-verdict.js +28 -1
  168. package/dist/surface/tools/browser-tools.js +75 -7
  169. package/dist/surface/tools/dynamic-tools.js +2 -1
  170. package/dist/surface/tools/empty-session-list.d.ts +2 -0
  171. package/dist/surface/tools/empty-session-list.js +4 -2
  172. package/dist/surface/tools/error-recovery.js +43 -22
  173. package/dist/surface/tools/invoke-tool.js +27 -20
  174. package/dist/surface/tools/lease-hint.d.ts +7 -0
  175. package/dist/surface/tools/lease-hint.js +10 -3
  176. package/dist/surface/tools/lease-readiness.d.ts +21 -0
  177. package/dist/surface/tools/lease-readiness.js +47 -0
  178. package/dist/surface/tools/lease-seed.d.ts +2 -0
  179. package/dist/surface/tools/lease-seed.js +2 -1
  180. package/dist/surface/tools/lease-tools.js +43 -15
  181. package/dist/surface/tools/real-input-attempt.js +70 -31
  182. package/dist/surface/tools/repeat-hint.d.ts +40 -0
  183. package/dist/surface/tools/repeat-hint.js +171 -0
  184. package/dist/surface/tools/run-report.d.ts +2 -2
  185. package/dist/surface/tools/run-report.js +27 -15
  186. package/dist/surface/tools/tool-kit.d.ts +12 -0
  187. package/dist/surface/tools/tool-kit.js +7 -0
  188. package/dist/surface/tools/tools.js +26 -13
  189. package/dist/telemetry/argument-shape.js +16 -7
  190. package/dist/telemetry/cli-telemetry.d.ts +10 -1
  191. package/dist/telemetry/cli-telemetry.js +15 -0
  192. package/dist/telemetry/init-telemetry.d.ts +2 -1
  193. package/dist/telemetry/init-telemetry.js +47 -2
  194. package/dist/telemetry/install-trace.js +23 -4
  195. package/dist/telemetry/onboarding-funnel.d.ts +19 -2
  196. package/dist/telemetry/onboarding-funnel.js +25 -3
  197. package/dist/telemetry/proxy-telemetry.d.ts +2 -1
  198. package/dist/telemetry/proxy-telemetry.js +2 -2
  199. package/dist/telemetry/session-metrics.d.ts +1 -1
  200. package/dist/telemetry/session-metrics.js +67 -27
  201. package/dist/telemetry/telemetry.d.ts +3 -1
  202. package/dist/telemetry/telemetry.js +60 -28
  203. package/dist/telemetry/tool-refused.d.ts +2 -2
  204. package/dist/telemetry/tool-refused.js +9 -2
  205. package/docs/architecture.md +3 -6
  206. package/docs/cli/doctor.mdx +41 -2
  207. package/docs/cli/help.mdx +4 -2
  208. package/docs/cli/license.mdx +4 -5
  209. package/docs/cli/setup.mdx +13 -2
  210. package/docs/cli/status.mdx +4 -0
  211. package/docs/cli.mdx +1 -1
  212. package/docs/deploy-checks.md +25 -0
  213. package/docs/desktop.mdx +1 -1
  214. package/docs/enterprise.md +15 -20
  215. package/docs/environment.mdx +1 -0
  216. package/docs/first-drive.md +1 -1
  217. package/docs/install-manual.mdx +16 -1
  218. package/docs/packages/engine.mdx +1 -1
  219. package/docs/packages/react.mdx +14 -2
  220. package/docs/packages/server.mdx +1 -1
  221. package/docs/packages/tauri.mdx +1 -1
  222. package/docs/packages/test.mdx +1 -1
  223. package/docs/packages/vite-plugin.mdx +1 -0
  224. package/docs/packages.mdx +1 -1
  225. package/docs/platform-integration.md +1 -1
  226. package/docs/skill.md +1 -1
  227. package/docs/telemetry.md +4 -2
  228. package/package.json +14 -10
  229. package/LICENSE-ENTERPRISE +0 -37
  230. package/dist/features/ee/audit-log.d.ts +0 -14
  231. package/dist/features/ee/audit-log.js +0 -11
@@ -1,14 +1,9 @@
1
1
  /**
2
- * Enterprise license-key verification + the assertEnterprise gate. ENTERPRISE CODE (Reticle Enterprise
3
- * License — see./LICENSE), not the FSL that covers the rest of the server.
2
+ * License-key verification for `reticle license` and the telemetry `licenseStatus` field.
4
3
  *
5
- * Offline + no phone-home (preserves the no-telemetry brand): a key is a signed payload Reticle issues
6
- * with its private key; this verifies it against the issuer's PUBLIC key with Ed25519. Dev/eval is a
7
- * no-op (requireLicense:false) so the gate never gets in a contributor's way; only a production-mode
8
- * caller with requireLicense:true must present a valid, unexpired key. The clock is injected (rule 7).
9
- *
10
- * Rules for this directory: the free server must never import from ee/; ee/ depends only on protocol +
11
- * node + zod. This file imports neither server core nor any sibling outside ee/.
4
+ * Offline, with no phone-home: a key is a signed payload that Reticle issues with its private key.
5
+ * This file verifies it against the issuer's PUBLIC key with Ed25519. Nothing in this package is
6
+ * gated on a key. The clock is injected (rule 7).
12
7
  */
13
8
  import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
14
9
  import { z } from 'zod';
@@ -22,8 +17,8 @@ export const LicenseStatus = {
22
17
  EXPIRED: 'expired',
23
18
  };
24
19
  /**
25
- * The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features
26
- * unlock.
20
+ * The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes the features that
21
+ * the key covers.
27
22
  *
28
23
  * `lid` is the stable licence id and the only safe identity: `org` is a display name a human typed at
29
24
  * signing time, so two organisations with the same name collapse into one and a rename splits one into
@@ -48,8 +43,9 @@ export function signLicenseKey(payload, privateKey) {
48
43
  }
49
44
  /** Verify a key against the issuer public key. Never throws — returns a structured status. */
50
45
  export function verifyLicenseKey(key, publicKey, now) {
51
- if (key === undefined || 0 === key.length)
46
+ if (key === undefined || 0 === key.length) {
52
47
  return { status: LicenseStatus.MISSING };
48
+ }
53
49
  const parts = key.split(KEY_SEP);
54
50
  if (parts.length !== 2 ||
55
51
  parts[0] === undefined ||
@@ -62,8 +58,9 @@ export function verifyLicenseKey(key, publicKey, now) {
62
58
  try {
63
59
  json = Buffer.from(parts[0], 'base64url').toString('utf8');
64
60
  const parsed = LicensePayloadSchema.safeParse(JSON.parse(json));
65
- if (!parsed.success)
61
+ if (!parsed.success) {
66
62
  return { status: LicenseStatus.MALFORMED };
63
+ }
67
64
  payload = parsed.data;
68
65
  }
69
66
  catch {
@@ -76,98 +73,45 @@ export function verifyLicenseKey(key, publicKey, now) {
76
73
  catch {
77
74
  return { status: LicenseStatus.BAD_SIGNATURE };
78
75
  }
79
- if (!signatureOk)
76
+ if (!signatureOk) {
80
77
  return { status: LicenseStatus.BAD_SIGNATURE };
81
- if (payload.exp <= now)
78
+ }
79
+ if (payload.exp <= now) {
82
80
  return { status: LicenseStatus.EXPIRED, payload };
81
+ }
83
82
  return { status: LicenseStatus.VALID, payload };
84
83
  }
85
84
  /**
86
- * Where to get a key. A named constant because it appears in the gate's error AND in what
87
- * `reticle license` prints, and a contact address that drifts between the two is a support request
88
- * that reaches nobody.
85
+ * Where to get a key. A named constant because `reticle license` prints it on every branch, and a
86
+ * contact address that drifts is a support request that reaches nobody.
89
87
  */
90
88
  export const LICENSE_CONTACT = 'hey@reticle.sh';
91
- /**
92
- * The features a licence gates in THIS build. The single source: `assertEnterprise` callers name a
93
- * member rather than a string literal, and `reticle license` reports the list, so what the CLI claims
94
- * is gated is derived from what is actually gated rather than a second list somebody maintains.
95
- *
96
- * It is deliberately short. Reticle's roadmap for enterprise is longer (SSO, SCIM, RBAC, multi-org,
97
- * policy gates), and none of it is here yet — printing a roadmap as though it were shipped is how a
98
- * buyer discovers on day two that the thing they paid for does not exist. What is listed is what the
99
- * running build will actually refuse without a key.
100
- */
101
- export const EnterpriseFeature = {
102
- /** Recording an audit event. */
103
- AUDIT_LOG: 'audit-log',
104
- };
105
- /** Thrown when a production-mode enterprise feature is used without a valid license. */
106
- export class EnterpriseLicenseError extends Error {
107
- feature;
108
- reason;
109
- constructor(feature, reason) {
110
- super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact ${LICENSE_CONTACT}.`);
111
- this.name = 'EnterpriseLicenseError';
112
- this.feature = feature;
113
- this.reason = reason;
114
- }
115
- }
116
- /**
117
- * Gate an enterprise feature. No-op in dev/eval (requireLicense:false). In production it throws
118
- * EnterpriseLicenseError unless a valid, unexpired key that covers `feature` is present.
119
- */
120
- export function assertEnterprise(feature, ctx, env = process.env, baked) {
121
- if (!ctx.requireLicense)
122
- return;
123
- // Resolved exactly as the env-entry point resolves it: BAKED first, env only as the dev/test
124
- // hatch. Reading the environment alone denies every valid licence on a real release, where the key
125
- // is baked and no env var is set. Two gates, one truth.
126
- const publicKey = ctx.publicKey ?? loadPublicKey(resolveIssuerPublicKeyPem(env, baked));
127
- if (publicKey === undefined)
128
- throw new EnterpriseLicenseError(feature, 'no-issuer-key');
129
- const check = verifyLicenseKey(ctx.key, publicKey, ctx.now());
130
- if (check.status !== LicenseStatus.VALID)
131
- throw new EnterpriseLicenseError(feature, check.status);
132
- const { features } = check.payload;
133
- if (features !== undefined && !features.includes(feature)) {
134
- throw new EnterpriseLicenseError(feature, 'feature-not-licensed');
135
- }
136
- }
137
89
  /** Env names that carry the activation: the operator's key, and the issuer public key baked at release. */
138
90
  export const LICENSE_KEY_ENV = 'RETICLE_LICENSE_KEY';
139
91
  export const LICENSE_PUBLIC_KEY_ENV = 'RETICLE_LICENSE_PUBLIC_KEY';
140
92
  /**
141
93
  * Issuer public key COMPILED INTO the build. Empty in the source tree; the release pipeline replaces
142
- * this literal with the real spki PEM. Baking it (rather than only reading env) is what makes the
143
- * enterprise gate fail closed: a self-hosted operator can no longer disable enforcement by simply
144
- * never setting RETICLE_LICENSE_PUBLIC_KEY, because the baked key takes precedence. The public key is
145
- * safe to ship openly — it can only verify licenses, never mint them (that needs the private key).
94
+ * this literal with the real spki PEM. The baked key takes precedence over RETICLE_LICENSE_PUBLIC_KEY,
95
+ * so an operator cannot swap in a key of their own. The public key is safe to ship openly: it can
96
+ * only verify licenses, never mint them (that needs the private key).
146
97
  */
147
98
  const BAKED_ISSUER_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAcg+u61Fs5pwDHcYxFGhaxVaQoUbAdyl+RUdSViZyxDY=\n-----END PUBLIC KEY-----";
148
99
  /**
149
- * Resolve the issuer public-key PEM enforcement uses: the baked-in release key wins, so it can't be
150
- * turned off from the environment. Only when nothing is baked (dev/repo) does the env var apply — that
100
+ * Resolve the issuer public-key PEM: the baked-in release key wins, so the environment cannot
101
+ * replace it. Only when nothing is baked (dev/repo) does the env var apply — that
151
102
  * env path is the test/self-eval escape hatch, not the production switch.
152
103
  */
153
104
  function resolveIssuerPublicKeyPem(env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
154
- if (baked.length > 0)
105
+ if (baked.length > 0) {
155
106
  return baked;
107
+ }
156
108
  const envPem = env[LICENSE_PUBLIC_KEY_ENV];
157
109
  return envPem !== undefined && envPem.length > 0 ? envPem : undefined;
158
110
  }
159
- /**
160
- * The two fields every report carries, spread into each branch. One object rather than a field on each
161
- * return, because the value that must never go missing is the one on the branch nobody was thinking
162
- * about — and the branch an unlicensed reader lands on is exactly that branch.
163
- */
164
- const LICENSE_OFFER = {
165
- gated: Object.values(EnterpriseFeature),
166
- contact: LICENSE_CONTACT,
167
- };
168
111
  function loadPublicKey(pem) {
169
- if (pem === undefined || 0 === pem.length)
112
+ if (pem === undefined || 0 === pem.length) {
170
113
  return undefined;
114
+ }
171
115
  try {
172
116
  return createPublicKey(pem);
173
117
  }
@@ -177,111 +121,74 @@ function loadPublicKey(pem) {
177
121
  }
178
122
  /**
179
123
  * Resolve activation entirely from the environment — the install mechanism: the release bakes the
180
- * issuer public key, the operator sets RETICLE_LICENSE_KEY. No public key configured ⇒ evaluation mode
181
- * (enterprise features run free, dev/test only). Offline, no phone-home.
124
+ * issuer public key, the operator sets RETICLE_LICENSE_KEY. No public key configured ⇒ evaluation mode.
125
+ * Offline, no phone-home.
182
126
  */
183
127
  export function describeLicense(now, env = process.env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
184
128
  const pem = resolveIssuerPublicKeyPem(env, baked);
185
129
  if (pem === undefined || 0 === pem.length) {
186
- // In a production runtime this is NOT a benign eval session — it is a mis-built release whose gate
187
- // is off, and assertEnterpriseFromEnv now denies rather than unlocking. Say so loudly here too.
130
+ // In a production runtime this is NOT a benign eval session: it is a mis-built release. Say so.
188
131
  return isProductionEnv(env)
189
132
  ? {
190
- ...LICENSE_OFFER,
133
+ contact: LICENSE_CONTACT,
191
134
  status: LicenseActivation.INVALID,
192
- detail: 'MISCONFIGURED — running in production with no issuer key baked; enterprise features are DENIED (rebuild with BAKED_ISSUER_PUBLIC_KEY_PEM stamped in)',
135
+ detail: 'MISCONFIGURED — running in production with no issuer key baked (rebuild with BAKED_ISSUER_PUBLIC_KEY_PEM stamped in)',
193
136
  }
194
137
  : {
195
- ...LICENSE_OFFER,
138
+ contact: LICENSE_CONTACT,
196
139
  status: LicenseActivation.EVAL,
197
- detail: 'evaluation mode: enterprise features run free (no issuer key configured)',
140
+ detail: 'evaluation mode (no issuer key configured)',
198
141
  };
199
142
  }
200
143
  const publicKey = loadPublicKey(pem);
201
- if (publicKey === undefined)
144
+ if (publicKey === undefined) {
202
145
  return {
203
- ...LICENSE_OFFER,
146
+ contact: LICENSE_CONTACT,
204
147
  status: LicenseActivation.INVALID,
205
148
  detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key`,
206
149
  };
150
+ }
207
151
  const check = verifyLicenseKey(env[LICENSE_KEY_ENV], publicKey, now);
208
152
  if (check.status === LicenseStatus.VALID) {
209
153
  const { lid, org, plan, exp, features } = check.payload;
210
- // A key scoped to features this build does not gate is `active` and unlocks nothing. Both halves
211
- // were already on screen (`features` against `gated`) and nothing joined them, so the one word a
212
- // customer actually reads told them they were fine while every gated call refused them.
213
- const coversNothingHere = features !== undefined && !features.some((f) => LICENSE_OFFER.gated.includes(f));
214
- const detail = coversNothingHere
215
- ? `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}. This key covers ${features?.join(', ')}, and nothing this build gates (${LICENSE_OFFER.gated.join(', ')}) is included. Contact ${LICENSE_CONTACT}`
216
- : `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`;
217
154
  return {
218
155
  contact: LICENSE_CONTACT,
219
- // The one active case that still needs the list: a key covering nothing this build gates is
220
- // reported here, and the sentence explaining it is unreadable without both sides named.
221
- ...(coversNothingHere ? { gated: LICENSE_OFFER.gated } : {}),
222
156
  status: LicenseActivation.ACTIVE,
223
157
  licenseId: lid,
224
158
  org,
225
159
  plan,
226
160
  expiresAt: exp,
227
161
  ...(features !== undefined ? { features } : {}),
228
- ...(coversNothingHere ? { coversNothingHere } : {}),
229
- detail,
162
+ detail: `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`,
230
163
  };
231
164
  }
232
165
  if (check.status === LicenseStatus.MISSING) {
233
166
  return {
234
- ...LICENSE_OFFER,
167
+ contact: LICENSE_CONTACT,
235
168
  status: LicenseActivation.MISSING,
236
- detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production. ` +
237
- `Request a key from ${LICENSE_CONTACT}`,
169
+ detail: `set ${LICENSE_KEY_ENV} to activate a license. Request a key from ${LICENSE_CONTACT}`,
238
170
  };
239
171
  }
240
172
  if (check.status === LicenseStatus.EXPIRED) {
241
173
  // The id rides an expired key so the lapse can be identified; without it an expiry is
242
174
  // indistinguishable from any other.
243
175
  return {
244
- ...LICENSE_OFFER,
176
+ contact: LICENSE_CONTACT,
245
177
  status: LicenseActivation.EXPIRED,
246
178
  licenseId: check.payload.lid,
247
179
  org: check.payload.org,
248
180
  expiresAt: check.payload.exp,
249
- detail: `license expired. Renew with ${LICENSE_CONTACT} to keep using enterprise features`,
181
+ detail: `license expired. Renew with ${LICENSE_CONTACT}`,
250
182
  };
251
183
  }
252
184
  return {
253
- ...LICENSE_OFFER,
185
+ contact: LICENSE_CONTACT,
254
186
  status: LicenseActivation.INVALID,
255
187
  detail: `license key rejected (${check.status}). ${LICENSE_CONTACT} can re-issue it`,
256
188
  };
257
189
  }
258
- /** True in a production runtime (NODE_ENV=production) — where a missing issuer key is a mis-built
259
- * release, not an eval session, so the gate must fail CLOSED rather than run features free. */
190
+ /** True in a production runtime (NODE_ENV=production), where a missing issuer key is a mis-built
191
+ * release, not an eval session. */
260
192
  function isProductionEnv(env) {
261
193
  return 'production' === env['NODE_ENV'];
262
194
  }
263
- /**
264
- * Gate an enterprise feature using env-resolved activation. Enforcement is ON when the issuer public key
265
- * is configured (a real release). Without it, behaviour splits on runtime:
266
- * - dev/eval (NODE_ENV ≠ production): features run FREE — never blocks a contributor or CI.
267
- * - production (NODE_ENV = production): FAIL CLOSED — a release that reaches production with no
268
- * resolvable issuer key was mis-built (the baked key wasn't stamped in, BAKED_ISSUER_PUBLIC_KEY_PEM
269
- * is still ''), and silently unlocking every ee feature with no key and no warning is the worst
270
- * outcome. Deny instead, with a distinct reason so the misconfiguration is obvious.
271
- */
272
- export function assertEnterpriseFromEnv(feature, now, env = process.env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
273
- const publicKey = loadPublicKey(resolveIssuerPublicKeyPem(env, baked));
274
- if (publicKey === undefined) {
275
- if (isProductionEnv(env)) {
276
- throw new EnterpriseLicenseError(feature, 'enterprise-gate-unconfigured');
277
- }
278
- return; // dev/eval — free
279
- }
280
- const key = env[LICENSE_KEY_ENV];
281
- assertEnterprise(feature, {
282
- requireLicense: true,
283
- now: () => now,
284
- ...(key !== undefined ? { key } : {}),
285
- publicKey,
286
- });
287
- }
@@ -0,0 +1,33 @@
1
+ import { VisualReason } from '@reticlehq/core';
2
+ import type { ToolDeps } from '../../surface/tools/tool-kit.js';
3
+ /**
4
+ * Ask the desktop shell to photograph its own window.
5
+ *
6
+ * Returns undefined for a plain web page (no capture helper) and for a desktop app that did not
7
+ * install `@reticlehq/electron/main`. Deliberately NOT a screen-region capture: photographing
8
+ * the glass returns whatever window is on top, which would save a picture of the user's editor as a
9
+ * visual baseline — the exact false green Reticle exists to eliminate.
10
+ */
11
+ export declare function desktopCapture(deps: ToolDeps, sessionId: string | undefined, fullPage: boolean): Promise<{
12
+ png?: Uint8Array;
13
+ reason?: VisualReason;
14
+ recommendation?: string;
15
+ }>;
16
+ /**
17
+ * Only read a capture the shell wrote. The path arrives from the PAGE, and the daemon must not
18
+ * become a file-read oracle for whatever a compromised renderer names.
19
+ *
20
+ * Two layouts are accepted, and the pair is the whole subtlety:
21
+ *
22
+ * - `<tmp>/<prefix><dir>/<prefix><n>.png` — the shell's own PRIVATE capture directory, created 0700
23
+ * so no other local user can read the screenshot or pre-place a symlink at the path we write.
24
+ * - `<tmp>/<prefix><pid>-<n>.png` — the legacy flat layout. Kept because the shell ships as a
25
+ * SEPARATE package on the user's own upgrade schedule: an app still on the older
26
+ * `@reticlehq/electron` talking to a newer daemon must keep getting screenshots, not silently
27
+ * start returning no image. It grants the daemon nothing it did not already have.
28
+ *
29
+ * Every comparison is an exact match against an UNRESOLVED path, which is what refuses traversal:
30
+ * `<tmp>/<prefix>x/../../etc/<prefix>passwd.png` has a grandparent of `<tmp>/<prefix>x/..`, not
31
+ * `<tmp>`. Exactly one directory level is allowed, so a deeper nesting is refused too.
32
+ */
33
+ export declare function isCapturePath(path: string): boolean;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The desktop shell's photograph of its own window, read from the file the shell wrote.
3
+ *
4
+ * Shared by `reticle_screenshot`, the drive frames and the step shots. The step shots use it only
5
+ * for a page SDK before 3.7, which has no `render` command and declares `capture` instead.
6
+ */
7
+ import { readFile, unlink } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { basename, dirname } from 'node:path';
10
+ import { asRecord, asString, ReticleCommand, RETICLE_CAPTURE_FILE_PREFIX, RETICLE_TAURI_INERT_MARKER, TAURI_INERT_RECOMMENDATION, VisualReason, } from '@reticlehq/core';
11
+ import { trackCaptureDirectory } from './capture-cleanup.js';
12
+ /**
13
+ * Ask the desktop shell to photograph its own window.
14
+ *
15
+ * Returns undefined for a plain web page (no capture helper) and for a desktop app that did not
16
+ * install `@reticlehq/electron/main`. Deliberately NOT a screen-region capture: photographing
17
+ * the glass returns whatever window is on top, which would save a picture of the user's editor as a
18
+ * visual baseline — the exact false green Reticle exists to eliminate.
19
+ */
20
+ export async function desktopCapture(deps, sessionId, fullPage) {
21
+ const session = deps.sessions.resolve(sessionId);
22
+ // A session that cannot take commands (no live browser behind it) simply has no pixels to give.
23
+ if (typeof session.command !== 'function') {
24
+ return {};
25
+ }
26
+ const res = await session.command(ReticleCommand.CAPTURE, { fullPage });
27
+ if (!res.ok) {
28
+ return {};
29
+ }
30
+ const r = asRecord(res.result);
31
+ if (r['ok'] !== true) {
32
+ // The shell refused a full-page capture it cannot do. That is a different answer from "capture
33
+ // broke", and the caller has to hear it — otherwise they read `fullPage` as honoured.
34
+ if (r['reason'] === VisualReason.FULL_PAGE_UNSUPPORTED) {
35
+ return { reason: VisualReason.FULL_PAGE_UNSUPPORTED };
36
+ }
37
+ // A Tauri release build with the capture compiled out: say that, never the web answer.
38
+ return true === asString(r['reason'])?.includes(RETICLE_TAURI_INERT_MARKER)
39
+ ? { reason: VisualReason.NO_PROVIDER, recommendation: TAURI_INERT_RECOMMENDATION }
40
+ : {};
41
+ }
42
+ const path = asString(r['path']);
43
+ if (path === undefined || !isCapturePath(path)) {
44
+ return {};
45
+ }
46
+ try {
47
+ const bytes = await readFile(path);
48
+ await unlink(path).catch(() => undefined);
49
+ trackCaptureDirectory(path);
50
+ return isCompletePng(bytes) ? { png: new Uint8Array(bytes) } : {};
51
+ }
52
+ catch {
53
+ return {};
54
+ }
55
+ }
56
+ /**
57
+ * Only read a capture the shell wrote. The path arrives from the PAGE, and the daemon must not
58
+ * become a file-read oracle for whatever a compromised renderer names.
59
+ *
60
+ * Two layouts are accepted, and the pair is the whole subtlety:
61
+ *
62
+ * - `<tmp>/<prefix><dir>/<prefix><n>.png` — the shell's own PRIVATE capture directory, created 0700
63
+ * so no other local user can read the screenshot or pre-place a symlink at the path we write.
64
+ * - `<tmp>/<prefix><pid>-<n>.png` — the legacy flat layout. Kept because the shell ships as a
65
+ * SEPARATE package on the user's own upgrade schedule: an app still on the older
66
+ * `@reticlehq/electron` talking to a newer daemon must keep getting screenshots, not silently
67
+ * start returning no image. It grants the daemon nothing it did not already have.
68
+ *
69
+ * Every comparison is an exact match against an UNRESOLVED path, which is what refuses traversal:
70
+ * `<tmp>/<prefix>x/../../etc/<prefix>passwd.png` has a grandparent of `<tmp>/<prefix>x/..`, not
71
+ * `<tmp>`. Exactly one directory level is allowed, so a deeper nesting is refused too.
72
+ */
73
+ export function isCapturePath(path) {
74
+ if (!basename(path).startsWith(RETICLE_CAPTURE_FILE_PREFIX)) {
75
+ return false;
76
+ }
77
+ const parent = dirname(path);
78
+ if (parent === tmpdir()) {
79
+ return true;
80
+ }
81
+ return dirname(parent) === tmpdir() && basename(parent).startsWith(RETICLE_CAPTURE_FILE_PREFIX);
82
+ }
83
+ /**
84
+ * A PNG is complete only if it ends with IEND. This check exists because a truncated image once
85
+ * sailed through as a saved baseline: the tool answered `saved: true` with a path, and the file was
86
+ * undecodable. A screenshot that cannot be read must fail loudly, never bank a corrupt baseline that
87
+ * a later visual_diff will compare against.
88
+ */
89
+ function isCompletePng(bytes) {
90
+ if (bytes.byteLength < 12) {
91
+ return false;
92
+ }
93
+ return 'IEND' === bytes.subarray(bytes.byteLength - 8, bytes.byteLength - 4).toString('ascii');
94
+ }