@gmickel/gno 1.23.0 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -12
- package/assets/skill/SKILL.md +37 -19
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +141 -0
- package/spec/db/schema.sql +101 -0
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/spec/output-schemas/setup-activation-result.schema.json +456 -0
- package/spec/output-schemas/setup-command-result.schema.json +93 -0
- package/spec/output-schemas/setup-receipt.schema.json +258 -0
- package/spec/output-schemas/setup-semantic-receipt.schema.json +195 -0
- package/src/cli/commands/completion/scripts.ts +2 -0
- package/src/cli/commands/embed.ts +7 -2
- package/src/cli/commands/setup-activation.ts +324 -0
- package/src/cli/commands/setup-semantic.ts +591 -0
- package/src/cli/commands/setup.ts +410 -0
- package/src/cli/program.ts +64 -0
- package/src/cli/setup-semantic-worker.ts +177 -0
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/config-mutation.ts +94 -64
- package/src/core/file-lock.ts +89 -36
- package/src/core/folder-setup-planning.ts +453 -0
- package/src/core/folder-setup.ts +490 -0
- package/src/core/setup-activation.ts +309 -0
- package/src/core/setup-receipt.ts +321 -0
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/connectors.ts +29 -2
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/routes/api.ts +19 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/** Security and admission boundary for loopback browser-clipper routes. */
|
|
2
|
+
|
|
3
|
+
import type { HttpMcpPeerServer } from "../mcp/http-security";
|
|
4
|
+
|
|
5
|
+
import { readClipperBoundedJson } from "./clipper-body";
|
|
6
|
+
import { clipperSecurityErrorResponse } from "./clipper-security-errors";
|
|
7
|
+
import { ReaderGate } from "./resident-admission";
|
|
8
|
+
|
|
9
|
+
export { readClipperBoundedJson } from "./clipper-body";
|
|
10
|
+
export { ReaderGate as ClipperRequestGate } from "./resident-admission";
|
|
11
|
+
export { clipperSecurityErrorResponse } from "./clipper-security-errors";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
14
|
+
const DEFAULT_MAX_REQUESTS_PER_MINUTE = 60;
|
|
15
|
+
const DEFAULT_MAX_CONCURRENT_REQUESTS = 4;
|
|
16
|
+
const DEFAULT_MAX_QUEUED_REQUESTS = 8;
|
|
17
|
+
const MAX_RATE_KEYS = 4096;
|
|
18
|
+
const RATE_WINDOW_MS = 60_000;
|
|
19
|
+
const PREFLIGHT_MAX_AGE_SECONDS = 600;
|
|
20
|
+
const CHROME_EXTENSION_ORIGIN =
|
|
21
|
+
/^chrome-extension:\/\/(?<extensionId>[a-p]{32})$/;
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_CLIPPER_ALLOWED_HEADERS = [
|
|
24
|
+
"authorization",
|
|
25
|
+
"content-type",
|
|
26
|
+
"idempotency-key",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export interface ClipperSecurityLimits {
|
|
30
|
+
maxBodyBytes: number;
|
|
31
|
+
maxRequestsPerMinute: number;
|
|
32
|
+
maxConcurrentRequests: number;
|
|
33
|
+
maxQueuedRequests: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClipperSecurityConfig {
|
|
37
|
+
allowedHosts: readonly string[];
|
|
38
|
+
sameOrigins: readonly string[];
|
|
39
|
+
allowedHeaders?: readonly string[];
|
|
40
|
+
limits?: Partial<ClipperSecurityLimits>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ClipperOriginPolicy =
|
|
44
|
+
| { allowOriginlessSafeGet?: boolean; kind: "same-origin" }
|
|
45
|
+
| { kind: "extension"; expectedOrigin?: string };
|
|
46
|
+
|
|
47
|
+
export interface ClipperAdmissionPolicy {
|
|
48
|
+
origin: ClipperOriginPolicy;
|
|
49
|
+
readJson?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ClipperPreflightPolicy {
|
|
53
|
+
origin: ClipperOriginPolicy;
|
|
54
|
+
methods: readonly string[];
|
|
55
|
+
headers?: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ClipperSecurityFailure {
|
|
59
|
+
ok: false;
|
|
60
|
+
response: Response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type ClipperSecurityResult<T> = { ok: true; value: T } | ClipperSecurityFailure;
|
|
64
|
+
|
|
65
|
+
export interface ClipperAdmission {
|
|
66
|
+
body?: unknown;
|
|
67
|
+
extensionId?: string;
|
|
68
|
+
origin: string;
|
|
69
|
+
peerAddress: string;
|
|
70
|
+
release(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RateState {
|
|
74
|
+
count: number;
|
|
75
|
+
windowStartedAt: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkedInteger(
|
|
79
|
+
value: number,
|
|
80
|
+
name: string,
|
|
81
|
+
allowZero = false
|
|
82
|
+
): number {
|
|
83
|
+
if (!Number.isInteger(value) || value < (allowZero ? 0 : 1)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${name} must be a ${allowZero ? "non-negative" : "positive"} integer`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validateHostEntry(host: string): boolean {
|
|
92
|
+
if (host !== host.trim() || host.length === 0) return false;
|
|
93
|
+
if (host.includes("*") || host.includes(",")) return false;
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(`http://${host}`);
|
|
96
|
+
return (
|
|
97
|
+
url.host.toLowerCase() === host.toLowerCase() &&
|
|
98
|
+
url.pathname === "/" &&
|
|
99
|
+
isLoopbackHostname(url.hostname)
|
|
100
|
+
);
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateSameOriginEntry(origin: string): boolean {
|
|
107
|
+
if (origin !== origin.trim() || origin.length === 0 || origin.includes("*")) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(origin);
|
|
112
|
+
return (
|
|
113
|
+
url.protocol === "http:" &&
|
|
114
|
+
url.username === "" &&
|
|
115
|
+
url.password === "" &&
|
|
116
|
+
url.pathname === "/" &&
|
|
117
|
+
url.search === "" &&
|
|
118
|
+
url.hash === "" &&
|
|
119
|
+
url.origin === origin &&
|
|
120
|
+
isLoopbackHostname(url.hostname)
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
128
|
+
if (hostname === "localhost") return true;
|
|
129
|
+
return isClipperLoopbackAddress(hostname.replace(/^\[|\]$/g, ""));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizedHeaderNames(headers: readonly string[]): string[] {
|
|
133
|
+
const normalized = headers.map((header) => header.trim().toLowerCase());
|
|
134
|
+
if (
|
|
135
|
+
normalized.some(
|
|
136
|
+
(header) =>
|
|
137
|
+
header.length === 0 ||
|
|
138
|
+
header.includes("*") ||
|
|
139
|
+
!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(header)
|
|
140
|
+
)
|
|
141
|
+
) {
|
|
142
|
+
throw new Error("Clipper allowed headers must contain exact header names");
|
|
143
|
+
}
|
|
144
|
+
return [...new Set(normalized)];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isClipperLoopbackAddress(address: string): boolean {
|
|
148
|
+
const normalized = address.toLowerCase();
|
|
149
|
+
if (normalized === "::1") return true;
|
|
150
|
+
const mapped = normalized.startsWith("::ffff:")
|
|
151
|
+
? normalized.slice("::ffff:".length)
|
|
152
|
+
: normalized;
|
|
153
|
+
const octets = mapped.split(".");
|
|
154
|
+
return (
|
|
155
|
+
octets.length === 4 &&
|
|
156
|
+
octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
|
|
157
|
+
Number(octets[0]) === 127 &&
|
|
158
|
+
octets.every((octet) => Number(octet) <= 255)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseClipperExtensionOrigin(
|
|
163
|
+
origin: string
|
|
164
|
+
): { extensionId: string; origin: string } | null {
|
|
165
|
+
const match = CHROME_EXTENSION_ORIGIN.exec(origin);
|
|
166
|
+
const extensionId = match?.groups?.extensionId;
|
|
167
|
+
return extensionId ? { extensionId, origin } : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function withClipperCors(response: Response, origin: string): Response {
|
|
171
|
+
const headers = new Headers(response.headers);
|
|
172
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
173
|
+
headers.append("Vary", "Origin");
|
|
174
|
+
return new Response(response.body, {
|
|
175
|
+
status: response.status,
|
|
176
|
+
statusText: response.statusText,
|
|
177
|
+
headers,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export class ClipperSecurityBoundary {
|
|
182
|
+
readonly #allowedHosts: readonly string[];
|
|
183
|
+
readonly #sameOrigins: ReadonlySet<string>;
|
|
184
|
+
readonly #allowedHeaders: readonly string[];
|
|
185
|
+
readonly #limits: ClipperSecurityLimits;
|
|
186
|
+
readonly #now: () => number;
|
|
187
|
+
readonly #rates = new Map<string, RateState>();
|
|
188
|
+
readonly #gate: ReaderGate;
|
|
189
|
+
|
|
190
|
+
constructor(
|
|
191
|
+
config: ClipperSecurityConfig,
|
|
192
|
+
options: { now?: () => number } = {}
|
|
193
|
+
) {
|
|
194
|
+
if (
|
|
195
|
+
config.allowedHosts.length === 0 ||
|
|
196
|
+
!config.allowedHosts.every(validateHostEntry)
|
|
197
|
+
) {
|
|
198
|
+
throw new Error("Clipper allowedHosts must contain exact Host values");
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
config.sameOrigins.length === 0 ||
|
|
202
|
+
!config.sameOrigins.every(validateSameOriginEntry)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"Clipper sameOrigins must contain exact loopback HTTP origins"
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const limits = {
|
|
209
|
+
maxBodyBytes: config.limits?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
210
|
+
maxRequestsPerMinute:
|
|
211
|
+
config.limits?.maxRequestsPerMinute ?? DEFAULT_MAX_REQUESTS_PER_MINUTE,
|
|
212
|
+
maxConcurrentRequests:
|
|
213
|
+
config.limits?.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
214
|
+
maxQueuedRequests:
|
|
215
|
+
config.limits?.maxQueuedRequests ?? DEFAULT_MAX_QUEUED_REQUESTS,
|
|
216
|
+
};
|
|
217
|
+
this.#allowedHosts = [...config.allowedHosts];
|
|
218
|
+
this.#sameOrigins = new Set(config.sameOrigins);
|
|
219
|
+
this.#allowedHeaders = normalizedHeaderNames(
|
|
220
|
+
config.allowedHeaders ?? DEFAULT_CLIPPER_ALLOWED_HEADERS
|
|
221
|
+
);
|
|
222
|
+
this.#limits = {
|
|
223
|
+
maxBodyBytes: checkedInteger(limits.maxBodyBytes, "Clipper body limit"),
|
|
224
|
+
maxRequestsPerMinute: checkedInteger(
|
|
225
|
+
limits.maxRequestsPerMinute,
|
|
226
|
+
"Clipper rate limit"
|
|
227
|
+
),
|
|
228
|
+
maxConcurrentRequests: checkedInteger(
|
|
229
|
+
limits.maxConcurrentRequests,
|
|
230
|
+
"Clipper concurrency limit"
|
|
231
|
+
),
|
|
232
|
+
maxQueuedRequests: checkedInteger(
|
|
233
|
+
limits.maxQueuedRequests,
|
|
234
|
+
"Clipper concurrency queue limit",
|
|
235
|
+
true
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
this.#now = options.now ?? Date.now;
|
|
239
|
+
this.#gate = new ReaderGate(
|
|
240
|
+
this.#limits.maxConcurrentRequests,
|
|
241
|
+
this.#limits.maxQueuedRequests
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async admit(
|
|
246
|
+
request: Request,
|
|
247
|
+
server: HttpMcpPeerServer,
|
|
248
|
+
policy: ClipperAdmissionPolicy
|
|
249
|
+
): Promise<ClipperSecurityResult<ClipperAdmission>> {
|
|
250
|
+
const boundary = this.#validateBoundary(request, server, policy.origin);
|
|
251
|
+
if (!boundary.ok) return boundary;
|
|
252
|
+
if (!this.#allowRate(boundary.value.peerAddress)) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
response: withClipperCors(
|
|
256
|
+
clipperSecurityErrorResponse("CLIPPER_RATE_LIMITED"),
|
|
257
|
+
boundary.value.origin
|
|
258
|
+
),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let release: (() => void) | undefined;
|
|
263
|
+
try {
|
|
264
|
+
release = await this.#gate.acquire(request.signal);
|
|
265
|
+
let body: unknown;
|
|
266
|
+
if (policy.readJson) {
|
|
267
|
+
const parsed = await readClipperBoundedJson(
|
|
268
|
+
request,
|
|
269
|
+
this.#limits.maxBodyBytes
|
|
270
|
+
);
|
|
271
|
+
if (!parsed.ok) {
|
|
272
|
+
release();
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
response: withClipperCors(parsed.response, boundary.value.origin),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
body = parsed.value;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ok: true,
|
|
282
|
+
value: {
|
|
283
|
+
...boundary.value,
|
|
284
|
+
body,
|
|
285
|
+
release,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
} catch (error) {
|
|
289
|
+
release?.();
|
|
290
|
+
const code =
|
|
291
|
+
error instanceof Error &&
|
|
292
|
+
error.message === "Resident reader queue is full"
|
|
293
|
+
? "CLIPPER_BUSY"
|
|
294
|
+
: "CLIPPER_ABORTED";
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
response: withClipperCors(
|
|
298
|
+
clipperSecurityErrorResponse(code),
|
|
299
|
+
boundary.value.origin
|
|
300
|
+
),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
handlePreflight(
|
|
306
|
+
request: Request,
|
|
307
|
+
server: HttpMcpPeerServer,
|
|
308
|
+
policy: ClipperPreflightPolicy
|
|
309
|
+
): Response {
|
|
310
|
+
const boundary = this.#validateBoundary(request, server, policy.origin);
|
|
311
|
+
if (!boundary.ok) return boundary.response;
|
|
312
|
+
if (!this.#allowRate(boundary.value.peerAddress)) {
|
|
313
|
+
return withClipperCors(
|
|
314
|
+
clipperSecurityErrorResponse("CLIPPER_RATE_LIMITED"),
|
|
315
|
+
boundary.value.origin
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const requestedMethod = request.headers
|
|
320
|
+
.get("access-control-request-method")
|
|
321
|
+
?.toUpperCase();
|
|
322
|
+
const allowedMethods = policy.methods.map((method) => method.toUpperCase());
|
|
323
|
+
if (!requestedMethod || !allowedMethods.includes(requestedMethod)) {
|
|
324
|
+
return clipperSecurityErrorResponse("CLIPPER_FORBIDDEN");
|
|
325
|
+
}
|
|
326
|
+
const allowedHeaders = new Set(
|
|
327
|
+
normalizedHeaderNames(policy.headers ?? this.#allowedHeaders)
|
|
328
|
+
);
|
|
329
|
+
const requestedHeaders = request.headers
|
|
330
|
+
.get("access-control-request-headers")
|
|
331
|
+
?.split(",")
|
|
332
|
+
.map((header) => header.trim().toLowerCase())
|
|
333
|
+
.filter(Boolean);
|
|
334
|
+
if (
|
|
335
|
+
requestedHeaders?.some((header) => !allowedHeaders.has(header)) ??
|
|
336
|
+
false
|
|
337
|
+
) {
|
|
338
|
+
return clipperSecurityErrorResponse("CLIPPER_FORBIDDEN");
|
|
339
|
+
}
|
|
340
|
+
const privateNetwork = request.headers.get(
|
|
341
|
+
"access-control-request-private-network"
|
|
342
|
+
);
|
|
343
|
+
if (privateNetwork !== null && privateNetwork !== "true") {
|
|
344
|
+
return clipperSecurityErrorResponse("CLIPPER_FORBIDDEN");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const headers = new Headers({
|
|
348
|
+
"Access-Control-Allow-Methods": allowedMethods.join(", "),
|
|
349
|
+
"Access-Control-Allow-Origin": boundary.value.origin,
|
|
350
|
+
"Access-Control-Max-Age": String(PREFLIGHT_MAX_AGE_SECONDS),
|
|
351
|
+
Vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
|
|
352
|
+
});
|
|
353
|
+
if (requestedHeaders && requestedHeaders.length > 0) {
|
|
354
|
+
headers.set("Access-Control-Allow-Headers", requestedHeaders.join(", "));
|
|
355
|
+
}
|
|
356
|
+
if (privateNetwork === "true") {
|
|
357
|
+
headers.set("Access-Control-Allow-Private-Network", "true");
|
|
358
|
+
}
|
|
359
|
+
return new Response(null, { status: 204, headers });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#validateBoundary(
|
|
363
|
+
request: Request,
|
|
364
|
+
server: HttpMcpPeerServer,
|
|
365
|
+
policy: ClipperOriginPolicy
|
|
366
|
+
):
|
|
367
|
+
| {
|
|
368
|
+
ok: true;
|
|
369
|
+
value: { extensionId?: string; origin: string; peerAddress: string };
|
|
370
|
+
}
|
|
371
|
+
| ClipperSecurityFailure {
|
|
372
|
+
const peer = server.requestIP(request);
|
|
373
|
+
if (!peer || !isClipperLoopbackAddress(peer.address)) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const host = request.headers.get("host");
|
|
380
|
+
if (
|
|
381
|
+
!host ||
|
|
382
|
+
!this.#allowedHosts.some(
|
|
383
|
+
(allowedHost) => allowedHost.toLowerCase() === host.toLowerCase()
|
|
384
|
+
)
|
|
385
|
+
) {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const requestOrigin = request.headers.get("origin");
|
|
392
|
+
const origin =
|
|
393
|
+
requestOrigin ??
|
|
394
|
+
(policy.kind === "same-origin" &&
|
|
395
|
+
policy.allowOriginlessSafeGet === true &&
|
|
396
|
+
request.method === "GET" &&
|
|
397
|
+
request.headers.get("sec-fetch-site") === "same-origin"
|
|
398
|
+
? `http://${host}`
|
|
399
|
+
: null);
|
|
400
|
+
if (!origin) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (policy.kind === "same-origin") {
|
|
407
|
+
if (!this.#sameOrigins.has(origin)) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, value: { origin, peerAddress: peer.address } };
|
|
414
|
+
}
|
|
415
|
+
const extension = parseClipperExtensionOrigin(origin);
|
|
416
|
+
if (
|
|
417
|
+
!extension ||
|
|
418
|
+
(policy.expectedOrigin && origin !== policy.expectedOrigin)
|
|
419
|
+
) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
value: { ...extension, peerAddress: peer.address },
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#allowRate(key: string): boolean {
|
|
432
|
+
const now = this.#now();
|
|
433
|
+
let state = this.#rates.get(key);
|
|
434
|
+
if (!state || now - state.windowStartedAt >= RATE_WINDOW_MS) {
|
|
435
|
+
state = { count: 0, windowStartedAt: now };
|
|
436
|
+
this.#rates.set(key, state);
|
|
437
|
+
}
|
|
438
|
+
state.count += 1;
|
|
439
|
+
if (this.#rates.size > MAX_RATE_KEYS) {
|
|
440
|
+
for (const [rateKey, candidate] of this.#rates) {
|
|
441
|
+
if (now - candidate.windowStartedAt >= RATE_WINDOW_MS) {
|
|
442
|
+
this.#rates.delete(rateKey);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (this.#rates.size > MAX_RATE_KEYS) this.#rates.clear();
|
|
446
|
+
}
|
|
447
|
+
return state.count <= this.#limits.maxRequestsPerMinute;
|
|
448
|
+
}
|
|
449
|
+
}
|
package/src/serve/connectors.ts
CHANGED
|
@@ -60,6 +60,13 @@ interface McpConnectorDefinition {
|
|
|
60
60
|
|
|
61
61
|
type ConnectorDefinition = SkillConnectorDefinition | McpConnectorDefinition;
|
|
62
62
|
|
|
63
|
+
export interface ConnectorDefinitionSummary {
|
|
64
|
+
id: string;
|
|
65
|
+
kind: "skill" | "mcp";
|
|
66
|
+
target: string;
|
|
67
|
+
scope: "user" | "project";
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
interface SkillInspection {
|
|
64
71
|
installed: boolean;
|
|
65
72
|
path: string;
|
|
@@ -129,7 +136,7 @@ function toSkillVerificationTarget(
|
|
|
129
136
|
};
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
const CONNECTOR_DEFINITIONS
|
|
139
|
+
const CONNECTOR_DEFINITIONS = [
|
|
133
140
|
{
|
|
134
141
|
id: "claude-code-skill",
|
|
135
142
|
appName: "Claude Code",
|
|
@@ -212,7 +219,27 @@ const CONNECTOR_DEFINITIONS: ConnectorDefinition[] = [
|
|
|
212
219
|
"Recommended default for Hermes Agent. Uses the standard ~/.hermes/skills path.",
|
|
213
220
|
},
|
|
214
221
|
},
|
|
215
|
-
] as const;
|
|
222
|
+
] as const satisfies readonly ConnectorDefinition[];
|
|
223
|
+
|
|
224
|
+
export type ConnectorId = (typeof CONNECTOR_DEFINITIONS)[number]["id"];
|
|
225
|
+
|
|
226
|
+
export function getConnectorDefinition(
|
|
227
|
+
id: string
|
|
228
|
+
): ConnectorDefinitionSummary | null {
|
|
229
|
+
const definition = CONNECTOR_DEFINITIONS.find((entry) => entry.id === id);
|
|
230
|
+
return definition
|
|
231
|
+
? {
|
|
232
|
+
id: definition.id,
|
|
233
|
+
kind: definition.installKind,
|
|
234
|
+
target: definition.target,
|
|
235
|
+
scope: definition.scope,
|
|
236
|
+
}
|
|
237
|
+
: null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function isConnectorId(id: string): id is ConnectorId {
|
|
241
|
+
return getConnectorDefinition(id) !== null;
|
|
242
|
+
}
|
|
216
243
|
|
|
217
244
|
export async function getConnectorStatuses(overrides?: {
|
|
218
245
|
cwd?: string;
|
package/src/serve/public/app.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { WorkspaceTabs } from "./components/WorkspaceTabs";
|
|
|
8
8
|
import { CaptureModalProvider, useCaptureModal } from "./hooks/useCaptureModal";
|
|
9
9
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
|
10
10
|
import { WorkspaceProvider } from "./hooks/useWorkspace";
|
|
11
|
+
import { consumeClipperPairLaunch } from "./lib/clipper-approval";
|
|
11
12
|
import { parseDocumentDeepLink } from "./lib/deep-links";
|
|
12
13
|
import { saveRecentDocument } from "./lib/navigation-state";
|
|
13
14
|
import {
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
} from "./lib/workspace-tabs";
|
|
23
24
|
import Ask from "./pages/Ask";
|
|
24
25
|
import Browse from "./pages/Browse";
|
|
26
|
+
import ClipperPairing from "./pages/ClipperPairing";
|
|
25
27
|
import Collections from "./pages/Collections";
|
|
26
28
|
import Connectors from "./pages/Connectors";
|
|
27
29
|
import Dashboard from "./pages/Dashboard";
|
|
@@ -354,4 +356,9 @@ if (!rootElement) {
|
|
|
354
356
|
throw new Error("Root element not found");
|
|
355
357
|
}
|
|
356
358
|
const root = createRoot(rootElement);
|
|
357
|
-
|
|
359
|
+
if (window.location.pathname === "/clipper/pair") {
|
|
360
|
+
const launch = consumeClipperPairLaunch(window.location, window.history);
|
|
361
|
+
root.render(<ClipperPairing pairId={launch.pairId} />);
|
|
362
|
+
} else {
|
|
363
|
+
root.render(<App />);
|
|
364
|
+
}
|