@orion-agents/orion-code 0.3.14 → 0.3.17

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 (49) hide show
  1. package/CHANGELOG.md +286 -0
  2. package/README.md +6 -8
  3. package/README.zh-CN.md +4 -5
  4. package/dist/runtime/release-receipts.d.ts +15 -3
  5. package/dist/runtime/release-receipts.js +27 -1
  6. package/dist/services/session-storage.d.ts +9 -0
  7. package/dist/services/session-storage.js +27 -1
  8. package/dist/web/git-compare-service.d.ts +45 -0
  9. package/dist/web/git-compare-service.js +160 -0
  10. package/dist/web/git-diff-document.d.ts +83 -0
  11. package/dist/web/git-diff-document.js +214 -0
  12. package/dist/web/git-file-source.d.ts +25 -0
  13. package/dist/web/git-file-source.js +21 -0
  14. package/dist/web/git-history-service.d.ts +151 -0
  15. package/dist/web/git-history-service.js +610 -0
  16. package/dist/web/git-index-transaction.d.ts +45 -0
  17. package/dist/web/git-index-transaction.js +174 -0
  18. package/dist/web/git-patch-builder.d.ts +30 -0
  19. package/dist/web/git-patch-builder.js +134 -0
  20. package/dist/web/git-read-model-service.d.ts +338 -18
  21. package/dist/web/git-read-model-service.js +1029 -110
  22. package/dist/web/git-version-service.d.ts +71 -0
  23. package/dist/web/git-version-service.js +255 -0
  24. package/dist/web/native-directory-picker.d.ts +63 -0
  25. package/dist/web/native-directory-picker.js +169 -0
  26. package/dist/web/protocol.d.ts +51 -2
  27. package/dist/web/server.js +384 -8
  28. package/dist/web/workbench-controller.d.ts +126 -10
  29. package/dist/web/workbench-controller.js +383 -15
  30. package/dist/web/workspace-open-telemetry.d.ts +55 -0
  31. package/dist/web/workspace-open-telemetry.js +73 -0
  32. package/dist/web-client/assets/FilesPanel-BARQCmne.js +1 -0
  33. package/dist/web-client/assets/GitPanel-DjB9eSiJ.js +4 -0
  34. package/dist/web-client/assets/ReviewPanel-YhEGlp-G.js +5 -0
  35. package/dist/web-client/assets/{TerminalPanel-IkCttxgw.js → TerminalPanel-DnWh3Vp2.js} +1 -1
  36. package/dist/web-client/assets/index--hnGXXjj.css +1 -0
  37. package/dist/web-client/assets/index-Drja0_BT.js +20 -0
  38. package/dist/web-client/assets/useAutoNotice-DfQi4G1X.js +1 -0
  39. package/dist/web-client/favicon.svg +9 -0
  40. package/dist/web-client/index.html +3 -2
  41. package/npm-shrinkwrap.json +2 -2
  42. package/package.json +1 -1
  43. package/dist/web-client/assets/DiffViewer-BrHqudpK.js +0 -5
  44. package/dist/web-client/assets/FilesPanel-BcFY6L1a.js +0 -1
  45. package/dist/web-client/assets/GitPanel-B6E5IEXm.js +0 -1
  46. package/dist/web-client/assets/ResourceSplitLayout-fPYE9JKJ.js +0 -1
  47. package/dist/web-client/assets/ReviewPanel-5ecTVmhT.js +0 -1
  48. package/dist/web-client/assets/index-DgVNIqmE.js +0 -20
  49. package/dist/web-client/assets/index-R6E589Fc.css +0 -1
@@ -12,8 +12,70 @@ const errors_1 = require("./errors");
12
12
  const cli_options_1 = require("./cli-options");
13
13
  const terminal_server_1 = require("./terminal-server");
14
14
  const workbench_controller_1 = require("./workbench-controller");
15
+ const native_directory_picker_1 = require("./native-directory-picker");
15
16
  const HOST = '127.0.0.1';
16
17
  const API_PREFIX = '/api/v1';
