@sandbox-kit/sandbox 0.1.0 → 0.1.2
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 +106 -46
- package/dist/client/client.d.ts +7 -126
- package/dist/client/client.js +37 -564
- package/dist/client/errors.d.ts +35 -0
- package/dist/client/errors.js +88 -0
- package/dist/client/operation.d.ts +12 -0
- package/dist/client/operation.js +73 -0
- package/dist/client/sandbox.d.ts +33 -0
- package/dist/client/sandbox.js +160 -0
- package/dist/client/types.d.ts +63 -0
- package/dist/client/types.js +3 -0
- package/dist/client/validation.d.ts +9 -0
- package/dist/client/validation.js +221 -0
- package/dist/contracts/sandbox_data_contracts.d.ts +10 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/internal/net_validate.d.ts +4 -0
- package/dist/internal/net_validate.js +53 -0
- package/package.json +9 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Portable error wrappers are handwritten. ErrorInfo / ErrorKind remain
|
|
2
|
+
// protobuf-owned contract data.
|
|
3
|
+
import { ErrorKind as ProtoErrorKind } from "@sandbox-kit/contracts/sandbox";
|
|
4
|
+
import { protoEnum } from "#internal/proto_enum.js";
|
|
5
|
+
// Ergonomic PascalCase names over protobuf enum values; not a second contract.
|
|
6
|
+
export const ErrorKind = protoEnum(ProtoErrorKind);
|
|
7
|
+
export class SandboxError extends Error {
|
|
8
|
+
kind;
|
|
9
|
+
provider;
|
|
10
|
+
operation;
|
|
11
|
+
field;
|
|
12
|
+
rule;
|
|
13
|
+
statusCode;
|
|
14
|
+
providerCode;
|
|
15
|
+
providerSource;
|
|
16
|
+
constructor(info, options) {
|
|
17
|
+
super(info.message, options);
|
|
18
|
+
this.name = "SandboxError";
|
|
19
|
+
this.kind = info.kind;
|
|
20
|
+
this.provider = info.provider;
|
|
21
|
+
this.operation = info.operation;
|
|
22
|
+
this.field = info.field;
|
|
23
|
+
this.rule = info.rule;
|
|
24
|
+
this.statusCode = info.statusCode;
|
|
25
|
+
this.providerCode = info.providerCode;
|
|
26
|
+
this.providerSource = info.providerSource;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function sandboxError(info, cause) {
|
|
30
|
+
return new SandboxError(info, cause === undefined ? undefined : { cause });
|
|
31
|
+
}
|
|
32
|
+
export function unsupported(provider, operation) {
|
|
33
|
+
throw sandboxError({
|
|
34
|
+
kind: ErrorKind.Unsupported,
|
|
35
|
+
provider,
|
|
36
|
+
operation,
|
|
37
|
+
field: "",
|
|
38
|
+
message: "sandbox-kit: provider does not support " + operation,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function withContext(error, provider, operation) {
|
|
42
|
+
if (error instanceof SandboxError) {
|
|
43
|
+
if (error.provider === provider && error.operation === operation)
|
|
44
|
+
return error;
|
|
45
|
+
return sandboxError({
|
|
46
|
+
kind: error.kind,
|
|
47
|
+
provider: error.provider || provider,
|
|
48
|
+
operation: error.operation || operation,
|
|
49
|
+
field: error.field,
|
|
50
|
+
message: error.message,
|
|
51
|
+
...(error.rule === undefined ? {} : { rule: error.rule }),
|
|
52
|
+
...(error.statusCode === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: { statusCode: error.statusCode }),
|
|
55
|
+
...(error.providerCode === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { providerCode: error.providerCode }),
|
|
58
|
+
...(error.providerSource === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { providerSource: error.providerSource }),
|
|
61
|
+
}, error.cause);
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof DOMException && error.name === "AbortError")
|
|
64
|
+
return sandboxError({
|
|
65
|
+
kind: ErrorKind.Canceled,
|
|
66
|
+
provider,
|
|
67
|
+
operation,
|
|
68
|
+
field: "",
|
|
69
|
+
message: error.message,
|
|
70
|
+
}, error);
|
|
71
|
+
if (error instanceof DOMException && error.name === "TimeoutError")
|
|
72
|
+
return sandboxError({
|
|
73
|
+
kind: ErrorKind.Timeout,
|
|
74
|
+
provider,
|
|
75
|
+
operation,
|
|
76
|
+
field: "",
|
|
77
|
+
message: error.message,
|
|
78
|
+
}, error);
|
|
79
|
+
return sandboxError({
|
|
80
|
+
kind: ErrorKind.Unknown,
|
|
81
|
+
provider,
|
|
82
|
+
operation,
|
|
83
|
+
field: "",
|
|
84
|
+
message: error instanceof Error
|
|
85
|
+
? error.message
|
|
86
|
+
: "sandbox-kit: provider operation failed",
|
|
87
|
+
}, error);
|
|
88
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OperationOptions } from "#client/types.js";
|
|
2
|
+
export declare function clone<T>(value: T): T;
|
|
3
|
+
export declare function operationSignal(options: OperationOptions, inheritedTimeoutMs?: number): {
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
dispose(): void;
|
|
6
|
+
};
|
|
7
|
+
export declare function operationOptions(options: OperationOptions, signal: AbortSignal | undefined): OperationOptions;
|
|
8
|
+
export declare function awaitWithSignal<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
9
|
+
/** Lifecycle request timeouts stay milliseconds for Backend / providers. */
|
|
10
|
+
export declare function publicTimeoutOptions(options: OperationOptions): {
|
|
11
|
+
timeout?: number;
|
|
12
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Operation deadline / AbortSignal helpers shared by Client and Sandbox.
|
|
2
|
+
import { ConfigFieldTimeout } from "#contracts/config_paths.js";
|
|
3
|
+
import { ErrorKind, sandboxError } from "#client/errors.js";
|
|
4
|
+
export function clone(value) {
|
|
5
|
+
return structuredClone(value);
|
|
6
|
+
}
|
|
7
|
+
export function operationSignal(options, inheritedTimeoutMs) {
|
|
8
|
+
const timeout = options.timeout ?? inheritedTimeoutMs;
|
|
9
|
+
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout < 0)) {
|
|
10
|
+
throw sandboxError({
|
|
11
|
+
kind: ErrorKind.InvalidArgument,
|
|
12
|
+
provider: "",
|
|
13
|
+
operation: "",
|
|
14
|
+
field: ConfigFieldTimeout,
|
|
15
|
+
message: "sandbox-kit: timeout must be a finite non-negative number",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if ((timeout === undefined || timeout === 0) && options.signal === undefined)
|
|
19
|
+
return { dispose() { } };
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
22
|
+
if (options.signal?.aborted)
|
|
23
|
+
abort();
|
|
24
|
+
else
|
|
25
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
26
|
+
const timer = timeout !== undefined && timeout > 0
|
|
27
|
+
? setTimeout(() => controller.abort(new DOMException("The operation timed out", "TimeoutError")), timeout)
|
|
28
|
+
: undefined;
|
|
29
|
+
return {
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
dispose() {
|
|
32
|
+
if (timer !== undefined)
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
options.signal?.removeEventListener("abort", abort);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function operationOptions(options, signal) {
|
|
39
|
+
return {
|
|
40
|
+
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
|
|
41
|
+
...(signal === undefined ? {} : { signal }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function abortReason(signal) {
|
|
45
|
+
const reason = signal.reason;
|
|
46
|
+
return reason instanceof DOMException &&
|
|
47
|
+
(reason.name === "AbortError" || reason.name === "TimeoutError")
|
|
48
|
+
? reason
|
|
49
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
50
|
+
}
|
|
51
|
+
export async function awaitWithSignal(operation, signal) {
|
|
52
|
+
if (signal?.aborted)
|
|
53
|
+
throw abortReason(signal);
|
|
54
|
+
const pending = operation();
|
|
55
|
+
if (signal === undefined)
|
|
56
|
+
return pending;
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const aborted = () => reject(abortReason(signal));
|
|
59
|
+
const cleanup = () => signal.removeEventListener("abort", aborted);
|
|
60
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
61
|
+
pending.then((value) => {
|
|
62
|
+
cleanup();
|
|
63
|
+
resolve(value);
|
|
64
|
+
}, (error) => {
|
|
65
|
+
cleanup();
|
|
66
|
+
reject(error);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Lifecycle request timeouts stay milliseconds for Backend / providers. */
|
|
71
|
+
export function publicTimeoutOptions(options) {
|
|
72
|
+
return options.timeout === undefined ? {} : { timeout: options.timeout };
|
|
73
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ExitResult, type LifecycleCapability, type SandboxInfo } from "#contracts/sandbox_data_contracts.js";
|
|
2
|
+
import type { Backend, DeleteCallOptions, OperationOptions, StopCallOptions, TerminateCallOptions } from "#client/types.js";
|
|
3
|
+
/** Coordinates sandbox operations with client closure. */
|
|
4
|
+
export interface ClientLifecycle {
|
|
5
|
+
begin(operation: string): () => void;
|
|
6
|
+
isClosed(): boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const sandboxConstructorToken: unique symbol;
|
|
9
|
+
/** Internal construction inputs for {@link Sandbox}; only Client may call. */
|
|
10
|
+
export interface SandboxInit {
|
|
11
|
+
backend: Backend;
|
|
12
|
+
owner: ClientLifecycle;
|
|
13
|
+
info: SandboxInfo;
|
|
14
|
+
timeout?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare class Sandbox implements AsyncDisposable {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(token: typeof sandboxConstructorToken, init: SandboxInit);
|
|
19
|
+
get id(): string;
|
|
20
|
+
get providerName(): string;
|
|
21
|
+
info(): SandboxInfo;
|
|
22
|
+
capabilities(): readonly LifecycleCapability[];
|
|
23
|
+
supports(capability: LifecycleCapability): boolean;
|
|
24
|
+
refresh(options?: OperationOptions): Promise<SandboxInfo>;
|
|
25
|
+
start(options?: OperationOptions): Promise<SandboxInfo>;
|
|
26
|
+
stop(options?: StopCallOptions): Promise<SandboxInfo>;
|
|
27
|
+
pause(options?: OperationOptions): Promise<SandboxInfo>;
|
|
28
|
+
terminate(options?: TerminateCallOptions): Promise<ExitResult>;
|
|
29
|
+
archive(options?: OperationOptions): Promise<SandboxInfo>;
|
|
30
|
+
delete(options?: DeleteCallOptions): Promise<SandboxInfo>;
|
|
31
|
+
close(options?: OperationOptions): Promise<void>;
|
|
32
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Sandbox handle runtime behavior is handwritten. Lifecycle methods dispatch
|
|
2
|
+
// through optional Backend capabilities.
|
|
3
|
+
import { SandboxState, } from "#contracts/sandbox_data_contracts.js";
|
|
4
|
+
import { ErrorKind, sandboxError, unsupported, withContext, } from "#client/errors.js";
|
|
5
|
+
import { awaitWithSignal, clone, operationOptions, operationSignal, publicTimeoutOptions, } from "#client/operation.js";
|
|
6
|
+
export const sandboxConstructorToken = Symbol("sandbox-kit.Sandbox");
|
|
7
|
+
export class Sandbox {
|
|
8
|
+
#backend;
|
|
9
|
+
#owner;
|
|
10
|
+
#timeout;
|
|
11
|
+
#info;
|
|
12
|
+
#closed = false;
|
|
13
|
+
#tail = Promise.resolve();
|
|
14
|
+
constructor(token, init) {
|
|
15
|
+
if (token !== sandboxConstructorToken)
|
|
16
|
+
throw new TypeError("Sandbox handles are created by Client");
|
|
17
|
+
this.#backend = init.backend;
|
|
18
|
+
this.#owner = init.owner;
|
|
19
|
+
this.#info = clone(init.info);
|
|
20
|
+
this.#timeout = init.timeout;
|
|
21
|
+
}
|
|
22
|
+
get id() {
|
|
23
|
+
return this.#info.id;
|
|
24
|
+
}
|
|
25
|
+
get providerName() {
|
|
26
|
+
return this.#info.provider;
|
|
27
|
+
}
|
|
28
|
+
info() {
|
|
29
|
+
return clone(this.#info);
|
|
30
|
+
}
|
|
31
|
+
capabilities() {
|
|
32
|
+
if (this.#closed || this.#owner.isClosed() || this.#info.terminal)
|
|
33
|
+
return [];
|
|
34
|
+
return this.#backend.capabilities?.(clone(this.#info)) ?? [];
|
|
35
|
+
}
|
|
36
|
+
supports(capability) {
|
|
37
|
+
return this.capabilities().includes(capability);
|
|
38
|
+
}
|
|
39
|
+
async refresh(options = {}) {
|
|
40
|
+
return this.#serialized("refresh", options, this.#timeout, async (signal) => {
|
|
41
|
+
const method = this.#backend.refresh;
|
|
42
|
+
if (!method)
|
|
43
|
+
unsupported(this.providerName, "refresh");
|
|
44
|
+
const info = await awaitWithSignal(() => method.call(this.#backend, this.id, operationOptions(options, signal)), signal);
|
|
45
|
+
this.#info = clone({ ...info, provider: this.providerName });
|
|
46
|
+
return this.info();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async start(options = {}) {
|
|
50
|
+
return this.#update("start", this.#backend.start, publicTimeoutOptions(options), options);
|
|
51
|
+
}
|
|
52
|
+
async stop(options = {}) {
|
|
53
|
+
const { force, ...call } = options;
|
|
54
|
+
return this.#update("stop", this.#backend.stop, {
|
|
55
|
+
...publicTimeoutOptions(call),
|
|
56
|
+
...(force === undefined ? {} : { force }),
|
|
57
|
+
}, call);
|
|
58
|
+
}
|
|
59
|
+
async pause(options = {}) {
|
|
60
|
+
return this.#update("pause", this.#backend.pause, publicTimeoutOptions(options), options);
|
|
61
|
+
}
|
|
62
|
+
async terminate(options = {}) {
|
|
63
|
+
const { wait, ...call } = options;
|
|
64
|
+
const request = {
|
|
65
|
+
...publicTimeoutOptions(call),
|
|
66
|
+
...(wait === undefined ? {} : { wait }),
|
|
67
|
+
};
|
|
68
|
+
return this.#serialized("terminate", call, call.timeout ?? this.#timeout, async (signal) => {
|
|
69
|
+
const method = this.#backend.terminate;
|
|
70
|
+
if (!method)
|
|
71
|
+
unsupported(this.providerName, "terminate");
|
|
72
|
+
const result = await awaitWithSignal(() => method.call(this.#backend, this.id, clone(request), operationOptions(call, signal)), signal);
|
|
73
|
+
this.#info = {
|
|
74
|
+
...this.#info,
|
|
75
|
+
state: SandboxState.Terminated,
|
|
76
|
+
terminal: true,
|
|
77
|
+
};
|
|
78
|
+
return result;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async archive(options = {}) {
|
|
82
|
+
return this.#update("archive", this.#backend.archive, publicTimeoutOptions(options), options);
|
|
83
|
+
}
|
|
84
|
+
async delete(options = {}) {
|
|
85
|
+
const { wait, ...call } = options;
|
|
86
|
+
return this.#update("delete", this.#backend.delete, {
|
|
87
|
+
...publicTimeoutOptions(call),
|
|
88
|
+
...(wait === undefined ? {} : { wait }),
|
|
89
|
+
}, call);
|
|
90
|
+
}
|
|
91
|
+
async close(options = {}) {
|
|
92
|
+
if (this.#closed)
|
|
93
|
+
return;
|
|
94
|
+
await this.#serialized("close", options, this.#timeout, async (signal) => {
|
|
95
|
+
const close = this.#backend.closeSandbox;
|
|
96
|
+
if (close)
|
|
97
|
+
await awaitWithSignal(() => close.call(this.#backend, this.id, operationOptions(options, signal)), signal);
|
|
98
|
+
this.#closed = true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async [Symbol.asyncDispose]() {
|
|
102
|
+
await this.close();
|
|
103
|
+
}
|
|
104
|
+
async #update(operation, method, request, options) {
|
|
105
|
+
return this.#serialized(operation, options, options.timeout ?? this.#timeout, async (signal) => {
|
|
106
|
+
if (!method)
|
|
107
|
+
unsupported(this.providerName, operation);
|
|
108
|
+
const info = await awaitWithSignal(() => method.call(this.#backend, this.id, clone(request), operationOptions(options, signal)), signal);
|
|
109
|
+
this.#info = clone({ ...info, provider: this.providerName });
|
|
110
|
+
return this.info();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async #serialized(operation, options, timeout, body) {
|
|
114
|
+
this.#ensureOpen(operation);
|
|
115
|
+
const releaseClient = this.#owner.begin(operation);
|
|
116
|
+
const previous = this.#tail;
|
|
117
|
+
let unlock;
|
|
118
|
+
this.#tail = new Promise((resolve) => {
|
|
119
|
+
unlock = resolve;
|
|
120
|
+
});
|
|
121
|
+
let acquired = false;
|
|
122
|
+
let scoped;
|
|
123
|
+
try {
|
|
124
|
+
scoped = operationSignal(options, timeout);
|
|
125
|
+
await awaitWithSignal(() => previous, scoped.signal);
|
|
126
|
+
acquired = true;
|
|
127
|
+
this.#ensureOpen(operation);
|
|
128
|
+
return await body(scoped.signal);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw withContext(error, this.providerName, operation);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
scoped?.dispose();
|
|
135
|
+
if (acquired)
|
|
136
|
+
unlock();
|
|
137
|
+
else
|
|
138
|
+
void previous.then(unlock, unlock);
|
|
139
|
+
releaseClient();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
#ensureOpen(operation) {
|
|
143
|
+
if (this.#closed || this.#owner.isClosed())
|
|
144
|
+
throw sandboxError({
|
|
145
|
+
kind: ErrorKind.FailedPrecondition,
|
|
146
|
+
provider: this.providerName,
|
|
147
|
+
operation,
|
|
148
|
+
field: "sandbox",
|
|
149
|
+
message: "sandbox-kit: sandbox handle is closed",
|
|
150
|
+
});
|
|
151
|
+
if (operation !== "close" && this.#info.terminal)
|
|
152
|
+
throw sandboxError({
|
|
153
|
+
kind: ErrorKind.FailedPrecondition,
|
|
154
|
+
provider: this.providerName,
|
|
155
|
+
operation,
|
|
156
|
+
field: "sandbox",
|
|
157
|
+
message: "sandbox-kit: sandbox lifecycle is terminal",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { MessageInitShape } from "@bufbuild/protobuf";
|
|
2
|
+
import { ConfigSchema, type APIKeyCredentials as WireAPIKeyCredentials, type AuthConfig, type BearerTokenCredentials as WireBearerTokenCredentials, type OAuthCredentials as WireOAuthCredentials, type TokenPairCredentials as WireTokenPairCredentials } from "@sandbox-kit/contracts/client";
|
|
3
|
+
import type { ArchiveOptions, CreateOptions, CreateResult, DeleteOptions, ExitResult, LifecycleCapability, PauseOptions, SandboxInfo, StartOptions, StopOptions, TerminateOptions } from "#contracts/sandbox_data_contracts.js";
|
|
4
|
+
import type { DurationFieldsToMs } from "#internal/duration_ms.js";
|
|
5
|
+
import type { RemapFields } from "#internal/input_remap.js";
|
|
6
|
+
import type { FlattenOneof } from "#internal/oneof_input.js";
|
|
7
|
+
import type { StripProto } from "#internal/strip_proto.js";
|
|
8
|
+
/** Public credential shapes (no protobuf `$typeName` / getters). */
|
|
9
|
+
export type APIKeyCredentials = StripProto<WireAPIKeyCredentials>;
|
|
10
|
+
export type TokenPairCredentials = StripProto<WireTokenPairCredentials>;
|
|
11
|
+
export type BearerTokenCredentials = StripProto<WireBearerTokenCredentials>;
|
|
12
|
+
export type OAuthCredentials = StripProto<WireOAuthCredentials>;
|
|
13
|
+
export type Scope = StripProto<import("@sandbox-kit/contracts/client").Scope>;
|
|
14
|
+
/** Public auth: set exactly one credential variant (no protobuf `{ case, value }`). */
|
|
15
|
+
export type AuthInput = FlattenOneof<NonNullable<AuthConfig["credentials"]>>;
|
|
16
|
+
/**
|
|
17
|
+
* Public client settings (ms timeout, flat auth).
|
|
18
|
+
* Prefer {@link ClientConfig} at construction (`Config` plus `provider`).
|
|
19
|
+
*/
|
|
20
|
+
export type Config = DurationFieldsToMs<RemapFields<StripProto<MessageInitShape<typeof ConfigSchema>>, {
|
|
21
|
+
auth: AuthInput;
|
|
22
|
+
}>>;
|
|
23
|
+
export interface OperationOptions {
|
|
24
|
+
readonly signal?: AbortSignal;
|
|
25
|
+
/** Operation deadline in milliseconds. */
|
|
26
|
+
readonly timeout?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Public stop options: Kit call options plus optional force. */
|
|
29
|
+
export type StopCallOptions = OperationOptions & {
|
|
30
|
+
readonly force?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** Public terminate options: Kit call options plus optional wait. */
|
|
33
|
+
export type TerminateCallOptions = OperationOptions & {
|
|
34
|
+
readonly wait?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** Public delete options: Kit call options plus optional wait. */
|
|
37
|
+
export type DeleteCallOptions = OperationOptions & {
|
|
38
|
+
readonly wait?: boolean;
|
|
39
|
+
};
|
|
40
|
+
export interface Backend {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
create?(request: CreateOptions, options: OperationOptions): Promise<CreateResult>;
|
|
43
|
+
attach?(id: string, options: OperationOptions): Promise<SandboxInfo>;
|
|
44
|
+
refresh?(id: string, options: OperationOptions): Promise<SandboxInfo>;
|
|
45
|
+
start?(id: string, request: StartOptions, options: OperationOptions): Promise<SandboxInfo>;
|
|
46
|
+
stop?(id: string, request: StopOptions, options: OperationOptions): Promise<SandboxInfo>;
|
|
47
|
+
pause?(id: string, request: PauseOptions, options: OperationOptions): Promise<SandboxInfo>;
|
|
48
|
+
terminate?(id: string, request: TerminateOptions, options: OperationOptions): Promise<ExitResult>;
|
|
49
|
+
archive?(id: string, request: ArchiveOptions, options: OperationOptions): Promise<SandboxInfo>;
|
|
50
|
+
delete?(id: string, request: DeleteOptions, options: OperationOptions): Promise<SandboxInfo>;
|
|
51
|
+
close(options: OperationOptions): Promise<void>;
|
|
52
|
+
closeSandbox?(id: string, options: OperationOptions): Promise<void>;
|
|
53
|
+
capabilities?(info: SandboxInfo): readonly LifecycleCapability[];
|
|
54
|
+
}
|
|
55
|
+
export interface Provider {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
/** Receives public {@link Config}. */
|
|
58
|
+
initialize(config: Config): Backend;
|
|
59
|
+
}
|
|
60
|
+
/** Provider selection stays handwritten and is never part of the portable Config message. */
|
|
61
|
+
export type ClientConfig = Config & {
|
|
62
|
+
readonly provider: Provider;
|
|
63
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CreateOptions } from "#contracts/sandbox_data_contracts.js";
|
|
2
|
+
import type { Config } from "#client/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Rejects public {@link CreateOptions} the shared contract cannot represent.
|
|
5
|
+
* Materializes wire form privately for Protovalidate. Provider-specific checks
|
|
6
|
+
* remain in each provider adapter.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateCreateOptions(value: CreateOptions, provider?: string, operation?: string): void;
|
|
9
|
+
export declare function validateConfig(value: Config, provider?: string, operation?: string): void;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Shared create/config validation is Protovalidate on materialized wire
|
|
2
|
+
// messages; secure_endpoint stays handwritten.
|
|
3
|
+
import { create, } from "@bufbuild/protobuf";
|
|
4
|
+
import { pathToString } from "@bufbuild/protobuf/reflect";
|
|
5
|
+
import { durationFromMs } from "@bufbuild/protobuf/wkt";
|
|
6
|
+
import { createValidator } from "@bufbuild/protovalidate";
|
|
7
|
+
import { ConfigSchema, } from "@sandbox-kit/contracts/client";
|
|
8
|
+
import { CreateOptionsSchema, } from "@sandbox-kit/contracts/sandbox";
|
|
9
|
+
import { materializeDurationFields } from "#internal/duration_ms.js";
|
|
10
|
+
import { metadataObjectFromMap } from "#metadata/metadata_object.js";
|
|
11
|
+
import { ErrorKind, sandboxError } from "#client/errors.js";
|
|
12
|
+
const contractValidator = createValidator({ failFast: true });
|
|
13
|
+
const stableRuleIDs = {
|
|
14
|
+
"enum.defined_only": "known_enum",
|
|
15
|
+
"duration.gte": "minimum",
|
|
16
|
+
"oneof.required": "exactly_one",
|
|
17
|
+
auth_credentials_required: "exactly_one",
|
|
18
|
+
cpu_limit: "limit",
|
|
19
|
+
memory_limit: "limit",
|
|
20
|
+
cpu_requires_positive: "requires_positive",
|
|
21
|
+
memory_requires_positive: "requires_positive",
|
|
22
|
+
restricted_requires_allowlist: "requires_any",
|
|
23
|
+
block_all_forbids_network: "forbids",
|
|
24
|
+
allow_all_forbids_allowlists: "forbids",
|
|
25
|
+
after_requires_duration: "requires_any",
|
|
26
|
+
after_forbids_duration: "forbids",
|
|
27
|
+
};
|
|
28
|
+
function validationError(input) {
|
|
29
|
+
throw sandboxError({
|
|
30
|
+
kind: ErrorKind.InvalidArgument,
|
|
31
|
+
provider: input.provider,
|
|
32
|
+
operation: input.operation,
|
|
33
|
+
field: input.field,
|
|
34
|
+
rule: input.rule,
|
|
35
|
+
message: "sandbox-kit: " + input.message,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function validSecureEndpoint(value) {
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(value);
|
|
41
|
+
if (!url.hostname || url.username || url.password || url.hash)
|
|
42
|
+
return false;
|
|
43
|
+
if (url.protocol === "https:")
|
|
44
|
+
return true;
|
|
45
|
+
if (url.protocol !== "http:")
|
|
46
|
+
return false;
|
|
47
|
+
const host = url.hostname.toLowerCase();
|
|
48
|
+
return (host === "localhost" ||
|
|
49
|
+
host === "[::1]" ||
|
|
50
|
+
host === "::1" ||
|
|
51
|
+
/^127(?:\.[0-9]{1,3}){3}$/.test(host));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Rejects public {@link CreateOptions} the shared contract cannot represent.
|
|
59
|
+
* Materializes wire form privately for Protovalidate. Provider-specific checks
|
|
60
|
+
* remain in each provider adapter.
|
|
61
|
+
*/
|
|
62
|
+
export function validateCreateOptions(value, provider = "", operation = "create") {
|
|
63
|
+
const message = materializeCreateOptions(value);
|
|
64
|
+
const result = contractValidator.validate(CreateOptionsSchema, message);
|
|
65
|
+
reportValidationResult(result, provider, operation);
|
|
66
|
+
}
|
|
67
|
+
function messageFieldHint(ruleID) {
|
|
68
|
+
switch (ruleID) {
|
|
69
|
+
case "cpu_limit":
|
|
70
|
+
case "cpu_requires_positive":
|
|
71
|
+
return "resources.cpu_limit_cores";
|
|
72
|
+
case "memory_limit":
|
|
73
|
+
case "memory_requires_positive":
|
|
74
|
+
return "resources.memory_limit_mib";
|
|
75
|
+
case "restricted_requires_allowlist":
|
|
76
|
+
return "network.outbound_cidrs,outbound_domains";
|
|
77
|
+
case "block_all_forbids_network":
|
|
78
|
+
return "network.outbound_cidrs,outbound_domains,private_network,linked_sandbox,inbound_cidrs,ports";
|
|
79
|
+
case "allow_all_forbids_allowlists":
|
|
80
|
+
return "network.outbound_cidrs,outbound_domains";
|
|
81
|
+
case "after_requires_duration":
|
|
82
|
+
case "after_forbids_duration":
|
|
83
|
+
return "after";
|
|
84
|
+
case "oneof.required":
|
|
85
|
+
return "tcp_port,command";
|
|
86
|
+
case "auth_credentials_required":
|
|
87
|
+
return "api_key,token_pair,bearer_token,oauth_refresh";
|
|
88
|
+
case "exactly_one":
|
|
89
|
+
return "client_secret,jwt_key";
|
|
90
|
+
default:
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function validateConfig(value, provider = "", operation = "create") {
|
|
95
|
+
const message = materializeClientConfig(value);
|
|
96
|
+
const result = contractValidator.validate(ConfigSchema, message);
|
|
97
|
+
reportValidationResult(result, provider, operation);
|
|
98
|
+
if (message.endpoint !== undefined &&
|
|
99
|
+
!validSecureEndpoint(message.endpoint)) {
|
|
100
|
+
validationError({
|
|
101
|
+
provider,
|
|
102
|
+
operation,
|
|
103
|
+
field: "endpoint",
|
|
104
|
+
rule: "secure_endpoint",
|
|
105
|
+
message: "must use HTTPS except for loopback HTTP development endpoints",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function reportValidationResult(result, provider, operation) {
|
|
110
|
+
if (result.kind === "valid")
|
|
111
|
+
return;
|
|
112
|
+
if (result.kind === "error") {
|
|
113
|
+
throw sandboxError({
|
|
114
|
+
kind: ErrorKind.InvalidArgument,
|
|
115
|
+
provider,
|
|
116
|
+
operation,
|
|
117
|
+
field: "",
|
|
118
|
+
message: "sandbox-kit: invalid configuration",
|
|
119
|
+
}, result.error);
|
|
120
|
+
}
|
|
121
|
+
const violation = result.violations[0];
|
|
122
|
+
const ruleID = violation.ruleId;
|
|
123
|
+
let field = pathToString(violation.field);
|
|
124
|
+
const rule = stableRuleIDs[ruleID] ?? ruleID;
|
|
125
|
+
if (!field) {
|
|
126
|
+
field = messageFieldHint(ruleID);
|
|
127
|
+
}
|
|
128
|
+
validationError({
|
|
129
|
+
provider,
|
|
130
|
+
operation,
|
|
131
|
+
field,
|
|
132
|
+
rule,
|
|
133
|
+
message: violation.message || "invalid configuration",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** Materializes a plain client config into the generated protobuf message. */
|
|
137
|
+
function materializeClientConfig(input) {
|
|
138
|
+
const { auth, timeout, ...rest } = input;
|
|
139
|
+
return create(ConfigSchema, {
|
|
140
|
+
...rest,
|
|
141
|
+
...(timeout === undefined ? {} : { timeout: durationFromMs(timeout) }),
|
|
142
|
+
...(auth === undefined
|
|
143
|
+
? {}
|
|
144
|
+
: { auth: { credentials: authCredentials(auth) } }),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Materializes plain create options into the generated protobuf message. */
|
|
148
|
+
function materializeCreateOptions(input) {
|
|
149
|
+
const { source, readiness, providerOptions, network, ...rest } = input;
|
|
150
|
+
const withDurations = materializeDurationFields(rest);
|
|
151
|
+
return create(CreateOptionsSchema, {
|
|
152
|
+
...withDurations,
|
|
153
|
+
...(network === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: { network: materializeNetworkConfig(network) }),
|
|
156
|
+
...(source === undefined ? {} : { source: { kind: sourceKind(source) } }),
|
|
157
|
+
...(readiness === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: { readiness: { check: readinessCheck(readiness) } }),
|
|
160
|
+
...(providerOptions === undefined
|
|
161
|
+
? {}
|
|
162
|
+
: { providerOptions: metadataObjectFromMap(providerOptions) }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Wrap public string-array allowlists into wire StringAllowlist messages. */
|
|
166
|
+
function materializeNetworkConfig(network) {
|
|
167
|
+
const { outboundCidrs, outboundDomains, inboundCidrs, ...rest } = network;
|
|
168
|
+
return {
|
|
169
|
+
...rest,
|
|
170
|
+
...(outboundCidrs === undefined
|
|
171
|
+
? {}
|
|
172
|
+
: { outboundCidrs: { entries: outboundCidrs } }),
|
|
173
|
+
...(outboundDomains === undefined
|
|
174
|
+
? {}
|
|
175
|
+
: { outboundDomains: { entries: outboundDomains } }),
|
|
176
|
+
...(inboundCidrs === undefined
|
|
177
|
+
? {}
|
|
178
|
+
: { inboundCidrs: { entries: inboundCidrs } }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function authCredentials(auth) {
|
|
182
|
+
if (auth.apiKey !== undefined)
|
|
183
|
+
return { case: "apiKey", value: auth.apiKey };
|
|
184
|
+
if (auth.tokenPair !== undefined)
|
|
185
|
+
return {
|
|
186
|
+
case: "tokenPair",
|
|
187
|
+
value: auth.tokenPair,
|
|
188
|
+
};
|
|
189
|
+
if (auth.bearerToken !== undefined)
|
|
190
|
+
return {
|
|
191
|
+
case: "bearerToken",
|
|
192
|
+
value: auth.bearerToken,
|
|
193
|
+
};
|
|
194
|
+
if (auth.oauthRefresh !== undefined)
|
|
195
|
+
return {
|
|
196
|
+
case: "oauthRefresh",
|
|
197
|
+
value: auth.oauthRefresh,
|
|
198
|
+
};
|
|
199
|
+
return { case: undefined };
|
|
200
|
+
}
|
|
201
|
+
function sourceKind(source) {
|
|
202
|
+
if (source.providerDefault !== undefined)
|
|
203
|
+
return {
|
|
204
|
+
case: "providerDefault",
|
|
205
|
+
value: {},
|
|
206
|
+
};
|
|
207
|
+
if (source.image !== undefined)
|
|
208
|
+
return { case: "image", value: source.image };
|
|
209
|
+
if (source.snapshot !== undefined)
|
|
210
|
+
return { case: "snapshot", value: source.snapshot };
|
|
211
|
+
if (source.warmPool !== undefined)
|
|
212
|
+
return { case: "warmPool", value: source.warmPool };
|
|
213
|
+
return { case: undefined };
|
|
214
|
+
}
|
|
215
|
+
function readinessCheck(readiness) {
|
|
216
|
+
if (readiness.tcpPort !== undefined)
|
|
217
|
+
return { case: "tcpPort", value: readiness.tcpPort };
|
|
218
|
+
if (readiness.command !== undefined)
|
|
219
|
+
return { case: "command", value: readiness.command };
|
|
220
|
+
return { case: undefined };
|
|
221
|
+
}
|