@gmickel/gno 1.24.0 → 1.26.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/README.md +18 -4
- package/assets/skill/SKILL.md +40 -17
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.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 +196 -4
- package/spec/db/schema.sql +102 -1
- 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/project-profile-apply.schema.json +209 -0
- package/spec/output-schemas/project-profile-command.schema.json +160 -0
- package/spec/output-schemas/query-diagnose.schema.json +7 -1
- package/spec/output-schemas/setup-profile-result.schema.json +87 -0
- package/spec/project-profile.schema.json +301 -0
- package/src/cli/commands/collection/add.ts +39 -45
- package/src/cli/commands/collection/remove.ts +28 -28
- package/src/cli/commands/collection/rename.ts +55 -73
- package/src/cli/commands/context/add.ts +37 -26
- package/src/cli/commands/context/rm.ts +47 -20
- package/src/cli/commands/init.ts +55 -125
- package/src/cli/commands/models/use.ts +43 -38
- package/src/cli/commands/profile-apply.ts +334 -0
- package/src/cli/commands/profile.ts +409 -0
- package/src/cli/commands/setup-activation.ts +205 -54
- package/src/cli/commands/setup-profile.ts +223 -0
- package/src/cli/commands/setup.ts +3 -0
- package/src/cli/program.ts +110 -9
- package/src/config/index.ts +3 -0
- package/src/config/project-profile.ts +367 -0
- package/src/config/saver.ts +16 -7
- package/src/config/types.ts +42 -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 +138 -76
- package/src/core/config-write-lock.ts +89 -0
- package/src/core/context-identity.ts +16 -0
- package/src/core/context-resolver.ts +2 -12
- package/src/core/file-lock.ts +20 -6
- package/src/core/folder-setup-planning.ts +6 -21
- package/src/core/folder-setup.ts +30 -2
- package/src/core/path-rules.ts +53 -0
- package/src/core/project-affinity-surface.ts +102 -7
- package/src/core/project-profile-apply-state.ts +268 -0
- package/src/core/project-profile-apply-validation.ts +95 -0
- package/src/core/project-profile-apply.ts +408 -0
- package/src/core/project-profile-canonical.ts +71 -0
- package/src/core/project-profile-diff.ts +302 -0
- package/src/core/project-profile-discovery.ts +519 -0
- package/src/core/project-profile-file.ts +37 -0
- package/src/core/project-profile-parser.ts +98 -0
- package/src/core/project-profile.ts +490 -0
- package/src/ingestion/walker.ts +85 -44
- package/src/llm/cache.ts +21 -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/config-sync.ts +2 -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/resident-runtime.ts +1 -0
- package/src/serve/routes/api.ts +20 -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/021-multi-context-identity.ts +37 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +83 -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/config-sync.ts
CHANGED
|
@@ -44,7 +44,7 @@ export async function applyConfigChange(
|
|
|
44
44
|
const result = await applyConfigChangeCore(
|
|
45
45
|
{
|
|
46
46
|
store,
|
|
47
|
-
configPath,
|
|
47
|
+
configPath: configPath ?? ctxHolder.actualConfigPath,
|
|
48
48
|
onConfigUpdated: (config) => {
|
|
49
49
|
ctxHolder.config = config;
|
|
50
50
|
ctxHolder.current = { ...ctxHolder.current, config };
|
|
@@ -72,7 +72,7 @@ export async function applyConfigChangeTyped<T>(
|
|
|
72
72
|
const result = await applyConfigChangeCore(
|
|
73
73
|
{
|
|
74
74
|
store,
|
|
75
|
-
configPath,
|
|
75
|
+
configPath: configPath ?? ctxHolder.actualConfigPath,
|
|
76
76
|
onConfigUpdated: (config) => {
|
|
77
77
|
ctxHolder.config = config;
|
|
78
78
|
ctxHolder.current = { ...ctxHolder.current, config };
|
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
|
+
}
|