@sealant/sdk-next 0.40.0-next.755 → 0.40.0-next.757

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/errors.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ForwardRefusalReason } from "@sealant/api-contracts";
1
2
  /**
2
3
  * Public SDK errors. The Effect core fails on a typed `Schema.TaggedError` channel; the facade maps
3
4
  * every tagged failure onto one of these PLAIN `Error` subclasses (a single `_tag` switch) so the
@@ -23,7 +24,7 @@ export declare class SealantRuntimeError extends SealantError {
23
24
  }
24
25
  /** A control-plane API request failed (HTTP-level or typed API error). */
25
26
  export declare class SealantApiError extends SealantError {
26
- readonly name = "SealantApiError";
27
+ readonly name: string;
27
28
  /** HTTP status, when the failure came from a response. */
28
29
  readonly status?: number;
29
30
  /**
@@ -48,6 +49,34 @@ export declare class SealantApiError extends SealantError {
48
49
  readonly cause?: unknown;
49
50
  });
50
51
  }
52
+ /**
53
+ * Why a workspace forward did not open. Core names every refusal it sends. The SDK adds
54
+ * `unrecognized-refusal` (Core sent a reason newer than this SDK; it is kept in `wireReason`),
55
+ * `protocol-unsupported` (the control plane predates typed refusals, or answered with something
56
+ * unreadable) and `transport-unavailable` (no answer could be read at all).
57
+ */
58
+ export type ForwardDialReason = ForwardRefusalReason | "unrecognized-refusal" | "protocol-unsupported" | "transport-unavailable";
59
+ export type ForwardDialFailure = {
60
+ readonly reason: ForwardRefusalReason;
61
+ readonly status: number;
62
+ readonly message: string;
63
+ } | {
64
+ readonly reason: "unrecognized-refusal";
65
+ readonly wireReason: string;
66
+ readonly status: number;
67
+ readonly message: string;
68
+ } | {
69
+ readonly reason: "protocol-unsupported" | "transport-unavailable";
70
+ readonly message: string;
71
+ };
72
+ /** A workspace forward was refused before it opened. Branch on `reason`. */
73
+ export declare class ForwardDialError extends SealantApiError {
74
+ readonly name: string;
75
+ readonly reason: ForwardDialReason;
76
+ /** The reason Core sent, when this SDK does not know it (`unrecognized-refusal`). */
77
+ readonly wireReason?: string;
78
+ constructor(failure: ForwardDialFailure);
79
+ }
51
80
  /** The requested operation is part of the public surface but not yet implemented in this slice. */
52
81
  export declare class SealantNotImplementedError extends SealantError {
53
82
  readonly name = "SealantNotImplementedError";
package/dist/errors.js CHANGED
@@ -60,6 +60,24 @@ export class SealantApiError extends SealantError {
60
60
  }
61
61
  }
62
62
  }
63
+ /** A workspace forward was refused before it opened. Branch on `reason`. */
64
+ export class ForwardDialError extends SealantApiError {
65
+ name = "ForwardDialError";
66
+ reason;
67
+ /** The reason Core sent, when this SDK does not know it (`unrecognized-refusal`). */
68
+ wireReason;
69
+ constructor(failure) {
70
+ super(failure.message, {
71
+ code: "ForwardDialError",
72
+ reason: failure.reason,
73
+ ...("status" in failure ? { status: failure.status } : {}),
74
+ });
75
+ this.reason = failure.reason;
76
+ if ("wireReason" in failure) {
77
+ this.wireReason = failure.wireReason;
78
+ }
79
+ }
80
+ }
63
81
  /** The requested operation is part of the public surface but not yet implemented in this slice. */
