@reticlehq/server 3.8.2 → 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.
- package/LICENSE +201 -104
- package/NOTICE +5 -0
- package/README.md +1 -1
- package/bin/launch.js +13 -0
- package/bin/package.json +3 -0
- package/bin/reticle.js +66 -8
- package/dist/chromium-launch-options.d.ts +25 -0
- package/dist/chromium-launch-options.js +21 -1
- package/dist/command/cli/adhoc-verdict.js +34 -7
- package/dist/command/cli/answers/gate-exit.d.ts +6 -2
- package/dist/command/cli/answers/gate-exit.js +12 -2
- package/dist/command/cli/ci-hint.d.ts +44 -0
- package/dist/command/cli/ci-hint.js +72 -0
- package/dist/command/cli/cli-doctor.d.ts +6 -1
- package/dist/command/cli/cli-doctor.js +45 -4
- package/dist/command/cli/cli-flow-commands.d.ts +7 -1
- package/dist/command/cli/cli-flow-commands.js +39 -17
- package/dist/command/cli/cli-kill.d.ts +21 -0
- package/dist/command/cli/cli-kill.js +46 -3
- package/dist/command/cli/cli-parse.d.ts +5 -2
- package/dist/command/cli/cli-parse.js +10 -5
- package/dist/command/cli/cli-usage.js +3 -2
- package/dist/command/cli/cli-verify.d.ts +6 -1
- package/dist/command/cli/cli-verify.js +32 -13
- package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
- package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
- package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
- package/dist/command/cli/doctor/doctor-fix.js +89 -0
- package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
- package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
- package/dist/command/cli/doctor/doctor-rows.js +4 -0
- package/dist/command/cli/doctor-repairs.d.ts +64 -0
- package/dist/command/cli/doctor-repairs.js +214 -0
- package/dist/command/cli/drive/drive-command.js +3 -7
- package/dist/command/cli/first-use-checks.d.ts +41 -0
- package/dist/command/cli/first-use-checks.js +127 -0
- package/dist/command/cli/first-verdict-node.d.ts +3 -0
- package/dist/command/cli/first-verdict-node.js +189 -0
- package/dist/command/cli/first-verdict.d.ts +126 -0
- package/dist/command/cli/first-verdict.js +267 -0
- package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
- package/dist/command/cli/launch/sdk-version-match.js +3 -2
- package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
- package/dist/command/cli/mcp-command.js +13 -4
- package/dist/command/cli/ports/port-holder.d.ts +2 -0
- package/dist/command/cli/ports/port-holder.js +11 -3
- package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
- package/dist/command/cli/setup-mcp-cli.js +98 -23
- package/dist/command/cli/status/status-lines.d.ts +1 -0
- package/dist/command/cli/status/status-lines.js +50 -17
- package/dist/command/cli/status-fields.js +17 -3
- package/dist/command/cli/try-command.d.ts +7 -0
- package/dist/command/cli/try-command.js +11 -3
- package/dist/command/cli.js +70 -33
- package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
- package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
- package/dist/command/daemon/daemon-argv.d.ts +9 -0
- package/dist/command/daemon/daemon-argv.js +11 -0
- package/dist/command/daemon/daemon.d.ts +10 -3
- package/dist/command/daemon/daemon.js +17 -6
- package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
- package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
- package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
- package/dist/command/daemon/lifetime/death-cause.js +130 -0
- package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
- package/dist/command/daemon/lifetime/heartbeat.js +4 -13
- package/dist/command/daemon/port-state.d.ts +14 -0
- package/dist/command/daemon/port-state.js +31 -0
- package/dist/command/setup/agent-configs.d.ts +2 -0
- package/dist/command/setup/agent-configs.js +42 -15
- package/dist/command/setup/agent-next-steps.d.ts +77 -0
- package/dist/command/setup/agent-next-steps.js +284 -0
- package/dist/command/setup/agent-writer.js +11 -6
- package/dist/command/setup/init/init-host.js +2 -0
- package/dist/command/setup/init/init-runtime.d.ts +6 -1
- package/dist/command/setup/init/init-runtime.js +57 -21
- package/dist/command/setup/mcp-launcher.d.ts +3 -0
- package/dist/command/setup/mcp-launcher.js +11 -0
- package/dist/command/setup/setup-command.js +30 -12
- package/dist/command/setup/setup-mcp.d.ts +8 -0
- package/dist/command/setup/setup-mcp.js +47 -20
- package/dist/command/setup/start-dev-server.d.ts +8 -0
- package/dist/command/setup/start-dev-server.js +43 -0
- package/dist/command/version/identity/server-version.d.ts +2 -0
- package/dist/command/version/identity/server-version.js +2 -0
- package/dist/command/version/version-skew.d.ts +7 -0
- package/dist/command/version/version-skew.js +13 -2
- package/dist/features/license/license.d.ts +11 -71
- package/dist/features/license/license.js +43 -136
- package/dist/features/visual/desktop-capture.d.ts +33 -0
- package/dist/features/visual/desktop-capture.js +94 -0
- package/dist/features/visual/step-shots.js +139 -35
- package/dist/features/visual/visual-tools.d.ts +0 -18
- package/dist/features/visual/visual-tools.js +2 -81
- package/dist/import-graph.js +5 -6
- package/dist/index.d.ts +3 -1
- package/dist/index.js +14 -5
- package/dist/judgement/runs/run-ledger.d.ts +7 -5
- package/dist/judgement/runs/run-ledger.js +98 -69
- package/dist/judgement/runs/verification-sync.d.ts +6 -3
- package/dist/judgement/runs/verification-sync.js +21 -10
- package/dist/language/flows/flow-store-for-session.js +7 -5
- package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
- package/dist/language/flows/recording/tape/recordings.js +41 -44
- package/dist/language/flows/replay.d.ts +17 -7
- package/dist/language/flows/replay.js +95 -44
- package/dist/launch-chromium.d.ts +1 -0
- package/dist/launch-chromium.js +49 -12
- package/dist/memory/cloud/sync-screenshots.js +6 -1
- package/dist/portal/bridge/bridge.d.ts +13 -4
- package/dist/portal/bridge/bridge.js +10 -9
- package/dist/portal/input/network-mock.js +3 -2
- package/dist/portal/input/real-input.d.ts +15 -0
- package/dist/portal/input/real-input.js +59 -3
- package/dist/portal/input/viewport-tools.js +6 -3
- package/dist/portal/pool/browser-pool.d.ts +5 -1
- package/dist/portal/pool/browser-pool.js +32 -38
- package/dist/portal/pool/context-permissions.d.ts +20 -17
- package/dist/portal/pool/context-permissions.js +63 -1
- package/dist/portal/pool/csp-widen.d.ts +62 -0
- package/dist/portal/pool/csp-widen.js +132 -0
- package/dist/portal/pool/lease-page-error.d.ts +29 -0
- package/dist/portal/pool/lease-page-error.js +18 -0
- package/dist/portal/pool/page-error-journal.d.ts +57 -0
- package/dist/portal/pool/page-error-journal.js +181 -0
- package/dist/portal/pool/playwright-launcher.js +21 -8
- package/dist/portal/pool/pool-contract.d.ts +22 -0
- package/dist/portal/pool/pool-lease.d.ts +35 -2
- package/dist/portal/pool/pool-lease.js +23 -0
- package/dist/portal/pool/zero-install.js +69 -1
- package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
- package/dist/portal/session/dev-server/dev-command.js +56 -8
- package/dist/portal/session/no-session-diagnosis.js +10 -3
- package/dist/portal/session/no-session-next-action.d.ts +12 -0
- package/dist/portal/session/no-session-next-action.js +44 -4
- package/dist/portal/session/no-session-watch.js +3 -1
- package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
- package/dist/portal/session/page-commands/undeclared-command.js +16 -7
- package/dist/portal/session/recorded-gaps.d.ts +1 -1
- package/dist/portal/session/recorded-gaps.js +5 -2
- package/dist/portal/session/session-manager.js +40 -21
- package/dist/portal/session/session.js +4 -3
- package/dist/portal/session/zero-install-first-call.d.ts +20 -1
- package/dist/portal/session/zero-install-first-call.js +34 -2
- package/dist/start-wiring.d.ts +29 -2
- package/dist/start-wiring.js +73 -9
- package/dist/surface/mcp/mcp-proxy.js +10 -2
- package/dist/surface/mcp/mcp.js +5 -3
- package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
- package/dist/surface/mcp/peer/agents-connected.js +58 -0
- package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
- package/dist/surface/tools/act/act-preflight.js +37 -17
- package/dist/surface/tools/act/act-target.js +3 -1
- package/dist/surface/tools/act/after-match.d.ts +7 -0
- package/dist/surface/tools/act/after-match.js +33 -0
- package/dist/surface/tools/act/resolve-target.d.ts +2 -0
- package/dist/surface/tools/act/resolve-target.js +35 -8
- package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
- package/dist/surface/tools/act/sequence-grade.js +8 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -0
- package/dist/surface/tools/act-tools.js +10 -10
- package/dist/surface/tools/assert/assert-verdict.js +28 -1
- package/dist/surface/tools/browser-tools.js +75 -7
- package/dist/surface/tools/dynamic-tools.js +2 -1
- package/dist/surface/tools/empty-session-list.d.ts +2 -0
- package/dist/surface/tools/empty-session-list.js +4 -2
- package/dist/surface/tools/error-recovery.js +43 -22
- package/dist/surface/tools/invoke-tool.js +27 -20
- package/dist/surface/tools/lease-hint.d.ts +7 -0
- package/dist/surface/tools/lease-hint.js +10 -3
- package/dist/surface/tools/lease-readiness.d.ts +21 -0
- package/dist/surface/tools/lease-readiness.js +47 -0
- package/dist/surface/tools/lease-seed.d.ts +2 -0
- package/dist/surface/tools/lease-seed.js +2 -1
- package/dist/surface/tools/lease-tools.js +43 -15
- package/dist/surface/tools/repeat-hint.d.ts +40 -0
- package/dist/surface/tools/repeat-hint.js +171 -0
- package/dist/surface/tools/run-report.d.ts +2 -2
- package/dist/surface/tools/run-report.js +27 -15
- package/dist/surface/tools/tool-kit.d.ts +12 -0
- package/dist/surface/tools/tool-kit.js +7 -0
- package/dist/surface/tools/tools.js +26 -13
- package/dist/telemetry/argument-shape.js +16 -7
- package/dist/telemetry/cli-telemetry.d.ts +10 -1
- package/dist/telemetry/cli-telemetry.js +15 -0
- package/dist/telemetry/init-telemetry.d.ts +2 -1
- package/dist/telemetry/init-telemetry.js +47 -2
- package/dist/telemetry/install-trace.js +23 -4
- package/dist/telemetry/onboarding-funnel.d.ts +19 -2
- package/dist/telemetry/onboarding-funnel.js +25 -3
- package/dist/telemetry/proxy-telemetry.d.ts +2 -1
- package/dist/telemetry/proxy-telemetry.js +2 -2
- package/dist/telemetry/session-metrics.d.ts +1 -1
- package/dist/telemetry/session-metrics.js +67 -27
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +60 -28
- package/dist/telemetry/tool-refused.d.ts +2 -2
- package/dist/telemetry/tool-refused.js +9 -2
- package/docs/architecture.md +3 -6
- package/docs/cli/doctor.mdx +41 -2
- package/docs/cli/help.mdx +4 -2
- package/docs/cli/license.mdx +4 -5
- package/docs/cli/setup.mdx +13 -2
- package/docs/cli/status.mdx +4 -0
- package/docs/cli.mdx +1 -1
- package/docs/deploy-checks.md +25 -0
- package/docs/desktop.mdx +1 -1
- package/docs/enterprise.md +15 -20
- package/docs/environment.mdx +1 -0
- package/docs/first-drive.md +1 -1
- package/docs/install-manual.mdx +16 -1
- package/docs/packages/engine.mdx +1 -1
- package/docs/packages/server.mdx +1 -1
- package/docs/packages/tauri.mdx +1 -1
- package/docs/packages/test.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/platform-integration.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/telemetry.md +4 -2
- package/package.json +13 -9
- package/LICENSE-ENTERPRISE +0 -37
- package/dist/features/ee/audit-log.d.ts +0 -14
- package/dist/features/ee/audit-log.js +0 -11
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
26
|
-
*
|
|
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
|
-
|
|
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
|
|
87
|
-
*
|
|
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.
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
|
150
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
133
|
+
contact: LICENSE_CONTACT,
|
|
191
134
|
status: LicenseActivation.INVALID,
|
|
192
|
-
detail: 'MISCONFIGURED — running in production with no issuer key baked
|
|
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
|
-
|
|
138
|
+
contact: LICENSE_CONTACT,
|
|
196
139
|
status: LicenseActivation.EVAL,
|
|
197
|
-
detail: 'evaluation mode
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
contact: LICENSE_CONTACT,
|
|
235
168
|
status: LicenseActivation.MISSING,
|
|
236
|
-
detail: `set ${LICENSE_KEY_ENV} to activate
|
|
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
|
-
|
|
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}
|
|
181
|
+
detail: `license expired. Renew with ${LICENSE_CONTACT}`,
|
|
250
182
|
};
|
|
251
183
|
}
|
|
252
184
|
return {
|
|
253
|
-
|
|
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)
|
|
259
|
-
* release, not an eval session
|
|
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
|
+
}
|