@penpair/core 0.4.1 → 0.5.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.
@@ -0,0 +1,466 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat } from "node:fs/promises";
3
+ import { commandSleep } from "../command-result.js";
4
+ import { writeCoordinator } from "../coordination.js";
5
+ import { guardFile } from "../file-guard.js";
6
+ import { runJournaledSteps, readJournalTarget, readJournal, listJournals, observationHash, JournalRunError, } from "../journal.js";
7
+ import { withPageSession, selectPage, rememberPage } from "../page-session.js";
8
+ import { runSnippet } from "../snippet.js";
9
+ import { ensureGitignoreEntry } from "../instance/gitignore.js";
10
+ import { atomicWriteFile } from "../atomic-output.js";
11
+ import { readProjectBytes } from "../bounded-file.js";
12
+ import { artifactPath, saveArtifact } from "./artifact-paths.js";
13
+ import { captureReleaseScreen } from "./live.js";
14
+ import { captureCopyEvidence, matchCopiedFrames, compareCopyEvidence, sourceEvidenceHash, } from "./copy-evidence.js";
15
+ import { nativeCopyInspect, nativeCopyCreatePage, nativeCopySelect, nativeCopyObserve, nativeCopyArrange, } from "./copy-snippets.js";
16
+ import { validateCopyRequest, readCopyPreview, copyRequestHash, } from "./copy-plan.js";
17
+ const operation = "design-release-copy";
18
+ async function observedDestination(client, fileId, pageId) {
19
+ await selectPage(client, {
20
+ id: pageId,
21
+ });
22
+ const result = await runSnippet(client, nativeCopyObserve(fileId, pageId), {
23
+ effect: "read",
24
+ retries: 0,
25
+ });
26
+ if (result.ok !== true ||
27
+ result.fileId !== fileId ||
28
+ result.pageId !== pageId ||
29
+ !Array.isArray(result.rootIds) ||
30
+ !Array.isArray(result.selectionIds) ||
31
+ !Array.isArray(result.rootFrames) ||
32
+ result.rootIds.some((id) => typeof id !== "string")) {
33
+ throw new Error("Destination inventory or selection was not independently observed");
34
+ }
35
+ return result;
36
+ }
37
+ /** Copy native boards through an explicitly supplied editor transport; preview never touches clipboard or selection. */
38
+ export async function copyReleaseBoards(client, root, request, options = {}) {
39
+ validateCopyRequest(request);
40
+ if (options.resume && (!options.apply || !options.runId)) {
41
+ throw new Error("Copy resume requires --apply and the original run ID");
42
+ }
43
+ client = guardFile(client, {
44
+ id: request.fileId,
45
+ }, {
46
+ mode: "strict",
47
+ });
48
+ return withPageSession(client, async () => {
49
+ const inspect = async (ownerKey) => readCopyPreview(await runSnippet(client, nativeCopyInspect(request.fileId, request.sourcePage, request.frameIds, request.releaseVersion, ownerKey), {
50
+ effect: "read",
51
+ retries: 0,
52
+ }), request);
53
+ const current = await inspect();
54
+ if (!options.apply) {
55
+ return {
56
+ schemaVersion: 1,
57
+ kind: "native-release-copy",
58
+ status: "preview",
59
+ preview: current,
60
+ problems: [
61
+ "Native browser copy/paste is experimental; structural and stable-render verification follow an explicit apply.",
62
+ ],
63
+ };
64
+ }
65
+ if (!options.clipboard) {
66
+ throw new Error("Native copy requires an explicitly configured browser clipboard transport");
67
+ }
68
+ const runId = options.runId ?? randomUUID();
69
+ if (!/^[A-Za-z0-9-]{1,100}$/.test(runId)) {
70
+ throw new Error("Invalid native copy run ID");
71
+ }
72
+ const assertNoConflictingRuns = async () => {
73
+ const records = await listJournals(root);
74
+ for (const previous of records.filter((record) => record.operation === operation && record.target.fileId === request.fileId)) {
75
+ if (previous.runId === runId) {
76
+ continue;
77
+ }
78
+ const record = await readJournal(root, previous.runId);
79
+ const payload = record.payload;
80
+ if (previous.state !== "verified" ||
81
+ (payload?.kind === "native-release-copy-plan" &&
82
+ copyRequestHash(payload.request) === copyRequestHash(request))) {
83
+ throw new Error(`Copy run ${previous.runId} already covers this transfer or requires reconciliation; use its explicit --resume before another paste`);
84
+ }
85
+ }
86
+ };
87
+ await assertNoConflictingRuns();
88
+ await ensureGitignoreEntry(root, ".penpair", {
89
+ directory: true,
90
+ comment: "penpair: private operation evidence",
91
+ });
92
+ const prefix = `.penpair/native-copy/${runId}`;
93
+ const fencePath = await artifactPath(root, `${prefix}/paste-attempt.json`, true);
94
+ let payload;
95
+ let attempted = false;
96
+ let pageAttempted = false;
97
+ if (options.resume) {
98
+ const previous = await readJournal(root, runId);
99
+ pageAttempted = previous.steps.some((step) => step.key === "page" && step.state !== "pending");
100
+ payload = previous.payload;
101
+ if (!payload ||
102
+ payload.kind !== "native-release-copy-plan" ||
103
+ copyRequestHash(payload.request) !== copyRequestHash(request)) {
104
+ throw new Error("Copy resume inputs changed; no clipboard operation was dispatched");
105
+ }
106
+ // The durable fence is written before the only paste call. A started
107
+ // journal step may have stopped earlier during page/selection preparation.
108
+ try {
109
+ await lstat(fencePath);
110
+ attempted = true;
111
+ }
112
+ catch (error) {
113
+ if (error.code !== "ENOENT") {
114
+ throw error;
115
+ }
116
+ }
117
+ }
118
+ else {
119
+ const sources = await captureCopyEvidence(client, root, request.fileId, current.sourcePageId, request.frameIds, `${prefix}/source`, options.redact);
120
+ payload = {
121
+ kind: "native-release-copy-plan",
122
+ request,
123
+ preview: current,
124
+ sources,
125
+ };
126
+ await saveArtifact(root, `${prefix}/plan.json`, `${JSON.stringify(payload)}\n`, options.redact);
127
+ }
128
+ if (Buffer.byteLength(JSON.stringify(payload)) > 16 * 1024 * 1024) {
129
+ throw new Error("Native transfer plan exceeds 16 MiB");
130
+ }
131
+ const baseline = payload.preview;
132
+ let pinnedDestinationId = baseline.destinationPageId;
133
+ const readSource = async () => {
134
+ for (const source of payload.sources) {
135
+ const observed = await captureReleaseScreen(client, request.fileId, baseline.sourcePageId, source.frameId);
136
+ if (observationHash(observed.nodes) !== observationHash(source.inspection.nodes)) {
137
+ throw new Error("Source board content changed; retain the run and make a new reviewed plan");
138
+ }
139
+ }
140
+ };
141
+ const targetPreview = async () => {
142
+ const value = await inspect(runId);
143
+ if (!baseline.destinationPageId &&
144
+ value.destinationPageId &&
145
+ value.createdPageOwner !== runId) {
146
+ throw new Error("A different page appeared at the planned destination");
147
+ }
148
+ if (pinnedDestinationId && value.destinationPageId !== pinnedDestinationId) {
149
+ throw new Error("Pinned destination page was replaced or renamed");
150
+ }
151
+ if (value.destinationPageId) {
152
+ pinnedDestinationId = value.destinationPageId;
153
+ }
154
+ if (value.sourcePageId !== baseline.sourcePageId ||
155
+ (baseline.destinationPageId && value.destinationPageId !== baseline.destinationPageId)) {
156
+ throw new Error("Pinned copy page identity changed");
157
+ }
158
+ return value;
159
+ };
160
+ const addedRoots = (value) => value.destinationRootIds.filter((id) => !baseline.destinationRootIds.includes(id));
161
+ const inventoryComplete = (value) => addedRoots(value).length === request.frameIds.length &&
162
+ baseline.destinationRootIds.every((id) => value.destinationRootIds.includes(id));
163
+ const pasteComplete = (value) => attempted &&
164
+ inventoryComplete(value) &&
165
+ pairs.length === request.frameIds.length &&
166
+ pairs.every((pair) => addedRoots(value).includes(pair.targetFrameId));
167
+ let pairs = [];
168
+ let placements = [];
169
+ const mappingPath = `${prefix}/mapping.json`;
170
+ try {
171
+ await artifactPath(root, mappingPath);
172
+ const saved = JSON.parse((await readProjectBytes(root, mappingPath, 1024 * 1024)).toString("utf8"));
173
+ if (saved.requestHash !== copyRequestHash(request) ||
174
+ !Array.isArray(saved.pairs) ||
175
+ saved.pairs.length !== request.frameIds.length ||
176
+ saved.pairs.some((pair, index) => !pair ||
177
+ pair.sourceFrameId !== request.frameIds[index] ||
178
+ typeof pair.targetFrameId !== "string") ||
179
+ new Set(saved.pairs.map((pair) => pair.targetFrameId)).size !==
180
+ saved.pairs.length) {
181
+ throw new Error("Saved native copy mapping is invalid");
182
+ }
183
+ pairs = saved.pairs;
184
+ }
185
+ catch (error) {
186
+ if (error.code !== "ENOENT") {
187
+ throw error;
188
+ }
189
+ }
190
+ const calculatePlacements = () => {
191
+ let x = 0;
192
+ const y = Math.max(1000, baseline.destinationBounds.bottom + 160);
193
+ return pairs.map((pair) => {
194
+ const source = baseline.sourceFrames.find((frame) => frame.id === pair.sourceFrameId);
195
+ const placement = {
196
+ id: pair.targetFrameId,
197
+ x,
198
+ y,
199
+ };
200
+ x += source.width + 160;
201
+ return placement;
202
+ });
203
+ };
204
+ const identifyPasted = async () => {
205
+ const value = await targetPreview();
206
+ if (!attempted || !inventoryComplete(value) || !value.destinationPageId) {
207
+ return value;
208
+ }
209
+ if (!pairs.length) {
210
+ const observed = await observedDestination(client, request.fileId, value.destinationPageId);
211
+ const ids = addedRoots(value);
212
+ if (observationHash([
213
+ ...observed.selectionIds,
214
+ ].sort()) !==
215
+ observationHash([
216
+ ...ids,
217
+ ].sort())) {
218
+ throw new Error("Pasted selection does not uniquely identify the new boards; inspect without replay");
219
+ }
220
+ const targets = await captureCopyEvidence(client, root, request.fileId, value.destinationPageId, ids, `${prefix}/pasted-${randomUUID()}`, options.redact);
221
+ pairs = matchCopiedFrames(payload.sources, targets);
222
+ await saveArtifact(root, mappingPath, JSON.stringify({
223
+ requestHash: copyRequestHash(request),
224
+ pairs,
225
+ }), options.redact);
226
+ }
227
+ placements = calculatePlacements();
228
+ return value;
229
+ };
230
+ const arrangementState = async () => {
231
+ const value = await identifyPasted();
232
+ if (!value.destinationPageId || !pasteComplete(value)) {
233
+ return value;
234
+ }
235
+ const observed = await observedDestination(client, request.fileId, value.destinationPageId);
236
+ value.arranged = placements.every((placement) => observed.rootFrames.some((frame) => frame.id === placement.id &&
237
+ frame.x === placement.x &&
238
+ frame.y === placement.y &&
239
+ frame.copyRunId === runId));
240
+ return value;
241
+ };
242
+ const pageStep = {
243
+ key: "page",
244
+ label: "Prepare the release destination page",
245
+ signature: nativeCopyCreatePage(request.fileId, request.releaseVersion, runId).code,
246
+ inspect: targetPreview,
247
+ apply: async () => {
248
+ await readSource();
249
+ pageAttempted = true;
250
+ const result = await runSnippet(client, nativeCopyCreatePage(request.fileId, request.releaseVersion, runId), {
251
+ effect: "write",
252
+ retries: 0,
253
+ });
254
+ if (typeof result.pageId !== "string") {
255
+ throw new Error("Created release page identity was not acknowledged");
256
+ }
257
+ rememberPage(client, {
258
+ fileId: request.fileId,
259
+ pageId: result.pageId,
260
+ });
261
+ return result;
262
+ },
263
+ verify: (value) => Boolean(value.destinationPageId) && value.destinationNamed !== false,
264
+ reconcile: (_before, value) => value.destinationPageId && value.destinationNamed !== false
265
+ ? "complete"
266
+ : pageAttempted && !value.destinationPageId
267
+ ? "conflict"
268
+ : "pending",
269
+ };
270
+ const pasteStep = {
271
+ key: "paste",
272
+ label: "Copy selected native boards and paste once",
273
+ signature: "native-clipboard-batch-v1-no-replay",
274
+ inspect: identifyPasted,
275
+ apply: async (value) => {
276
+ if (attempted ||
277
+ !value.destinationPageId ||
278
+ addedRoots(value).length ||
279
+ !baseline.destinationRootIds.every((id) => value.destinationRootIds.includes(id))) {
280
+ throw new Error("Paste is fenced or destination changed; inspect without replaying");
281
+ }
282
+ await readSource();
283
+ const clipboard = await options.clipboard();
284
+ try {
285
+ await selectPage(client, {
286
+ id: baseline.sourcePageId,
287
+ });
288
+ await runSnippet(client, nativeCopySelect(request.fileId, baseline.sourcePageId, request.frameIds), {
289
+ effect: "write",
290
+ retries: 0,
291
+ });
292
+ const selection = await observedDestination(client, request.fileId, baseline.sourcePageId);
293
+ if (observationHash([
294
+ ...selection.selectionIds,
295
+ ].sort()) !==
296
+ observationHash([
297
+ ...request.frameIds,
298
+ ].sort())) {
299
+ throw new Error("Source board selection changed before copy");
300
+ }
301
+ await clipboard.copy(baseline.sourcePageId, request.frameIds);
302
+ await selectPage(client, {
303
+ id: value.destinationPageId,
304
+ });
305
+ await runSnippet(client, nativeCopySelect(request.fileId, value.destinationPageId, []), {
306
+ effect: "write",
307
+ retries: 0,
308
+ });
309
+ const destination = await targetPreview();
310
+ if (observationHash(destination.destinationRootIds) !==
311
+ observationHash(baseline.destinationRootIds)) {
312
+ throw new Error("Destination changed before paste");
313
+ }
314
+ const empty = await observedDestination(client, request.fileId, value.destinationPageId);
315
+ if (empty.selectionIds.length) {
316
+ throw new Error("Destination selection changed before paste");
317
+ }
318
+ await atomicWriteFile(fencePath, JSON.stringify({
319
+ schemaVersion: 1,
320
+ runId,
321
+ fileId: request.fileId,
322
+ pageId: value.destinationPageId,
323
+ requestHash: copyRequestHash(request),
324
+ }));
325
+ attempted = true;
326
+ await clipboard.paste(value.destinationPageId);
327
+ for (let poll = 0; poll < 25; poll++) {
328
+ if (inventoryComplete(await targetPreview())) {
329
+ return {
330
+ dispatched: true,
331
+ };
332
+ }
333
+ await (options.sleep ?? commandSleep)(200);
334
+ }
335
+ throw new Error("Paste acknowledgment did not establish new boards; reconcile without replay");
336
+ }
337
+ finally {
338
+ await clipboard.close();
339
+ }
340
+ },
341
+ verify: pasteComplete,
342
+ reconcile: (_before, value) => pasteComplete(value)
343
+ ? "complete"
344
+ : attempted || addedRoots(value).length
345
+ ? "conflict"
346
+ : "pending",
347
+ };
348
+ const arrangeStep = {
349
+ key: "arrange",
350
+ label: "Identify and arrange only the new native copies",
351
+ signature: "native-copy-arrange-v1",
352
+ inspect: arrangementState,
353
+ apply: async (value) => {
354
+ if (!value.destinationPageId || !pasteComplete(value)) {
355
+ throw new Error("Copied root inventory is incomplete");
356
+ }
357
+ placements = calculatePlacements();
358
+ await readSource();
359
+ await selectPage(client, {
360
+ id: value.destinationPageId,
361
+ });
362
+ return runSnippet(client, nativeCopyArrange(request.fileId, value.destinationPageId, placements, runId), {
363
+ effect: "write",
364
+ retries: 0,
365
+ });
366
+ },
367
+ verify: (value) => pasteComplete(value) && value.arranged === true,
368
+ reconcile: (_before, value) => pasteComplete(value) ? (value.arranged === true ? "complete" : "pending") : "conflict",
369
+ };
370
+ let finalResult;
371
+ let receiptPersisted = false;
372
+ try {
373
+ await runJournaledSteps([
374
+ pageStep,
375
+ pasteStep,
376
+ arrangeStep,
377
+ ], {
378
+ root,
379
+ operation,
380
+ input: request,
381
+ payload,
382
+ runId,
383
+ resume: options.resume,
384
+ target: () => readJournalTarget(client),
385
+ coordinate: async (identity, target) => {
386
+ const lease = await writeCoordinator(client, {
387
+ root,
388
+ operation,
389
+ warn: options.onStep,
390
+ })(identity, target);
391
+ try {
392
+ await assertNoConflictingRuns();
393
+ return lease;
394
+ }
395
+ catch (error) {
396
+ await lease.release();
397
+ throw error;
398
+ }
399
+ },
400
+ redact: options.redact,
401
+ onStep: options.onStep,
402
+ finalVerify: async () => {
403
+ const destination = await arrangementState();
404
+ if (!destination.destinationPageId ||
405
+ !pasteComplete(destination) ||
406
+ !destination.arranged) {
407
+ return false;
408
+ }
409
+ const targets = await captureCopyEvidence(client, root, request.fileId, destination.destinationPageId, pairs.map((pair) => pair.targetFrameId), `${prefix}/target-${randomUUID()}`, options.redact);
410
+ const verifiedPairs = matchCopiedFrames(payload.sources, targets);
411
+ if (observationHash(verifiedPairs) !== observationHash(pairs)) {
412
+ throw new Error("Copied identities changed before final readback");
413
+ }
414
+ const verification = compareCopyEvidence(payload.sources, targets, pairs);
415
+ const originals = await captureCopyEvidence(client, root, request.fileId, baseline.sourcePageId, request.frameIds, `${prefix}/source-readback-${randomUUID()}`, options.redact);
416
+ if (sourceEvidenceHash(originals) !== sourceEvidenceHash(payload.sources)) {
417
+ throw new Error("Original boards changed during transfer; independent reconciliation is required");
418
+ }
419
+ await selectPage(client, {
420
+ id: destination.destinationPageId,
421
+ });
422
+ finalResult = {
423
+ schemaVersion: 1,
424
+ kind: "native-release-copy",
425
+ status: verification.nativeVerified && verification.pixelsMatch ? "copied" : "incomplete",
426
+ preview: destination,
427
+ runId,
428
+ receiptPath: `${prefix}/receipt.json`,
429
+ pairs,
430
+ nativeVerified: verification.nativeVerified,
431
+ pixelsMatch: verification.pixelsMatch,
432
+ problems: verification.problems,
433
+ };
434
+ const receipt = JSON.stringify({
435
+ ...finalResult,
436
+ nodeMap: verification.nodeMap,
437
+ sourceEvidence: payload.sources.map(({ inspection: _inspection, ...source }) => source),
438
+ targetEvidence: targets.map(({ inspection: _inspection, ...target }) => target),
439
+ }, null, 2);
440
+ const receiptFile = await artifactPath(root, finalResult.receiptPath, true);
441
+ if (options.redact && options.redact(receipt) !== receipt) {
442
+ throw new Error("Copy receipt requires credential redaction");
443
+ }
444
+ await atomicWriteFile(receiptFile, `${receipt}\n`, {
445
+ replace: true,
446
+ });
447
+ receiptPersisted = true;
448
+ return verification.nativeVerified && verification.pixelsMatch;
449
+ },
450
+ });
451
+ }
452
+ catch (error) {
453
+ if (finalResult?.status === "incomplete" &&
454
+ receiptPersisted &&
455
+ error instanceof JournalRunError &&
456
+ error.outcome === "failed") {
457
+ return finalResult;
458
+ }
459
+ throw error;
460
+ }
461
+ if (!finalResult) {
462
+ throw new Error("Native copy final verification did not run");
463
+ }
464
+ return finalResult;
465
+ });
466
+ }
@@ -6,3 +6,4 @@ export { finalizeRelease } from "./finalize.js";
6
6
  export { checkRelease } from "./check.js";
