@orion-agents/orion-code 0.3.15 → 0.3.18
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/CHANGELOG.md +254 -0
- package/dist/runtime/release-receipts.d.ts +15 -3
- package/dist/runtime/release-receipts.js +27 -1
- package/dist/services/session-storage.d.ts +9 -0
- package/dist/services/session-storage.js +27 -1
- package/dist/web/git-compare-service.d.ts +45 -0
- package/dist/web/git-compare-service.js +160 -0
- package/dist/web/git-diff-document.d.ts +83 -0
- package/dist/web/git-diff-document.js +214 -0
- package/dist/web/git-file-source.d.ts +25 -0
- package/dist/web/git-file-source.js +21 -0
- package/dist/web/git-history-service.d.ts +151 -0
- package/dist/web/git-history-service.js +610 -0
- package/dist/web/git-index-transaction.d.ts +45 -0
- package/dist/web/git-index-transaction.js +174 -0
- package/dist/web/git-patch-builder.d.ts +30 -0
- package/dist/web/git-patch-builder.js +138 -0
- package/dist/web/git-read-model-service.d.ts +338 -18
- package/dist/web/git-read-model-service.js +1039 -110
- package/dist/web/git-version-service.d.ts +71 -0
- package/dist/web/git-version-service.js +255 -0
- package/dist/web/native-directory-picker.d.ts +63 -0
- package/dist/web/native-directory-picker.js +169 -0
- package/dist/web/protocol.d.ts +51 -2
- package/dist/web/server.js +384 -8
- package/dist/web/workbench-controller.d.ts +126 -10
- package/dist/web/workbench-controller.js +383 -15
- package/dist/web/workspace-open-telemetry.d.ts +55 -0
- package/dist/web/workspace-open-telemetry.js +73 -0
- package/dist/web-client/assets/FilesPanel-BARQCmne.js +1 -0
- package/dist/web-client/assets/GitPanel-DjB9eSiJ.js +4 -0
- package/dist/web-client/assets/ReviewPanel-YhEGlp-G.js +5 -0
- package/dist/web-client/assets/{TerminalPanel-BpsLphLv.js → TerminalPanel-DnWh3Vp2.js} +1 -1
- package/dist/web-client/assets/{index-D3sxrAh_.css → index--hnGXXjj.css} +1 -1
- package/dist/web-client/assets/index-Drja0_BT.js +20 -0
- package/dist/web-client/assets/{useAutoNotice-BeSf9Dfq.js → useAutoNotice-DfQi4G1X.js} +1 -1
- package/dist/web-client/index.html +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/dist/web-client/assets/DiffViewer-C0A42Lma.js +0 -5
- package/dist/web-client/assets/FilesPanel-DiXKnFMj.js +0 -1
- package/dist/web-client/assets/GitPanel--_Yrbldp.js +0 -1
- package/dist/web-client/assets/ReviewPanel-BilzL9Ma.js +0 -1
- package/dist/web-client/assets/index-C2M48tcK.js +0 -20
package/dist/web/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
'
|
|
771
|
+
'summary',
|
|
772
|
+
'body',
|
|
491
773
|
'expectedRepositoryRevision',
|
|
492
774
|
]);
|
|
493
775
|
const requestId = requireUuid(body.requestId, 'requestId');
|
|
494
776
|
const contextGuard = requireContextGuardRecord(body);
|
|
495
|
-
|
|
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', {
|
|
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
|
|
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
|
-
|
|
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';
|