@hyperbrowser/sdk 1.1.0 → 1.2.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 +104 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +2 -0
- package/dist/error.d.ts +3 -0
- package/dist/error.js +5 -1
- package/dist/sandbox/process.d.ts +65 -3
- package/dist/sandbox/process.js +248 -4
- package/dist/sandbox/secret-references.d.ts +15 -0
- package/dist/sandbox/secret-references.js +56 -0
- package/dist/secrets-envelope.d.ts +25 -0
- package/dist/secrets-envelope.js +124 -0
- package/dist/services/base.js +51 -3
- package/dist/services/sandboxes.d.ts +10 -1
- package/dist/services/sandboxes.js +61 -2
- package/dist/services/secrets.d.ts +39 -0
- package/dist/services/secrets.js +203 -0
- package/dist/services/sessions.d.ts +2 -0
- package/dist/services/sessions.js +2 -0
- package/dist/services/webmcp.d.ts +16 -0
- package/dist/services/webmcp.js +60 -0
- package/dist/types/constants.d.ts +1 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/index.js +15 -0
- package/dist/types/sandbox.d.ts +42 -0
- package/dist/types/secret.d.ts +67 -0
- package/dist/types/secret.js +2 -0
- package/dist/types/session.d.ts +3 -0
- package/dist/types/webmcp.d.ts +84 -0
- package/dist/types/webmcp.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SecretsPublicKey, SecretValue } from "./types/secret";
|
|
2
|
+
/** The largest secret value the platform accepts. */
|
|
3
|
+
export declare const MAX_SECRET_VALUE_BYTES: number;
|
|
4
|
+
/**
|
|
5
|
+
* Apply the platform's value rules locally, so a bad value is reported before
|
|
6
|
+
* anything is created rather than when a sandbox starts.
|
|
7
|
+
*/
|
|
8
|
+
export declare const secretValueBytes: (value: SecretValue) => Buffer;
|
|
9
|
+
/** Encrypts values to a verified secrets public key. */
|
|
10
|
+
export declare class SecretsSealer {
|
|
11
|
+
private readonly kid;
|
|
12
|
+
private readonly publicKey;
|
|
13
|
+
private constructor();
|
|
14
|
+
/**
|
|
15
|
+
* Check that `key` is a supported RSA key whose ID matches the key itself. A
|
|
16
|
+
* mismatch means a corrupted response or a misconfigured server; where the
|
|
17
|
+
* key comes from rests on TLS to the API.
|
|
18
|
+
*/
|
|
19
|
+
static fromPublicKey(key: SecretsPublicKey): SecretsSealer;
|
|
20
|
+
/**
|
|
21
|
+
* Encrypt `value` as a compact JWE (RSA-OAEP-256 + A256GCM) bound to a
|
|
22
|
+
* secret's encryption context.
|
|
23
|
+
*/
|
|
24
|
+
seal(encryptionContext: string, value: SecretValue): string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SecretsSealer = exports.secretValueBytes = exports.MAX_SECRET_VALUE_BYTES = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const error_1 = require("./error");
|
|
9
|
+
/** The largest secret value the platform accepts. */
|
|
10
|
+
exports.MAX_SECRET_VALUE_BYTES = 32 * 1024;
|
|
11
|
+
const MIN_SECRETS_KEY_BITS = 3072;
|
|
12
|
+
const SECRETS_KEY_EXPONENT = 65537;
|
|
13
|
+
const invalid = (message) => new error_1.HyperbrowserError(message, { code: "invalid_secret_value", retryable: false });
|
|
14
|
+
const base64Url = (data) => Buffer.from(data).toString("base64url");
|
|
15
|
+
/**
|
|
16
|
+
* Apply the platform's value rules locally, so a bad value is reported before
|
|
17
|
+
* anything is created rather than when a sandbox starts.
|
|
18
|
+
*/
|
|
19
|
+
const secretValueBytes = (value) => {
|
|
20
|
+
if (typeof value !== "string" && !(value instanceof Uint8Array)) {
|
|
21
|
+
throw invalid("Secret value must be a string or bytes");
|
|
22
|
+
}
|
|
23
|
+
const bytes = Buffer.from(value);
|
|
24
|
+
if (bytes.length === 0) {
|
|
25
|
+
throw invalid("Secret value is empty");
|
|
26
|
+
}
|
|
27
|
+
if (bytes.length > exports.MAX_SECRET_VALUE_BYTES) {
|
|
28
|
+
throw invalid(`Secret value is more than ${exports.MAX_SECRET_VALUE_BYTES} bytes`);
|
|
29
|
+
}
|
|
30
|
+
if (bytes.includes(0)) {
|
|
31
|
+
throw invalid("Secret value must not contain NUL bytes");
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw invalid("Secret value must be valid UTF-8");
|
|
38
|
+
}
|
|
39
|
+
return bytes;
|
|
40
|
+
};
|
|
41
|
+
exports.secretValueBytes = secretValueBytes;
|
|
42
|
+
const minimalBytes = (value) => {
|
|
43
|
+
let start = 0;
|
|
44
|
+
while (start < value.length - 1 && value[start] === 0)
|
|
45
|
+
start += 1;
|
|
46
|
+
return value.subarray(start);
|
|
47
|
+
};
|
|
48
|
+
/** RFC 7638 JWK SHA-256 thumbprint of an RSA key. */
|
|
49
|
+
const rsaThumbprint = (n, e) => base64Url(crypto_1.default
|
|
50
|
+
.createHash("sha256")
|
|
51
|
+
.update(`{"e":"${base64Url(e)}","kty":"RSA","n":"${base64Url(n)}"}`)
|
|
52
|
+
.digest());
|
|
53
|
+
/** Encrypts values to a verified secrets public key. */
|
|
54
|
+
class SecretsSealer {
|
|
55
|
+
constructor(kid, publicKey) {
|
|
56
|
+
this.kid = kid;
|
|
57
|
+
this.publicKey = publicKey;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check that `key` is a supported RSA key whose ID matches the key itself. A
|
|
61
|
+
* mismatch means a corrupted response or a misconfigured server; where the
|
|
62
|
+
* key comes from rests on TLS to the API.
|
|
63
|
+
*/
|
|
64
|
+
static fromPublicKey(key) {
|
|
65
|
+
const unsupported = (message) => new error_1.HyperbrowserError(message, { code: "invalid_secrets_public_key", retryable: false });
|
|
66
|
+
if (key?.alg !== "RSA-OAEP-256" || key.enc !== "A256GCM" || key.jwk?.kty !== "RSA") {
|
|
67
|
+
throw unsupported(`Unsupported secrets public key (${key?.alg}/${key?.enc})`);
|
|
68
|
+
}
|
|
69
|
+
const n = minimalBytes(Buffer.from(key.jwk.n ?? "", "base64url"));
|
|
70
|
+
const e = minimalBytes(Buffer.from(key.jwk.e ?? "", "base64url"));
|
|
71
|
+
if (n.length === 0 || n[0] === 0 || e.length === 0 || e.length > 4) {
|
|
72
|
+
throw unsupported("Secrets public key is malformed");
|
|
73
|
+
}
|
|
74
|
+
// The same policy the server and vm-service enforce for their keys.
|
|
75
|
+
const bits = n.length * 8 - Math.clz32(n[0]) + 24;
|
|
76
|
+
if (bits < MIN_SECRETS_KEY_BITS || e.readUIntBE(0, e.length) !== SECRETS_KEY_EXPONENT) {
|
|
77
|
+
throw unsupported(`Secrets public key must be at least ${MIN_SECRETS_KEY_BITS}-bit RSA with exponent ${SECRETS_KEY_EXPONENT}`);
|
|
78
|
+
}
|
|
79
|
+
if (rsaThumbprint(n, e) !== key.kid) {
|
|
80
|
+
throw unsupported("Secrets public key does not match its key ID");
|
|
81
|
+
}
|
|
82
|
+
const publicKey = crypto_1.default.createPublicKey({
|
|
83
|
+
key: { kty: "RSA", n: base64Url(n), e: base64Url(e) },
|
|
84
|
+
format: "jwk",
|
|
85
|
+
});
|
|
86
|
+
return new SecretsSealer(key.kid, publicKey);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Encrypt `value` as a compact JWE (RSA-OAEP-256 + A256GCM) bound to a
|
|
90
|
+
* secret's encryption context.
|
|
91
|
+
*/
|
|
92
|
+
seal(encryptionContext, value) {
|
|
93
|
+
const plaintext = (0, exports.secretValueBytes)(value);
|
|
94
|
+
const protectedHeader = base64Url(Buffer.from(JSON.stringify({
|
|
95
|
+
alg: "RSA-OAEP-256",
|
|
96
|
+
enc: "A256GCM",
|
|
97
|
+
kid: this.kid,
|
|
98
|
+
ctx: encryptionContext,
|
|
99
|
+
})));
|
|
100
|
+
const cek = crypto_1.default.randomBytes(32);
|
|
101
|
+
const iv = crypto_1.default.randomBytes(12);
|
|
102
|
+
try {
|
|
103
|
+
const encryptedKey = crypto_1.default.publicEncrypt({
|
|
104
|
+
key: this.publicKey,
|
|
105
|
+
padding: crypto_1.default.constants.RSA_PKCS1_OAEP_PADDING,
|
|
106
|
+
oaepHash: "sha256",
|
|
107
|
+
}, cek);
|
|
108
|
+
const cipher = crypto_1.default.createCipheriv("aes-256-gcm", cek, iv);
|
|
109
|
+
cipher.setAAD(Buffer.from(protectedHeader, "ascii"));
|
|
110
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
111
|
+
return [
|
|
112
|
+
protectedHeader,
|
|
113
|
+
base64Url(encryptedKey),
|
|
114
|
+
base64Url(iv),
|
|
115
|
+
base64Url(ciphertext),
|
|
116
|
+
base64Url(cipher.getAuthTag()),
|
|
117
|
+
].join(".");
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
cek.fill(0);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
exports.SecretsSealer = SecretsSealer;
|
package/dist/services/base.js
CHANGED
|
@@ -10,6 +10,28 @@ const retry_1 = require("../retry");
|
|
|
10
10
|
const getRequestId = (response) => {
|
|
11
11
|
return response.headers.get("x-request-id") || response.headers.get("request-id") || undefined;
|
|
12
12
|
};
|
|
13
|
+
/** The API's machine codes, such as SECRET_NOT_FOUND. */
|
|
14
|
+
const SERVER_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
15
|
+
/** Codes whose retryable status default (such as 503) would be wrong. */
|
|
16
|
+
const NON_RETRYABLE_ERROR_CODES = new Set(["SECRETS_NOT_CONFIGURED"]);
|
|
17
|
+
/** The sandbox and secret an error is about, when the API names them. */
|
|
18
|
+
const errorMessageContext = (errorData) => {
|
|
19
|
+
let context = "";
|
|
20
|
+
// A failed create may leave a sandbox the caller has to stop.
|
|
21
|
+
if (typeof errorData.sandboxId === "string" && errorData.sandboxId.trim()) {
|
|
22
|
+
context += ` (sandbox ${errorData.sandboxId.trim()})`;
|
|
23
|
+
}
|
|
24
|
+
// Which of several secrets could not be used.
|
|
25
|
+
const secret = errorData.details;
|
|
26
|
+
if (secret && typeof secret.env === "string" && secret.env.trim()) {
|
|
27
|
+
const parts = [
|
|
28
|
+
typeof secret.secretId === "string" ? `secret ${secret.secretId}` : undefined,
|
|
29
|
+
typeof secret.version === "number" ? `version ${secret.version}` : undefined,
|
|
30
|
+
].filter(Boolean);
|
|
31
|
+
context += ` (${secret.env.trim()}${parts.length > 0 ? `: ${parts.join(", ")}` : ""})`;
|
|
32
|
+
}
|
|
33
|
+
return context;
|
|
34
|
+
};
|
|
13
35
|
class BaseService {
|
|
14
36
|
constructor(apiKey, baseUrl, timeout = 30000) {
|
|
15
37
|
this.apiKey = apiKey;
|
|
@@ -71,25 +93,51 @@ class BaseService {
|
|
|
71
93
|
let errorMessage;
|
|
72
94
|
let errorDetails;
|
|
73
95
|
let errorCode;
|
|
96
|
+
let decodingFailed = false;
|
|
97
|
+
let decodingCause;
|
|
98
|
+
let retryable = retry_1.RETRYABLE_STATUS_CODES.has(response.status);
|
|
74
99
|
try {
|
|
75
100
|
const errorData = await response.json();
|
|
76
101
|
errorDetails = errorData;
|
|
77
102
|
errorCode = typeof errorData?.code === "string" ? errorData.code : undefined;
|
|
103
|
+
// Some routes send the code as "error" next to a message, for
|
|
104
|
+
// example API_KEY_SCOPE_DENIED.
|
|
105
|
+
if (errorCode === undefined &&
|
|
106
|
+
errorData?.message &&
|
|
107
|
+
typeof errorData.error === "string" &&
|
|
108
|
+
SERVER_ERROR_CODE_PATTERN.test(errorData.error)) {
|
|
109
|
+
errorCode = errorData.error;
|
|
110
|
+
}
|
|
111
|
+
// The server knows better when it says so: an ambiguous process
|
|
112
|
+
// start with secrets is a 502 that must not be retried blindly.
|
|
113
|
+
if (typeof errorData?.retryable === "boolean") {
|
|
114
|
+
retryable = errorData.retryable;
|
|
115
|
+
}
|
|
116
|
+
else if (errorCode !== undefined && NON_RETRYABLE_ERROR_CODES.has(errorCode)) {
|
|
117
|
+
retryable = false;
|
|
118
|
+
}
|
|
119
|
+
const message = errorData.message || errorData.error;
|
|
78
120
|
errorMessage =
|
|
79
|
-
|
|
121
|
+
typeof message === "string" && message
|
|
122
|
+
? `${message}${errorMessageContext(errorData)}`
|
|
123
|
+
: message || `HTTP error! status: ${response.status}`;
|
|
80
124
|
}
|
|
81
125
|
catch (error) {
|
|
82
126
|
if (init?.signal?.aborted)
|
|
83
127
|
throw error;
|
|
128
|
+
decodingFailed = true;
|
|
129
|
+
decodingCause = error;
|
|
84
130
|
errorMessage = `HTTP error! status: ${response.status}`;
|
|
85
131
|
}
|
|
86
|
-
|
|
132
|
+
const ErrorType = decodingFailed ? error_1.ResponseDecodingError : error_1.HyperbrowserError;
|
|
133
|
+
throw new ErrorType(errorMessage, {
|
|
87
134
|
statusCode: response.status,
|
|
88
135
|
code: errorCode,
|
|
89
136
|
requestId: getRequestId(response),
|
|
90
|
-
retryable
|
|
137
|
+
retryable,
|
|
91
138
|
service: "control",
|
|
92
139
|
details: errorDetails,
|
|
140
|
+
cause: decodingCause,
|
|
93
141
|
method: init?.method ?? "GET",
|
|
94
142
|
path: (0, error_1.errorRequestPath)(path),
|
|
95
143
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SandboxFilesApi } from "../sandbox/files";
|
|
2
|
-
import { SandboxProcessHandle, SandboxProcessesApi } from "../sandbox/process";
|
|
2
|
+
import { SandboxProcessHandle, SandboxProcessesApi, SecretProcessStartPayload, SecretProcessStartResponse } from "../sandbox/process";
|
|
3
3
|
import { SandboxTerminalApi } from "../sandbox/terminal";
|
|
4
4
|
import { BasicResponse } from "../types/session";
|
|
5
5
|
import { BuildSandboxImageFromDockerImageOptions, BuildSandboxImageFromDockerfileOptions, CompleteSandboxImageBuildParams, CreateSandboxImageBuildParams, CreateSandboxParams, StartSandboxFromSnapshotParams, SandboxRuntimeSession, SandboxRuntimeTarget, GetOrBuildSandboxImageOptions, ReuseSandboxDockerImageParams, SandboxDockerImageReuseResult, SandboxImageBuildResolution, SandboxImageBuildWaitOptions, SandboxImageSummary, SandboxDetail, SandboxExposeParams, SandboxExposeResult, SandboxExecParams, SandboxExecOptions, SandboxImageBuild, SandboxImageBuildCreateResult, SandboxImageBuildListParams, SandboxImageBuildListResponse, SandboxImageListParams, SandboxImageListResponse, SandboxListParams, SandboxListResponse, SandboxMemorySnapshotParams, SandboxMemorySnapshotResult, SandboxNetworkPolicyPatch, SandboxNetworkUpdateResult, SandboxProcessResult, SandboxImageDeleteResult, SandboxSnapshotDeleteResult, SandboxSnapshotListParams, SandboxSnapshotListResponse, SandboxSnapshotSummary, SandboxUnexposeResult } from "../types/sandbox";
|
|
@@ -110,7 +110,16 @@ export declare class SandboxesService extends BaseService {
|
|
|
110
110
|
/** Create a build, upload requested artifacts, complete it, and optionally wait. */
|
|
111
111
|
private submitImageBuild;
|
|
112
112
|
private completeImageBuildResilient;
|
|
113
|
+
/**
|
|
114
|
+
* Start a process with secrets through the control API. Never retried: a
|
|
115
|
+
* failed start may still have run the command.
|
|
116
|
+
*/
|
|
117
|
+
startSecretProcess(id: string, payload: SecretProcessStartPayload, signal?: AbortSignal): Promise<SecretProcessStartResponse>;
|
|
113
118
|
updateNetwork(id: string, policy: SandboxNetworkPolicyPatch): Promise<SandboxNetworkUpdateResult>;
|
|
114
119
|
unexpose(id: string, port: number): Promise<SandboxUnexposeResult>;
|
|
120
|
+
/**
|
|
121
|
+
* With secrets, a create whose result is unknown is never retried: the
|
|
122
|
+
* sandbox may exist and be running its startup command with the secrets.
|
|
123
|
+
*/
|
|
115
124
|
private createDetail;
|
|
116
125
|
}
|
|
@@ -6,12 +6,15 @@ const files_1 = require("../sandbox/files");
|
|
|
6
6
|
const base_1 = require("../sandbox/base");
|
|
7
7
|
const runtime_path_1 = require("../sandbox/runtime-path");
|
|
8
8
|
const process_1 = require("../sandbox/process");
|
|
9
|
+
const secret_references_1 = require("../sandbox/secret-references");
|
|
9
10
|
const terminal_1 = require("../sandbox/terminal");
|
|
10
11
|
const image_build_1 = require("../sandbox/image-build");
|
|
11
12
|
const retry_1 = require("../retry");
|
|
12
13
|
const normalize_1 = require("./normalize");
|
|
13
14
|
const base_2 = require("./base");
|
|
14
15
|
const RUNTIME_SESSION_REFRESH_BUFFER_MS = 60000;
|
|
16
|
+
/** Runtime waits for secret processes stay well under the request timeout. */
|
|
17
|
+
const SECRET_PROCESS_WAIT_CHUNK_MS = 20000;
|
|
15
18
|
const normalizeSandbox = (sandbox) => {
|
|
16
19
|
const { vcpus, memMiB, diskSizeMiB, ...rest } = sandbox;
|
|
17
20
|
return {
|
|
@@ -27,6 +30,7 @@ const normalizeSandbox = (sandbox) => {
|
|
|
27
30
|
timeoutMinutes: (0, normalize_1.optionalInteger)(rest.timeoutMinutes),
|
|
28
31
|
creditsUsed: (0, normalize_1.optionalNumber)(rest.creditsUsed) ?? null,
|
|
29
32
|
exposedPorts: rest.exposedPorts ?? [],
|
|
33
|
+
containsSecrets: rest.containsSecrets ?? false,
|
|
30
34
|
network: rest.network ? { ...rest.network, allowOut: rest.network.allowOut ?? [], denyOut: rest.network.denyOut ?? [] } : rest.network,
|
|
31
35
|
};
|
|
32
36
|
};
|
|
@@ -66,6 +70,10 @@ const serializeCreateSandboxParams = (params) => {
|
|
|
66
70
|
params.runtimeClass !== "gvisor-cpu")
|
|
67
71
|
throw new error_1.HyperbrowserError("Unsupported sandbox runtimeClass");
|
|
68
72
|
if (typeof params.imageName === "string") {
|
|
73
|
+
const secrets = (0, secret_references_1.serializeSecretReferences)(params.secrets);
|
|
74
|
+
// The startup command with secrets runs only in Firecracker sandboxes.
|
|
75
|
+
if (secrets !== undefined && params.runtimeClass === "gvisor-cpu")
|
|
76
|
+
throw new error_1.HyperbrowserError("secrets are not supported with runtimeClass gvisor-cpu");
|
|
69
77
|
return {
|
|
70
78
|
runtimeClass: params.runtimeClass,
|
|
71
79
|
imageName: params.imageName,
|
|
@@ -81,9 +89,14 @@ const serializeCreateSandboxParams = (params) => {
|
|
|
81
89
|
allowInternetAccess: params.allowInternetAccess,
|
|
82
90
|
allowOut: params.allowOut,
|
|
83
91
|
denyOut: params.denyOut,
|
|
92
|
+
secrets,
|
|
84
93
|
};
|
|
85
94
|
}
|
|
86
95
|
const snapshotParams = params;
|
|
96
|
+
if (snapshotParams.secrets !== undefined) {
|
|
97
|
+
// Snapshot launches do not run a startup command.
|
|
98
|
+
throw new error_1.HyperbrowserError("secrets are only supported for image launches");
|
|
99
|
+
}
|
|
87
100
|
if (snapshotParams.cpu !== undefined ||
|
|
88
101
|
snapshotParams.memoryMiB !== undefined ||
|
|
89
102
|
snapshotParams.diskMiB !== undefined) {
|
|
@@ -194,7 +207,11 @@ class SandboxHandle {
|
|
|
194
207
|
this.detail = detail;
|
|
195
208
|
this.runtimeSession = SandboxHandle.toRuntimeSession(detail);
|
|
196
209
|
this.transport = new base_1.RuntimeTransport((forceRefresh) => this.resolveRuntimeConnection(forceRefresh), service.runtimeTimeout, service.runtimeProxyOverride);
|
|
197
|
-
this.processes = new process_1.SandboxProcessesApi(this.transport
|
|
210
|
+
this.processes = new process_1.SandboxProcessesApi(this.transport, {
|
|
211
|
+
sandboxId: detail.id,
|
|
212
|
+
waitChunkMs: Math.max(1, Math.floor(Math.min(SECRET_PROCESS_WAIT_CHUNK_MS, service.runtimeTimeout / 2))),
|
|
213
|
+
start: (payload, signal) => service.startSecretProcess(this.id, payload, signal),
|
|
214
|
+
});
|
|
198
215
|
this.files = new files_1.SandboxFilesApi(this.transport, () => this.resolveRuntimeSocketConnectionInfo(), service.runtimeProxyOverride);
|
|
199
216
|
this.terminal = new terminal_1.SandboxTerminalApi(this.transport, () => this.resolveRuntimeSocketConnectionInfo(), service.runtimeProxyOverride);
|
|
200
217
|
this.pty = this.terminal;
|
|
@@ -1053,6 +1070,27 @@ class SandboxesService extends base_2.BaseService {
|
|
|
1053
1070
|
return this.completeImageBuild(buildId, params);
|
|
1054
1071
|
}
|
|
1055
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Start a process with secrets through the control API. Never retried: a
|
|
1075
|
+
* failed start may still have run the command.
|
|
1076
|
+
*/
|
|
1077
|
+
async startSecretProcess(id, payload, signal) {
|
|
1078
|
+
try {
|
|
1079
|
+
return await this.request(`/sandbox/${encodeURIComponent(id)}/processes`, {
|
|
1080
|
+
method: "POST",
|
|
1081
|
+
body: JSON.stringify(payload),
|
|
1082
|
+
signal,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
catch (error) {
|
|
1086
|
+
if (error instanceof error_1.HyperbrowserError) {
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
throw new error_1.HyperbrowserError(`Failed to start a process with secrets in sandbox ${id}`, {
|
|
1090
|
+
cause: error,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1056
1094
|
async updateNetwork(id, policy) {
|
|
1057
1095
|
try {
|
|
1058
1096
|
return await this.request(`/sandbox/${id}/network`, {
|
|
@@ -1081,15 +1119,36 @@ class SandboxesService extends base_2.BaseService {
|
|
|
1081
1119
|
throw new error_1.HyperbrowserError(`Failed to unexpose port ${port} for sandbox ${id}`);
|
|
1082
1120
|
}
|
|
1083
1121
|
}
|
|
1122
|
+
/**
|
|
1123
|
+
* With secrets, a create whose result is unknown is never retried: the
|
|
1124
|
+
* sandbox may exist and be running its startup command with the secrets.
|
|
1125
|
+
*/
|
|
1084
1126
|
async createDetail(params) {
|
|
1127
|
+
const body = serializeCreateSandboxParams(params);
|
|
1085
1128
|
try {
|
|
1086
1129
|
const detail = await this.request("/sandbox", {
|
|
1087
1130
|
method: "POST",
|
|
1088
|
-
body: JSON.stringify(
|
|
1131
|
+
body: JSON.stringify(body),
|
|
1089
1132
|
});
|
|
1133
|
+
if (body.secrets !== undefined &&
|
|
1134
|
+
(typeof detail?.id !== "string" || !detail.id.trim())) {
|
|
1135
|
+
throw new Error("The API accepted the create but returned no sandbox ID");
|
|
1136
|
+
}
|
|
1090
1137
|
return normalizeSandboxDetail(detail);
|
|
1091
1138
|
}
|
|
1092
1139
|
catch (error) {
|
|
1140
|
+
if (body.secrets !== undefined && (0, process_1.requestMayHaveArrived)(error)) {
|
|
1141
|
+
throw new error_1.HyperbrowserError(`could not confirm the create result: ${error instanceof Error
|
|
1142
|
+
? error.message.replace(/^\[Hyperbrowser\]: /, "")
|
|
1143
|
+
: String(error)}; a sandbox may have been created with the secrets, so list sandboxes before retrying`, {
|
|
1144
|
+
code: "secret_process_outcome_unknown",
|
|
1145
|
+
retryable: false,
|
|
1146
|
+
service: "control",
|
|
1147
|
+
cause: error,
|
|
1148
|
+
method: "POST",
|
|
1149
|
+
path: "/sandbox",
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1093
1152
|
if (error instanceof error_1.HyperbrowserError) {
|
|
1094
1153
|
throw error;
|
|
1095
1154
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CreateSecretParams, Secret, SecretDeleteResult, SecretDetail, SecretListParams, SecretListResponse, SecretsPublicKey, SecretValue, SecretVersion, SecretVersionDeleteResult, UpdateSecretParams } from "../types/secret";
|
|
2
|
+
import { BaseService } from "./base";
|
|
3
|
+
/**
|
|
4
|
+
* Secrets are encrypted locally to the platform's public key; the API only
|
|
5
|
+
* ever receives envelopes, and values are never returned.
|
|
6
|
+
*
|
|
7
|
+
* Methods that take `secret` accept its ID or its exact name. A UUID-shaped
|
|
8
|
+
* argument is tried as an ID first and then as a name, since names may look
|
|
9
|
+
* like UUIDs.
|
|
10
|
+
*/
|
|
11
|
+
export declare class SecretsService extends BaseService {
|
|
12
|
+
/** The key secret values are encrypted to. */
|
|
13
|
+
getPublicKey(): Promise<SecretsPublicKey>;
|
|
14
|
+
list(params?: SecretListParams): Promise<SecretListResponse>;
|
|
15
|
+
/**
|
|
16
|
+
* Create a secret, and store its first version when `value` is given. The
|
|
17
|
+
* value and the platform key are checked before the secret is created, so a
|
|
18
|
+
* bad value never leaves an empty secret behind.
|
|
19
|
+
*/
|
|
20
|
+
create(params: CreateSecretParams): Promise<Secret>;
|
|
21
|
+
/** Metadata and versions; never the value. */
|
|
22
|
+
get(secret: string): Promise<SecretDetail>;
|
|
23
|
+
update(secret: string, params: UpdateSecretParams): Promise<Secret>;
|
|
24
|
+
/** Encrypt `value` locally and store it as the secret's next version. */
|
|
25
|
+
addVersion(secret: string, value: SecretValue): Promise<SecretVersion>;
|
|
26
|
+
/**
|
|
27
|
+
* Permanently delete a secret and its versions. Values already delivered to
|
|
28
|
+
* sandboxes are not revoked.
|
|
29
|
+
*/
|
|
30
|
+
delete(secret: string): Promise<SecretDeleteResult>;
|
|
31
|
+
/** Delete one stored version. The latest version cannot be deleted. */
|
|
32
|
+
deleteVersion(secret: string, version: number): Promise<SecretVersionDeleteResult>;
|
|
33
|
+
private prepareSealer;
|
|
34
|
+
private uploadVersion;
|
|
35
|
+
private resolve;
|
|
36
|
+
private resolveDetail;
|
|
37
|
+
/** Found by ID, the result already includes the versions. */
|
|
38
|
+
private lookup;
|
|
39
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SecretsService = void 0;
|
|
4
|
+
const error_1 = require("../error");
|
|
5
|
+
const secret_references_1 = require("../sandbox/secret-references");
|
|
6
|
+
const secrets_envelope_1 = require("../secrets-envelope");
|
|
7
|
+
const base_1 = require("./base");
|
|
8
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
/** The most secrets one list page can hold. */
|
|
10
|
+
const MAX_LIST_LIMIT = 100;
|
|
11
|
+
/** Teams hold at most 100 secrets, so one page of search results covers every match. */
|
|
12
|
+
const NAME_LOOKUP_LIMIT = MAX_LIST_LIMIT;
|
|
13
|
+
const secretPath = (id) => `/secrets/${encodeURIComponent(id)}`;
|
|
14
|
+
const rethrow = (error, message) => {
|
|
15
|
+
if (error instanceof error_1.HyperbrowserError) {
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
throw new error_1.HyperbrowserError(message, { cause: error });
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Secrets are encrypted locally to the platform's public key; the API only
|
|
22
|
+
* ever receives envelopes, and values are never returned.
|
|
23
|
+
*
|
|
24
|
+
* Methods that take `secret` accept its ID or its exact name. A UUID-shaped
|
|
25
|
+
* argument is tried as an ID first and then as a name, since names may look
|
|
26
|
+
* like UUIDs.
|
|
27
|
+
*/
|
|
28
|
+
class SecretsService extends base_1.BaseService {
|
|
29
|
+
/** The key secret values are encrypted to. */
|
|
30
|
+
async getPublicKey() {
|
|
31
|
+
try {
|
|
32
|
+
return await this.request("/secrets/public-key");
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return rethrow(error, "Failed to get the secrets public key");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async list(params = {}) {
|
|
39
|
+
const { page, limit } = params;
|
|
40
|
+
if (page !== undefined && (!Number.isSafeInteger(page) || page < 1)) {
|
|
41
|
+
throw new error_1.HyperbrowserError("page must be a positive integer");
|
|
42
|
+
}
|
|
43
|
+
if (limit !== undefined &&
|
|
44
|
+
(!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT)) {
|
|
45
|
+
throw new error_1.HyperbrowserError(`limit must be an integer from 1 to ${MAX_LIST_LIMIT}`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return await this.request("/secrets", undefined, {
|
|
49
|
+
search: params.search,
|
|
50
|
+
page: params.page,
|
|
51
|
+
limit: params.limit,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return rethrow(error, "Failed to list secrets");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Create a secret, and store its first version when `value` is given. The
|
|
60
|
+
* value and the platform key are checked before the secret is created, so a
|
|
61
|
+
* bad value never leaves an empty secret behind.
|
|
62
|
+
*/
|
|
63
|
+
async create(params) {
|
|
64
|
+
const { name, description, value } = params ?? {};
|
|
65
|
+
if (typeof name !== "string" || !secret_references_1.SECRET_NAME_PATTERN.test(name)) {
|
|
66
|
+
throw new error_1.HyperbrowserError("Secret name must be 1-128 letters, digits, '_' or '-', starting with a letter or digit");
|
|
67
|
+
}
|
|
68
|
+
const sealer = value === undefined ? undefined : await this.prepareSealer(value);
|
|
69
|
+
let secret;
|
|
70
|
+
try {
|
|
71
|
+
secret = await this.request("/secrets", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: JSON.stringify({ name, description }),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return rethrow(error, `Failed to create secret ${name}`);
|
|
78
|
+
}
|
|
79
|
+
if (sealer === undefined) {
|
|
80
|
+
return secret;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const version = await this.uploadVersion(secret, sealer, value);
|
|
84
|
+
return { ...secret, latestVersion: version.version };
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// Retrying create would fail with "name taken"; the value has to be
|
|
88
|
+
// stored on the secret that now exists.
|
|
89
|
+
throw new error_1.HyperbrowserError(`Secret ${secret.name} (${secret.id}) was created, but storing its value failed: ${error instanceof Error ? error.message.replace(/^\[Hyperbrowser\]: /, "") : String(error)}. Store it with secrets.addVersion(${JSON.stringify(secret.name)}, value).`, {
|
|
90
|
+
code: "secret_value_not_stored",
|
|
91
|
+
retryable: false,
|
|
92
|
+
service: "control",
|
|
93
|
+
details: { secret },
|
|
94
|
+
cause: error,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Metadata and versions; never the value. */
|
|
99
|
+
async get(secret) {
|
|
100
|
+
try {
|
|
101
|
+
return await this.resolveDetail(secret);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return rethrow(error, `Failed to get secret ${secret}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async update(secret, params) {
|
|
108
|
+
try {
|
|
109
|
+
const { id } = await this.resolve(secret);
|
|
110
|
+
return await this.request(secretPath(id), {
|
|
111
|
+
method: "PATCH",
|
|
112
|
+
body: JSON.stringify({ description: params.description }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return rethrow(error, `Failed to update secret ${secret}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Encrypt `value` locally and store it as the secret's next version. */
|
|
120
|
+
async addVersion(secret, value) {
|
|
121
|
+
const sealer = await this.prepareSealer(value);
|
|
122
|
+
try {
|
|
123
|
+
return await this.uploadVersion(await this.resolve(secret), sealer, value);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
return rethrow(error, `Failed to add a version to secret ${secret}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Permanently delete a secret and its versions. Values already delivered to
|
|
131
|
+
* sandboxes are not revoked.
|
|
132
|
+
*/
|
|
133
|
+
async delete(secret) {
|
|
134
|
+
try {
|
|
135
|
+
const { id } = await this.resolve(secret);
|
|
136
|
+
return await this.request(secretPath(id), { method: "DELETE" });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return rethrow(error, `Failed to delete secret ${secret}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Delete one stored version. The latest version cannot be deleted. */
|
|
143
|
+
async deleteVersion(secret, version) {
|
|
144
|
+
if (!Number.isSafeInteger(version) || version < 1 || version > secret_references_1.MAX_SECRET_VERSION) {
|
|
145
|
+
throw new error_1.HyperbrowserError(`version must be an integer from 1 to ${secret_references_1.MAX_SECRET_VERSION}`);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const { id } = await this.resolve(secret);
|
|
149
|
+
return await this.request(`${secretPath(id)}/versions/${version}`, {
|
|
150
|
+
method: "DELETE",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
return rethrow(error, `Failed to delete version ${version} of secret ${secret}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async prepareSealer(value) {
|
|
158
|
+
(0, secrets_envelope_1.secretValueBytes)(value);
|
|
159
|
+
return secrets_envelope_1.SecretsSealer.fromPublicKey(await this.getPublicKey());
|
|
160
|
+
}
|
|
161
|
+
uploadVersion(secret, sealer, value) {
|
|
162
|
+
return this.request(`${secretPath(secret.id)}/versions`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: JSON.stringify({ envelope: sealer.seal(secret.encryptionContext, value) }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
async resolve(secret) {
|
|
168
|
+
return (await this.lookup(secret)).secret;
|
|
169
|
+
}
|
|
170
|
+
async resolveDetail(secret) {
|
|
171
|
+
const found = await this.lookup(secret);
|
|
172
|
+
return found.detail ?? (await this.request(secretPath(found.secret.id)));
|
|
173
|
+
}
|
|
174
|
+
/** Found by ID, the result already includes the versions. */
|
|
175
|
+
async lookup(key) {
|
|
176
|
+
if (typeof key !== "string" || !key.trim()) {
|
|
177
|
+
throw new error_1.HyperbrowserError("A secret ID or name is required");
|
|
178
|
+
}
|
|
179
|
+
key = key.trim();
|
|
180
|
+
if (UUID_PATTERN.test(key)) {
|
|
181
|
+
try {
|
|
182
|
+
const detail = await this.request(secretPath(key));
|
|
183
|
+
return { secret: detail, detail };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (!(error instanceof error_1.HyperbrowserError) || error.statusCode !== 404) {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const { secrets } = await this.list({ search: key, limit: NAME_LOOKUP_LIMIT });
|
|
192
|
+
const secret = secrets.find((candidate) => candidate.name === key);
|
|
193
|
+
if (!secret) {
|
|
194
|
+
throw new error_1.HyperbrowserError(`Secret ${JSON.stringify(key)} not found`, {
|
|
195
|
+
statusCode: 404,
|
|
196
|
+
code: "SECRET_NOT_FOUND",
|
|
197
|
+
service: "control",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { secret };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
exports.SecretsService = SecretsService;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BasicResponse, CaptchaEvaluationParams, CaptchaEvaluationResponse, CreateSessionParams, CreateSessionSnapshotResponse, GetActiveSessionsCountResponse, GetSessionDownloadsUrlResponse, GetSessionRecordingUrlResponse, GetSessionVideoRecordingUrlResponse, SessionDetail, SessionListParams, SessionListResponse, SessionRecording, UploadFileOptions, UploadFileResponse, SessionEventLogListParams, SessionEventLogListResponse, UpdateSessionProfileParams, UpdateSessionProxyParams, UpdateSessionScreenParams, UpdateSessionSolveCaptchasParams, UpdateSessionSolveCaptchasResponse, SessionGetParams } from "../types/session";
|
|
2
2
|
import { BaseService } from "./base";
|
|
3
|
+
import { WebMCPService } from "./webmcp";
|
|
3
4
|
/**
|
|
4
5
|
* Service for managing session event logs
|
|
5
6
|
*/
|
|
@@ -13,6 +14,7 @@ declare class SessionEventLogsService extends BaseService {
|
|
|
13
14
|
}
|
|
14
15
|
export declare class SessionsService extends BaseService {
|
|
15
16
|
readonly eventLogs: SessionEventLogsService;
|
|
17
|
+
readonly webmcp: WebMCPService;
|
|
16
18
|
constructor(apiKey: string, baseUrl: string, timeout: number);
|
|
17
19
|
/**
|
|
18
20
|
* Create a new browser session
|
|
@@ -31,6 +31,7 @@ const fs_1 = require("fs");
|
|
|
31
31
|
const path = __importStar(require("path"));
|
|
32
32
|
const form_data_1 = __importDefault(require("form-data"));
|
|
33
33
|
const base_1 = require("./base");
|
|
34
|
+
const webmcp_1 = require("./webmcp");
|
|
34
35
|
const client_1 = require("../client");
|
|
35
36
|
const CAPTCHA_EVALUATION_REQUEST_TIMEOUT_MS = 185000;
|
|
36
37
|
/**
|
|
@@ -61,6 +62,7 @@ class SessionsService extends base_1.BaseService {
|
|
|
61
62
|
constructor(apiKey, baseUrl, timeout) {
|
|
62
63
|
super(apiKey, baseUrl, timeout);
|
|
63
64
|
this.eventLogs = new SessionEventLogsService(apiKey, baseUrl, timeout);
|
|
65
|
+
this.webmcp = new webmcp_1.WebMCPService(apiKey, baseUrl, timeout);
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
66
68
|
* Create a new browser session
|