@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.
Files changed (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +196 -4
  15. package/spec/db/schema.sql +102 -1
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. 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
+ }
@@ -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 };
@@ -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
- root.render(<App />);
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
+ }