@miosa/sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miosa/sdk",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "TypeScript SDK for the MIOSA API — cloud VM desktop infrastructure for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "MIOSA <hello@miosa.ai>",
package/src/client.ts CHANGED
@@ -15,6 +15,9 @@ import { CronJobs } from "./resources/cron-jobs.js";
15
15
  import { Dashboard } from "./resources/dashboard.js";
16
16
  import { Databases } from "./resources/databases.js";
17
17
  import { Deployments } from "./resources/deployments.js";
18
+ import { EgressAudit } from "./resources/egressAudit.js";
19
+ import { EgressNetwork } from "./resources/egressNetwork.js";
20
+ import { EgressSecrets } from "./resources/egressSecrets.js";
18
21
  import { Email } from "./resources/email.js";
19
22
  import { Embeddings } from "./resources/embeddings.js";
20
23
  import { ExternalKeys } from "./resources/external-keys.js";
@@ -34,10 +37,14 @@ import { SandboxTemplates } from "./resources/sandbox-templates.js";
34
37
  import { Settings } from "./resources/settings.js";
35
38
  import { SnapshotsStandalone } from "./resources/snapshots-standalone.js";
36
39
  import { Storage } from "./resources/storage.js";
40
+ import { OrgInvites } from "./resources/org-invites.js";
37
41
  import { Tenant } from "./resources/tenant.js";
38
42
  import { Usage } from "./resources/usage.js";
39
43
  import { Volumes } from "./resources/volumes.js";
40
44
  import { Webhooks } from "./resources/webhooks.js";
45
+ import { WorkspaceInvites } from "./resources/workspace-invites.js";
46
+ import { WorkspaceMembers } from "./resources/workspace-members.js";
47
+ import { Workspaces } from "./resources/workspaces.js";
41
48
  import type { MiosaClientConfig } from "./types.js";
42
49
 
43
50
  const DEFAULT_BASE_URL = "https://api.miosa.ai/api/v1";