18
+ /**
19
+ * Names that always resolve to this machine.
20
+ *
21
+ * The Host check exists to stop DNS rebinding: an attacker who points their own domain at
22
+ * 127.0.0.1 still sends *their* name in the Host header, and that is what must be refused.
23
+ * `localhost` is not attacker-controlled — it is resolved from the OS hosts file — so refusing
24
+ * it only broke the most natural way to open the UI (`http://localhost:4242` returned 421 for
25
+ * every request, including the document itself). The loopback block is accepted for the same
26
+ * reason: the whole of 127.0.0.0/8 is this machine.
27
+ */
28
+ const LOOPBACK_HOSTNAMES = new Set(['localhost', '::1', '[::1]']);
29
+ function isLoopbackHostname(hostname) {
30
+ const value = hostname.toLowerCase();
31
+ if (LOOPBACK_HOSTNAMES.has(value))
32
+ return true;
33
+ const octets = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(value);
34
+ if (!octets)
35
+ return false;
36
+ if (octets[1] !== '127')
37
+ return false;
38
+ return octets.slice(2).every(part => Number(part) <= 255);
39
+ }
40
+ /** Splits a Host header or an Origin into comparable parts. */
41
+ function parseEndpoint(value) {
42
+ if (!value)
43
+ return null;
44
+ try {
45
+ const url = new URL(value.includes('://') ? value : `http://${value}`);
46
+ return Object.freeze({
47
+ protocol: url.protocol,
48
+ hostname: url.hostname.toLowerCase(),
49
+ // `new URL` normalises an explicit :80 away, so both sides go through this same path
50
+ // and stay comparable.
51
+ port: url.port || '80',
52
+ });
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * True when `actual` names the same loopback endpoint as the listener's `origin`.
60
+ *
61
+ * Shared by the Host and Origin checks on purpose: they used to compare differently, so fixing
62
+ * one would have left the other still refusing `localhost` — and a mutation refused at the
63
+ * Origin step looks nothing like a document refused at the Host step.
64
+ *
65
+ * The properties that make the check meaningful are preserved: the hostname must be a loopback
66
+ * name (this is what stops DNS rebinding) and the port and scheme must match the listener.
67
+ */
68
+ function isSameLoopbackEndpoint(actual, origin) {
69
+ const expected = parseEndpoint(origin);
70
+ const candidate = parseEndpoint(actual);
71
+ if (expected === null || candidate === null)
72
+ return false;
73
+ if (candidate.port !== expected.port)
74
+ return false;
75
+ if (candidate.protocol !== expected.protocol)
76
+ return false;
77
+ return isLoopbackHostname(candidate.hostname);
78
+ }
17
79
  async function startOrionWebServer(options) {
18
80
  const requestedPort = options.port ?? cli_options_1.DEFAULT_WEB_PORT;
19
81
  if (!Number.isSafeInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
@@ -23,7 +85,14 @@ async function startOrionWebServer(options) {
23
85
  if (!/^[A-Za-z0-9_-]+$/u.test(nonce)) {
24
86
  throw new Error('Web nonce must use unpadded base64url characters.');
25
87
  }
26
- const workbench = options.workbench ?? (await workbench_controller_1.WebWorkbenchController.create({ cwd: options.cwd }));
88
+ // v0.3.17 — E2E drives the picker from a script file so CI never opens a real
89
+ // Finder dialog. Unset in production, which keeps the native adapter.
90
+ const fixturePicker = (0, native_directory_picker_1.fixturePickerFromEnvironment)();
91
+ const workbench = options.workbench ??
92
+ (await workbench_controller_1.WebWorkbenchController.create({
93
+ cwd: options.cwd,
94
+ ...(fixturePicker ? { nativeDirectoryPicker: fixturePicker } : {}),
95
+ }));
27
96
  const staticRoot = options.staticRoot ?? resolveDefaultStaticRoot();
28
97
  let origin = '';
29
98
  let closing;
@@ -185,7 +254,7 @@ async function handleRequest(context) {
185
254
  const contextGuard = requireContextGuardRecord(body);
186
255
  const workspace = requireText(body.path, 'path', 4096);
187
256
  const result = await context.workbench.executeMutation(requestId, 'workspace.activate', { path: workspace, ...contextGuard }, async () => {
188
- await context.workbench.switchWorkspace(workspace, contextGuard);
257
+ await context.workbench.switchWorkspace(workspace, contextGuard, requestId);
189
258
  return {
190
259
  requestId,
191
260
  active: context.workbench.workspace,
@@ -195,6 +264,42 @@ async function handleRequest(context) {
195
264
  sendJson(response, 200, result);
196
265
  return;
197
266
  }
267
+ // v0.3.16 — open the OS directory picker. A browse action: the result is a
268
+ // path the client must still confirm, so nothing is registered or activated.
269
+ // v0.3.17 — browse admission: authenticated and single-flight, but NOT a
270
+ // mutation. It must not raise the global Context mutation admission or enter
271
+ // the idempotency ledger, otherwise an open Finder dialog looks like a busy
272
+ // Runtime and a project switch is refused while the user is still browsing.
273
+ if (method === 'POST' && path === '/workspaces/pick-directory') {
274
+ assertMutation(request, context.nonce, context.origin);
275
+ const body = requireRecord(await readJson(request), 'Directory pick request');
276
+ assertOnlyKeys(body, ['requestId', 'title', 'initialPath']);
277
+ const requestId = requireUuid(body.requestId, 'requestId');
278
+ const title = optionalText(body.title, 'title', 200);
279
+ const initialPath = optionalText(body.initialPath, 'initialPath', 4096);
280
+ const picked = await context.workbench.pickDirectory({ title, initialPath, requestId });
281
+ sendJson(response, 200, {
282
+ requestId,
283
+ outcome: picked.kind,
284
+ ...(picked.kind === 'selected' ? { path: picked.path } : {}),
285
+ ...(picked.kind === 'unavailable' ? { reason: picked.reason } : {}),
286
+ });
287
+ return;
288
+ }
289
+ // v0.3.16 — read-only preview of a candidate directory. Authenticated
290
+ // transport only: no mutation reservation and no idempotency cache, because
291
+ // this writes nothing and a retry is harmless.
292
+ if (method === 'POST' && path === '/workspaces/inspect') {
293
+ assertMutation(request, context.nonce, context.origin);
294
+ const body = requireRecord(await readJson(request), 'Workspace inspect request');
295
+ assertOnlyKeys(body, ['requestId', 'path', 'source']);
296
+ const requestId = requireUuid(body.requestId, 'requestId');
297
+ const candidatePath = requireText(body.path, 'path', 4096);
298
+ const source = requireCandidateSource(optionalText(body.source, 'source', 32));
299
+ const candidate = await context.workbench.inspectWorkspaceFast(candidatePath, source, requestId);
300
+ sendJson(response, 200, { requestId, candidate });
301
+ return;
302
+ }
198
303
  if (method === 'GET' && path === '/sessions') {
199
304
  const contextGuard = requireContextGuardQuery(url);
200
305
  sendJson(response, 200, collectionPage(url, 'sessions', context.workbench.listSessions(contextGuard), session => session.id));
@@ -423,11 +528,19 @@ async function handleRequest(context) {
423
528
  }
424
529
  if (method === 'GET' && path === '/git/status') {
425
530
  const contextGuard = requireContextGuardQuery(url);
531
+ const group = url.searchParams.get('group');
532
+ const searchQuery = url.searchParams.get('query');
426
533
  sendJson(response, 200, await context.workbench.gitStatus(contextGuard, {
427
534
  ...(url.searchParams.get('cursor')
428
535
  ? { cursor: url.searchParams.get('cursor') }
429
536
  : {}),
430
537
  pageSize: boundedInteger(url.searchParams.get('pageSize'), 200, 1, 2000),
538
+ // v0.3.17 S1 — filtering happens in the Host so result counts cover the whole
539
+ // matching set. v0.3.17 S4 — the cast is to the *working-tree* source set: a status
540
+ // query can never filter by the comparison-only `commit` source, and the service
541
+ // validates the value either way.
542
+ ...(group ? { group: group } : {}),
543
+ ...(searchQuery ? { query: searchQuery } : {}),
431
544
  }));
432
545
  return;
433
546
  }
@@ -479,6 +592,174 @@ async function handleRequest(context) {
479
592
  sendJson(response, 200, result);
480
593
  return;
481
594
  }
595
+ if (method === 'GET' && path === '/git/commit/preview') {
596
+ const contextGuard = requireContextGuardQuery(url);
597
+ sendJson(response, 200, await context.workbench.gitCommitPreview(contextGuard));
598
+ return;
599
+ }
600
+ // v0.3.17 S4 — history reads. All of these are read-only: none of them can touch the
601
+ // working tree or the index, which is the S4 exit criterion.
602
+ if (method === 'GET' && path === '/git/refs') {
603
+ const contextGuard = requireContextGuardQuery(url);
604
+ sendJson(response, 200, await context.workbench.gitRefs(contextGuard));
605
+ return;
606
+ }
607
+ if (method === 'GET' && path === '/git/history') {
608
+ const contextGuard = requireContextGuardQuery(url);
609
+ sendJson(response, 200, await context.workbench.gitHistory(contextGuard, {
610
+ ...optionalQueryParams(url, ['message', 'author', 'sha', 'path', 'since', 'until', 'rev', 'cursor']),
611
+ pageSize: boundedInteger(url.searchParams.get('pageSize'), 30, 1, 100),
612
+ }));
613
+ return;
614
+ }
615
+ if (method === 'GET' && path === '/git/compare') {
616
+ const contextGuard = requireContextGuardQuery(url);
617
+ const baseRef = url.searchParams.get('base');
618
+ const headRef = url.searchParams.get('head');
619
+ const mode = url.searchParams.get('mode') === 'merge-base' ? 'merge-base' : 'snapshot';
620
+ if (!baseRef || !headRef)
621
+ throw new HttpProblem(400, 'base and head are required.');
622
+ sendJson(response, 200, await context.workbench.gitCompare(contextGuard, { baseRef, headRef, mode }));
623
+ return;
624
+ }
625
+ if (method === 'GET' && path === '/git/compare/file') {
626
+ const contextGuard = requireContextGuardQuery(url);
627
+ const baseOid = url.searchParams.get('baseOid');
628
+ const headOid = url.searchParams.get('headOid');
629
+ const filePath = url.searchParams.get('path');
630
+ if (!baseOid || !headOid || !filePath) {
631
+ throw new HttpProblem(400, 'baseOid, headOid and path are required.');
632
+ }
633
+ sendJson(response, 200, await context.workbench.gitCompareFileDiff(contextGuard, {
634
+ baseOid,
635
+ headOid,
636
+ path: filePath,
637
+ ...(url.searchParams.get('cursor')
638
+ ? { cursor: url.searchParams.get('cursor') }
639
+ : {}),
640
+ }));
641
+ return;
642
+ }
643
+ if (method === 'GET' && path === '/git/blame') {
644
+ const contextGuard = requireContextGuardQuery(url);
645
+ const filePath = url.searchParams.get('path');
646
+ if (!filePath)
647
+ throw new HttpProblem(400, 'path is required.');
648
+ sendJson(response, 200, await context.workbench.gitBlame(contextGuard, {
649
+ path: filePath,
650
+ ...(url.searchParams.get('rev') ? { rev: url.searchParams.get('rev') } : {}),
651
+ limit: boundedInteger(url.searchParams.get('limit'), 2000, 1, 5000),
652
+ }));
653
+ return;
654
+ }
655
+ if (method === 'GET' && path === '/git/graph') {
656
+ const contextGuard = requireContextGuardQuery(url);
657
+ sendJson(response, 200, await context.workbench.gitGraph(contextGuard, {
658
+ limit: boundedInteger(url.searchParams.get('limit'), 200, 1, 2000),
659
+ }));
660
+ return;
661
+ }
662
+ if (method === 'GET' && path === '/git/files-target') {
663
+ const contextGuard = requireContextGuardQuery(url);
664
+ const fileToken = url.searchParams.get('fileToken');
665
+ if (!fileToken)
666
+ throw new HttpProblem(400, 'fileToken is required.');
667
+ sendJson(response, 200, await context.workbench.gitFilesTarget(contextGuard, fileToken));
668
+ return;
669
+ }
670
+ if (method === 'GET' && path === '/git/submodules') {
671
+ const contextGuard = requireContextGuardQuery(url);
672
+ sendJson(response, 200, await context.workbench.gitSubmodules(contextGuard));
673
+ return;
674
+ }
675
+ if (method === 'GET' && path === '/git/conflict') {
676
+ const contextGuard = requireContextGuardQuery(url);
677
+ const filePath = url.searchParams.get('path');
678
+ if (!filePath)
679
+ throw new HttpProblem(400, 'path is required.');
680
+ sendJson(response, 200, await context.workbench.gitConflictVersions(contextGuard, filePath));
681
+ return;
682
+ }
683
+ if (method === 'GET' && path === '/git/blob') {
684
+ const contextGuard = requireContextGuardQuery(url);
685
+ const filePath = url.searchParams.get('path');
686
+ const rev = url.searchParams.get('rev');
687
+ if (!filePath || !rev)
688
+ throw new HttpProblem(400, 'path and rev are required.');
689
+ sendJson(response, 200, await context.workbench.gitBlob(contextGuard, { path: filePath, rev }));
690
+ return;
691
+ }
692
+ if (method === 'GET' && path === '/git/file-history') {
693
+ const contextGuard = requireContextGuardQuery(url);
694
+ const filePath = url.searchParams.get('path');
695
+ if (!filePath)
696
+ throw new HttpProblem(400, 'path is required.');
697
+ sendJson(response, 200, await context.workbench.gitFileHistory(contextGuard, {
698
+ path: filePath,
699
+ ...(url.searchParams.get('rev') ? { rev: url.searchParams.get('rev') } : {}),
700
+ limit: boundedInteger(url.searchParams.get('limit'), 30, 1, 200),
701
+ }));
702
+ return;
703
+ }
704
+ if (method === 'GET' && path.startsWith('/git/commit/')) {
705
+ const contextGuard = requireContextGuardQuery(url);
706
+ const [, , , rawOid, sub] = path.split('/');
707
+ const oid = decodeURIComponent(rawOid ?? '');
708
+ const parentIndex = boundedInteger(url.searchParams.get('parent'), 0, 0, 63);
709
+ if (sub === 'files') {
710
+ sendJson(response, 200, await context.workbench.gitCommitFiles(contextGuard, oid, parentIndex));
711
+ return;
712
+ }
713
+ if (sub === 'diff') {
714
+ const filePath = url.searchParams.get('path');
715
+ if (!filePath)
716
+ throw new HttpProblem(400, 'path is required.');
717
+ sendJson(response, 200, await context.workbench.gitCommitDiff(contextGuard, {
718
+ oid,
719
+ path: filePath,
720
+ parentIndex,
721
+ ...(url.searchParams.get('cursor')
722
+ ? { cursor: url.searchParams.get('cursor') }
723
+ : {}),
724
+ }));
725
+ return;
726
+ }
727
+ sendJson(response, 200, await context.workbench.gitCommitDetail(contextGuard, oid, parentIndex));
728
+ return;
729
+ }
730
+ // v0.3.17 S3 — hunk / line level staging. The body carries ids only; the Host rebuilds the
731
+ // patch from the reviewed document, so a browser can never submit patch text or Git flags.
732
+ if (method === 'POST' && path === '/git/patch') {
733
+ assertMutation(request, context.nonce, context.origin, 'git_mutation_forbidden');
734
+ assertGitUserGesture(request);
735
+ const body = requireRecord(await readJson(request), 'Git patch request');
736
+ assertOnlyKeys(body, [
737
+ 'requestId',
738
+ 'expectedContextRevision',
739
+ 'workspaceId',
740
+ 'fileId',
741
+ 'hunkIds',
742
+ 'lineIds',
743
+ 'expectedRepositoryRevision',
744
+ ]);
745
+ const requestId = requireUuid(body.requestId, 'requestId');
746
+ const contextGuard = requireContextGuardRecord(body);
747
+ const fileId = requireText(body.fileId, 'fileId', 256);
748
+ const hunkIds = optionalStringList(body.hunkIds, 'hunkIds', 200, 256);
749
+ const lineIds = optionalStringList(body.lineIds, 'lineIds', 2000, 256);
750
+ if (hunkIds.length === 0 && lineIds.length === 0) {
751
+ throw new errors_1.WebWorkbenchError(400, 'Select at least one hunk or line.', 'git_patch_invalid');
752
+ }
753
+ const expectedRepositoryRevision = requireGitRevision(body.expectedRepositoryRevision);
754
+ const result = await context.workbench.executeMutation(requestId, 'git.patch', { fileId, hunkIds, lineIds, expectedRepositoryRevision, ...contextGuard }, async () => context.workbench.gitApplyPatch(contextGuard, {
755
+ fileId,
756
+ hunkIds,
757
+ lineIds,
758
+ expectedRepositoryRevision,
759
+ }));
760
+ sendJson(response, 200, result);
761
+ return;
762
+ }
482
763
  if (method === 'POST' && path === '/git/commit') {
483
764
  assertMutation(request, context.nonce, context.origin, 'git_mutation_forbidden');
484
765
  assertGitUserGesture(request);
@@ -487,14 +768,23 @@ async function handleRequest(context) {
487
768
  'requestId',
488
769
  'expectedContextRevision',
489
770
  'workspaceId',
490
- 'message',
771
+ 'summary',
772
+ 'body',
491
773
  'expectedRepositoryRevision',
492
774
  ]);
493
775
  const requestId = requireUuid(body.requestId, 'requestId');
494
776
  const contextGuard = requireContextGuardRecord(body);
495
- const message = requireText(body.message, 'message', 2000);
777
+ // Newlines are legitimate in a commit body; only control characters are rejected, and
778
+ // that check lives with the message builder so the two never drift apart.
779
+ const summary = requireText(body.summary, 'summary', 2000);
780
+ const commitBody = body.body === undefined ? undefined : requireTextAllowingNewlines(body.body, 'body', 20000);
496
781
  const expectedRepositoryRevision = requireGitRevision(body.expectedRepositoryRevision);
497
- const result = await context.workbench.executeMutation(requestId, 'git.commit', { message, expectedRepositoryRevision, ...contextGuard }, async () => context.workbench.gitCommit(contextGuard, { message, expectedRepositoryRevision }));
782
+ const result = await context.workbench.executeMutation(requestId, 'git.commit', { summary, body: commitBody, expectedRepositoryRevision, ...contextGuard }, async () => context.workbench.gitCommit(contextGuard, {
783
+ summary,
784
+ ...(commitBody === undefined ? {} : { body: commitBody }),
785
+ expectedRepositoryRevision,
786
+ requestId,
787
+ }));
498
788
  sendJson(response, 200, result);
499
789
  return;
500
790
  }
@@ -508,6 +798,22 @@ async function handleRequest(context) {
508
798
  }));
509
799
  return;
510
800
  }
801
+ const gitDiffV2Match = path.match(/^\/git\/diff-v2\/([^/]+)$/);
802
+ if (method === 'GET' && gitDiffV2Match) {
803
+ const contextGuard = requireContextGuardQuery(url);
804
+ sendJson(response, 200, await context.workbench.gitDiffDocument(contextGuard, {
805
+ fileId: safeDecodePathSegment(gitDiffV2Match[1]),
806
+ ...(url.searchParams.get('cursor')
807
+ ? { cursor: url.searchParams.get('cursor') }
808
+ : {}),
809
+ lineLimit: boundedInteger(url.searchParams.get('lineLimit'), 240, 1, 500),
810
+ byteLimit: boundedInteger(url.searchParams.get('byteLimit'), 256 * 1024, 1024, 1024 * 1024),
811
+ // v0.3.17 S5 — bound into the pagination cursor by the service.
812
+ ...(url.searchParams.get('ignoreWhitespace') === '1' ? { ignoreWhitespace: true } : {}),
813
+ ...(url.searchParams.get('wordDiff') === '1' ? { wordDiff: true } : {}),
814
+ }));
815
+ return;
816
+ }
511
817
  const gitDiffMatch = path.match(/^\/git\/diff\/([^/]+)$/);
512
818
  if (method === 'GET' && gitDiffMatch) {
513
819
  const contextGuard = requireContextGuardQuery(url);
@@ -630,7 +936,7 @@ function openEventStream(request, response, url, workbench) {
630
936
  response.once('close', detach);
631
937
  }
632
938
  function assertMutation(request, nonce, origin, forbiddenCode = 'request_forbidden') {
633
- if (request.headers.origin !== origin) {
939
+ if (!isSameLoopbackEndpoint(request.headers.origin ?? '', origin)) {
634
940
  throw new HttpProblem(403, 'Mutation requires the exact loopback Origin.', forbiddenCode);
635
941
  }
636
942
  const fetchSite = request.headers['sec-fetch-site'];
@@ -685,11 +991,16 @@ function assertTerminalUserGesture(request) {
685
991
  throw new HttpProblem(403, 'Terminal creation requires an explicit browser user gesture.', 'terminal_user_gesture_required');
686
992
  }
687
993
  }
994
+ /**
995
+ * Rejects any request whose Host is not this machine and this port.
996
+ *
997
+ * `evil.invalid` / `attacker.invalid` stay refused (the anti-rebinding property), while
998
+ * `localhost` and the loopback block are accepted because they name the same listener.
999
+ */
688
1000
  function assertHost(request, origin) {
689
1001
  if (!origin)
690
1002
  return;
691
- const expected = new URL(origin).host;
692
- if (request.headers.host !== expected) {
1003
+ if (!isSameLoopbackEndpoint(request.headers.host ?? '', origin)) {
693
1004
  throw new HttpProblem(421, 'Request Host does not match the loopback listener.');
694
1005
  }
695
1006
  }
@@ -831,6 +1142,8 @@ function defaultProblemCode(status) {
831
1142
  return 'unsupported_media_type';
832
1143
  case 421:
833
1144
  return 'misdirected_request';
1145
+ case 429:
1146
+ return 'too_many_requests';
834
1147
  case 503:
835
1148
  return 'service_unavailable';
836
1149
  default:
@@ -843,6 +1156,16 @@ function requireRecord(value, name) {
843
1156
  }
844
1157
  return value;
845
1158
  }
1159
+ /** v0.3.17 S4 — copies only the named, non-empty query parameters. */
1160
+ function optionalQueryParams(url, names) {
1161
+ const out = {};
1162
+ for (const name of names) {
1163
+ const value = url.searchParams.get(name);
1164
+ if (value !== null && value !== '')
1165
+ out[name] = value;
1166
+ }
1167
+ return out;
1168
+ }
846
1169
  function requireText(value, name, maxLength) {
847
1170
  if (typeof value !== 'string' || !value.trim()) {
848
1171
  throw new HttpProblem(400, `${name} must be a non-empty string.`);
@@ -851,6 +1174,57 @@ function requireText(value, name, maxLength) {
851
1174
  throw new HttpProblem(400, `${name} is too long.`);
852
1175
  return value.trim();
853
1176
  }
1177
+ function optionalText(value, name, maxLength) {
1178
+ if (value === undefined || value === null)
1179
+ return undefined;
1180
+ return requireText(value, name, maxLength);
1181
+ }
1182
+ /**
1183
+ * v0.3.17 S3 — commit bodies legitimately span lines, so trimming happens without
1184
+ * rejecting newlines. Control characters are rejected later by `normalizeCommitMessage`,
1185
+ * which is the single owner of that rule.
1186
+ */
1187
+ function requireTextAllowingNewlines(value, name, maxLength) {
1188
+ if (typeof value !== 'string') {
1189
+ throw new HttpProblem(400, `${name} must be a string.`);
1190
+ }
1191
+ if (value.length > maxLength)
1192
+ throw new HttpProblem(400, `${name} is too long.`);
1193
+ return value;
1194
+ }
1195
+ /** v0.3.17 S3 — bounded list of opaque ids; never interpreted as paths or flags. */
1196
+ function optionalStringList(value, name, maxItems, maxItemLength) {
1197
+ if (value === undefined || value === null)
1198
+ return [];
1199
+ if (!Array.isArray(value))
1200
+ throw new HttpProblem(400, `${name} must be an array.`);
1201
+ if (value.length > maxItems)
1202
+ throw new HttpProblem(400, `${name} has too many entries.`);
1203
+ const items = [];
1204
+ for (const entry of value) {
1205
+ if (typeof entry !== 'string' || !entry || entry.length > maxItemLength) {
1206
+ throw new HttpProblem(400, `${name} contains an invalid entry.`);
1207
+ }
1208
+ items.push(entry);
1209
+ }
1210
+ return items;
1211
+ }
1212
+ /** v0.3.16 — where a workspace candidate came from; `manual` is the default. */
1213
+ const CANDIDATE_SOURCES = [
1214
+ 'picker',
1215
+ 'recent',
1216
+ 'pinned',
1217
+ 'discovered',
1218
+ 'manual',
1219
+ ];
1220
+ function requireCandidateSource(value) {
1221
+ if (value === undefined)
1222
+ return 'manual';
1223
+ const source = CANDIDATE_SOURCES.find(candidate => candidate === value);
1224
+ if (!source)
1225
+ throw new HttpProblem(400, 'Unknown workspace candidate source.');
1226
+ return source;
1227
+ }
854
1228
  function requireUuid(value, name) {
855
1229
  const text = requireText(value, name, 128);
856
1230
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(text)) {
@@ -899,6 +1273,8 @@ function statusTitle(status) {
899
1273
  return 'Unsupported media type';
900
1274
  if (status === 421)
901
1275
  return 'Misdirected request';
1276
+ if (status === 429)
1277
+ return 'Too many requests';
902
1278
  if (status === 503)
903
1279
  return 'Service unavailable';
904
1280
  return 'Internal server error';