64
82
  export class SealantNotImplementedError extends SealantError {
65
83
  name = "SealantNotImplementedError";
@@ -0,0 +1,18 @@
1
+ import { type ForwardBinding } from "@sealant/api-contracts";
2
+ import type { WorkspaceForward, WorkspaceForwardOptions } from "../types.js";
3
+ export interface ForwardEndpoint {
4
+ readonly baseUrl: string;
5
+ readonly apiKey: string | undefined;
6
+ readonly ownerUserId: string;
7
+ }
8
+ export declare const openForward: (endpoint: ForwardEndpoint, workspaceId: string, port: number, options?: WorkspaceForwardOptions | undefined) => Promise<WorkspaceForward>;
9
+ export interface ForwardV2Options {
10
+ readonly host?: "127.0.0.1" | "localhost" | "docker";
11
+ readonly signal?: AbortSignal;
12
+ readonly deadlineMs?: number;
13
+ }
14
+ export interface OpenedForwardV2 {
15
+ readonly binding: ForwardBinding;
16
+ readonly forward: WorkspaceForward;
17
+ }
18
+ export declare const openForwardV2: (endpoint: ForwardEndpoint, workspaceId: string, port: number, options?: ForwardV2Options | undefined) => Promise<OpenedForwardV2>;
@@ -0,0 +1,207 @@
1
+ import { FORWARD_V2_MAX_FRAME_BYTES, FORWARD_V2_SUBPROTOCOL, forwardOpenFrameSchema, forwardRefusalReasonSchema, } from "@sealant/api-contracts";
2
+ import { Option, Schema } from "effect";
3
+ import { ForwardDialError } from "../errors.js";
4
+ const forwardUrl = (endpoint, path, query) => {
5
+ const url = new URL(path, endpoint.baseUrl);
6
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
7
+ for (const [name, value] of Object.entries(query)) {
8
+ if (value !== undefined)
9
+ url.searchParams.set(name, value);
10
+ }
11
+ // The owner assertion always rides the URL (a service principal needs it alongside its
12
+ // key, and WebSocket cannot carry headers — same contract as the session attach).
13
+ url.searchParams.set("ownerUserId", endpoint.ownerUserId);
14
+ if (endpoint.apiKey !== undefined) {
15
+ url.searchParams.set("token", endpoint.apiKey);
16
+ }
17
+ return url;
18
+ };
19
+ const bridgeForward = (ws, maxFrameBytes = Number.POSITIVE_INFINITY) => {
20
+ const pending = [];
21
+ let wake;
22
+ let finished = false;
23
+ const closedResolver = Promise.withResolvers();
24
+ const finish = (reason) => {
25
+ if (finished) {
26
+ return;
27
+ }
28
+ finished = true;
29
+ closedResolver.resolve(reason);
30
+ wake?.();
31
+ };
32
+ ws.addEventListener("close", () => finish("closed"));
33
+ const receive = (event) => {
34
+ if (typeof event.data === "string") {
35
+ try {
36
+ const frame = JSON.parse(event.data);
37
+ if (frame.t === "end") {
38
+ finish("end");
39
+ }
40
+ }
41
+ catch { }
42
+ return;
43
+ }
44
+ pending.push(new Uint8Array(event.data));
45
+ wake?.();
46
+ };
47
+ const output = {
48
+ [Symbol.asyncIterator]: () => ({
49
+ next: async () => {
50
+ for (;;) {
51
+ const chunk = pending.shift();
52
+ if (chunk !== undefined) {
53
+ return { done: false, value: chunk };
54
+ }
55
+ if (finished) {
56
+ return { done: true, value: undefined };
57
+ }
58
+ await new Promise((r) => {
59
+ wake = r;
60
+ });
61
+ wake = undefined;
62
+ }
63
+ },
64
+ }),
65
+ };
66
+ const forward = {
67
+ send: (input) => {
68
+ let offset = 0;
69
+ do {
70
+ // Copy into a plain ArrayBuffer-backed view (WebSocket.send rejects SharedArrayBuffer views).
71
+ ws.send(new Uint8Array(input.subarray(offset, offset + maxFrameBytes)).buffer);
72
+ offset += maxFrameBytes;
73
+ } while (offset < input.byteLength);
74
+ },
75
+ eof: () => {
76
+ ws.send(JSON.stringify({ t: "eof" }));
77
+ },
78
+ output,
79
+ closed: closedResolver.promise,
80
+ close: () => {
81
+ finish("closed");
82
+ ws.close();
83
+ },
84
+ };
85
+ return { forward, receive };
86
+ };
87
+ export const openForward = (endpoint, workspaceId, port, options) => {
88
+ const url = forwardUrl(endpoint, `/v1/workspaces/${workspaceId}/forward`, {
89
+ port: String(port),
90
+ host: options?.host,
91
+ protocol: options?.protocol === "udp" ? "udp" : undefined,
92
+ });
93
+ return new Promise((resolve, reject) => {
94
+ const ws = new WebSocket(url);
95
+ ws.binaryType = "arraybuffer";
96
+ const { forward, receive } = bridgeForward(ws);
97
+ ws.addEventListener("message", receive);
98
+ ws.addEventListener("open", () => resolve(forward), { once: true });
99
+ ws.addEventListener("error", () => reject(new Error(`workspace forward failed: could not connect to ${url.host} (is anything listening on 127.0.0.1:${port} in the workspace?)`)), { once: true });
100
+ });
101
+ };
102
+ const FORWARD_V2_OPEN_DEADLINE_MS = 30_000;
103
+ const decodeOpenFrame = Schema.decodeUnknownOption(Schema.fromJsonString(forwardOpenFrameSchema));
104
+ const isKnownRefusal = Schema.is(forwardRefusalReasonSchema);
105
+ const refusalError = (frame) => new ForwardDialError(isKnownRefusal(frame.reason)
106
+ ? { reason: frame.reason, status: frame.status, message: frame.message }
107
+ : {
108
+ reason: "unrecognized-refusal",
109
+ wireReason: frame.reason,
110
+ status: frame.status,
111
+ message: frame.message,
112
+ });
113
+ /** A gated Core before v2 answers the route 401, an open one 404. Core with v2 answers 426. */
114
+ const answeredLikeCoreBeforeV2 = (status) => status === 401 || status === 404;
115
+ const failedHandshakeError = async (url, origin, signal) => {
116
+ const probe = new URL(url.pathname, url);
117
+ probe.protocol = url.protocol === "wss:" ? "https:" : "http:";
118
+ const unavailable = (detail) => new ForwardDialError({
119
+ reason: "transport-unavailable",
120
+ message: `The forward could not open a WebSocket to ${origin}; ${detail}.`,
121
+ });
122
+ try {
123
+ const response = await fetch(probe, { signal });
124
+ await response.body?.cancel();
125
+ if (answeredLikeCoreBeforeV2(response.status)) {
126
+ return new ForwardDialError({
127
+ reason: "protocol-unsupported",
128
+ message: `${origin} does not serve /forward/v2; it predates typed forward refusals.`,
129
+ });
130
+ }
131
+ return unavailable(`a plain request to the route got ${response.status}`);
132
+ }
133
+ catch {
134
+ return unavailable("a plain request to the route got no answer");
135
+ }
136
+ };
137
+ export const openForwardV2 = (endpoint, workspaceId, port, options) => {
138
+ const host = options?.host ?? "127.0.0.1";
139
+ const signal = options?.signal;
140
+ const deadlineMs = options?.deadlineMs ?? FORWARD_V2_OPEN_DEADLINE_MS;
141
+ const url = forwardUrl(endpoint, `/v1/workspaces/${workspaceId}/forward/v2`, {
142
+ port: String(port),
143
+ host,
144
+ });
145
+ const origin = new URL(endpoint.baseUrl).origin;
146
+ return new Promise((resolve, reject) => {
147
+ if (signal?.aborted === true)
148
+ return reject(signal.reason);
149
+ const ws = new WebSocket(url, FORWARD_V2_SUBPROTOCOL);
150
+ ws.binaryType = "arraybuffer";
151
+ const { forward, receive } = bridgeForward(ws, FORWARD_V2_MAX_FRAME_BYTES);
152
+ const probing = new AbortController();
153
+ let phase = "handshake";
154
+ const settle = (next) => {
155
+ phase = next;
156
+ clearTimeout(deadline);
157
+ signal?.removeEventListener("abort", onAbort);
158
+ probing.abort();
159
+ };
160
+ const fail = (error) => {
161
+ if (phase === "open" || phase === "failed")
162
+ return;
163
+ settle("failed");
164
+ ws.close();
165
+ reject(error);
166
+ };
167
+ const unreadable = (message) => fail(new ForwardDialError({ reason: "protocol-unsupported", message }));
168
+ const onAbort = () => fail(signal?.reason);
169
+ const deadline = setTimeout(() => fail(new ForwardDialError({
170
+ reason: "transport-unavailable",
171
+ message: `${origin} did not open the forward within ${deadlineMs} ms.`,
172
+ })), deadlineMs);
173
+ signal?.addEventListener("abort", onAbort, { once: true });
174
+ ws.addEventListener("open", () => {
175
+ phase = "awaiting-answer";
176
+ if (ws.protocol !== FORWARD_V2_SUBPROTOCOL) {
177
+ unreadable(`${origin} accepted the forward without the ${FORWARD_V2_SUBPROTOCOL} protocol.`);
178
+ }
179
+ });
180
+ ws.addEventListener("message", (event) => {
181
+ if (phase === "open")
182
+ return receive(event);
183
+ if (phase !== "awaiting-answer")
184
+ return;
185
+ const frame = typeof event.data === "string" ? decodeOpenFrame(event.data) : Option.none();
186
+ if (Option.isNone(frame)) {
187
+ return unreadable(`${origin} answered the forward with a frame this SDK cannot read.`);
188
+ }
189
+ if (frame.value.t === "refused")
190
+ return fail(refusalError(frame.value));
191
+ settle("open");
192
+ resolve({ binding: frame.value.binding, forward });
193
+ });
194
+ ws.addEventListener("close", () => {
195
+ if (phase === "awaiting-answer") {
196
+ return fail(new ForwardDialError({
197
+ reason: "transport-unavailable",
198
+ message: `The forward's connection to ${origin} closed before the control plane answered.`,
199
+ }));
200
+ }
201
+ if (phase !== "handshake")
202
+ return;
203
+ phase = "probing";
204
+ void failedHandshakeError(url, origin, probing.signal).then(fail);
205
+ });
206
+ });
207
+ };
@@ -7,6 +7,7 @@ import { elapsed } from "../internal/clock.js";
7
7
  import { mapAccountRef } from "../internal/credentials.js";
8
8
  import { parseTtlSeconds } from "../internal/duration.js";
9
9
  import { requireProcessUser } from "../internal/process-user.js";
10
+ import { openForward } from "./forward.js";
10
11
  import { makeInteractiveSession } from "./session.js";
11
12
  /** The wire runtime as the SDK reports it. */
12
13
  export const toRuntimeInfo = (runtime) => ({
@@ -796,105 +797,11 @@ export const makeWorkspace = (ctx, init) => {
796
797
  sshAsRoot: async () => {
797
798
  await ctx.runtime.run(clearWorkspaceSshUserOp(init.id, { ownerUserId: ctx.config.hostLocal.ownerUserId }));
798
799
  },
799
- forward: (port, options) => openForward(ctx, init.id, port, options),
800
+ forward: (port, options) => openForward({
801
+ baseUrl: ctx.config.baseUrl,
802
+ apiKey: ctx.config.apiKey,
803
+ ownerUserId: ctx.config.hostLocal.ownerUserId,
804
+ }, init.id, port, options),
800
805
  };
801
806
  return workspace;
802
807
  };
803
- /**
804
- * Open the held-WebSocket port forward (the byte-pipe data plane, mirroring
805
- * the session attachment): binary frames are payload bytes in both
806
- * directions; text frames are control JSON — `{"t":"eof"}` up for half-close,
807
- * `{"t":"end"}` down when the remote closes. Auth rides the connect
808
- * (`?token=` / `?ownerUserId=`), never per frame. The server refuses the
809
- * upgrade with a plain HTTP status when nothing listens on the port, which
810
- * surfaces here as the connect rejection.
811
- */
812
- const openForward = (ctx, workspaceId, port, options) => {
813
- const config = ctx.config;
814
- const url = new URL(`/v1/workspaces/${workspaceId}/forward`, config.baseUrl);
815
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
816
- url.searchParams.set("port", String(port));
817
- if (options?.host !== undefined) {
818
- url.searchParams.set("host", options.host);
819
- }
820
- if (options?.protocol === "udp") {
821
- url.searchParams.set("protocol", "udp");
822
- }
823
- // The owner assertion always rides the URL (a service principal needs it alongside its
824
- // key, and WebSocket cannot carry headers — same contract as the session attach).
825
- url.searchParams.set("ownerUserId", config.hostLocal.ownerUserId);
826
- if (config.apiKey !== undefined) {
827
- url.searchParams.set("token", config.apiKey);
828
- }
829
- return new Promise((resolve, reject) => {
830
- const ws = new WebSocket(url);
831
- ws.binaryType = "arraybuffer";
832
- // Push-queue bridging WS message events to the pull-based async iterable.
833
- const pending = [];
834
- let wake;
835
- let finished = false;
836
- const closedResolver = Promise.withResolvers();
837
- const closed = closedResolver.promise;
838
- const finish = (reason) => {
839
- if (finished) {
840
- return;
841
- }
842
- finished = true;
843
- closedResolver.resolve(reason);
844
- wake?.();
845
- };
846
- ws.addEventListener("message", (event) => {
847
- if (typeof event.data === "string") {
848
- try {
849
- const frame = JSON.parse(event.data);
850
- if (frame.t === "end") {
851
- finish("end");
852
- }
853
- }
854
- catch {
855
- // Unknown text frame — ignore.
856
- }
857
- return;
858
- }
859
- pending.push(new Uint8Array(event.data));
860
- wake?.();
861
- });
862
- ws.addEventListener("close", () => finish("closed"));
863
- const output = {
864
- [Symbol.asyncIterator]: () => ({
865
- next: async () => {
866
- for (;;) {
867
- const chunk = pending.shift();
868
- if (chunk !== undefined) {
869
- return { done: false, value: chunk };
870
- }
871
- if (finished) {
872
- return { done: true, value: undefined };
873
- }
874
- await new Promise((r) => {
875
- wake = r;
876
- });
877
- wake = undefined;
878
- }
879
- },
880
- }),
881
- };
882
- const forward = {
883
- send: (input) => {
884
- // Copy into a plain ArrayBuffer-backed view (WebSocket.send rejects SharedArrayBuffer views).
885
- ws.send(new Uint8Array(input).buffer);
886
- },
887
- eof: () => {
888
- ws.send(JSON.stringify({ t: "eof" }));
889
- },
890
- output,
891
- closed,
892
- close: () => {
893
- finish("closed");
894
- ws.close();
895
- },
896
- };
897
- ws.addEventListener("open", () => resolve(forward), { once: true });
898
- ws.addEventListener("error", () => reject(new Error(`workspace forward failed: could not connect to ${url.host} (is anything listening on 127.0.0.1:${port} in the workspace?)`)), { once: true });
899
- });
900
- };
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export { Sealant } from "./client.js";
18
18
  export { claudeCode, codex, customHarness, opencode, pi } from "./harness.js";
19
- export { SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
19
+ export { ForwardDialError, type ForwardDialFailure, type ForwardDialReason, SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
20
20
  export type * from "./types.js";
21
21
  export { findWorkspaceEnvReservedRule, findWorkspaceSecretEnvReservedRule, formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, WORKSPACE_ENV_MAX_ENTRIES, WORKSPACE_ENV_MAX_NAME_LENGTH, WORKSPACE_ENV_MAX_TOTAL_BYTES, WORKSPACE_ENV_MAX_VALUE_BYTES, WORKSPACE_ENV_NAME_PATTERN, WORKSPACE_ENV_SECRET_MARKERS, } from "@sealant/api-contracts/workspace-environment";
22
22
  export type { WorkspaceEnvIssue, WorkspaceEnvParseResult, WorkspaceEnvReservedRule, } from "@sealant/api-contracts/workspace-environment";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export { Sealant } from "./client.js";
18
18
  export { claudeCode, codex, customHarness, opencode, pi } from "./harness.js";
19
- export { SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
19
+ export { ForwardDialError, SealantApiError, SealantError, SealantNotImplementedError, SealantRuntimeError, } from "./errors.js";
20
20
  // The workspace environment policy (`CreateOptions.env` validation) is public API: downstream
21
21
  // products validate at their own boundaries with the exact rules the platform enforces.
22
22
  export { findWorkspaceEnvReservedRule, findWorkspaceSecretEnvReservedRule, formatWorkspaceEnvIssue, parseWorkspaceEnv, parseWorkspaceSecretEnv, WORKSPACE_ENV_MAX_ENTRIES, WORKSPACE_ENV_MAX_NAME_LENGTH, WORKSPACE_ENV_MAX_TOTAL_BYTES, WORKSPACE_ENV_MAX_VALUE_BYTES, WORKSPACE_ENV_NAME_PATTERN, WORKSPACE_ENV_SECRET_MARKERS, } from "@sealant/api-contracts/workspace-environment";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/sdk-next",
3
- "version": "0.40.0-next.755",
3
+ "version": "0.40.0-next.757",
4
4
  "description": "The fluent public SDK for Sealant — create a workspace, run a harness, replay the record.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -26,12 +26,12 @@
26
26
  "node": ">=20"
27
27
  },
28
28
  "dependencies": {
29
- "@sealant/api-contracts": "npm:@sealant/api-contracts-next@0.40.0-next.755"
29
+ "@sealant/api-contracts": "npm:@sealant/api-contracts-next@0.40.0-next.757"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "effect": "^4.0.5"
33
33
  },
34
- "gitHead": "f51aa3c26804adc254ee7d8706397737bf59a2b9",
34
+ "gitHead": "30986c255bb3b259e1821aac7da33537cce06160",
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  }