@playcraft/cli 0.0.59 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/agent/local-backend.js +1 -1
- package/dist/utils/agent-api-client.js +50 -7
- package/dist/utils/tool-operation-journal.js +82 -16
- package/dist/workspace-runtime/codex/app-server.js +135 -169
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +19 -2
- package/dist/workspace-runtime/codex/launcher-client.js +155 -0
- package/dist/workspace-runtime/codex/loopback.js +118 -37
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +213 -200
- package/dist/workspace-runtime/persistence/sqlite-client.js +61 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1025 -0
- package/dist/workspace-runtime/serve.js +35 -1
- package/dist/workspace-runtime/server/desktop-host.js +314 -0
- package/dist/workspace-runtime/server/dispatch.js +108 -48
- package/dist/workspace-runtime/server/http.js +260 -12
- package/dist/workspace-runtime/server/terminal-host.js +243 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +276 -0
- package/dist/workspace-runtime/workspaces/git-credential-helper.js +66 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +154 -0
- package/dist/workspace-runtime/workspaces/git.js +16 -3
- package/dist/workspace-runtime/workspaces/prepare.js +29 -172
- package/dist/workspace-runtime/workspaces/registry.js +12 -2
- package/package.json +7 -4
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
|
|
2
2
|
import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
5
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
6
6
|
import { canExecuteKind, executeOperation } from './dispatch.js';
|
|
7
7
|
import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
|
|
8
8
|
import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
|
|
9
9
|
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
10
10
|
import { readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
11
|
+
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
12
|
+
import { terminalHost } from './terminal-host.js';
|
|
11
13
|
export async function handleRuntimeRequest(state, request, response) {
|
|
12
14
|
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
13
|
-
|
|
15
|
+
const access = authorizeRuntime(state, request);
|
|
16
|
+
if (!access) {
|
|
14
17
|
writeError(response, runtimeError('unauthenticated', 'control_token_required'));
|
|
15
18
|
return;
|
|
16
19
|
}
|
|
20
|
+
if (access === 'read' && !readSourcePath(request.method ?? 'GET', url.pathname)) {
|
|
21
|
+
writeJson(response, 403, { error: 'source_read_only' });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
17
24
|
try {
|
|
18
25
|
if (request.method === 'GET' && url.pathname === '/runtime/health') {
|
|
19
26
|
writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
|
|
@@ -28,6 +35,7 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
28
35
|
generation,
|
|
29
36
|
runtimeEpoch: state.runtimeEpoch,
|
|
30
37
|
instanceToken: state.instanceToken,
|
|
38
|
+
sourceReadToken: state.journal.sourceReadToken(),
|
|
31
39
|
workspaceMode: state.config.workspaceMode,
|
|
32
40
|
executionGroup: {
|
|
33
41
|
replaced: state.journal.executionGroup().replaced,
|
|
@@ -56,6 +64,11 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
56
64
|
writeJson(response, 200, handshake(state));
|
|
57
65
|
return;
|
|
58
66
|
}
|
|
67
|
+
const historyPath = sessionHistoryPath(url.pathname);
|
|
68
|
+
if (request.method === 'GET' && historyPath) {
|
|
69
|
+
await handleSessionHistory(state, response, historyPath.workspaceId, historyPath.sessionId);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
59
72
|
if (isUnimplementedWorkbenchPath(url.pathname)) {
|
|
60
73
|
if (writeDestroyed(state, response))
|
|
61
74
|
return;
|
|
@@ -80,11 +93,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
80
93
|
});
|
|
81
94
|
return;
|
|
82
95
|
}
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
writeJson(response, 200, {
|
|
86
|
-
events: state.journal.listEvents(Number.isInteger(after) ? after : 0),
|
|
87
|
-
});
|
|
96
|
+
if (url.pathname === '/runtime/events' || url.pathname.startsWith('/runtime/events/')) {
|
|
97
|
+
await handleSourceEvents(state, request, response, url);
|
|
88
98
|
return;
|
|
89
99
|
}
|
|
90
100
|
if (request.method === 'GET' && url.pathname === '/runtime/files') {
|
|
@@ -143,6 +153,10 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
143
153
|
await handleTerminalIdentity(state, request, response, url.pathname.slice('/runtime/terminals/'.length));
|
|
144
154
|
return;
|
|
145
155
|
}
|
|
156
|
+
if (desktopRoute(url.pathname)) {
|
|
157
|
+
await desktopHost(state).handleHttp(request, response, url);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
146
160
|
if (request.method === 'GET' && url.pathname === '/runtime/operations') {
|
|
147
161
|
const kind = operationKindSchema.safeParse(url.searchParams.get('kind'));
|
|
148
162
|
const clientRequestId = url.searchParams.get('clientRequestId');
|
|
@@ -219,9 +233,10 @@ function handshake(state) {
|
|
|
219
233
|
: capability('terminal', true);
|
|
220
234
|
}
|
|
221
235
|
if (item.name === 'desktop') {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
236
|
+
if (destroyed)
|
|
237
|
+
return capability('desktop', false, 'runtime_destroyed');
|
|
238
|
+
const status = desktopHost(state).snapshot().status;
|
|
239
|
+
return status === 'ready' ? capability('desktop', true) : capability('desktop', false, status);
|
|
225
240
|
}
|
|
226
241
|
if (item.name === 'parameters') {
|
|
227
242
|
return destroyed
|
|
@@ -502,7 +517,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
|
|
|
502
517
|
writeError(response, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
503
518
|
return;
|
|
504
519
|
}
|
|
505
|
-
writeError(response, runtimeError('capability_unavailable', '
|
|
520
|
+
writeError(response, runtimeError('capability_unavailable', 'websocket_required'));
|
|
506
521
|
return;
|
|
507
522
|
}
|
|
508
523
|
if (request.method === 'GET' && !action) {
|
|
@@ -517,6 +532,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
|
|
|
517
532
|
status: 'destroyed',
|
|
518
533
|
destroyedAt: new Date().toISOString(),
|
|
519
534
|
});
|
|
535
|
+
terminalHost(state).revoke(destroyed.terminalId, 'terminal_destroyed');
|
|
520
536
|
writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
|
|
521
537
|
return;
|
|
522
538
|
}
|
|
@@ -529,9 +545,67 @@ function unavailableReason(state, kind) {
|
|
|
529
545
|
return 'capability_not_implemented';
|
|
530
546
|
return 'adapter_not_started';
|
|
531
547
|
}
|
|
548
|
+
function sessionHistoryPath(pathname) {
|
|
549
|
+
const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
|
|
550
|
+
if (!match?.[1] || !match[2])
|
|
551
|
+
return undefined;
|
|
552
|
+
try {
|
|
553
|
+
return {
|
|
554
|
+
workspaceId: decodeURIComponent(match[1]),
|
|
555
|
+
sessionId: decodeURIComponent(match[2]),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
532
562
|
function isUnimplementedWorkbenchPath(pathname) {
|
|
533
563
|
const parts = pathname.split('/').filter(Boolean);
|
|
534
|
-
return parts[0] === 'runtime' &&
|
|
564
|
+
return parts[0] === 'runtime' && parts.includes('previews');
|
|
565
|
+
}
|
|
566
|
+
async function handleSessionHistory(state, response, workspaceId, sessionId) {
|
|
567
|
+
if (writeDestroyed(state, response))
|
|
568
|
+
return;
|
|
569
|
+
const session = state.journal.getSession(sessionId);
|
|
570
|
+
if (!session || (session.workspaceId && session.workspaceId !== workspaceId)) {
|
|
571
|
+
writeJson(response, 404, { error: 'session_not_found' });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (state.config.workspaceMode === 'multi' && session.workspaceId !== workspaceId) {
|
|
575
|
+
writeJson(response, 404, { error: 'session_not_found' });
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const readThread = state.native?.readThread;
|
|
579
|
+
if (!readThread) {
|
|
580
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
let read;
|
|
584
|
+
try {
|
|
585
|
+
read = await readThread({ threadId: session.threadId, sessionId });
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (!read || read.threadId !== session.threadId) {
|
|
592
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const body = nativeHistoryReadSchema.safeParse({
|
|
596
|
+
source: 'native',
|
|
597
|
+
sessionId,
|
|
598
|
+
workspaceId,
|
|
599
|
+
threadId: read.threadId,
|
|
600
|
+
...(read.threadName ? { threadName: read.threadName } : {}),
|
|
601
|
+
truncated: read.truncated,
|
|
602
|
+
items: read.items,
|
|
603
|
+
});
|
|
604
|
+
if (!body.success) {
|
|
605
|
+
writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
writeJson(response, 200, body.data);
|
|
535
609
|
}
|
|
536
610
|
async function readObservedParameters(state, workspaceId) {
|
|
537
611
|
const root = await workspaceRoot(state, workspaceId);
|
|
@@ -554,6 +628,180 @@ function writeJson(response, status, body) {
|
|
|
554
628
|
});
|
|
555
629
|
response.end(payload);
|
|
556
630
|
}
|
|
631
|
+
function authorizeRuntime(state, request) {
|
|
632
|
+
const presented = extractControlToken(request.headers);
|
|
633
|
+
if (controlTokenMatches(state.config.controlToken, presented))
|
|
634
|
+
return 'control';
|
|
635
|
+
const dedicated = headerValue(request.headers['x-playcraft-source-read-token']);
|
|
636
|
+
const candidate = dedicated || presented;
|
|
637
|
+
if (candidate && controlTokenMatches(state.journal.sourceReadToken(), candidate))
|
|
638
|
+
return 'read';
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
function readSourcePath(method, pathname) {
|
|
642
|
+
if (method === 'GET' && pathname === '/runtime/events')
|
|
643
|
+
return true;
|
|
644
|
+
if (method === 'POST' && pathname === '/runtime/events/replay-pins')
|
|
645
|
+
return true;
|
|
646
|
+
if (method === 'DELETE' && pathname.startsWith('/runtime/events/replay-pins/'))
|
|
647
|
+
return true;
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
async function handleSourceEvents(state, request, response, url) {
|
|
651
|
+
if (request.method === 'POST' && url.pathname === '/runtime/events/replay-pins') {
|
|
652
|
+
const pin = await state.journal.createReplayPin({
|
|
653
|
+
runtimeId: state.config.runtimeId,
|
|
654
|
+
generation: state.config.generation,
|
|
655
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
656
|
+
ttlMs: Number(asRecord(await readJson(request)).ttlMs) || undefined,
|
|
657
|
+
});
|
|
658
|
+
writeJson(response, 201, pin);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (request.method === 'DELETE' && url.pathname.startsWith('/runtime/events/replay-pins/')) {
|
|
662
|
+
await state.journal.deleteReplayPin(decodeURIComponent(url.pathname.slice('/runtime/events/replay-pins/'.length)));
|
|
663
|
+
writeJson(response, 200, { deleted: true });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (request.method === 'POST' && url.pathname === '/runtime/events/ack') {
|
|
667
|
+
const parsed = sourceAckSchema.safeParse(await readJson(request));
|
|
668
|
+
if (!parsed.success) {
|
|
669
|
+
writeJson(response, 400, { error: 'invalid_source_ack' });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
await state.journal.acknowledgeSource(parsed.data);
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
writeJson(response, 409, { code: error instanceof Error ? error.message : 'ack_rejected' });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
writeJson(response, 200, { ok: true, acceptedLeaseFence: state.journal.acceptedLeaseFence() });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (request.method === 'GET' && url.pathname === '/runtime/events') {
|
|
683
|
+
const after = Number(url.searchParams.get('after') ?? '0');
|
|
684
|
+
const safeAfter = Number.isInteger(after) && after >= 0 ? after : 0;
|
|
685
|
+
const epochQuery = url.searchParams.get('runtimeEpoch');
|
|
686
|
+
const runtimeEpoch = epochQuery === null ? state.runtimeEpoch : Number(epochQuery);
|
|
687
|
+
const query = {
|
|
688
|
+
runtimeId: state.config.runtimeId,
|
|
689
|
+
generation: state.config.generation,
|
|
690
|
+
runtimeEpoch: Number.isInteger(runtimeEpoch) ? runtimeEpoch : state.runtimeEpoch,
|
|
691
|
+
after: safeAfter,
|
|
692
|
+
};
|
|
693
|
+
if (String(request.headers.accept ?? '').includes('text/event-stream')) {
|
|
694
|
+
writeSourceStream(state, request, response, query);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (epochQuery === null) {
|
|
698
|
+
const events = state.journal.listEvents(safeAfter);
|
|
699
|
+
const page = state.journal.readSourcePage({ ...query, limit: 1 });
|
|
700
|
+
writeJson(response, 200, {
|
|
701
|
+
events,
|
|
702
|
+
retainedFrom: page.ok ? page.retainedFrom : page.retainedFrom,
|
|
703
|
+
highWater: page.ok ? page.highWater : page.highWater,
|
|
704
|
+
nextAfter: events.at(-1)?.source.sourceSequence ?? safeAfter,
|
|
705
|
+
runtimeId: query.runtimeId,
|
|
706
|
+
generation: query.generation,
|
|
707
|
+
runtimeEpoch: query.runtimeEpoch,
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const page = state.journal.readSourcePage(query);
|
|
712
|
+
if (!page.ok) {
|
|
713
|
+
writeJson(response, 409, {
|
|
714
|
+
code: page.code,
|
|
715
|
+
retainedFrom: page.retainedFrom,
|
|
716
|
+
highWater: page.highWater,
|
|
717
|
+
coveredThrough: page.coveredThrough,
|
|
718
|
+
});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
writeJson(response, 200, {
|
|
722
|
+
events: page.events,
|
|
723
|
+
retainedFrom: page.retainedFrom,
|
|
724
|
+
highWater: page.highWater,
|
|
725
|
+
nextAfter: page.nextAfter,
|
|
726
|
+
runtimeId: page.runtimeId,
|
|
727
|
+
generation: page.generation,
|
|
728
|
+
runtimeEpoch: page.runtimeEpoch,
|
|
729
|
+
});
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
writeJson(response, 404, { error: 'not_found' });
|
|
733
|
+
}
|
|
734
|
+
function writeSourceStream(state, request, response, query) {
|
|
735
|
+
let cursor = query.after;
|
|
736
|
+
const buffered = [];
|
|
737
|
+
const matches = (event) => event.source.runtimeId === query.runtimeId &&
|
|
738
|
+
event.source.generation === query.generation &&
|
|
739
|
+
event.source.runtimeEpoch === query.runtimeEpoch &&
|
|
740
|
+
event.source.sourceSequence > cursor;
|
|
741
|
+
const unsubscribe = state.journal.onSourceEvent((event) => {
|
|
742
|
+
if (matches(event))
|
|
743
|
+
buffered.push(event);
|
|
744
|
+
});
|
|
745
|
+
const page = state.journal.readSourcePage({ ...query, limit: 500 });
|
|
746
|
+
response.writeHead(200, {
|
|
747
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
748
|
+
'cache-control': 'no-cache, no-transform',
|
|
749
|
+
connection: 'keep-alive',
|
|
750
|
+
'x-accel-buffering': 'no',
|
|
751
|
+
});
|
|
752
|
+
const writeEvent = (name, data) => {
|
|
753
|
+
response.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
754
|
+
};
|
|
755
|
+
if (!page.ok) {
|
|
756
|
+
writeEvent('source.gap', {
|
|
757
|
+
code: page.code,
|
|
758
|
+
retainedFrom: page.retainedFrom,
|
|
759
|
+
highWater: page.highWater,
|
|
760
|
+
coveredThrough: page.coveredThrough,
|
|
761
|
+
});
|
|
762
|
+
response.end();
|
|
763
|
+
unsubscribe();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
writeEvent('source.ready', {
|
|
767
|
+
runtimeId: page.runtimeId,
|
|
768
|
+
generation: page.generation,
|
|
769
|
+
runtimeEpoch: page.runtimeEpoch,
|
|
770
|
+
retainedFrom: page.retainedFrom,
|
|
771
|
+
highWater: page.highWater,
|
|
772
|
+
nextAfter: page.nextAfter,
|
|
773
|
+
});
|
|
774
|
+
for (const event of page.events) {
|
|
775
|
+
writeEvent('source', event);
|
|
776
|
+
cursor = event.source.sourceSequence;
|
|
777
|
+
}
|
|
778
|
+
for (const event of buffered) {
|
|
779
|
+
if (!matches(event))
|
|
780
|
+
continue;
|
|
781
|
+
writeEvent('source', event);
|
|
782
|
+
cursor = event.source.sourceSequence;
|
|
783
|
+
}
|
|
784
|
+
unsubscribe();
|
|
785
|
+
const follow = state.journal.onSourceEvent((event) => {
|
|
786
|
+
if (!matches(event))
|
|
787
|
+
return;
|
|
788
|
+
writeEvent('source', event);
|
|
789
|
+
cursor = event.source.sourceSequence;
|
|
790
|
+
});
|
|
791
|
+
const heartbeat = setInterval(() => {
|
|
792
|
+
response.write(`: ping\n\n`);
|
|
793
|
+
}, 10_000);
|
|
794
|
+
heartbeat.unref?.();
|
|
795
|
+
const stop = () => {
|
|
796
|
+
clearInterval(heartbeat);
|
|
797
|
+
follow();
|
|
798
|
+
};
|
|
799
|
+
request.on('close', stop);
|
|
800
|
+
response.on('close', stop);
|
|
801
|
+
}
|
|
802
|
+
function headerValue(value) {
|
|
803
|
+
return Array.isArray(value) ? value[0] : value;
|
|
804
|
+
}
|
|
557
805
|
function asRecord(value) {
|
|
558
806
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
559
807
|
? value
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { WebSocketServer } from 'ws';
|
|
5
|
+
import { appendTerminalOutput, createTerminalOutputLog, decodeTerminalBytes, runtimeError, terminalClientFrameSchema, terminalOutputSince, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { resolveWorkspaceFile } from '../workspaces/files.js';
|
|
7
|
+
import { workspaceRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const pty = require('node-pty');
|
|
11
|
+
const commands = new WeakMap();
|
|
12
|
+
const hosts = new WeakMap();
|
|
13
|
+
export function setTerminalCommand(state, command) {
|
|
14
|
+
commands.set(state, command);
|
|
15
|
+
}
|
|
16
|
+
export function terminalHost(state) {
|
|
17
|
+
let host = hosts.get(state);
|
|
18
|
+
if (!host) {
|
|
19
|
+
host = new TerminalHost(state);
|
|
20
|
+
hosts.set(state, host);
|
|
21
|
+
}
|
|
22
|
+
return host;
|
|
23
|
+
}
|
|
24
|
+
export function disposeTerminalHost(state) {
|
|
25
|
+
hosts.get(state)?.dispose();
|
|
26
|
+
hosts.delete(state);
|
|
27
|
+
}
|
|
28
|
+
export class TerminalHost {
|
|
29
|
+
state;
|
|
30
|
+
sessions = new Map();
|
|
31
|
+
sockets = new WebSocketServer({ noServer: true });
|
|
32
|
+
constructor(state) {
|
|
33
|
+
this.state = state;
|
|
34
|
+
}
|
|
35
|
+
async handleUpgrade(request, socket, head) {
|
|
36
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
37
|
+
const match = /^\/runtime\/terminals\/([^/]+)\/connect$/.exec(url.pathname);
|
|
38
|
+
if (!match || !controlTokenMatches(this.state.config.controlToken, extractControlToken(request.headers))) {
|
|
39
|
+
rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (this.state.journal.destroyed()) {
|
|
43
|
+
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const terminalId = decodeURIComponent(match[1]);
|
|
47
|
+
const terminal = this.state.journal.getTerminal(terminalId);
|
|
48
|
+
if (!terminal) {
|
|
49
|
+
rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (terminal.status === 'destroyed') {
|
|
53
|
+
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let session = this.sessions.get(terminal.terminalId);
|
|
57
|
+
if (!session) {
|
|
58
|
+
try {
|
|
59
|
+
session = this.spawn(terminal, await terminalDirectory(this.state, terminal));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'pty_unavailable'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const active = session;
|
|
67
|
+
this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(active, ws));
|
|
68
|
+
}
|
|
69
|
+
revoke(terminalId, reason) {
|
|
70
|
+
const session = this.sessions.get(terminalId);
|
|
71
|
+
if (!session)
|
|
72
|
+
return;
|
|
73
|
+
this.sessions.delete(terminalId);
|
|
74
|
+
const frame = { type: 'revoked', reason };
|
|
75
|
+
for (const listener of session.listeners)
|
|
76
|
+
listener(frame);
|
|
77
|
+
session.listeners.clear();
|
|
78
|
+
try {
|
|
79
|
+
session.pty.kill();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* the process may already have exited */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
dispose() {
|
|
86
|
+
for (const terminalId of [...this.sessions.keys()])
|
|
87
|
+
this.revoke(terminalId, 'runtime_destroyed');
|
|
88
|
+
this.sockets.close();
|
|
89
|
+
}
|
|
90
|
+
spawn(terminal, cwd) {
|
|
91
|
+
const command = commands.get(this.state) ?? defaultShell();
|
|
92
|
+
const session = {
|
|
93
|
+
streamId: randomUUID(),
|
|
94
|
+
log: createTerminalOutputLog(),
|
|
95
|
+
listeners: new Set(),
|
|
96
|
+
pty: pty.spawn(command.file, command.args, {
|
|
97
|
+
name: 'xterm-256color',
|
|
98
|
+
cols: 80,
|
|
99
|
+
rows: 24,
|
|
100
|
+
cwd,
|
|
101
|
+
env: shellEnv(command.file, cwd),
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
session.pty.onData((data) => publish(session, data));
|
|
105
|
+
session.pty.onExit(({ exitCode, signal }) => {
|
|
106
|
+
session.exited = {
|
|
107
|
+
code: exitCode,
|
|
108
|
+
...(signal === undefined ? {} : { signal: String(signal) }),
|
|
109
|
+
};
|
|
110
|
+
const frame = {
|
|
111
|
+
type: 'exit',
|
|
112
|
+
streamId: session.streamId,
|
|
113
|
+
code: exitCode,
|
|
114
|
+
...(session.exited.signal ? { signal: session.exited.signal } : {}),
|
|
115
|
+
};
|
|
116
|
+
for (const listener of session.listeners)
|
|
117
|
+
listener(frame);
|
|
118
|
+
});
|
|
119
|
+
this.sessions.set(terminal.terminalId, session);
|
|
120
|
+
return session;
|
|
121
|
+
}
|
|
122
|
+
attach(session, ws) {
|
|
123
|
+
let open = true;
|
|
124
|
+
let resumed = false;
|
|
125
|
+
let cursor = 0;
|
|
126
|
+
const send = (frame) => {
|
|
127
|
+
if (open && ws.readyState === ws.OPEN)
|
|
128
|
+
ws.send(JSON.stringify(frame));
|
|
129
|
+
};
|
|
130
|
+
const listener = (frame) => {
|
|
131
|
+
if (!resumed)
|
|
132
|
+
return;
|
|
133
|
+
if (frame.type === 'output') {
|
|
134
|
+
const end = frame.offset + decodeTerminalBytes(frame.data).length;
|
|
135
|
+
if (end <= cursor)
|
|
136
|
+
return;
|
|
137
|
+
cursor = Math.max(cursor, end);
|
|
138
|
+
}
|
|
139
|
+
send(frame);
|
|
140
|
+
};
|
|
141
|
+
session.listeners.add(listener);
|
|
142
|
+
ws.on('message', (raw) => {
|
|
143
|
+
let body;
|
|
144
|
+
try {
|
|
145
|
+
body = JSON.parse(String(raw));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const parsed = terminalClientFrameSchema.safeParse(body);
|
|
151
|
+
if (!parsed.success || session.exited && parsed.data.type !== 'resume')
|
|
152
|
+
return;
|
|
153
|
+
const frame = parsed.data;
|
|
154
|
+
if (frame.type === 'resume') {
|
|
155
|
+
if (frame.streamId && frame.streamId !== session.streamId) {
|
|
156
|
+
send({ type: 'reset', streamId: session.streamId, offset: session.log.end });
|
|
157
|
+
cursor = session.log.end;
|
|
158
|
+
resumed = true;
|
|
159
|
+
if (session.exited)
|
|
160
|
+
send(exitFrame(session));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const since = terminalOutputSince(session.log, frame.offset);
|
|
164
|
+
if (since.gap)
|
|
165
|
+
send({ type: 'gap', streamId: session.streamId, offset: since.offset });
|
|
166
|
+
for (const item of since.frames) {
|
|
167
|
+
send({ type: 'output', streamId: session.streamId, offset: item.offset, data: item.data });
|
|
168
|
+
}
|
|
169
|
+
cursor = session.log.end;
|
|
170
|
+
resumed = true;
|
|
171
|
+
if (session.exited)
|
|
172
|
+
send(exitFrame(session));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!resumed)
|
|
176
|
+
return;
|
|
177
|
+
if (frame.type === 'resize') {
|
|
178
|
+
session.pty.resize(frame.cols, frame.rows);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
session.pty.write(Buffer.from(decodeTerminalBytes(frame.data)).toString('utf8'));
|
|
182
|
+
});
|
|
183
|
+
const detach = () => {
|
|
184
|
+
open = false;
|
|
185
|
+
session.listeners.delete(listener);
|
|
186
|
+
};
|
|
187
|
+
ws.on('close', detach);
|
|
188
|
+
ws.on('error', detach);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function publish(session, text) {
|
|
192
|
+
const before = session.log.end;
|
|
193
|
+
session.log = appendTerminalOutput(session.log, text);
|
|
194
|
+
const since = terminalOutputSince(session.log, before);
|
|
195
|
+
for (const item of since.frames) {
|
|
196
|
+
const frame = {
|
|
197
|
+
type: 'output',
|
|
198
|
+
streamId: session.streamId,
|
|
199
|
+
offset: item.offset,
|
|
200
|
+
data: item.data,
|
|
201
|
+
};
|
|
202
|
+
for (const listener of session.listeners)
|
|
203
|
+
listener(frame);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function exitFrame(session) {
|
|
207
|
+
return {
|
|
208
|
+
type: 'exit',
|
|
209
|
+
streamId: session.streamId,
|
|
210
|
+
code: session.exited?.code ?? null,
|
|
211
|
+
...(session.exited?.signal ? { signal: session.exited.signal } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function terminalDirectory(state, terminal) {
|
|
215
|
+
const root = await workspaceRoot(state, terminal.workspaceId);
|
|
216
|
+
if (!terminal.cwd)
|
|
217
|
+
return root;
|
|
218
|
+
const resolved = await resolveWorkspaceFile(root, terminal.cwd);
|
|
219
|
+
const stat = await lstat(resolved);
|
|
220
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
221
|
+
throw new Error('pty_unavailable');
|
|
222
|
+
return resolved;
|
|
223
|
+
}
|
|
224
|
+
function defaultShell() {
|
|
225
|
+
const shell = process.env.SHELL;
|
|
226
|
+
const file = shell && shell.startsWith('/') ? shell : '/bin/sh';
|
|
227
|
+
return { file, args: ['-i'] };
|
|
228
|
+
}
|
|
229
|
+
function shellEnv(file, cwd) {
|
|
230
|
+
return {
|
|
231
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
232
|
+
HOME: process.env.HOME ?? cwd,
|
|
233
|
+
TERM: 'xterm-256color',
|
|
234
|
+
LANG: process.env.LANG ?? 'C.UTF-8',
|
|
235
|
+
SHELL: file,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function rejectUpgrade(socket, status, body) {
|
|
239
|
+
const payload = JSON.stringify(body);
|
|
240
|
+
const title = status === 401 ? 'Unauthorized' : status === 404 ? 'Not Found' : 'Conflict';
|
|
241
|
+
socket.write(`HTTP/1.1 ${status} ${title}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(payload)}\r\nconnection: close\r\n\r\n${payload}`);
|
|
242
|
+
socket.destroy();
|
|
243
|
+
}
|