@reticlehq/server 2.9.0 → 2.10.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/SKILL.md +17 -17
- package/dist/capsule/capsule-store.d.ts +2 -2
- package/dist/cli/cli-parse.d.ts +1 -1
- package/dist/cli/cli-parse.js +1 -1
- package/dist/cli/cli-verify.js +1 -1
- package/dist/crawl/crawl.d.ts +7 -2
- package/dist/crawl/crawl.js +52 -9
- package/dist/ee/audit-log.js +2 -2
- package/dist/events/body-failures.d.ts +2 -2
- package/dist/events/contradiction-folds.d.ts +45 -0
- package/dist/events/contradiction-folds.js +42 -0
- package/dist/events/contradictions.d.ts +19 -1
- package/dist/events/contradictions.js +17 -2
- package/dist/events/echo-mismatch.d.ts +2 -2
- package/dist/events/stale-response.d.ts +2 -3
- package/dist/events/stale-response.js +49 -2
- package/dist/events/unit-mismatch.d.ts +2 -2
- package/dist/flows/flow-tools.js +1 -1
- package/dist/flows/verify-change-tools.js +1 -1
- package/dist/honesty/verified.js +16 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/init/mcp.d.ts +1 -2
- package/dist/init/mcp.js +4 -1
- package/dist/license/license.d.ts +69 -4
- package/dist/license/license.js +84 -15
- package/dist/mcp/mcp-proxy.d.ts +2 -27
- package/dist/mcp/mcp-proxy.js +4 -88
- package/dist/mcp/mcp.d.ts +18 -2
- package/dist/mcp/mcp.js +31 -10
- package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
- package/dist/mcp/proxy-daemon-probe.js +48 -0
- package/dist/mcp/proxy-handshake.js +1 -1
- package/dist/mcp/server-instructions.js +2 -2
- package/dist/mcp/sse-frame-parser.d.ts +19 -0
- package/dist/mcp/sse-frame-parser.js +41 -0
- package/dist/session/no-session-diagnosis.d.ts +7 -0
- package/dist/session/no-session-diagnosis.js +23 -2
- package/dist/session/no-session-watch.js +7 -0
- package/dist/session/session-readiness.js +1 -1
- package/dist/telemetry/daemon-telemetry.js +7 -2
- package/dist/telemetry/instrumentation-stall.d.ts +7 -0
- package/dist/telemetry/instrumentation-stall.js +13 -0
- package/dist/telemetry/license-activation.d.ts +48 -0
- package/dist/telemetry/license-activation.js +57 -0
- package/dist/telemetry/telemetry.js +10 -1
- package/dist/tools/dynamic-tools.js +1 -1
- package/dist/tools/error-recovery.js +1 -1
- package/dist/tools/feedback-tools.js +31 -6
- package/dist/tools/invoke-tool.d.ts +1 -1
- package/dist/tools/invoke-tool.js +10 -5
- package/dist/tools/tool-kit.d.ts +30 -3
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tool-surface.js +2 -3
- package/dist/tools/tools.js +16 -1
- package/docs/agent-cheatsheet.md +2 -2
- package/docs/capabilities.mdx +4 -4
- package/docs/cli/hunt.mdx +2 -2
- package/docs/deploy-checks.md +2 -2
- package/docs/docs.json +1 -1
- package/docs/enterprise.md +7 -3
- package/docs/flows.md +4 -4
- package/docs/gates.md +1 -1
- package/docs/recipes.mdx +2 -2
- package/docs/telemetry-contract.md +17 -0
- package/docs/tools-overview.mdx +1 -1
- package/docs/tools-session-and-feedback.mdx +5 -0
- package/docs/tools-tools-and-run.mdx +15 -15
- package/docs/usage.md +3 -3
- package/package.json +2 -2
package/dist/honesty/verified.js
CHANGED
|
@@ -133,7 +133,23 @@ export function decideVerified(inputs) {
|
|
|
133
133
|
// A vacuous green: nothing was actually proved. Grade NONE means no signal, no request, no state
|
|
134
134
|
// change and no element was pinned — the assertion could not have failed, which makes passing it
|
|
135
135
|
// evidence of nothing.
|
|
136
|
+
// Two different facts used to share this answer. Everything above has already ruled out a failure,
|
|
137
|
+
// a contradiction, a dirty capture, a pending write and an unread one — so when the window ALSO
|
|
138
|
+
// settled, the engine did not fail to see: it saw the whole window, ran every oracle, and found
|
|
139
|
+
// nothing wrong. That is `no-fault`, and telling it apart from "I could not see" is the difference
|
|
140
|
+
// between "assert something" and "look again with better coverage".
|
|
141
|
+
//
|
|
142
|
+
// Gated on `true === settled` rather than on a caller's say-so. A call that returned before the app
|
|
143
|
+
// stopped moving has not earned this, whatever it declared — which is what stops `no-fault` from
|
|
144
|
+
// becoming the green-forever button an always-available "nothing was wrong" would be.
|
|
136
145
|
if (honesty.grade === HonestyGrade.NONE) {
|
|
146
|
+
if (true === settled) {
|
|
147
|
+
return {
|
|
148
|
+
verified: Verified.NO_FAULT,
|
|
149
|
+
verifiedReason: VerifiedReason.NOTHING_DECLARED,
|
|
150
|
+
because: 'the page settled and no channel reported a problem, but nothing was declared to prove — this is not verification. Assert a consequence the action CHANGES (a signal, a request, a route, or store state) to get one',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
137
153
|
return {
|
|
138
154
|
verified: Verified.UNKNOWN,
|
|
139
155
|
verifiedReason: VerifiedReason.VACUOUS_GRADE,
|
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,24 @@ export { VisualStore } from './visual/visual-store.js';
|
|
|
28
28
|
export { diffPng } from './visual/visual-diff.js';
|
|
29
29
|
export type { VisualDiffResult, VisualRect, DiffOptions } from './visual/visual-diff.js';
|
|
30
30
|
export { crawl } from './crawl/crawl.js';
|
|
31
|
+
/**
|
|
32
|
+
* The contradiction pass, and the seam a consumer adds its own rules through.
|
|
33
|
+
*
|
|
34
|
+
* Exported because a service embedding this engine grades a recording the same way the tools do, and
|
|
35
|
+
* without these it would have to re-implement the fold — a second implementation of the one thing
|
|
36
|
+
* this product is judged on, and the one nobody dogfoods is the one that rots.
|
|
37
|
+
*/
|
|
38
|
+
export { findContradictions } from './events/contradictions.js';
|
|
39
|
+
/**
|
|
40
|
+
* The MCP server factory, so a consumer can serve the tool surface it composed.
|
|
41
|
+
*
|
|
42
|
+
* Without this the composition seam is unreachable from outside the package: a consumer can build the
|
|
43
|
+
* list and has nothing to hand it to.
|
|
44
|
+
*/
|
|
45
|
+
export { createMcpServer } from './mcp/mcp.js';
|
|
46
|
+
export type { Contradiction, ContradictionOptions } from './events/contradictions.js';
|
|
47
|
+
export { registerContradictionFold, registeredContradictionFolds, } from './events/contradiction-folds.js';
|
|
48
|
+
export type { ContradictionFold } from './events/contradiction-folds.js';
|
|
31
49
|
export { MCP_SSE_PATH, MCP_MESSAGE_PATH } from '@reticlehq/core';
|
|
32
50
|
export { BrowserPool, DEFAULT_LEASE_TTL_MS } from './pool/browser-pool.js';
|
|
33
51
|
export type { Lease, Launcher, PooledBrowser } from './pool/browser-pool.js';
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,22 @@ export { ProjectStore } from './project/project-store.js';
|
|
|
72
72
|
export { VisualStore } from './visual/visual-store.js';
|
|
73
73
|
export { diffPng } from './visual/visual-diff.js';
|
|
74
74
|
export { crawl } from './crawl/crawl.js';
|
|
75
|
+
/**
|
|
76
|
+
* The contradiction pass, and the seam a consumer adds its own rules through.
|
|
77
|
+
*
|
|
78
|
+
* Exported because a service embedding this engine grades a recording the same way the tools do, and
|
|
79
|
+
* without these it would have to re-implement the fold — a second implementation of the one thing
|
|
80
|
+
* this product is judged on, and the one nobody dogfoods is the one that rots.
|
|
81
|
+
*/
|
|
82
|
+
export { findContradictions } from './events/contradictions.js';
|
|
83
|
+
/**
|
|
84
|
+
* The MCP server factory, so a consumer can serve the tool surface it composed.
|
|
85
|
+
*
|
|
86
|
+
* Without this the composition seam is unreachable from outside the package: a consumer can build the
|
|
87
|
+
* list and has nothing to hand it to.
|
|
88
|
+
*/
|
|
89
|
+
export { createMcpServer } from './mcp/mcp.js';
|
|
90
|
+
export { registerContradictionFold, registeredContradictionFolds, } from './events/contradiction-folds.js';
|
|
75
91
|
export { MCP_SSE_PATH, MCP_MESSAGE_PATH } from '@reticlehq/core';
|
|
76
92
|
export { BrowserPool, DEFAULT_LEASE_TTL_MS } from './pool/browser-pool.js';
|
|
77
93
|
export { playwrightLauncher, resolveMaxContexts } from './pool/playwright-launcher.js';
|
package/dist/init/mcp.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* project's `.reticle.json` in its CWD (see cli-port.ts). The port belongs to the project, not the
|
|
12
12
|
* global agent config.
|
|
13
13
|
*/
|
|
14
|
-
export
|
|
14
|
+
export { MCP_SERVER_NAME } from '@reticlehq/core';
|
|
15
15
|
/**
|
|
16
16
|
* The registered command is STILL bare `npx` on every platform, deliberately.
|
|
17
17
|
*
|
|
@@ -76,4 +76,3 @@ export declare function claudeAvailableProbe(): {
|
|
|
76
76
|
};
|
|
77
77
|
/** Printed when the `claude` CLI isn't available — register Reticle globally once, by hand. */
|
|
78
78
|
export declare function mcpManual(): string;
|
|
79
|
-
export {};
|
package/dist/init/mcp.js
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
* global agent config.
|
|
13
13
|
*/
|
|
14
14
|
import { RETICLE_NPM_PACKAGE } from '../version/server-version.js';
|
|
15
|
-
|
|
15
|
+
// The name is a wire identity and lives in the contract; re-exported here so the registration writers
|
|
16
|
+
// below (and their tests) keep reading it from the module that USES it.
|
|
17
|
+
export { MCP_SERVER_NAME } from '@reticlehq/core';
|
|
18
|
+
import { MCP_SERVER_NAME } from '@reticlehq/core';
|
|
16
19
|
/**
|
|
17
20
|
* The registered command is STILL bare `npx` on every platform, deliberately.
|
|
18
21
|
*
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { type KeyObject } from 'node:crypto';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { LicenseActivation } from '@reticlehq/core';
|
|
15
16
|
/** Outcome of verifying a license key. */
|
|
16
17
|
export declare const LicenseStatus: {
|
|
17
18
|
readonly VALID: "valid";
|
|
@@ -21,18 +22,31 @@ export declare const LicenseStatus: {
|
|
|
21
22
|
readonly EXPIRED: "expired";
|
|
22
23
|
};
|
|
23
24
|
export type LicenseStatus = (typeof LicenseStatus)[keyof typeof LicenseStatus];
|
|
24
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features
|
|
27
|
+
* unlock.
|
|
28
|
+
*
|
|
29
|
+
* `lid` is the STABLE LICENSE ID and the only safe join key for per-customer usage: `org` is a display
|
|
30
|
+
* name a human typed at signing time, so two customers called "Acme" merge into one and a customer who
|
|
31
|
+
* renames splits into two. Every key carries one (minted by the issuer, `scripts/issue-license.mjs`),
|
|
32
|
+
* which is why it is required rather than optional — an optional id is one that goes missing on exactly
|
|
33
|
+
* the keys somebody later needs to attribute, and a key without it should be re-issued, not silently
|
|
34
|
+
* counted as nobody.
|
|
35
|
+
*/
|
|
25
36
|
declare const LicensePayloadSchema: z.ZodObject<{
|
|
37
|
+
lid: z.ZodString;
|
|
26
38
|
org: z.ZodString;
|
|
27
39
|
plan: z.ZodString;
|
|
28
40
|
exp: z.ZodNumber;
|
|
29
41
|
features: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
30
42
|
}, "strip", z.ZodTypeAny, {
|
|
43
|
+
lid: string;
|
|
31
44
|
org: string;
|
|
32
45
|
plan: string;
|
|
33
46
|
exp: number;
|
|
34
47
|
features?: string[] | undefined;
|
|
35
48
|
}, {
|
|
49
|
+
lid: string;
|
|
36
50
|
org: string;
|
|
37
51
|
plan: string;
|
|
38
52
|
exp: number;
|
|
@@ -42,13 +56,44 @@ export type LicensePayload = z.infer<typeof LicensePayloadSchema>;
|
|
|
42
56
|
type LicenseCheck = {
|
|
43
57
|
status: typeof LicenseStatus.VALID;
|
|
44
58
|
payload: LicensePayload;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* EXPIRED carries the payload; the other failures cannot. The line is the SIGNATURE, not the
|
|
62
|
+
* clock: an expired key was genuinely issued by us and its claims are exactly as trustworthy as
|
|
63
|
+
* they were yesterday, so the licence id is safe to use. A malformed or wrongly-signed key has no
|
|
64
|
+
* verified claims at all, and naming a customer from one would mean trusting whatever was pasted.
|
|
65
|
+
*/
|
|
66
|
+
| {
|
|
67
|
+
status: typeof LicenseStatus.EXPIRED;
|
|
68
|
+
payload: LicensePayload;
|
|
45
69
|
} | {
|
|
46
|
-
status: Exclude<LicenseStatus, typeof LicenseStatus.VALID>;
|
|
70
|
+
status: Exclude<LicenseStatus, typeof LicenseStatus.VALID | typeof LicenseStatus.EXPIRED>;
|
|
47
71
|
};
|
|
48
72
|
/** Sign a payload into a license key — the ISSUER side (Reticle's private key). Exposed for the issuer tool + tests. */
|
|
49
73
|
export declare function signLicenseKey(payload: LicensePayload, privateKey: KeyObject): string;
|
|
50
74
|
/** Verify a key against the issuer public key. Never throws — returns a structured status. */
|
|
51
75
|
export declare function verifyLicenseKey(key: string | undefined, publicKey: KeyObject, now: number): LicenseCheck;
|
|
76
|
+
/**
|
|
77
|
+
* Where to get a key. A named constant because it appears in the gate's error AND in what
|
|
78
|
+
* `reticle license` prints, and a contact address that drifts between the two is a support request
|
|
79
|
+
* that reaches nobody.
|
|
80
|
+
*/
|
|
81
|
+
export declare const LICENSE_CONTACT = "hey@reticle.sh";
|
|
82
|
+
/**
|
|
83
|
+
* The features a licence gates in THIS build. The single source: `assertEnterprise` callers name a
|
|
84
|
+
* member rather than a string literal, and `reticle license` reports the list, so what the CLI claims
|
|
85
|
+
* is gated is derived from what is actually gated rather than a second list somebody maintains.
|
|
86
|
+
*
|
|
87
|
+
* It is deliberately short. Reticle's roadmap for enterprise is longer (SSO, SCIM, RBAC, multi-org,
|
|
88
|
+
* policy gates), and none of it is here yet — printing a roadmap as though it were shipped is how a
|
|
89
|
+
* buyer discovers on day two that the thing they paid for does not exist. What is listed is what the
|
|
90
|
+
* running build will actually refuse without a key.
|
|
91
|
+
*/
|
|
92
|
+
export declare const EnterpriseFeature: {
|
|
93
|
+
/** Recording an audit event. */
|
|
94
|
+
readonly AUDIT_LOG: "audit-log";
|
|
95
|
+
};
|
|
96
|
+
export type EnterpriseFeature = (typeof EnterpriseFeature)[keyof typeof EnterpriseFeature];
|
|
52
97
|
/** Thrown when a production-mode enterprise feature is used without a valid license. */
|
|
53
98
|
export declare class EnterpriseLicenseError extends Error {
|
|
54
99
|
readonly feature: string;
|
|
@@ -71,8 +116,28 @@ export declare function assertEnterprise(feature: string, ctx: GateContext): voi
|
|
|
71
116
|
export declare const LICENSE_KEY_ENV = "RETICLE_LICENSE_KEY";
|
|
72
117
|
export declare const LICENSE_PUBLIC_KEY_ENV = "RETICLE_LICENSE_PUBLIC_KEY";
|
|
73
118
|
/** The human-facing state of enterprise activation on this machine (what `reticle license status` shows). */
|
|
74
|
-
interface LicenseReport {
|
|
75
|
-
|
|
119
|
+
export interface LicenseReport {
|
|
120
|
+
/**
|
|
121
|
+
* Typed against core's closed list rather than re-listing the strings here: this status is reported
|
|
122
|
+
* on every telemetry event, so a member added in one place and not the other is a silently
|
|
123
|
+
* miscounted column (telemetry contract, rule 4).
|
|
124
|
+
*/
|
|
125
|
+
status: LicenseActivation;
|
|
126
|
+
/** The stable license id — what usage is attributed to. Present only when `status` is `active`. */
|
|
127
|
+
licenseId?: string;
|
|
128
|
+
/**
|
|
129
|
+
* What a licence unlocks in THIS build, and where to get one. Always reported, including on an
|
|
130
|
+
* unlicensed install: `reticle license` was the one command that talks about licensing and it named
|
|
131
|
+
* neither, so it told an interested reader to set a variable without saying what it would unlock or
|
|
132
|
+
* how to obtain it. A dead end at exactly the moment somebody is asking.
|
|
133
|
+
*/
|
|
134
|
+
gated: readonly string[];
|
|
135
|
+
contact: string;
|
|
136
|
+
/**
|
|
137
|
+
* The key is valid, and scoped to features this build does not gate, so it unlocks nothing here.
|
|
138
|
+
* Present only when true: a `false` on every healthy licence is noise on the common case.
|
|
139
|
+
*/
|
|
140
|
+
coversNothingHere?: boolean;
|
|
76
141
|
org?: string;
|
|
77
142
|
plan?: string;
|
|
78
143
|
expiresAt?: number;
|
package/dist/license/license.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { LicenseActivation } from '@reticlehq/core';
|
|
15
16
|
/** Outcome of verifying a license key. */
|
|
16
17
|
export const LicenseStatus = {
|
|
17
18
|
VALID: 'valid',
|
|
@@ -20,8 +21,19 @@ export const LicenseStatus = {
|
|
|
20
21
|
BAD_SIGNATURE: 'bad-signature',
|
|
21
22
|
EXPIRED: 'expired',
|
|
22
23
|
};
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The signed claims inside a key. `exp` is epoch ms; `features` (optional) scopes which ee features
|
|
26
|
+
* unlock.
|
|
27
|
+
*
|
|
28
|
+
* `lid` is the STABLE LICENSE ID and the only safe join key for per-customer usage: `org` is a display
|
|
29
|
+
* name a human typed at signing time, so two customers called "Acme" merge into one and a customer who
|
|
30
|
+
* renames splits into two. Every key carries one (minted by the issuer, `scripts/issue-license.mjs`),
|
|
31
|
+
* which is why it is required rather than optional — an optional id is one that goes missing on exactly
|
|
32
|
+
* the keys somebody later needs to attribute, and a key without it should be re-issued, not silently
|
|
33
|
+
* counted as nobody.
|
|
34
|
+
*/
|
|
24
35
|
const LicensePayloadSchema = z.object({
|
|
36
|
+
lid: z.string().min(1),
|
|
25
37
|
org: z.string(),
|
|
26
38
|
plan: z.string(),
|
|
27
39
|
exp: z.number(),
|
|
@@ -68,15 +80,35 @@ export function verifyLicenseKey(key, publicKey, now) {
|
|
|
68
80
|
if (!signatureOk)
|
|
69
81
|
return { status: LicenseStatus.BAD_SIGNATURE };
|
|
70
82
|
if (payload.exp <= now)
|
|
71
|
-
return { status: LicenseStatus.EXPIRED };
|
|
83
|
+
return { status: LicenseStatus.EXPIRED, payload };
|
|
72
84
|
return { status: LicenseStatus.VALID, payload };
|
|
73
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Where to get a key. A named constant because it appears in the gate's error AND in what
|
|
88
|
+
* `reticle license` prints, and a contact address that drifts between the two is a support request
|
|
89
|
+
* that reaches nobody.
|
|
90
|
+
*/
|
|
91
|
+
export const LICENSE_CONTACT = 'hey@reticle.sh';
|
|
92
|
+
/**
|
|
93
|
+
* The features a licence gates in THIS build. The single source: `assertEnterprise` callers name a
|
|
94
|
+
* member rather than a string literal, and `reticle license` reports the list, so what the CLI claims
|
|
95
|
+
* is gated is derived from what is actually gated rather than a second list somebody maintains.
|
|
96
|
+
*
|
|
97
|
+
* It is deliberately short. Reticle's roadmap for enterprise is longer (SSO, SCIM, RBAC, multi-org,
|
|
98
|
+
* policy gates), and none of it is here yet — printing a roadmap as though it were shipped is how a
|
|
99
|
+
* buyer discovers on day two that the thing they paid for does not exist. What is listed is what the
|
|
100
|
+
* running build will actually refuse without a key.
|
|
101
|
+
*/
|
|
102
|
+
export const EnterpriseFeature = {
|
|
103
|
+
/** Recording an audit event. */
|
|
104
|
+
AUDIT_LOG: 'audit-log',
|
|
105
|
+
};
|
|
74
106
|
/** Thrown when a production-mode enterprise feature is used without a valid license. */
|
|
75
107
|
export class EnterpriseLicenseError extends Error {
|
|
76
108
|
feature;
|
|
77
109
|
reason;
|
|
78
110
|
constructor(feature, reason) {
|
|
79
|
-
super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact
|
|
111
|
+
super(`Reticle Enterprise feature "${feature}" requires a valid license (${reason}). Contact ${LICENSE_CONTACT}.`);
|
|
80
112
|
this.name = 'EnterpriseLicenseError';
|
|
81
113
|
this.feature = feature;
|
|
82
114
|
this.reason = reason;
|
|
@@ -122,7 +154,7 @@ export const LICENSE_PUBLIC_KEY_ENV = 'RETICLE_LICENSE_PUBLIC_KEY';
|
|
|
122
154
|
* never setting RETICLE_LICENSE_PUBLIC_KEY, because the baked key takes precedence. The public key is
|
|
123
155
|
* safe to ship openly — it can only verify licenses, never mint them (that needs the private key).
|
|
124
156
|
*/
|
|
125
|
-
const BAKED_ISSUER_PUBLIC_KEY_PEM =
|
|
157
|
+
const BAKED_ISSUER_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAcg+u61Fs5pwDHcYxFGhaxVaQoUbAdyl+RUdSViZyxDY=\n-----END PUBLIC KEY-----";
|
|
126
158
|
/**
|
|
127
159
|
* Resolve the issuer public-key PEM enforcement uses: the baked-in release key wins, so it can't be
|
|
128
160
|
* turned off from the environment. Only when nothing is baked (dev/repo) does the env var apply — that
|
|
@@ -134,6 +166,15 @@ function resolveIssuerPublicKeyPem(env, baked = BAKED_ISSUER_PUBLIC_KEY_PEM) {
|
|
|
134
166
|
const envPem = env[LICENSE_PUBLIC_KEY_ENV];
|
|
135
167
|
return envPem !== undefined && envPem.length > 0 ? envPem : undefined;
|
|
136
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* The two fields every report carries, spread into each branch. One object rather than a field on each
|
|
171
|
+
* return, because the value that must never go missing is the one on the branch nobody was thinking
|
|
172
|
+
* about — and the branch an unlicensed reader lands on is exactly that branch.
|
|
173
|
+
*/
|
|
174
|
+
const LICENSE_OFFER = {
|
|
175
|
+
gated: Object.values(EnterpriseFeature),
|
|
176
|
+
contact: LICENSE_CONTACT,
|
|
177
|
+
};
|
|
137
178
|
function loadPublicKey(pem) {
|
|
138
179
|
if (pem === undefined || 0 === pem.length)
|
|
139
180
|
return undefined;
|
|
@@ -156,42 +197,70 @@ export function describeLicense(now, env = process.env, baked = BAKED_ISSUER_PUB
|
|
|
156
197
|
// is off, and assertEnterpriseFromEnv now denies rather than unlocking. Say so loudly here too.
|
|
157
198
|
return isProductionEnv(env)
|
|
158
199
|
? {
|
|
159
|
-
|
|
200
|
+
...LICENSE_OFFER,
|
|
201
|
+
status: LicenseActivation.INVALID,
|
|
160
202
|
detail: 'MISCONFIGURED — running in production with no issuer key baked; enterprise features are DENIED (rebuild with BAKED_ISSUER_PUBLIC_KEY_PEM stamped in)',
|
|
161
203
|
}
|
|
162
204
|
: {
|
|
163
|
-
|
|
205
|
+
...LICENSE_OFFER,
|
|
206
|
+
status: LicenseActivation.EVAL,
|
|
164
207
|
detail: 'evaluation mode: enterprise features run free (no issuer key configured)',
|
|
165
208
|
};
|
|
166
209
|
}
|
|
167
210
|
const publicKey = loadPublicKey(pem);
|
|
168
211
|
if (publicKey === undefined)
|
|
169
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
...LICENSE_OFFER,
|
|
214
|
+
status: LicenseActivation.INVALID,
|
|
215
|
+
detail: `${LICENSE_PUBLIC_KEY_ENV} is not a valid public key`,
|
|
216
|
+
};
|
|
170
217
|
const check = verifyLicenseKey(env[LICENSE_KEY_ENV], publicKey, now);
|
|
171
218
|
if (check.status === LicenseStatus.VALID) {
|
|
172
|
-
const { org, plan, exp, features } = check.payload;
|
|
219
|
+
const { lid, org, plan, exp, features } = check.payload;
|
|
220
|
+
// A key scoped to features this build does not gate is `active` and unlocks nothing. Both halves
|
|
221
|
+
// were already on screen (`features` against `gated`) and nothing joined them, so the one word a
|
|
222
|
+
// customer actually reads told them they were fine while every gated call refused them.
|
|
223
|
+
const coversNothingHere = features !== undefined && !features.some((f) => LICENSE_OFFER.gated.includes(f));
|
|
224
|
+
const detail = coversNothingHere
|
|
225
|
+
? `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}`
|
|
226
|
+
: `licensed to ${org} (${plan}), expires ${new Date(exp).toISOString()}`;
|
|
173
227
|
return {
|
|
174
|
-
|
|
228
|
+
...LICENSE_OFFER,
|
|
229
|
+
status: LicenseActivation.ACTIVE,
|
|
230
|
+
licenseId: lid,
|
|
175
231
|
org,
|
|
176
232
|
plan,
|
|
177
233
|
expiresAt: exp,
|
|
178
234
|
...(features !== undefined ? { features } : {}),
|
|
179
|
-
|
|
235
|
+
...(coversNothingHere ? { coversNothingHere } : {}),
|
|
236
|
+
detail,
|
|
180
237
|
};
|
|
181
238
|
}
|
|
182
239
|
if (check.status === LicenseStatus.MISSING) {
|
|
183
240
|
return {
|
|
184
|
-
|
|
185
|
-
|
|
241
|
+
...LICENSE_OFFER,
|
|
242
|
+
status: LicenseActivation.MISSING,
|
|
243
|
+
detail: `set ${LICENSE_KEY_ENV} to activate enterprise features in production. ` +
|
|
244
|
+
`Request a key from ${LICENSE_CONTACT}`,
|
|
186
245
|
};
|
|
187
246
|
}
|
|
188
247
|
if (check.status === LicenseStatus.EXPIRED) {
|
|
248
|
+
// The id rides an expired key so a lapse can be attributed to a CUSTOMER. Without it, telemetry
|
|
249
|
+
// reports that somebody's licence ran out and cannot say whose, which is not a renewal signal.
|
|
189
250
|
return {
|
|
190
|
-
|
|
191
|
-
|
|
251
|
+
...LICENSE_OFFER,
|
|
252
|
+
status: LicenseActivation.EXPIRED,
|
|
253
|
+
licenseId: check.payload.lid,
|
|
254
|
+
org: check.payload.org,
|
|
255
|
+
expiresAt: check.payload.exp,
|
|
256
|
+
detail: `license expired. Renew with ${LICENSE_CONTACT} to keep using enterprise features`,
|
|
192
257
|
};
|
|
193
258
|
}
|
|
194
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
...LICENSE_OFFER,
|
|
261
|
+
status: LicenseActivation.INVALID,
|
|
262
|
+
detail: `license key rejected (${check.status}). ${LICENSE_CONTACT} can re-issue it`,
|
|
263
|
+
};
|
|
195
264
|
}
|
|
196
265
|
/** True in a production runtime (NODE_ENV=production) — where a missing issuer key is a mis-built
|
|
197
266
|
* release, not an eval session, so the gate must fail CLOSED rather than run features free. */
|
package/dist/mcp/mcp-proxy.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { SseFrameParser, type SseFrame } from './sse-frame-parser.js';
|
|
2
|
+
export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
|
|
1
3
|
/**
|
|
2
4
|
* Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
|
|
3
5
|
* proxy. Where the code lives is a file-size decision; where it is imported from is an API.
|
|
@@ -92,33 +94,6 @@ export declare class HandshakeReplay {
|
|
|
92
94
|
/** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
|
|
93
95
|
shouldSuppressInbound(line: string): boolean;
|
|
94
96
|
}
|
|
95
|
-
/** One parsed Server-Sent-Events frame: the event name (defaulted to "message") and its data. */
|
|
96
|
-
interface SseFrame {
|
|
97
|
-
event: string;
|
|
98
|
-
data: string;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Incremental SSE frame parser for the MCP front door.
|
|
102
|
-
*
|
|
103
|
-
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
104
|
-
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
105
|
-
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
106
|
-
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
107
|
-
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
108
|
-
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
109
|
-
*/
|
|
110
|
-
export declare class SseFrameParser {
|
|
111
|
-
#private;
|
|
112
|
-
push(chunk: string): SseFrame[];
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Returns true if something is already listening on the reticle port.
|
|
116
|
-
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
117
|
-
* inside the daemon just to check reachability.
|
|
118
|
-
*/
|
|
119
|
-
export declare function probeDaemon(port: number): Promise<boolean>;
|
|
120
|
-
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
121
|
-
export declare function waitForDaemon(port: number): Promise<void>;
|
|
122
97
|
/**
|
|
123
98
|
* Turn an `endpoint` frame's data into a POST target, or null when there is nothing usable in it.
|
|
124
99
|
*
|
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -2,8 +2,11 @@ import * as http from 'node:http';
|
|
|
2
2
|
import { localInitializeResponse, isHandshakeLine, drainLines, MAX_STDIN_LINE_BYTES, } from './proxy-handshake.js';
|
|
3
3
|
import { ToolCatalogCache } from './tool-catalog-cache.js';
|
|
4
4
|
import { toolsChangedNotification } from './proxy-handshake.js';
|
|
5
|
-
import * as net from 'node:net';
|
|
6
5
|
import { LOOPBACK_HOST, MCP_SSE_PATH, MCP_SHUTDOWN_EVENT, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
6
|
+
import { SseFrameParser } from './sse-frame-parser.js';
|
|
7
|
+
export { SseFrameParser } from './sse-frame-parser.js';
|
|
8
|
+
export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
|
|
9
|
+
import { probeDaemon } from './proxy-daemon-probe.js';
|
|
7
10
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
8
11
|
import { buildServerInstructions } from './server-instructions.js';
|
|
9
12
|
import { hasProjectConnectedBefore } from '../session/connection-memory.js';
|
|
@@ -26,20 +29,6 @@ export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, re
|
|
|
26
29
|
import { proxyLog } from './proxy-log.js';
|
|
27
30
|
import { log } from '../log.js';
|
|
28
31
|
import { OutageReason, OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
29
|
-
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
30
|
-
/**
|
|
31
|
-
* How long to wait for the spawned daemon's port to accept connections before giving up. The default
|
|
32
|
-
* suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
|
|
33
|
-
* RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
|
|
34
|
-
*/
|
|
35
|
-
const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
|
|
36
|
-
const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
|
|
37
|
-
? envDaemonReadyTimeoutMs
|
|
38
|
-
: DEFAULT_DAEMON_READY_TIMEOUT_MS;
|
|
39
|
-
const DAEMON_POLL_INTERVAL_MS = 100;
|
|
40
|
-
function delay(ms) {
|
|
41
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
-
}
|
|
43
32
|
/** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
|
|
44
33
|
const RECONNECT_BASE_MS = 250;
|
|
45
34
|
const RECONNECT_CAP_MS = 5_000;
|
|
@@ -264,79 +253,6 @@ export class HandshakeReplay {
|
|
|
264
253
|
return true;
|
|
265
254
|
}
|
|
266
255
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Incremental SSE frame parser for the MCP front door.
|
|
269
|
-
*
|
|
270
|
-
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
271
|
-
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
272
|
-
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
273
|
-
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
274
|
-
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
275
|
-
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
276
|
-
*/
|
|
277
|
-
export class SseFrameParser {
|
|
278
|
-
#buffer = '';
|
|
279
|
-
#event = '';
|
|
280
|
-
#data = '';
|
|
281
|
-
push(chunk) {
|
|
282
|
-
this.#buffer += chunk;
|
|
283
|
-
// Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
|
|
284
|
-
// next chunk (it may complete later).
|
|
285
|
-
const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
286
|
-
const lines = normalised.split('\n');
|
|
287
|
-
this.#buffer = lines.pop() ?? '';
|
|
288
|
-
const frames = [];
|
|
289
|
-
for (const line of lines) {
|
|
290
|
-
if ('' === line) {
|
|
291
|
-
if (this.#data !== '') {
|
|
292
|
-
frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
|
|
293
|
-
}
|
|
294
|
-
this.#event = '';
|
|
295
|
-
this.#data = '';
|
|
296
|
-
}
|
|
297
|
-
else if (line.startsWith('event:')) {
|
|
298
|
-
this.#event = line.slice(6).trim();
|
|
299
|
-
}
|
|
300
|
-
else if (line.startsWith('data:')) {
|
|
301
|
-
const val = line.slice(5).trim();
|
|
302
|
-
this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
return frames;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Returns true if something is already listening on the reticle port.
|
|
310
|
-
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
311
|
-
* inside the daemon just to check reachability.
|
|
312
|
-
*/
|
|
313
|
-
export function probeDaemon(port) {
|
|
314
|
-
return new Promise((resolve) => {
|
|
315
|
-
const socket = new net.Socket();
|
|
316
|
-
socket.setTimeout(500);
|
|
317
|
-
socket.on('connect', () => {
|
|
318
|
-
socket.destroy();
|
|
319
|
-
resolve(true);
|
|
320
|
-
});
|
|
321
|
-
socket.on('error', () => resolve(false));
|
|
322
|
-
socket.on('timeout', () => {
|
|
323
|
-
socket.destroy();
|
|
324
|
-
resolve(false);
|
|
325
|
-
});
|
|
326
|
-
socket.connect(port, LOOPBACK_HOST);
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
330
|
-
export async function waitForDaemon(port) {
|
|
331
|
-
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
|
332
|
-
while (Date.now() < deadline) {
|
|
333
|
-
const reachable = await probeDaemon(port);
|
|
334
|
-
if (reachable)
|
|
335
|
-
return;
|
|
336
|
-
await delay(DAEMON_POLL_INTERVAL_MS);
|
|
337
|
-
}
|
|
338
|
-
throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
|
|
339
|
-
}
|
|
340
256
|
/**
|
|
341
257
|
* POST one JSON-RPC line into the daemon's session. Resolves null on success, or a short reason
|
|
342
258
|
* when the request never reached a server that will answer it.
|
package/dist/mcp/mcp.d.ts
CHANGED
|
@@ -35,7 +35,14 @@ export declare function encodeResult(result: unknown, encoding: string): string;
|
|
|
35
35
|
* "Call reticle_tools { names: [...] } for its parameters" — leaving it out made our own advice a
|
|
36
36
|
* dead end on the one surface that advertises everything.
|
|
37
37
|
*/
|
|
38
|
-
export declare function advertisedTools(surface: ToolSurface
|
|
38
|
+
export declare function advertisedTools(surface: ToolSurface,
|
|
39
|
+
/**
|
|
40
|
+
* The full table to advertise from. Defaults to what this package ships.
|
|
41
|
+
*
|
|
42
|
+
* A consumer embedding the engine passes its own composition — ours plus its own — so its tools
|
|
43
|
+
* reach the wire without editing the shipped array.
|
|
44
|
+
*/
|
|
45
|
+
tools?: readonly ToolDef[]): ToolDef[];
|
|
39
46
|
/**
|
|
40
47
|
* Exactly what gets advertised for one tool under a profile: trimmed description, lean input shape,
|
|
41
48
|
* and (non-lean only) the output schema.
|
|
@@ -59,4 +66,13 @@ export declare function createMcpServer(deps: ToolDeps, profile?: ToolSurface,
|
|
|
59
66
|
* told to instrument a project that is already wired loses a paragraph, while an agent not told
|
|
60
67
|
* loses the entire install — and the second is what the field overwhelmingly shows.
|
|
61
68
|
*/
|
|
62
|
-
previouslyConnected?: boolean
|
|
69
|
+
previouslyConnected?: boolean,
|
|
70
|
+
/**
|
|
71
|
+
* The full tool table this server serves. Defaults to what this package ships.
|
|
72
|
+
*
|
|
73
|
+
* Threaded rather than read from the module so a consumer can serve its own tools on one surface
|
|
74
|
+
* with ours. It reaches all three places that need the WHOLE table and not just the advertised
|
|
75
|
+
* slice: the profile filter, the `reticle_run` hatch, and the unadvertised-tool help — a consumer
|
|
76
|
+
* tool missing from either of the last two is a tool the agent is actively told does not exist.
|
|
77
|
+
*/
|
|
78
|
+
tools?: readonly ToolDef[]): McpServer;
|