Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/managed-npm-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"sandbox": minor
"@vercel/sandbox": minor
---

Add `credentials: { npm: true }` to `Sandbox.create` and `--credentials=npm` to `create`, `run`, and `sh`, so a sandbox can install your team's private npm packages with Vercel managed credentials injected into the sandbox network policy. `--credentials` takes a comma-separated list, so later credential types share the same flag.
7 changes: 7 additions & 0 deletions packages/sandbox/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Options:
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
--failover-regions <REGION,...|none> Comma-separated regions the sandbox can fail over to (e.g. --failover-regions sfo1,fra1). Must not include the sandbox region. Pass "none" for no failover regions, overriding the project default. [optional]
--network-id <NETWORK_ID> Secure Compute network ID from your team's Settings > Networking (requires an Enterprise plan) [optional]
--credentials <PROVIDER,...> Comma-separated Vercel managed credentials to inject into the sandbox network policy. [optional]
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
--keep-last-snapshots <COUNT> Keep only the N most recent snapshots of this sandbox (1-10). [optional]
--keep-last-snapshots-for <DURATION|none> Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
Expand Down Expand Up @@ -196,6 +197,7 @@ Options:
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
--failover-regions <REGION,...|none> Comma-separated regions the sandbox can fail over to (e.g. --failover-regions sfo1,fra1). Must not include the sandbox region. Pass "none" for no failover regions, overriding the project default. [optional]
--network-id <NETWORK_ID> Secure Compute network ID from your team's Settings > Networking (requires an Enterprise plan) [optional]
--credentials <PROVIDER,...> Comma-separated Vercel managed credentials to inject into the sandbox network policy. [optional]
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
--keep-last-snapshots <COUNT> Keep only the N most recent snapshots of this sandbox (1-10). [optional]
--keep-last-snapshots-for <DURATION|none> Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
Expand Down Expand Up @@ -256,6 +258,10 @@ Examples:
– Create and connect to a sandbox without a network access

$ sandbox run --network-policy=none --connect

– Create a sandbox that can install your team's private npm packages