7
7
  export { releaseContext } from "./context.js";
8
8
  export { validateDesignRelease, validateReleasePlan, hashDesignRelease, hashDesignRelease as releaseDesignHash, } from "./validation.js";
9
+ export * from "./copy.js";
@@ -5,3 +5,4 @@ export { finalizeRelease } from "./finalize.js";
5
5
  export { checkRelease } from "./check.js";
6
6
  export { releaseContext } from "./context.js";
7
7
  export { validateDesignRelease, validateReleasePlan, hashDesignRelease, hashDesignRelease as releaseDesignHash, } from "./validation.js";
8
+ export * from "./copy.js";
@@ -13,9 +13,9 @@ const string = {
13
13
  minLength: 1,
14
14
  maxLength: 1000,
15
15
  };
16
- const integer = (max) => ({
16
+ const integer = (max, min = 0) => ({
17
17
  type: "integer",
18
- minimum: 0,
18
+ minimum: min,
19
19
  maximum: max,
20
20
  });
21
21
  const definitions = [
@@ -247,6 +247,83 @@ const definitions = [
247
247
  ],
248
248
  localWrite: true,
249
249
  },
250
+ {
251
+ name: "design_release_copy",
252
+ annotations: {
253
+ readOnlyHint: false,
254
+ destructiveHint: false,
255
+ idempotentHint: false,
256
+ openWorldHint: true,
257
+ },
258
+ description: "Experimental same-file native board copy to release/<version>, preview by default. Requires a configured project, pinned file and connected Penpot plugin; advertised only with --allow-writes. Applying also requires an already enabled loopback browser debugging port, existing clipboard read/write access and focused editor canvas. Does not start a browser or grant permissions. Host-independent CLI/MCP operation; no automatic fallback to the assistant's computer-control tools. The successful native-UI trial used an injected test driver; bundled CDP/Penpot qualification remains pending. Preserves originals, changes focus/clipboard, never blindly replays an uncertain paste and does not approve a release.",
259
+ properties: {
260
+ sourcePage: {
261
+ ...string,
262
+ description: "Exact source page name or ID within the project's pinned Penpot file.",
263
+ },
264
+ frameIds: {
265
+ type: "array",
266
+ items: string,
267
+ minItems: 1,
268
+ maxItems: 100,
269
+ uniqueItems: true,
270
+ description: "Observed native board IDs on the source page; selections must not overlap.",
271
+ },
272
+ releaseVersion: {
273
+ ...string,
274
+ pattern: "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$",
275
+ description: "Version naming the new or existing destination page: release/<version>.",
276
+ },
277
+ debugPort: {
278
+ ...integer(65535, 1),
279
+ description: "Required for apply: an existing loopback CDP port. This does not enable debugging or grant clipboard access.",
280
+ },
281
+ apply: {
282
+ type: "boolean",
283
+ default: false,
284
+ description: "False previews only. True explicitly authorizes native copying and requires debugPort plus existing browser clipboard read/write access.",
285
+ },
286
+ resume: {
287
+ ...string,
288
+ description: "Original run ID for explicit reconciliation with apply=true and the same inputs; never blindly repeats a paste.",
289
+ },
290
+ },
291
+ required: [
292
+ "sourcePage",
293
+ "frameIds",
294
+ "releaseVersion",
295
+ ],
296
+ write: true,
297
+ command: (a) => [
298
+ "design-release",
299
+ "copy",
300
+ "--source-page",
301
+ String(a.sourcePage),
302
+ ...a.frameIds.flatMap((frameId) => [
303
+ "--frame",
304
+ frameId,
305
+ ]),
306
+ "--release",
307
+ String(a.releaseVersion),
308
+ ...(a.debugPort !== undefined
309
+ ? [
310
+ "--debug-port",
311
+ String(a.debugPort),
312
+ ]
313
+ : []),
314
+ ...(a.apply
315
+ ? [
316
+ "--apply",
317
+ ]
318
+ : []),
319
+ ...(a.resume
320
+ ? [
321
+ "--resume",
322
+ String(a.resume),
323
+ ]
324
+ : []),
325
+ ],
326
+ },
250
327
  {
251
328
  name: "run_registered",
252
329
  annotations: {
@@ -329,6 +406,7 @@ function validate(def, raw) {
329
406
  !value ||
330
407
  value.startsWith("-") ||
331
408
  value.length > (schema.maxLength ?? 1000) ||
409
+ (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) ||
332
410
  /[\x00-\x1f]/.test(value))) {
333
411
  throw new Error(`Invalid ${key}`);
334
412
  }
@@ -336,7 +414,22 @@ function validate(def, raw) {
336
414
  throw new Error(`Invalid ${key}`);
337
415
  }
338
416
  if (schema.type === "integer" &&
339
- (!Number.isInteger(value) || Number(value) < 0 || Number(value) > schema.maximum)) {
417
+ (!Number.isInteger(value) ||
418
+ Number(value) < (schema.minimum ?? 0) ||
419
+ Number(value) > schema.maximum)) {
420
+ throw new Error(`Invalid ${key}`);
421
+ }
422
+ if (schema.type === "array" &&
423
+ (!Array.isArray(value) ||
424
+ value.length < (schema.minItems ?? 0) ||
425
+ value.length > (schema.maxItems ?? 100) ||
426
+ (schema.uniqueItems && new Set(value).size !== value.length) ||
427
+ value.some((item) => schema.items?.type !== "string" ||
428
+ typeof item !== "string" ||
429
+ !item.trim() ||
430
+ item.startsWith("-") ||
431
+ item.length > (schema.items.maxLength ?? 1000) ||
432
+ /[\x00-\x1f]/.test(item)))) {
340
433
  throw new Error(`Invalid ${key}`);
341
434
  }
342
435
  if (key === "params" &&
@@ -351,6 +444,9 @@ function validate(def, raw) {
351
444
  if ((a.resume || a.checkpoint) && !a.apply) {
352
445
  throw new Error("Explicit resume requires apply");
353
446
  }
447
+ if (def.name === "design_release_copy" && a.apply && a.debugPort === undefined) {
448
+ throw new Error("Native copy apply requires debugPort");
449
+ }
354
450
  return a;
355
451
  }
356
452
  /** Long-lived server with an immutable explicit root and no process.chdir or raw-code tool. */
@@ -0,0 +1,17 @@
1
+ import { type BrowserTargetOptions } from "./browser-wake.js";
2
+ export interface NativeClipboard {
3
+ copy(expectedPageId: string, expectedFrameIds: string[]): Promise<void>;
4
+ paste(expectedPageId: string): Promise<void>;
5
+ close(): Promise<void>;
6
+ }
7
+ export interface ClipboardSocket extends EventTarget {
8
+ readyState: number;
9
+ send(data: string): void;
10
+ close(): void;
11
+ }
12
+ export interface NativeClipboardOptions extends BrowserTargetOptions {
13
+ /** Tests may substitute the protocol transport; ordinary commands use Node's WebSocket. */
14
+ createSocket?: (url: string) => ClipboardSocket;
15
+ }
16
+ /** Holds a short exclusive clipboard session; closing releases control, never the browser. */
17
+ export declare function connectNativeClipboard(options: NativeClipboardOptions): Promise<NativeClipboard>;