@@ -58,6 +65,24 @@ const DEFAULT_MAX_RETRIES = 3;
58
65
  * ```
59
66
  */
60
67
  export class Miosa {
68
+ /** Workspace CRUD — create, list, get, update, delete, and sub-resource queries. */
69
+ readonly workspaces: Workspaces;
70
+
71
+ /** Per-workspace user roster — list, add, update role, remove. */
72
+ readonly workspaceMembers: WorkspaceMembers;
73
+
74
+ /**
75
+ * Workspace invite flow — create invite, list, revoke, preview, accept.
76
+ * Sending to an email already in the org adds the user directly.
77
+ */
78
+ readonly workspaceInvites: WorkspaceInvites;
79
+
80
+ /**
81
+ * Org invite flow — create invite, list, revoke, preview, accept.
82
+ * Requires admin/owner role for write operations.
83
+ */
84
+ readonly orgInvites: OrgInvites;
85
+
61
86
  /** Current tenant plan, limits, and live usage counters. */
62
87
  readonly tenant: Tenant;
63
88
 
@@ -183,6 +208,17 @@ export class Miosa {
183
208
  /** Admin: fleet-wide snapshot index. */
184
209
  readonly snapshotsStandalone: SnapshotsStandalone;
185
210
 
211
+ // ── Egress (security) namespaces ───────────────────────────────────────────
212
+
213
+ /** Encrypted secret + OAuth credential vault (`/egress/secrets`). */
214
+ readonly secrets: EgressSecrets;
215
+
216
+ /** Egress allowlist + policies — host-level firewall (`/egress/policies`). */
217
+ readonly network: EgressNetwork;
218
+
219
+ /** Egress audit log — every outbound request, paginated query + tail. */
220
+ readonly audit: EgressAudit;
221
+
186
222
  private readonly http: HttpClient;
187
223
 
188
224
  constructor(config: MiosaClientConfig) {
@@ -206,6 +242,10 @@ export class Miosa {
206
242
  maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
207
243
  });
208
244
 
245
+ this.workspaces = new Workspaces(this.http);
246
+ this.workspaceMembers = new WorkspaceMembers(this.http);
247
+ this.workspaceInvites = new WorkspaceInvites(this.http);
248
+ this.orgInvites = new OrgInvites(this.http);
209
249
  this.tenant = new Tenant(this.http);
210
250
  this.regions = new Regions(this.http);
211
251
  this.settings = new Settings(this.http);
@@ -245,5 +285,9 @@ export class Miosa {
245
285
  this.email = new Email(this.http);
246
286
  this.builderSessions = new BuilderSessions(this.http);
247
287
  this.snapshotsStandalone = new SnapshotsStandalone(this.http);
288
+ // Egress (security) namespaces
289
+ this.secrets = new EgressSecrets(this.http);
290
+ this.network = new EgressNetwork(this.http);
291
+ this.audit = new EgressAudit(this.http);
248
292
  }
249
293
  }
package/src/index.ts CHANGED
@@ -245,6 +245,54 @@ export type {
245
245
  ApiKeyCreateParams,
246
246
  } from "./resources/api-keys.js";
247
247
 
248
+ // Workspace CRUD
249
+ export { Workspaces } from "./resources/workspaces.js";
250
+ export type {
251
+ WorkspaceId as TenantWorkspaceId,
252
+ WorkspaceData,
253
+ WorkspaceCreateParams,
254
+ WorkspaceUpdateParams,
255
+ WorkspaceComputerTemplateCreateParams,
256
+ } from "./resources/workspaces.js";
257
+
258
+ // Members & Invites
259
+ export { WorkspaceMembers } from "./resources/workspace-members.js";
260
+ export type {
261
+ WorkspaceRole,
262
+ WorkspaceMember,
263
+ WorkspaceMemberRecord,
264
+ AddWorkspaceMemberParams,
265
+ UpdateWorkspaceMemberRoleParams,
266
+ WorkspaceMemberListResponse,
267
+ WorkspaceMemberRecordResponse,
268
+ WorkspaceMemberDeleteResponse,
269
+ } from "./resources/workspace-members.js";
270
+ export { WorkspaceInvites } from "./resources/workspace-invites.js";
271
+ export type {
272
+ WorkspaceInvite,
273
+ WorkspaceInvitePreview,
274
+ CreateWorkspaceInviteParams,
275
+ CreateWorkspaceInviteResponse,
276
+ WorkspaceInviteCreatedResponse,
277
+ WorkspaceMemberAddedResponse,
278
+ WorkspaceInviteListResponse,
279
+ WorkspaceInviteRevokeResponse,
280
+ AcceptWorkspaceInviteResponse,
281
+ } from "./resources/workspace-invites.js";
282
+ export { OrgInvites } from "./resources/org-invites.js";
283
+ export type {
284
+ OrgRole,
285
+ OrgInvite,
286
+ OrgInviteCreated,
287
+ OrgInvitePreview,
288
+ TenantSummary,
289
+ CreateOrgInviteParams,
290
+ OrgInviteCreatedResponse,
291
+ OrgInviteListResponse,
292
+ OrgInviteRevokeResponse,
293
+ AcceptOrgInviteResponse,
294
+ } from "./resources/org-invites.js";
295
+
248
296
  // P2 resources
249
297
  export { Tenant } from "./resources/tenant.js";
250
298
  export type { TenantPlan } from "./resources/tenant.js";
@@ -369,6 +417,58 @@ export {
369
417
  SandboxTags,
370
418
  } from "./resources/sandboxes.js";
371
419
 
420
+ // Egress (security) — secrets, network, audit
421
+ export {
422
+ EgressSecrets,
423
+ OAuthFlow,
424
+ SandboxSecrets,
425
+ ComputerSecrets,
426
+ } from "./resources/egressSecrets.js";
427
+ export type {
428
+ EgressSecretType,
429
+ EgressSecretScope,
430
+ EgressSecretData,
431
+ EgressBindingData,
432
+ OauthProvider,
433
+ SecretSetParams,
434
+ SecretListParams,
435
+ SecretRotateParams,
436
+ BindingCreateParams,
437
+ BindingListParams,
438
+ OauthConnectParams,
439
+ OauthStartResult,
440
+ OauthStatusResult,
441
+ } from "./resources/egressSecrets.js";
442
+ export {
443
+ EgressNetwork,
444
+ SandboxNetwork,
445
+ ComputerNetwork,
446
+ } from "./resources/egressNetwork.js";
447
+ export type {
448
+ EgressPolicyMode,
449
+ EgressRuleEffect,
450
+ EgressPolicyData,
451
+ EgressAllowlistRule,
452
+ EgressSuggestion,
453
+ AllowParams,
454
+ PolicyCreateParams,
455
+ PolicyUpdateParams,
456
+ ModeParams,
457
+ SuggestionsParams,
458
+ PolicyListParams,
459
+ RulesListParams,
460
+ } from "./resources/egressNetwork.js";
461
+ export {
462
+ EgressAudit,
463
+ SandboxAudit,
464
+ ComputerAudit,
465
+ } from "./resources/egressAudit.js";
466
+ export type {
467
+ EgressAuditEvent,
468
+ AuditListParams,
469
+ AuditTailParams,
470
+ } from "./resources/egressAudit.js";
471
+
372
472
  // Error types
373
473
  export {
374
474
  MiosaError,
@@ -47,6 +47,9 @@ export interface ApiKeyCreateParams {
47
47
  expires_at?: string;
48
48
  expiresAt?: string;
49
49
  idempotencyKey?: string;
50
+ /** Scope this key to a single workspace. Omit for a tenant-wide key. */
51
+ workspaceId?: string;
52
+ workspace_id?: string;
50
53
  [key: string]: unknown;
51
54
  }
52
55
 
@@ -100,10 +103,11 @@ export class ApiKeys {
100
103
  }
101
104
 
102
105
  async create(params: ApiKeyCreateParams): Promise<ApiKeyCreateResult> {
103
- const { idempotencyKey: ikey, expiresAt, ...rest } = params;
106
+ const { idempotencyKey: ikey, expiresAt, workspaceId, ...rest } = params;
104
107
  const body = stripUndefined({
105
108
  ...rest,
106
109
  expires_at: expiresAt ?? rest.expires_at,
110
+ workspace_id: workspaceId ?? rest.workspace_id,
107
111
  });
108
112
  const data = await this.http.request<unknown>("/api-keys", {
109
113
  method: "POST",
@@ -10,6 +10,9 @@ import { ComputerTerminal } from "./computer-terminal.js";
10
10
  import { ComputerVolumes } from "./computer-volumes.js";
11
11
  import { CustomDomains } from "./custom_domains.js";
12
12
  import { Desktop } from "./desktop.js";
13
+ import { ComputerAudit } from "./egressAudit.js";
14
+ import { ComputerNetwork } from "./egressNetwork.js";
15
+ import { ComputerSecrets } from "./egressSecrets.js";
13
16
  import { Events } from "./events.js";
14
17
  import { Exec } from "./exec.js";
15
18
  import { Files } from "./files.js";
@@ -112,6 +115,15 @@ export class Computer {
112
115
  /** Volume attachment — list, attach, detach. */
113
116
  readonly volumes: ComputerVolumes;
114
117
 
118
+ /** Encrypted secrets + OAuth credentials scoped to this computer. */
119
+ readonly secrets: ComputerSecrets;
120
+
121
+ /** Egress allowlist + policies scoped to this computer. */
122
+ readonly network: ComputerNetwork;
123
+
124
+ /** Egress audit log + live tail scoped to this computer. */
125
+ readonly audit: ComputerAudit;
126
+
115
127
  private readonly http: HttpClient;
116
128
 
117
129
  constructor(http: HttpClient, data: ComputerData) {
@@ -134,6 +146,10 @@ export class Computer {
134
146
  this.logs = new ComputerLogs(http, id);
135
147
  this.ports = new ComputerPorts(http, id);
136
148
  this.volumes = new ComputerVolumes(http, id);
149
+ // Egress (security) namespaces — pre-scoped to this computer id.
150
+ this.secrets = new ComputerSecrets(http, id);
151
+ this.network = new ComputerNetwork(http, id);
152
+ this.audit = new ComputerAudit(http, id);
137
153
  }
138
154
 
139
155
  get id(): ComputerId {
@@ -55,6 +55,13 @@ export interface CronJobCreateParams {
55
55
  name: string;
56
56
  schedule: string;
57
57
  idempotencyKey?: string;
58
+ // White-label attribution
59
+ externalWorkspaceId?: string;
60
+ external_workspace_id?: string;
61
+ externalUserId?: string;
62
+ external_user_id?: string;
63
+ externalProjectId?: string;
64
+ external_project_id?: string;
58
65
  [key: string]: unknown;
59
66
  }
60
67
 
@@ -27,6 +27,13 @@ export interface CustomDomainData {
27
27
 
28
28
  export interface CustomDomainRegisterParams {
29
29
  fqdn: string;
30
+ // White-label attribution
31
+ externalWorkspaceId?: string;
32
+ external_workspace_id?: string;
33
+ externalUserId?: string;
34
+ external_user_id?: string;
35
+ externalProjectId?: string;
36
+ external_project_id?: string;
30
37
  }
31
38
 
32
39
  // ─── CustomDomains resource ──────────────────────────────────────────────────
@@ -95,7 +95,7 @@ export interface DeploymentData {
95
95
  * with `source_sandbox_id` on the version row. Will become nullable.
96
96
  */
97
97
  repo_url?: string;
98
- repo_provider?: "github";
98
+ repo_provider?: "github" | "gitlab" | "bitbucket";
99
99
  branch?: string;
100
100
  build_command?: string | null;
101
101
  run_command?: string | null;
@@ -278,6 +278,9 @@ export interface DeploymentCreateParams extends ExternalAttribution {
278
278
  autoDeploy?: boolean;
279
279
  database?: DeploymentDatabaseRequest;
280
280
  metadata?: Record<string, unknown>;
281
+ /** Pin build and runtime VMs to a specific DC. One of: "us-west", "us-east", "us-mia". */
282
+ target_region?: string;
283
+ targetRegion?: string;
281
284
  idempotencyKey?: string;
282
285
  }
283
286
 
@@ -617,6 +620,7 @@ export class Deployments {
617
620
  auto_deploy: params.autoDeploy ?? params.auto_deploy,
618
621
  database: params.database,
619
622
  metadata: params.metadata,
623
+ target_region: params.targetRegion ?? params.target_region,
620
624
  ...attributionBody(params),
621
625
  });
622
626
  const data = await this.http.request<unknown>("/deployments", {
@@ -735,9 +739,16 @@ export class Deployments {
735
739
  return unwrap(data) as DeploymentBuildData;
736
740
  }
737
741
 
738
- async listEnv(deploymentId: string): Promise<Record<string, unknown>[]> {
742
+ async listEnv(
743
+ deploymentId: string,
744
+ opts: { environment?: string } = {},
745
+ ): Promise<Record<string, unknown>[]> {
746
+ const query = stripUndefined({ environment: opts.environment });
739
747
  const data = await this.http.get<unknown>(
740
748
  `/deployments/${deploymentId}/env`,
749
+ Object.keys(query).length
750
+ ? (query as Record<string, string | number | boolean | undefined>)
751
+ : undefined,
741
752
  );
742
753
  return listItems<Record<string, unknown>>(data);
743
754
  }
@@ -0,0 +1,318 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { HttpClient } from "../http.js";
3
+ import { EgressAudit, SandboxAudit } from "./egressAudit.js";
4
+ import { EgressNetwork, SandboxNetwork } from "./egressNetwork.js";
5
+ import { EgressSecrets, OAuthFlow, SandboxSecrets } from "./egressSecrets.js";
6
+
7
+ const mockGet = vi.fn();
8
+ const mockPost = vi.fn();
9
+ const mockPatch = vi.fn();
10
+ const mockDelete = vi.fn();
11
+ const mockRequest = vi.fn();
12
+ const mockStream = vi.fn();
13
+ const mockGetBinary = vi.fn();
14
+
15
+ function makeHttp(): HttpClient {
16
+ const http = {} as HttpClient;
17
+ http.get = mockGet;
18
+ http.post = mockPost;
19
+ http.patch = mockPatch;
20
+ http.delete = mockDelete;
21
+ http.request = mockRequest;
22
+ http.stream = mockStream;
23
+ http.getBinary = mockGetBinary;
24
+ return http;
25
+ }
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ // ─── client.secrets ──────────────────────────────────────────────────────────
32
+
33
+ describe("EgressSecrets", () => {
34
+ it("set() POSTs to /egress/secrets with the right body", async () => {
35
+ mockPost.mockResolvedValue({
36
+ data: { id: "sec_1", name: "OPENAI_API_KEY" },
37
+ });
38
+
39
+ const client = new EgressSecrets(makeHttp());
40
+ const secret = await client.set({
41
+ name: "OPENAI_API_KEY",
42
+ value: "sk-abc123",
43
+ type: "api_key",
44
+ scope: "user",
45
+ exposeAsEnv: "OPENAI_API_KEY",
46
+ });
47
+
48
+ expect(mockPost).toHaveBeenCalledWith("/egress/secrets", {
49
+ name: "OPENAI_API_KEY",
50
+ value: "sk-abc123",
51
+ type: "api_key",
52
+ scope: "user",
53
+ expose_as_env: "OPENAI_API_KEY",
54
+ });
55
+ expect(secret.id).toBe("sec_1");
56
+ });
57
+
58
+ it("list({ scope: 'user' }) GETs /egress/secrets?scope=user", async () => {
59
+ mockGet.mockResolvedValue({ data: [{ id: "sec_1" }, { id: "sec_2" }] });
60
+
61
+ const result = await new EgressSecrets(makeHttp()).list({ scope: "user" });
62
+
63
+ expect(mockGet).toHaveBeenCalledWith("/egress/secrets", { scope: "user" });
64
+ expect(result).toHaveLength(2);
65
+ });
66
+
67
+ it("get() calls /egress/secrets/:id", async () => {
68
+ mockGet.mockResolvedValue({ data: { id: "sec_1" } });
69
+ await new EgressSecrets(makeHttp()).get("sec_1");
70
+ expect(mockGet).toHaveBeenCalledWith("/egress/secrets/sec_1");
71
+ });
72
+
73
+ it("rotate() PATCHes /egress/secrets/:id with new value", async () => {
74
+ mockPatch.mockResolvedValue({ data: { id: "sec_1" } });
75
+ await new EgressSecrets(makeHttp()).rotate("sec_1", {
76
+ newValue: "sk-new",
77
+ expiresAt: "2027-01-01T00:00:00Z",
78
+ });
79
+ expect(mockPatch).toHaveBeenCalledWith("/egress/secrets/sec_1", {
80
+ value: "sk-new",
81
+ expires_at: "2027-01-01T00:00:00Z",
82
+ });
83
+ });
84
+
85
+ it("delete() DELETEs /egress/secrets/:id", async () => {
86
+ mockDelete.mockResolvedValue(undefined);
87
+ await new EgressSecrets(makeHttp()).delete("sec_1");
88
+ expect(mockDelete).toHaveBeenCalledWith("/egress/secrets/sec_1");
89
+ });
90
+
91
+ it("connect() returns an OAuthFlow with authorizeUrl + state", async () => {
92
+ mockPost.mockResolvedValue({
93
+ data: {
94
+ authorize_url: "https://github.com/login/oauth/authorize?state=xyz",
95
+ state: "xyz",
96
+ },
97
+ });
98
+
99
+ const flow = await new EgressSecrets(makeHttp()).connect({
100
+ provider: "github",
101
+ exposeAsEnv: "GITHUB_TOKEN",
102
+ });
103
+
104
+ expect(mockPost).toHaveBeenCalledWith("/egress/oauth/start", {
105
+ provider: "github",
106
+ expose_as_env: "GITHUB_TOKEN",
107
+ });
108
+ expect(flow).toBeInstanceOf(OAuthFlow);
109
+ expect(flow.authorizeUrl).toBe(
110
+ "https://github.com/login/oauth/authorize?state=xyz",
111
+ );
112
+ expect(flow.state).toBe("xyz");
113
+ });
114
+
115
+ it("OAuthFlow.waitForCompletion polls /egress/oauth/status", async () => {
116
+ mockPost.mockResolvedValue({
117
+ data: { authorize_url: "https://example.com", state: "xyz" },
118
+ });
119
+ mockGet.mockResolvedValueOnce({
120
+ data: { status: "completed", secret_id: "sec_new" },
121
+ });
122
+
123
+ const flow = await new EgressSecrets(makeHttp()).connect({
124
+ provider: "github",
125
+ });
126
+ const result = await flow.waitForCompletion({
127
+ timeoutSec: 2,
128
+ pollIntervalMs: 1,
129
+ });
130
+
131
+ expect(mockGet).toHaveBeenCalledWith("/egress/oauth/status", {
132
+ state: "xyz",
133
+ });
134
+ expect(result.status).toBe("completed");
135
+ });
136
+
137
+ it("createBinding() POSTs to /egress/bindings", async () => {
138
+ mockPost.mockResolvedValue({ data: { id: "bnd_1" } });
139
+ await new EgressSecrets(makeHttp()).createBinding({
140
+ secretId: "sec_1",
141
+ resourceId: "sbx_1",
142
+ resourceType: "sandbox",
143
+ exposeAsEnv: "OPENAI_API_KEY",
144
+ });
145
+ expect(mockPost).toHaveBeenCalledWith("/egress/bindings", {
146
+ secret_id: "sec_1",
147
+ resource_id: "sbx_1",
148
+ resource_type: "sandbox",
149
+ expose_as_env: "OPENAI_API_KEY",
150
+ });
151
+ });
152
+
153
+ it("providers() GETs /egress/oauth/providers", async () => {
154
+ mockGet.mockResolvedValue({
155
+ data: [{ name: "github" }, { name: "slack" }],
156
+ });
157
+ const out = await new EgressSecrets(makeHttp()).providers();
158
+ expect(mockGet).toHaveBeenCalledWith("/egress/oauth/providers");
159
+ expect(out).toHaveLength(2);
160
+ });
161
+ });
162
+
163
+ // ─── client.network ──────────────────────────────────────────────────────────
164
+
165
+ describe("EgressNetwork", () => {
166
+ it("allow() POSTs to /egress/allowlist with effect=allow", async () => {
167
+ mockPost.mockResolvedValue({ data: { id: "rul_1" } });
168
+ await new EgressNetwork(makeHttp()).allow("api.openai.com", {
169
+ methods: ["GET", "POST"],
170
+ pathGlob: "/v1/*",
171
+ });
172
+ expect(mockPost).toHaveBeenCalledWith("/egress/allowlist", {
173
+ host: "api.openai.com",
174
+ effect: "allow",
175
+ methods: ["GET", "POST"],
176
+ path_glob: "/v1/*",
177
+ });
178
+ });
179
+
180
+ it("deny() POSTs with effect=deny", async () => {
181
+ mockPost.mockResolvedValue({ data: { id: "rul_2" } });
182
+ await new EgressNetwork(makeHttp()).deny("169.254.169.254");
183
+ expect(mockPost).toHaveBeenCalledWith("/egress/allowlist", {
184
+ host: "169.254.169.254",
185
+ effect: "deny",
186
+ });
187
+ });
188
+
189
+ it("lockdown() PATCHes /egress/policies with mode=enforce", async () => {
190
+ mockPatch.mockResolvedValue({ data: {} });
191
+ await new EgressNetwork(makeHttp()).lockdown();
192
+ expect(mockPatch).toHaveBeenCalledWith("/egress/policies", {
193
+ mode: "enforce",
194
+ });
195
+ });
196
+
197
+ it("observe() PATCHes /egress/policies with mode=audit_only", async () => {
198
+ mockPatch.mockResolvedValue({ data: {} });
199
+ await new EgressNetwork(makeHttp()).observe();
200
+ expect(mockPatch).toHaveBeenCalledWith("/egress/policies", {
201
+ mode: "audit_only",
202
+ });
203
+ });
204
+
205
+ it("suggestions() GETs /egress/audit/suggestions with resource_id", async () => {
206
+ mockGet.mockResolvedValue({ data: [] });
207
+ await new EgressNetwork(makeHttp()).suggestions({
208
+ resourceId: "sbx_1",
209
+ since: "1d",
210
+ });
211
+ expect(mockGet).toHaveBeenCalledWith("/egress/audit/suggestions", {
212
+ resource_id: "sbx_1",
213
+ since: "1d",
214
+ });
215
+ });
216
+
217
+ it("policies() GETs /egress/policies", async () => {
218
+ mockGet.mockResolvedValue({ data: [{ id: "pol_1" }] });
219
+ const out = await new EgressNetwork(makeHttp()).policies();
220
+ expect(mockGet).toHaveBeenCalledWith("/egress/policies", {});
221
+ expect(out).toHaveLength(1);
222
+ });
223
+
224
+ it("removeRule() DELETEs /egress/allowlist/:id", async () => {
225
+ mockDelete.mockResolvedValue(undefined);
226
+ await new EgressNetwork(makeHttp()).removeRule("rul_1");
227
+ expect(mockDelete).toHaveBeenCalledWith("/egress/allowlist/rul_1");
228
+ });
229
+ });
230
+
231
+ // ─── client.audit ────────────────────────────────────────────────────────────
232
+
233
+ describe("EgressAudit", () => {
234
+ it("list() GETs /egress/audit", async () => {
235
+ mockGet.mockResolvedValue({
236
+ data: [
237
+ { id: "evt_1", host: "api.openai.com" },
238
+ { id: "evt_2", host: "github.com" },
239
+ ],
240
+ });
241
+ const out = await new EgressAudit(makeHttp()).list();
242
+ expect(mockGet).toHaveBeenCalledWith("/egress/audit", {});
243
+ expect(out).toHaveLength(2);
244
+ });
245
+
246
+ it("list() passes filters as query params", async () => {
247
+ mockGet.mockResolvedValue({ data: [] });
248
+ await new EgressAudit(makeHttp()).list({
249
+ resourceId: "sbx_1",
250
+ host: "api.openai.com",
251
+ action: "denied",
252
+ limit: 50,
253
+ });
254
+ expect(mockGet).toHaveBeenCalledWith("/egress/audit", {
255
+ resource_id: "sbx_1",
256
+ host: "api.openai.com",
257
+ action: "denied",
258
+ limit: 50,
259
+ });
260
+ });
261
+
262
+ it("get() returns a single event", async () => {
263
+ mockGet.mockResolvedValue({ data: { id: "evt_1" } });
264
+ const out = await new EgressAudit(makeHttp()).get("evt_1");
265
+ expect(mockGet).toHaveBeenCalledWith("/egress/audit/evt_1");
266
+ expect(out.id).toBe("evt_1");
267
+ });
268
+ });
269
+
270
+ // ─── sandbox.* scoped namespaces ─────────────────────────────────────────────
271
+
272
+ describe("Sandbox-scoped egress", () => {
273
+ it("sandbox.secrets.set() injects resource_id + resource_type", async () => {
274
+ mockPost.mockResolvedValue({ data: { id: "sec_1" } });
275
+ await new SandboxSecrets(makeHttp(), "sbx_abc").set({
276
+ name: "OPENAI_API_KEY",
277
+ value: "sk-abc",
278
+ });
279
+ expect(mockPost).toHaveBeenCalledWith("/egress/secrets", {
280
+ name: "OPENAI_API_KEY",
281
+ value: "sk-abc",
282
+ type: "api_key",
283
+ scope: "user",
284
+ resource_id: "sbx_abc",
285
+ resource_type: "sandbox",
286
+ });
287
+ });
288
+
289
+ it("sandbox.audit.list() includes resource_id in query params", async () => {
290
+ mockGet.mockResolvedValue({ data: [] });
291
+ await new SandboxAudit(makeHttp(), "sbx_abc").list();
292
+ expect(mockGet).toHaveBeenCalledWith("/egress/audit", {
293
+ resource_id: "sbx_abc",
294
+ resource_type: "sandbox",
295
+ });
296
+ });
297
+
298
+ it("sandbox.network.allow() scopes the rule to the sandbox", async () => {
299
+ mockPost.mockResolvedValue({ data: { id: "rul_1" } });
300
+ await new SandboxNetwork(makeHttp(), "sbx_abc").allow("api.openai.com");
301
+ expect(mockPost).toHaveBeenCalledWith("/egress/allowlist", {
302
+ host: "api.openai.com",
303
+ effect: "allow",
304
+ resource_id: "sbx_abc",
305
+ resource_type: "sandbox",
306
+ });
307
+ });
308
+
309
+ it("sandbox.network.lockdown() scopes the policy patch", async () => {
310
+ mockPatch.mockResolvedValue({ data: {} });
311
+ await new SandboxNetwork(makeHttp(), "sbx_abc").lockdown();
312
+ expect(mockPatch).toHaveBeenCalledWith("/egress/policies", {
313
+ mode: "enforce",
314
+ resource_id: "sbx_abc",
315
+ resource_type: "sandbox",
316
+ });
317
+ });
318
+ });