$ sandbox create --credentials=npm
```

## `sandbox sh`
Expand All @@ -282,6 +288,7 @@ Options:
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
--failover-regions <REGION,...|none> Comma-separated regions the sandbox can fail over to (e.g. --failover-regions sfo1,fra1). Must not include the sandbox region. Pass "none" for no failover regions, overriding the project default. [optional]
--network-id <NETWORK_ID> Secure Compute network ID from your team's Settings > Networking (requires an Enterprise plan) [optional]
--credentials <PROVIDER,...> Comma-separated Vercel managed credentials to inject into the sandbox network policy. [optional]
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
--keep-last-snapshots <COUNT> Keep only the N most recent snapshots of this sandbox (1-10). [optional]
--keep-last-snapshots-for <DURATION|none> Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
Expand Down
41 changes: 41 additions & 0 deletions packages/sandbox/src/args/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as cmd from "cmd-ts";

export const CREDENTIAL_PROVIDERS = ["npm"] as const;
export type CredentialProvider = (typeof CREDENTIAL_PROVIDERS)[number];

function isCredentialProvider(value: string): value is CredentialProvider {
return (CREDENTIAL_PROVIDERS as readonly string[]).includes(value);
}

export const credentialProviderListType = cmd.extendType(cmd.string, {
displayName: "PROVIDER,...",
async from(value) {
const providers = [
...new Set(
value
.split(",")
.map((provider) => provider.trim())
.filter((provider) => provider !== ""),
),
];
if (providers.length === 0) {
throw new Error("Credentials cannot be empty.");
}
const unsupported = providers.filter(
(provider) => !isCredentialProvider(provider),
);
if (unsupported.length > 0) {
throw new Error(
`Unsupported credentials: ${unsupported.join(", ")}. Supported: ${CREDENTIAL_PROVIDERS.join(", ")}.`,
);
}
return providers.filter(isCredentialProvider);
},
});

export const credentials = cmd.option({
long: "credentials",
type: cmd.optional(credentialProviderListType),
description:
"Comma-separated Vercel managed credentials to inject into the sandbox network policy.",
});
13 changes: 13 additions & 0 deletions packages/sandbox/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { mounts } from "../args/drive";
import { startLatestVersionCheck } from "../util/check-latest-version";
import { region, failoverRegions } from "../args/region";
import { networkId } from "../args/network-id";
import { credentials } from "../args/credentials";
import { defaultShell } from "../interactive-shell/default-shell";
import { agentNames, agents } from "./agents";

Expand Down Expand Up @@ -79,6 +80,7 @@ export const args = {
region,
failoverRegions,
networkId,
credentials,
...snapshotRetentionArgs,
...networkPolicyArgs,
scope,
Expand Down Expand Up @@ -108,6 +110,11 @@ export const create = cmd.command({
description: "Create and connect to a sandbox without a network access",
command: `sandbox run --network-policy=none --connect`,
},
{
description:
"Create a sandbox that can install your team's private npm packages",
command: `sandbox create --credentials=npm`,
},
],
async handler(input) {
const {
Expand All @@ -129,6 +136,7 @@ export const create = cmd.command({
region,
failoverRegions,
networkId,
credentials,
snapshotExpiration,
keepLastSnapshots,
keepLastSnapshotsFor,
Expand Down Expand Up @@ -184,6 +192,9 @@ export const create = cmd.command({
const resources = vcpus ? { vcpus } : undefined;
const tagsObj = Object.keys(tags).length > 0 ? tags : undefined;
const mountsObj = Object.keys(mounts).length > 0 ? mounts : undefined;
const credentialsObj = credentials?.includes("npm")
? { npm: true as const }
: undefined;
const spinner = silent ? undefined : ora("Creating sandbox...").start();
const sandbox = snapshot
? await sandboxClient.create({
Expand All @@ -207,6 +218,7 @@ export const create = cmd.command({
? ms(snapshotExpiration)
: undefined,
keepLastSnapshots: keepLastSnapshotsPayload,
credentials: credentialsObj,
__interactive: true,
})
: await sandboxClient.create({
Expand All @@ -230,6 +242,7 @@ export const create = cmd.command({
? ms(snapshotExpiration)
: undefined,
keepLastSnapshots: keepLastSnapshotsPayload,
credentials: credentialsObj,
__interactive: true,
});
spinner?.stop();
Expand Down
24 changes: 24 additions & 0 deletions packages/sandbox/test/commands/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ describe("create command", () => {
);
});

test("requests the team's npm credentials with --credentials=npm", async () => {
const { create } = await import("../../src/commands/create.ts");
await cmd.run(create, [
"--credentials=npm",
"--scope=team",
"--project=proj",
"--silent",
]);

expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ credentials: { npm: true } }),
);
});

test("rejects credentials it does not support, listing the supported ones", async () => {
const { credentialProviderListType } = await import(
"../../src/args/credentials.ts"
);

await expect(credentialProviderListType.from("npm,github")).rejects.toThrow(
"Unsupported credentials: github. Supported: npm.",
);
});

test("defaults the network policy to allow-all", async () => {
const { create } = await import("../../src/commands/create.ts");
await cmd.run(create, ["--scope=team", "--project=proj", "--silent"]);
Expand Down
12 changes: 12 additions & 0 deletions packages/vercel-sandbox/src/api-client/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1974,6 +1974,18 @@ describe("APIClient", () => {
expect(JSON.parse(opts.body)).not.toHaveProperty("networkId");
});

it("forwards credentials in the request body", async () => {
mockFetch.mockResolvedValue(sandboxResponse());

await client.createSandbox({
projectId: "proj_123",
credentials: { npm: true },
});

const [, opts] = mockFetch.mock.calls[0];
expect(JSON.parse(opts.body).credentials).toEqual({ npm: true });
});

it("forwards region and failoverRegions in the request body", async () => {
mockFetch.mockResolvedValue(sandboxResponse());

Expand Down
3 changes: 3 additions & 0 deletions packages/vercel-sandbox/src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { toAPINetworkPolicy } from "../utils/network-policy.js";
import { getPrivateParams, WithPrivate } from "../utils/types.js";
import { detectAgentName } from "../utils/detect-agent.js";
import type { RUNTIMES, SandboxRegion } from "../constants.js";
import type { SandboxCredentials } from "../sandbox.js";
import type { SandboxMetaData } from "./validators.js";
import { withRetry } from "./with-retry.js";
import { setTimeout as delay } from "node:timers/promises";
Expand Down Expand Up @@ -261,6 +262,7 @@ export class APIClient extends BaseClient {
mounts?: SandboxMetaData["mounts"];
region?: SandboxRegion;
failoverRegions?: SandboxRegion[];
credentials?: SandboxCredentials;
signal?: AbortSignal;
}>,
) {
Expand Down Expand Up @@ -292,6 +294,7 @@ export class APIClient extends BaseClient {
mounts: params.mounts,
region: params.region,
failoverRegions: params.failoverRegions,
credentials: params.credentials,
...privateParams,
}),
signal: params.signal,
Expand Down
1 change: 1 addition & 0 deletions packages/vercel-sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
type NetworkPolicyKeyValueMatcher,
type NetworkPolicyMatch,
type NetworkPolicyMatcher,
type SandboxCredentials,
type SandboxMountMode,
type SandboxMounts,
Sandbox,
Expand Down
18 changes: 18 additions & 0 deletions packages/vercel-sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ export interface BaseCreateSandboxParams {
* Enable or disable automatic restore of the filesystem between sessions.
*/
persistent?: boolean;
/**
* Vercel managed credentials injected into the sandbox network policy.
*
* @example
* await Sandbox.create({ credentials: { npm: true } });
*/
credentials?: SandboxCredentials;
/**
* Default snapshot expiration in milliseconds.
* When set, snapshots created for this sandbox will expire after this duration.
Expand Down Expand Up @@ -184,6 +191,16 @@ export interface BaseCreateSandboxParams {
onResume?: (sandbox: Sandbox) => Promise<void>;
}

/**
* Vercel managed credentials injected into the sandbox network policy.
*/
export interface SandboxCredentials {
/**
* Let the sandbox install your team's private npm packages.
*/
npm?: true;
}

export type SandboxMountMode = "read-write" | "snapshot";
export type SandboxMounts = Record<
string,
Expand Down Expand Up @@ -815,6 +832,7 @@ export class Sandbox implements ExecutionContext {
signal: params?.signal,
name: params?.name,
persistent: params?.persistent,
credentials: params?.credentials,
...privateParams,
});

Expand Down
13 changes: 13 additions & 0 deletions skills/sandbox/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,19 @@ const sandbox = await Sandbox.create({
});
```

### With Private npm Packages

Pass `credentials: { npm: true }` when the sandbox runs a coding agent or installs your team's private npm packages. Vercel injects the team's npm credentials into the sandbox network policy, so installs authenticate with no token inside the sandbox.

```typescript
const sandbox = await Sandbox.create({ credentials: { npm: true } });
await sandbox.runCommand("npm", ["install"]);
```

- Covers installs with npm, pnpm, and Yarn 1. Publishing from the sandbox stays unauthenticated.
- When the team has no npm credentials, the sandbox is created without them.
- CLI: `sandbox create --credentials=npm`.

### From Tarball

```typescript
Expand Down
Loading