Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,11 @@ const main = async () => {
await sandbox.files.writeText("/tmp/watch-demo.txt", "watch me");
await watch.stop();

const proc = await sandbox.processes.start({
command: "bash",
args: ["-lc", "echo process-started && sleep 1 && echo process-finished"],
const proc = await sandbox.processes.start(
"echo process-started && sleep 1 && echo process-finished",
{
runAs: "root",
}
});

for await (const event of proc.stream()) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperbrowser/sdk",
"version": "0.89.0",
"version": "0.89.1",
"description": "Node SDK for Hyperbrowser API",
"author": "",
"repository": {
Expand Down
69 changes: 51 additions & 18 deletions src/sandbox/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,9 +406,20 @@ export class SandboxFilesApi {
constructor(
private readonly transport: RuntimeTransport,
private readonly getConnectionInfo: () => Promise<RuntimeConnectionInfo>,
private readonly runtimeProxyOverride?: string
private readonly runtimeProxyOverride?: string,
private readonly defaultRunAs?: string
) {}

withRunAs(runAs?: string): SandboxFilesApi {
const normalized = runAs?.trim();
return new SandboxFilesApi(
this.transport,
this.getConnectionInfo,
this.runtimeProxyOverride,
normalized ? normalized : undefined
);
}

async exists(path: string): Promise<boolean> {
try {
await this.getInfo(path);
Expand All @@ -428,7 +439,7 @@ export class SandboxFilesApi {
const response = await this.transport.requestJSON<FileStatWireResponse>(
"/sandbox/files/stat",
undefined,
{ path }
this.withRunAsQuery({ path })
);
return normalizeFileInfo(response.file);
}
Expand All @@ -441,10 +452,10 @@ export class SandboxFilesApi {
const response = await this.transport.requestJSON<FileListWireResponse>(
"/sandbox/files",
undefined,
{
this.withRunAsQuery({
path,
depth: options.depth ?? 1,
}
})
);

return response.entries.map(normalizeFileInfo);
Expand Down Expand Up @@ -535,7 +546,7 @@ export class SandboxFilesApi {
"/sandbox/files/write",
{
method: "POST",
body: JSON.stringify({ files: encodedFiles }),
body: this.withRunAsBody({ files: encodedFiles }),
headers: {
"content-type": "application/json",
},
Expand Down Expand Up @@ -577,7 +588,7 @@ export class SandboxFilesApi {
method: "PUT",
body,
},
{ path }
this.withRunAsQuery({ path })
);

return {
Expand All @@ -588,7 +599,7 @@ export class SandboxFilesApi {

async download(path: string): Promise<Buffer> {
return this.transport.requestBuffer("/sandbox/files/download", undefined, {
path,
...this.withRunAsQuery({ path }),
});
}

Expand All @@ -597,7 +608,7 @@ export class SandboxFilesApi {
"/sandbox/files/mkdir",
{
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
parents: options.parents,
mode: options.mode,
Expand All @@ -616,7 +627,7 @@ export class SandboxFilesApi {
"/sandbox/files/move",
{
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
from: oldPath,
to: newPath,
}),
Expand All @@ -632,7 +643,7 @@ export class SandboxFilesApi {
async remove(path: string, options: { recursive?: boolean } = {}): Promise<void> {
await this.transport.requestJSON<FileMutationWireResponse>("/sandbox/files/delete", {
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
recursive: options.recursive,
}),
Expand All @@ -647,7 +658,7 @@ export class SandboxFilesApi {
"/sandbox/files/copy",
{
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
from: params.source,
to: params.destination,
recursive: params.recursive,
Expand All @@ -665,7 +676,7 @@ export class SandboxFilesApi {
async chmod(params: SandboxFileChmodParams): Promise<void> {
await this.transport.requestJSON<{ success: boolean }>("/sandbox/files/chmod", {
method: "POST",
body: JSON.stringify(params),
body: this.withRunAsBody(params),
headers: {
"content-type": "application/json",
},
Expand All @@ -675,7 +686,7 @@ export class SandboxFilesApi {
async chown(params: SandboxFileChownParams): Promise<void> {
await this.transport.requestJSON<{ success: boolean }>("/sandbox/files/chown", {
method: "POST",
body: JSON.stringify(params),
body: this.withRunAsBody(params),
headers: {
"content-type": "application/json",
},
Expand All @@ -691,7 +702,7 @@ export class SandboxFilesApi {
"/sandbox/files/watch",
{
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
recursive: options.recursive,
}),
Expand All @@ -717,7 +728,7 @@ export class SandboxFilesApi {
): Promise<SandboxPresignedUrl> {
return this.transport.requestJSON<SandboxPresignedUrl>("/sandbox/files/presign-upload", {
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
expiresInSeconds: options.expiresInSeconds,
oneTime: options.oneTime,
Expand All @@ -734,7 +745,7 @@ export class SandboxFilesApi {
): Promise<SandboxPresignedUrl> {
return this.transport.requestJSON<SandboxPresignedUrl>("/sandbox/files/presign-download", {
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
expiresInSeconds: options.expiresInSeconds,
oneTime: options.oneTime,
Expand All @@ -752,7 +763,7 @@ export class SandboxFilesApi {
): Promise<FileReadWireResponse> {
return this.transport.requestJSON<FileReadWireResponse>("/sandbox/files/read", {
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
offset: options.offset,
length: options.length,
Expand All @@ -774,7 +785,7 @@ export class SandboxFilesApi {
"/sandbox/files/write",
{
method: "POST",
body: JSON.stringify({
body: this.withRunAsBody({
path,
data,
encoding,
Expand All @@ -789,4 +800,26 @@ export class SandboxFilesApi {

return normalizeWriteInfo(response.files[0]!);
}

private withRunAsQuery<T extends Record<string, string | number | boolean | undefined>>(
params: T
): T & { runAs?: string } {
if (!this.defaultRunAs) {
return params;
}
return {
...params,
runAs: this.defaultRunAs,
};
}

private withRunAsBody<T extends object>(body: T): string {
if (!this.defaultRunAs) {
return JSON.stringify(body);
}
return JSON.stringify({
...body,
runAs: this.defaultRunAs,
});
}
}
61 changes: 55 additions & 6 deletions src/sandbox/process.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { RuntimeSSEEvent, RuntimeTransport } from "./base";
import {
SandboxExecParams,
SandboxExecOptions,
SandboxProcessListParams,
SandboxProcessListResponse,
SandboxProcessResult,
Expand Down Expand Up @@ -56,6 +57,7 @@ interface StartProcessResponse {
}

const DEFAULT_PROCESS_KILL_WAIT_MS = 5_000;
const SHELL_SAFE_TOKEN_PATTERN = /^[A-Za-z0-9_@%+=:,./-]+$/;

const normalizeProcessSummary = (process: RawProcessSummary): SandboxProcessSummary => ({
id: process.id,
Expand Down Expand Up @@ -117,16 +119,51 @@ const normalizeStreamEvent = (event: RuntimeSSEEvent): SandboxProcessStreamEvent
return null;
};

const quoteShellToken = (token: string): string => {
if (token.length === 0) {
return "''";
}

return SHELL_SAFE_TOKEN_PATTERN.test(token)
? token
: `'${token.replace(/'/g, `'\"'\"'`)}'`;
};

const buildShellCommand = (command: string, args?: string[]): string => {
if (!args || args.length === 0) {
return command;
}

return [command, ...args].map((token) => quoteShellToken(token)).join(" ");
};

const normalizeLegacyProcessParams = (input: SandboxExecParams): SandboxExecParams => ({
...input,
command: buildShellCommand(input.command, input.args),
args: undefined,
useShell: undefined,
});

const buildProcessPayload = (input: SandboxExecParams) => ({
command: input.command,
args: input.args,
cwd: input.cwd,
env: input.env,
timeoutMs: input.timeoutMs,
timeout_sec: input.timeoutSec,
useShell: input.useShell,
runAs: input.runAs,
});

const normalizeExecParams = (
input: string | SandboxExecParams,
options?: SandboxExecOptions
): SandboxExecParams =>
typeof input === "string"
? normalizeLegacyProcessParams({
command: input,
...options,
})
: normalizeLegacyProcessParams(input);

const encodeStdinPayload = (input: SandboxProcessStdinParams) => {
if (input.data === undefined) {
return {
Expand Down Expand Up @@ -283,10 +320,16 @@ export class SandboxProcessHandle {
export class SandboxProcessesApi {
constructor(private readonly transport: RuntimeTransport) {}

async exec(input: SandboxExecParams): Promise<SandboxProcessResult> {
async exec(command: string, options?: SandboxExecOptions): Promise<SandboxProcessResult>;
async exec(input: SandboxExecParams): Promise<SandboxProcessResult>;
async exec(
input: string | SandboxExecParams,
options?: SandboxExecOptions
): Promise<SandboxProcessResult> {
const params = normalizeExecParams(input, options);
const response = await this.transport.requestJSON<ExecResponse>("/sandbox/exec", {
method: "POST",
body: JSON.stringify(buildProcessPayload(input)),
body: JSON.stringify(buildProcessPayload(params)),
headers: {
"content-type": "application/json",
},
Expand All @@ -295,10 +338,16 @@ export class SandboxProcessesApi {
return normalizeProcessResult(response.result);
}

async start(input: SandboxExecParams): Promise<SandboxProcessHandle> {
async start(command: string, options?: SandboxExecOptions): Promise<SandboxProcessHandle>;
async start(input: SandboxExecParams): Promise<SandboxProcessHandle>;
async start(
input: string | SandboxExecParams,
options?: SandboxExecOptions
): Promise<SandboxProcessHandle> {
const params = normalizeExecParams(input, options);
const response = await this.transport.requestJSON<StartProcessResponse>("/sandbox/processes", {
method: "POST",
body: JSON.stringify(buildProcessPayload(input)),
body: JSON.stringify(buildProcessPayload(params)),
headers: {
"content-type": "application/json",
},
Expand Down
19 changes: 11 additions & 8 deletions src/services/sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SandboxExposeParams,
SandboxExposeResult,
SandboxExecParams,
SandboxExecOptions,
SandboxImageListResponse,
SandboxListParams,
SandboxListResponse,
Expand Down Expand Up @@ -276,15 +277,17 @@ export class SandboxHandle {
return buildSandboxExposedUrl(this.runtime, port);
}

async exec(input: string | SandboxExecParams): Promise<SandboxProcessResult> {
const params =
typeof input === "string"
? {
command: input,
}
: input;
async exec(input: string, options?: SandboxExecOptions): Promise<SandboxProcessResult>;
async exec(input: SandboxExecParams): Promise<SandboxProcessResult>;
async exec(
input: string | SandboxExecParams,
options?: SandboxExecOptions
): Promise<SandboxProcessResult> {
if (typeof input === "string") {
return this.processes.exec(input, options);
}

return this.processes.exec(params);
return this.processes.exec(input);
}

async getProcess(processId: string): Promise<SandboxProcessHandle> {
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export {
SandboxUnexposeResult,
SandboxProcessStatus,
SandboxExecParams,
SandboxExecOptions,
SandboxProcessSummary,
SandboxProcessResult,
SandboxProcessListParams,
Expand Down
5 changes: 5 additions & 0 deletions src/types/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,19 @@ export type SandboxProcessStatus =

export interface SandboxExecParams {
command: string;
/** @deprecated Legacy compatibility only. Converted into a single shell command string. */
args?: string[];
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
timeoutSec?: number;
runAs?: string;
/** @deprecated Ignored for process APIs. Commands always execute via `/bin/sh -lc` server-side. */
useShell?: boolean;
}

export type SandboxExecOptions = Omit<SandboxExecParams, "command">;

export interface SandboxProcessSummary {
id: string;
status: SandboxProcessStatus;
Expand Down
5 changes: 5 additions & 0 deletions tests/load-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ dotenv.config({
path: path.resolve(__dirname, ".env"),
quiet: true,
});

dotenv.config({
path: path.resolve(__dirname, "..", ".env"),
quiet: true,
});
Loading