@opengeni/sdk 0.3.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.
- package/dist/index.d.ts +634 -5
- package/dist/index.js +265 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/client.ts +190 -0
- package/src/desktop.ts +152 -0
- package/src/index.ts +99 -0
- package/src/terminal.ts +91 -0
- package/src/types.ts +366 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Framework-agnostic TypeScript SDK for the OpenGeni API: typed client, session lifecycle, SSE event streaming with reconnect + replay-by-sequence, and proxy re-streaming helpers.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@opengeni/contracts": "workspace:*",
|
|
39
|
+
"@opengeni/deployment": "workspace:*",
|
|
39
40
|
"tsup": "^8.5.0",
|
|
40
41
|
"typescript": "^6.0.3",
|
|
41
42
|
"zod": "^4.2.1"
|
package/src/client.ts
CHANGED
|
@@ -52,9 +52,46 @@ import type {
|
|
|
52
52
|
SessionEvent,
|
|
53
53
|
SessionGoal,
|
|
54
54
|
SessionTurn,
|
|
55
|
+
// Stream surfacing (Phase 5): capability negotiation + viewer lifecycle + config.
|
|
56
|
+
SessionCapabilities,
|
|
57
|
+
AttachViewerRequest,
|
|
58
|
+
AttachViewerResponse,
|
|
59
|
+
AcknowledgeStreamRequest,
|
|
60
|
+
AcknowledgeStreamResponse,
|
|
61
|
+
ViewerHeartbeatRequest,
|
|
62
|
+
ViewerHeartbeatResponse,
|
|
63
|
+
// Channel-A structured services (P4.4).
|
|
64
|
+
FsListRequest,
|
|
65
|
+
FsListResponse,
|
|
66
|
+
FsReadRequest,
|
|
67
|
+
FsReadResponse,
|
|
68
|
+
FsWriteRequest,
|
|
69
|
+
FsWriteResponse,
|
|
70
|
+
FsDeleteRequest,
|
|
71
|
+
FsDeleteResponse,
|
|
72
|
+
FsMoveRequest,
|
|
73
|
+
FsMoveResponse,
|
|
74
|
+
FsMkdirRequest,
|
|
75
|
+
FsMkdirResponse,
|
|
76
|
+
GitStatusRequest,
|
|
77
|
+
GitStatusResponse,
|
|
78
|
+
GitDiffRequest,
|
|
79
|
+
GitDiffResponse,
|
|
80
|
+
GitLogRequest,
|
|
81
|
+
GitLogResponse,
|
|
82
|
+
GitShowRequest,
|
|
83
|
+
GitShowResponse,
|
|
84
|
+
TerminalExecRequest,
|
|
85
|
+
TerminalExecResponse,
|
|
86
|
+
PtyOpenRequest,
|
|
87
|
+
PtyOpenResponse,
|
|
88
|
+
PtyWriteRequest,
|
|
89
|
+
PtyResizeRequest,
|
|
90
|
+
PtyCloseRequest,
|
|
55
91
|
ToolRef,
|
|
56
92
|
UpdateScheduledTaskRequest,
|
|
57
93
|
UpdateSessionGoalRequest,
|
|
94
|
+
UpdateSessionRequest,
|
|
58
95
|
UpdateSessionTurnRequest,
|
|
59
96
|
UpdateWorkspaceEnvironmentRequest,
|
|
60
97
|
UpdateWorkspaceMemberRequest,
|
|
@@ -129,6 +166,10 @@ export class OpenGeniClient {
|
|
|
129
166
|
return await this.requestJson<Session>("GET", `/v1/workspaces/${workspaceId}/sessions/${sessionId}`);
|
|
130
167
|
}
|
|
131
168
|
|
|
169
|
+
async updateSession(workspaceId: string, sessionId: string, request: UpdateSessionRequest): Promise<Session> {
|
|
170
|
+
return await this.requestJson<Session>("PATCH", `/v1/workspaces/${workspaceId}/sessions/${sessionId}`, request);
|
|
171
|
+
}
|
|
172
|
+
|
|
132
173
|
async listSessions(workspaceId: string, options: { limit?: number } = {}): Promise<Session[]> {
|
|
133
174
|
return await this.requestJson<Session[]>("GET", `/v1/workspaces/${workspaceId}/sessions`, undefined, {
|
|
134
175
|
...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
|
|
@@ -403,6 +444,144 @@ export class OpenGeniClient {
|
|
|
403
444
|
return await this.requestJson<CompactSessionContextResult>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/context/compact`, {});
|
|
404
445
|
}
|
|
405
446
|
|
|
447
|
+
// --- Channel-A structured services (P4.4) ------------------------------------
|
|
448
|
+
// FileSystem (Pierre tree), Git (Pierre diff), Terminal (exec + PTY). Each is a
|
|
449
|
+
// synchronous API-direct point query; the fs.changed/git.changed/terminal.pty.*
|
|
450
|
+
// notifications + the PTY output stream arrive on the existing event SSE.
|
|
451
|
+
|
|
452
|
+
/** FileSystem: list a directory tree (feeds the Pierre file tree). */
|
|
453
|
+
async fsList(workspaceId: string, sessionId: string, request: FsListRequest = {}): Promise<FsListResponse> {
|
|
454
|
+
return await this.requestJson<FsListResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/list`, request);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** FileSystem: read a file (text or base64; binary-safe, size-capped). */
|
|
458
|
+
async fsRead(workspaceId: string, sessionId: string, request: FsReadRequest): Promise<FsReadResponse> {
|
|
459
|
+
return await this.requestJson<FsReadResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/read`, request);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** FileSystem: write a file (last-writer-wins; emits fs.changed). */
|
|
463
|
+
async fsWrite(workspaceId: string, sessionId: string, request: FsWriteRequest): Promise<FsWriteResponse> {
|
|
464
|
+
return await this.requestJson<FsWriteResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/write`, request);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** FileSystem: delete a path (emits fs.changed). */
|
|
468
|
+
async fsDelete(workspaceId: string, sessionId: string, request: FsDeleteRequest): Promise<FsDeleteResponse> {
|
|
469
|
+
return await this.requestJson<FsDeleteResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/delete`, request);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** FileSystem: move/rename a path (emits fs.changed; 409 if destination exists and overwrite is false). */
|
|
473
|
+
async fsMove(workspaceId: string, sessionId: string, request: FsMoveRequest): Promise<FsMoveResponse> {
|
|
474
|
+
return await this.requestJson<FsMoveResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/move`, request);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** FileSystem: create a directory (emits fs.changed; recursive defaults to true). */
|
|
478
|
+
async fsMkdir(workspaceId: string, sessionId: string, request: FsMkdirRequest): Promise<FsMkdirResponse> {
|
|
479
|
+
return await this.requestJson<FsMkdirResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/mkdir`, request);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Git: working-tree/index status (the Pierre file-status feed). */
|
|
483
|
+
async gitStatus(workspaceId: string, sessionId: string, request: GitStatusRequest = {}): Promise<GitStatusResponse> {
|
|
484
|
+
return await this.requestJson<GitStatusResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/git/status`, request);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Git: structured diff hunks (the Pierre diff feed). */
|
|
488
|
+
async gitDiff(workspaceId: string, sessionId: string, request: GitDiffRequest = {}): Promise<GitDiffResponse> {
|
|
489
|
+
return await this.requestJson<GitDiffResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/git/diff`, request);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Git: commit log. */
|
|
493
|
+
async gitLog(workspaceId: string, sessionId: string, request: GitLogRequest = {}): Promise<GitLogResponse> {
|
|
494
|
+
return await this.requestJson<GitLogResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/git/log`, request);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Git: show a commit (diff vs first parent) or fetch a raw blob at a ref. */
|
|
498
|
+
async gitShow(workspaceId: string, sessionId: string, request: GitShowRequest): Promise<GitShowResponse> {
|
|
499
|
+
return await this.requestJson<GitShowResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/git/show`, request);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Terminal: run a bounded command, returning buffered stdout/stderr inline. */
|
|
503
|
+
async terminalExec(workspaceId: string, sessionId: string, request: TerminalExecRequest): Promise<TerminalExecResponse> {
|
|
504
|
+
return await this.requestJson<TerminalExecResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/terminal/exec`, request);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Terminal: open an interactive PTY. Output streams on the event SSE as
|
|
508
|
+
* terminal.pty.output.delta; drive it with terminalPtyWrite. */
|
|
509
|
+
async terminalPtyOpen(workspaceId: string, sessionId: string, request: PtyOpenRequest = {}): Promise<PtyOpenResponse> {
|
|
510
|
+
return await this.requestJson<PtyOpenResponse>("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/terminal/pty`, request);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Terminal: send stdin to an open PTY (output rides A1). */
|
|
514
|
+
async terminalPtyWrite(workspaceId: string, sessionId: string, request: PtyWriteRequest): Promise<void> {
|
|
515
|
+
await this.requestVoid("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/terminal/pty/write`, request);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Terminal: resize an open PTY. */
|
|
519
|
+
async terminalPtyResize(workspaceId: string, sessionId: string, request: PtyResizeRequest): Promise<void> {
|
|
520
|
+
await this.requestVoid("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/terminal/pty/resize`, request);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Terminal: close an open PTY (idempotent). */
|
|
524
|
+
async terminalPtyClose(workspaceId: string, sessionId: string, request: PtyCloseRequest): Promise<void> {
|
|
525
|
+
await this.requestVoid("POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/terminal/pty/close`, request);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- Stream surfacing: capability negotiation + viewer lifecycle (Phase 5) ---
|
|
529
|
+
// The capability doc is the single source of UI truth (degradation is always a
|
|
530
|
+
// value, never a crash). The desktop pixel plane (Channel B) is gated behind an
|
|
531
|
+
// un-redacted-acknowledgment + a viewer holder; the structured terminal/files/
|
|
532
|
+
// git surfaces (Channel A) ride the methods above and the event SSE.
|
|
533
|
+
|
|
534
|
+
/** Read the negotiated capability doc for a session WITHOUT acquiring a viewer
|
|
535
|
+
* holder (no warm, no spawn). Drives capability-gated rendering: which
|
|
536
|
+
* surfaces mount, the per-surface unavailability reasons, and the lease
|
|
537
|
+
* liveness the client polls on while `cold`/`warming`. The desktop URL/token
|
|
538
|
+
* are minted in-process only when the box is warm AND the principal has
|
|
539
|
+
* acknowledged the un-redacted plane. */
|
|
540
|
+
async getStreamCapabilities(workspaceId: string, sessionId: string): Promise<SessionCapabilities> {
|
|
541
|
+
return await this.requestJson<SessionCapabilities>(
|
|
542
|
+
"GET", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/stream-capabilities`);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Record the calling principal's acknowledgment of the un-redacted desktop
|
|
546
|
+
* pixel plane (and, when the box is shared, the shared-exposure disclosure).
|
|
547
|
+
* The desktop viewer-attach path returns 409 until this is recorded. */
|
|
548
|
+
async acknowledgeStream(
|
|
549
|
+
workspaceId: string, sessionId: string, request: AcknowledgeStreamRequest = {},
|
|
550
|
+
): Promise<AcknowledgeStreamResponse> {
|
|
551
|
+
return await this.requestJson<AcknowledgeStreamResponse>(
|
|
552
|
+
"POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/stream-capabilities/acknowledge`, request);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Attach a viewer holder (refcounted liveness — keeps the box warm while
|
|
556
|
+
* watched/used), spinning the box up in-process when cold, and mint the scoped
|
|
557
|
+
* direct-to-provider URLs for the requested plane(s). `request.desktop:true`
|
|
558
|
+
* opts into the un-redacted pixel plane and mints the noVNC URL — that plane
|
|
559
|
+
* alone throws `OpenGeniApiError(409)` when the un-redacted/shared
|
|
560
|
+
* acknowledgment is missing (the consent gate). A terminal-only attach
|
|
561
|
+
* (`desktop` omitted/false) warms the box + mints the pty-ws terminal cell with
|
|
562
|
+
* NO consent gate. An omitted `viewerId` mints a fresh one. */
|
|
563
|
+
async attachViewer(
|
|
564
|
+
workspaceId: string, sessionId: string, request: AttachViewerRequest = {},
|
|
565
|
+
): Promise<AttachViewerResponse> {
|
|
566
|
+
return await this.requestJson<AttachViewerResponse>(
|
|
567
|
+
"POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/viewers`, request);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Heartbeat a viewer holder (Channel-A app-level liveness). A closed laptop
|
|
571
|
+
* stops sending these → the reaper drops the holder within ~90s. Echoes
|
|
572
|
+
* `leaseEpoch` so a superseded epoch is rejected (`alive:false` → re-attach). */
|
|
573
|
+
async heartbeatViewer(
|
|
574
|
+
workspaceId: string, sessionId: string, viewerId: string, request: ViewerHeartbeatRequest,
|
|
575
|
+
): Promise<ViewerHeartbeatResponse> {
|
|
576
|
+
return await this.requestJson<ViewerHeartbeatResponse>(
|
|
577
|
+
"POST", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/viewers/${viewerId}/heartbeat`, request);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Detach a viewer (delete this holder; idempotent delete-my-row). */
|
|
581
|
+
async detachViewer(workspaceId: string, sessionId: string, viewerId: string): Promise<void> {
|
|
582
|
+
await this.requestVoid("DELETE", `/v1/workspaces/${workspaceId}/sessions/${sessionId}/viewers/${viewerId}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
406
585
|
// --- Access + workspaces -----------------------------------------------------
|
|
407
586
|
|
|
408
587
|
/**
|
|
@@ -670,6 +849,17 @@ export class OpenGeniClient {
|
|
|
670
849
|
);
|
|
671
850
|
}
|
|
672
851
|
|
|
852
|
+
/**
|
|
853
|
+
* Delete a document from a base. Removes the document row and its indexed
|
|
854
|
+
* chunks while leaving the uploaded file asset available for other uses.
|
|
855
|
+
*/
|
|
856
|
+
async deleteDocument(workspaceId: string, baseId: string, documentId: string): Promise<void> {
|
|
857
|
+
await this.requestVoid(
|
|
858
|
+
"DELETE",
|
|
859
|
+
`/v1/workspaces/${workspaceId}/document-bases/${baseId}/documents/${documentId}`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
673
863
|
async searchDocuments(
|
|
674
864
|
workspaceId: string,
|
|
675
865
|
baseId: string,
|
package/src/desktop.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Zero-dependency desktop (noVNC) transport contract.
|
|
2
|
+
//
|
|
3
|
+
// The actual `@novnc/novnc` RFB import is a browser-only DOM dependency and
|
|
4
|
+
// lives in `@opengeni/react` (lazily imported, SSR-safe). The framework-agnostic
|
|
5
|
+
// SDK ships only the transport CONTRACT: a URL assembler, the minimal RFB
|
|
6
|
+
// surface the component drives, the connection state machine, and the
|
|
7
|
+
// rotation/fence reducers — all pure and unit-testable, with no DOM and no deps.
|
|
8
|
+
//
|
|
9
|
+
// Channel A (terminal-as-events, files, git) needs NO new transport here: those
|
|
10
|
+
// are event projections + the synchronous fs/git/terminal point queries on
|
|
11
|
+
// `OpenGeniClient`. Only the desktop pixel plane (Channel B, direct-to-provider)
|
|
12
|
+
// needs this.
|
|
13
|
+
|
|
14
|
+
import type { DesktopStreamCapability, StreamUrlRotatedPayload } from "./types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Translate the negotiated desktop capability into the WebSocket URL the noVNC
|
|
18
|
+
* RFB client connects to. The scoped provider token is ALREADY embedded in the
|
|
19
|
+
* minted `url` (Modal tunnel host, Blaxel `bl_preview_token`, Daytona signed
|
|
20
|
+
* preview) by `session.resolveExposedPort(6080)` — we do NOT append `cap.token`
|
|
21
|
+
* as a query param (that double-auth was an adversarial-review bug: the box runs
|
|
22
|
+
* `-nopw` in v1, so the RFB password is meaningless and the real auth is the
|
|
23
|
+
* tunnel token in the host). We only normalize the scheme to `ws`/`wss` and, when
|
|
24
|
+
* the minted URL points at a `vnc.html` viewer page, rewrite it to the
|
|
25
|
+
* websockify socket path noVNC actually dials.
|
|
26
|
+
*/
|
|
27
|
+
export function desktopSocketUrl(cap: Pick<DesktopStreamCapability, "url">): string {
|
|
28
|
+
if (!cap.url) {
|
|
29
|
+
throw new Error("desktop capability has no url (transport is null)");
|
|
30
|
+
}
|
|
31
|
+
const u = new URL(cap.url);
|
|
32
|
+
// https → wss, http → ws (noVNC dials a WebSocket, not the HTTP viewer page).
|
|
33
|
+
if (u.protocol === "https:") {
|
|
34
|
+
u.protocol = "wss:";
|
|
35
|
+
} else if (u.protocol === "http:") {
|
|
36
|
+
u.protocol = "ws:";
|
|
37
|
+
}
|
|
38
|
+
// A minted `…/vnc.html` (or `…/vnc_lite.html`) viewer page → the websockify
|
|
39
|
+
// socket the page itself would open (`…/websockify`). A bare host/path with no
|
|
40
|
+
// viewer page is already the socket path; leave it untouched.
|
|
41
|
+
if (/\/vnc(_lite)?\.html$/.test(u.pathname)) {
|
|
42
|
+
u.pathname = u.pathname.replace(/\/vnc(_lite)?\.html$/, "/websockify");
|
|
43
|
+
u.search = "";
|
|
44
|
+
u.hash = "";
|
|
45
|
+
}
|
|
46
|
+
return u.toString();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The minimal RFB surface the React component drives. Lets tests (and 3rd
|
|
51
|
+
* parties swapping noVNC for a WebRTC client in v3) provide a fake without the
|
|
52
|
+
* DOM. Matches `@novnc/novnc`'s RFB constructor + lifecycle.
|
|
53
|
+
*/
|
|
54
|
+
export interface DesktopRfbLike {
|
|
55
|
+
viewOnly: boolean;
|
|
56
|
+
scaleViewport: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* 1:1 viewport clipping. We always drive this FALSE: with clipping on, noVNC
|
|
59
|
+
* paints the framebuffer pixel-for-pixel and scrolls/crops to the container
|
|
60
|
+
* (the "zoomed in" look). FALSE lets `scaleViewport` shrink the 1280x800 frame
|
|
61
|
+
* to fit the panel (aspect-preserved). Declared so the hook can pin it instead
|
|
62
|
+
* of relying on noVNC's default — `scaleViewport=true` forces clip off
|
|
63
|
+
* internally, but a stale/partial state on reconnect could leave it on.
|
|
64
|
+
*/
|
|
65
|
+
clipViewport: boolean;
|
|
66
|
+
addEventListener(
|
|
67
|
+
type: "connect" | "disconnect" | "securityfailure",
|
|
68
|
+
cb: (e?: unknown) => void,
|
|
69
|
+
): void;
|
|
70
|
+
removeEventListener?: (
|
|
71
|
+
type: "connect" | "disconnect" | "securityfailure",
|
|
72
|
+
cb: (e?: unknown) => void,
|
|
73
|
+
) => void;
|
|
74
|
+
disconnect(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type DesktopRfbFactory = (
|
|
78
|
+
target: HTMLElement,
|
|
79
|
+
url: string,
|
|
80
|
+
opts: { credentials?: { password?: string | undefined } | undefined },
|
|
81
|
+
) => DesktopRfbLike;
|
|
82
|
+
|
|
83
|
+
export type DesktopConnectionState =
|
|
84
|
+
| "idle"
|
|
85
|
+
| "negotiating"
|
|
86
|
+
| "connecting"
|
|
87
|
+
| "connected"
|
|
88
|
+
| "rotating"
|
|
89
|
+
| "reconnecting"
|
|
90
|
+
| "error"
|
|
91
|
+
| "ended";
|
|
92
|
+
|
|
93
|
+
export type DesktopStreamEvent =
|
|
94
|
+
| { type: "negotiated" }
|
|
95
|
+
| { type: "connected" }
|
|
96
|
+
| { type: "disconnected" }
|
|
97
|
+
| { type: "rotate" }
|
|
98
|
+
| { type: "fail" }
|
|
99
|
+
| { type: "abort" };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure reducer for the desktop connection lifecycle. The component owns the RFB
|
|
103
|
+
* object + DOM; this owns the transitions so they are unit-testable. Mirrors the
|
|
104
|
+
* Channel-A stream reducer discipline.
|
|
105
|
+
*/
|
|
106
|
+
export function nextDesktopState(
|
|
107
|
+
current: DesktopConnectionState,
|
|
108
|
+
ev: DesktopStreamEvent,
|
|
109
|
+
): DesktopConnectionState {
|
|
110
|
+
switch (ev.type) {
|
|
111
|
+
case "negotiated":
|
|
112
|
+
return "connecting";
|
|
113
|
+
case "connected":
|
|
114
|
+
return "connected";
|
|
115
|
+
case "rotate":
|
|
116
|
+
return current === "connected" ? "rotating" : current;
|
|
117
|
+
case "disconnected":
|
|
118
|
+
// A deliberate teardown (ended) stays ended; everything else reconnects.
|
|
119
|
+
return current === "ended" ? "ended" : "reconnecting";
|
|
120
|
+
case "fail":
|
|
121
|
+
return "error";
|
|
122
|
+
case "abort":
|
|
123
|
+
return "ended";
|
|
124
|
+
default:
|
|
125
|
+
return current;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// `DesktopStreamCapability` is the desktop cell of `SessionCapabilities`; alias
|
|
130
|
+
// it for the rotation reducer below without depending on the whole doc.
|
|
131
|
+
export type DesktopStreamCapabilityLike = {
|
|
132
|
+
url: string | null;
|
|
133
|
+
token: string | null;
|
|
134
|
+
expiresAt: string | null;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Apply a `stream.url.rotated` event onto a desktop capability, fencing on
|
|
139
|
+
* leaseEpoch (split-brain). A rotation minted under an epoch the client has
|
|
140
|
+
* already advanced PAST is from a superseded owner and is dropped (returns
|
|
141
|
+
* null); otherwise the fresh url/token/expiresAt are folded in.
|
|
142
|
+
*/
|
|
143
|
+
export function applyUrlRotation<T extends DesktopStreamCapabilityLike>(
|
|
144
|
+
cap: T,
|
|
145
|
+
payload: StreamUrlRotatedPayload,
|
|
146
|
+
knownEpoch: number,
|
|
147
|
+
): T | null {
|
|
148
|
+
if (payload.leaseEpoch < knownEpoch) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return { ...cap, url: payload.url, token: payload.token, expiresAt: payload.expiresAt };
|
|
152
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,27 @@ export {
|
|
|
11
11
|
export type { ProxySessionEventStreamOptions, SseReStreamOptions } from "./proxy";
|
|
12
12
|
export { parseSseStream } from "./sse";
|
|
13
13
|
export type { SseMessage } from "./sse";
|
|
14
|
+
// Desktop (noVNC) transport contract — pure, zero-dep (the RFB import lives in
|
|
15
|
+
// @opengeni/react). URL assembler + connection state machine + rotation fence.
|
|
16
|
+
export { desktopSocketUrl, nextDesktopState, applyUrlRotation } from "./desktop";
|
|
17
|
+
export type {
|
|
18
|
+
DesktopRfbLike,
|
|
19
|
+
DesktopRfbFactory,
|
|
20
|
+
DesktopConnectionState,
|
|
21
|
+
DesktopStreamEvent,
|
|
22
|
+
} from "./desktop";
|
|
23
|
+
// Interactive terminal (ttyd PTY-over-websocket) transport contract — pure,
|
|
24
|
+
// zero-dep (the WebSocket + xterm attach live in @opengeni/react). URL assembler
|
|
25
|
+
// + the ttyd wire-protocol frame codec, symmetric with the desktop above.
|
|
26
|
+
export {
|
|
27
|
+
terminalSocketUrl,
|
|
28
|
+
ttydAuthFrame,
|
|
29
|
+
ttydInputFrame,
|
|
30
|
+
ttydResizeFrame,
|
|
31
|
+
TTYD_SUBPROTOCOL,
|
|
32
|
+
TtydClientCommand,
|
|
33
|
+
TtydServerCommand,
|
|
34
|
+
} from "./terminal";
|
|
14
35
|
export { streamSessionEvents } from "./stream";
|
|
15
36
|
export type {
|
|
16
37
|
SessionEventStreamTransport,
|
|
@@ -49,6 +70,7 @@ export type {
|
|
|
49
70
|
CapabilityPackSkillFile,
|
|
50
71
|
CapabilityRuntime,
|
|
51
72
|
CapabilitySource,
|
|
73
|
+
CapabilityUnavailableReason,
|
|
52
74
|
ClientConfig,
|
|
53
75
|
ClientModel,
|
|
54
76
|
CompactSessionContextResult,
|
|
@@ -101,10 +123,19 @@ export type {
|
|
|
101
123
|
Permission,
|
|
102
124
|
ProductAccessMode,
|
|
103
125
|
ReasoningEffort,
|
|
126
|
+
RecordingAvailablePayload,
|
|
127
|
+
RecordingCodec,
|
|
128
|
+
RecordingContentType,
|
|
129
|
+
RecordingFailedPayload,
|
|
130
|
+
RecordingFailedReason,
|
|
131
|
+
RecordingMode,
|
|
132
|
+
RecordingStartedPayload,
|
|
104
133
|
RegisterCapabilityPackRequest,
|
|
105
134
|
RepositoryResourceRef,
|
|
106
135
|
ResourceRef,
|
|
107
136
|
SandboxBackend,
|
|
137
|
+
SandboxCapabilityName,
|
|
138
|
+
SandboxOs,
|
|
108
139
|
ScheduledTask,
|
|
109
140
|
ScheduledTaskAgentConfig,
|
|
110
141
|
ScheduledTaskAgentConfigInput,
|
|
@@ -117,6 +148,27 @@ export type {
|
|
|
117
148
|
ScheduledTaskStatus,
|
|
118
149
|
ScheduledTaskTriggerType,
|
|
119
150
|
Session,
|
|
151
|
+
SessionCapabilities,
|
|
152
|
+
// Per-surface capability cell aliases (views of SessionCapabilities).
|
|
153
|
+
FileSystemCapability,
|
|
154
|
+
TerminalCapability,
|
|
155
|
+
GitCapability,
|
|
156
|
+
DesktopStreamCapability,
|
|
157
|
+
RecordingCapability,
|
|
158
|
+
ComputerUseCapability,
|
|
159
|
+
// Stream-surfacing client surface (Phase 5).
|
|
160
|
+
ClientAuthConfig,
|
|
161
|
+
StreamUrlRotatedPayload,
|
|
162
|
+
StreamOpenedPayload,
|
|
163
|
+
StreamClosedPayload,
|
|
164
|
+
StreamRevokedPayload,
|
|
165
|
+
AttachViewerRequest,
|
|
166
|
+
AttachViewerResponse,
|
|
167
|
+
ViewerHolder,
|
|
168
|
+
AcknowledgeStreamRequest,
|
|
169
|
+
AcknowledgeStreamResponse,
|
|
170
|
+
ViewerHeartbeatRequest,
|
|
171
|
+
ViewerHeartbeatResponse,
|
|
120
172
|
SessionEvent,
|
|
121
173
|
SessionEventType,
|
|
122
174
|
SessionGoal,
|
|
@@ -124,12 +176,59 @@ export type {
|
|
|
124
176
|
SessionGoalStatus,
|
|
125
177
|
SessionStatus,
|
|
126
178
|
SessionStatusChangedPayload,
|
|
179
|
+
SessionStructuredCapabilities,
|
|
127
180
|
SessionTurn,
|
|
128
181
|
SessionTurnSource,
|
|
129
182
|
SessionTurnStatus,
|
|
183
|
+
// Channel-A structured services (P4.4) — A1 payloads + A2 request/response.
|
|
184
|
+
SandboxCommandOutputDeltaPayload,
|
|
185
|
+
FsChangeKind,
|
|
186
|
+
FsChangedPayload,
|
|
187
|
+
GitChangedPayload,
|
|
188
|
+
TerminalPtyStartedPayload,
|
|
189
|
+
TerminalPtyOutputDeltaPayload,
|
|
190
|
+
TerminalPtyExitedPayload,
|
|
191
|
+
FsNodeType,
|
|
192
|
+
FsTreeNode,
|
|
193
|
+
FsEncoding,
|
|
194
|
+
FsListRequest,
|
|
195
|
+
FsListResponse,
|
|
196
|
+
FsReadRequest,
|
|
197
|
+
FsReadResponse,
|
|
198
|
+
FsWriteRequest,
|
|
199
|
+
FsWriteResponse,
|
|
200
|
+
FsDeleteRequest,
|
|
201
|
+
FsDeleteResponse,
|
|
202
|
+
FsMoveRequest,
|
|
203
|
+
FsMoveResponse,
|
|
204
|
+
FsMkdirRequest,
|
|
205
|
+
FsMkdirResponse,
|
|
206
|
+
GitFileStatusCode,
|
|
207
|
+
GitFileStatus,
|
|
208
|
+
GitStatusRequest,
|
|
209
|
+
GitStatusResponse,
|
|
210
|
+
GitDiffLineType,
|
|
211
|
+
GitDiffLine,
|
|
212
|
+
GitDiffHunk,
|
|
213
|
+
GitFileDiff,
|
|
214
|
+
GitDiffRequest,
|
|
215
|
+
GitDiffResponse,
|
|
216
|
+
GitLogRequest,
|
|
217
|
+
GitCommit,
|
|
218
|
+
GitLogResponse,
|
|
219
|
+
GitShowRequest,
|
|
220
|
+
GitShowResponse,
|
|
221
|
+
TerminalExecRequest,
|
|
222
|
+
TerminalExecResponse,
|
|
223
|
+
PtyOpenRequest,
|
|
224
|
+
PtyOpenResponse,
|
|
225
|
+
PtyWriteRequest,
|
|
226
|
+
PtyResizeRequest,
|
|
227
|
+
PtyCloseRequest,
|
|
130
228
|
ToolRef,
|
|
131
229
|
UpdateScheduledTaskRequest,
|
|
132
230
|
UpdateSessionGoalRequest,
|
|
231
|
+
UpdateSessionRequest,
|
|
133
232
|
UpdateSessionTurnRequest,
|
|
134
233
|
UpdateWorkspaceEnvironmentRequest,
|
|
135
234
|
UpdateWorkspaceMemberRequest,
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Zero-dependency interactive-terminal (ttyd PTY-over-websocket) transport
|
|
2
|
+
// contract. Symmetric with `desktop.ts`: the SDK ships only the pure transport
|
|
3
|
+
// CONTRACT (a URL assembler + the ttyd wire-protocol frame codec), with no DOM
|
|
4
|
+
// and no deps. The actual WebSocket open + xterm attach lives in `@opengeni/react`
|
|
5
|
+
// (`use-terminal-stream.ts`).
|
|
6
|
+
//
|
|
7
|
+
// The interactive terminal is a REAL PTY streamed over the SAME Modal raw-TLS
|
|
8
|
+
// tunnel as the desktop noVNC, with the SAME scoped stream-token mechanism. The
|
|
9
|
+
// box bakes `ttyd` on `TERMINAL_STREAM_PORT` (7681); `session.resolveExposedPort`
|
|
10
|
+
// mints the tunnel URL. The Terminal capability cell carries transport "pty-ws" +
|
|
11
|
+
// that url + the scoped token when the box is warm and a viewer is attached.
|
|
12
|
+
|
|
13
|
+
import type { TerminalCapability } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Translate the negotiated `pty-ws` Terminal capability into the WebSocket URL
|
|
17
|
+
* the ttyd client dials. The scoped provider token is ALREADY embedded in the
|
|
18
|
+
* minted `url` (the Modal tunnel host) by `session.resolveExposedPort(7681)` —
|
|
19
|
+
* we do NOT append `cap.token` (identical posture to the desktop: the gate is the
|
|
20
|
+
* unguessable short-TTL tunnel URL + the server-recorded scoped stream token; ttyd
|
|
21
|
+
* runs `--writable` with no `-c` credential in v1). We only normalize the scheme
|
|
22
|
+
* to `ws`/`wss`; a bare host is already the ttyd websocket endpoint.
|
|
23
|
+
*/
|
|
24
|
+
export function terminalSocketUrl(cap: Pick<TerminalCapability, "url">): string {
|
|
25
|
+
if (!cap.url) {
|
|
26
|
+
throw new Error("terminal capability has no url (transport is not pty-ws)");
|
|
27
|
+
}
|
|
28
|
+
const u = new URL(cap.url);
|
|
29
|
+
// https → wss, http → ws (ttyd is dialed as a WebSocket, not an HTTP page).
|
|
30
|
+
if (u.protocol === "https:") {
|
|
31
|
+
u.protocol = "wss:";
|
|
32
|
+
} else if (u.protocol === "http:") {
|
|
33
|
+
u.protocol = "ws:";
|
|
34
|
+
}
|
|
35
|
+
return u.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── ttyd wire protocol ───────────────────────────────────────────────────────
|
|
39
|
+
// ttyd frames are a single ASCII command char + payload. These mirror ttyd's
|
|
40
|
+
// `protocol.h` Command enum. Client→server and server→client share the byte 0/1/2
|
|
41
|
+
// values but mean different things per direction (see below).
|
|
42
|
+
|
|
43
|
+
/** ttyd subprotocol — REQUIRED on the WebSocket handshake or ttyd refuses it. */
|
|
44
|
+
export const TTYD_SUBPROTOCOL = "tty";
|
|
45
|
+
|
|
46
|
+
/** Client→server command bytes (the first char of each outbound text frame). */
|
|
47
|
+
export const TtydClientCommand = {
|
|
48
|
+
/** stdin: "0" + raw input bytes. */
|
|
49
|
+
INPUT: "0",
|
|
50
|
+
/** window resize: "1" + JSON.stringify({ columns, rows }). */
|
|
51
|
+
RESIZE: "1",
|
|
52
|
+
/** flow-control pause (back-pressure): "2". */
|
|
53
|
+
PAUSE: "2",
|
|
54
|
+
/** flow-control resume: "3". */
|
|
55
|
+
RESUME: "3",
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
/** Server→client command bytes (the first char of each inbound frame). */
|
|
59
|
+
export const TtydServerCommand = {
|
|
60
|
+
/** stdout/stderr: "0" + raw output bytes (write the rest into xterm). */
|
|
61
|
+
OUTPUT: "0",
|
|
62
|
+
/** set the window title: "1" + title string. */
|
|
63
|
+
SET_WINDOW_TITLE: "1",
|
|
64
|
+
/** ttyd client preferences JSON: "2" + json (ignored by us). */
|
|
65
|
+
SET_PREFERENCES: "2",
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The ttyd handshake's first frame: an auth message. ttyd expects
|
|
70
|
+
* `JSON.stringify({ AuthToken })` as the FIRST text frame on the socket. We send
|
|
71
|
+
* an empty token — our gate is the tunnel URL + scoped stream token, NOT a ttyd
|
|
72
|
+
* `-c` basic-auth credential (which the box does not set in v1). Optional ttyd
|
|
73
|
+
* `columns`/`rows` can ride this frame to seed the PTY size before the first
|
|
74
|
+
* resize. Pure (string-building only) so it stays unit-testable in the SDK.
|
|
75
|
+
*/
|
|
76
|
+
export function ttydAuthFrame(opts?: { columns?: number; rows?: number }): string {
|
|
77
|
+
const frame: { AuthToken: string; columns?: number; rows?: number } = { AuthToken: "" };
|
|
78
|
+
if (opts?.columns && opts.columns > 0) frame.columns = opts.columns;
|
|
79
|
+
if (opts?.rows && opts.rows > 0) frame.rows = opts.rows;
|
|
80
|
+
return JSON.stringify(frame);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build a client→server INPUT (stdin) frame: "0" + data. */
|
|
84
|
+
export function ttydInputFrame(data: string): string {
|
|
85
|
+
return TtydClientCommand.INPUT + data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build a client→server RESIZE frame: "1" + JSON.stringify({ columns, rows }). */
|
|
89
|
+
export function ttydResizeFrame(columns: number, rows: number): string {
|
|
90
|
+
return TtydClientCommand.RESIZE + JSON.stringify({ columns, rows });
|
|
91
|
+
}
|