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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ first run. Edit that file for instance-local settings such as:

```sh
AWS_REGION=us-west-2
ROOTCELL_AWS_SECRETS_MANAGER_PROVIDERS={"aws-prod":{"aws_profile":"prod","aws_region":"us-west-2"},"aws-dev":{"aws_profile":"dev"}}
ROOTCELL_SUBNET_POOL_START=192.168.100.0
ROOTCELL_SUBNET_POOL_END=192.168.254.0
```
Expand Down Expand Up @@ -397,6 +398,7 @@ secrets may come from different providers:

```sh
AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key
OTHER_TOKEN=aws-prod:other-token-a1b2c3
```

For example, to inject an additional `ANTHROPIC_API_KEY`:
Expand All @@ -406,6 +408,14 @@ security add-generic-password -a "$USER" -s anthropic-api-key -w "<your-key>"
echo 'ANTHROPIC_API_KEY=macos-keychain:anthropic-api-key' >> "$INSTANCE_DIR/secrets.env"
```

AWS Secrets Manager providers are registered in `<instance-dir>/.env` with
`ROOTCELL_AWS_SECRETS_MANAGER_PROVIDERS`. The JSON object keys are provider ids;
each value includes `aws_profile` and optional `aws_region`. If `aws_region` is
omitted, rootcell uses the region configured for that AWS profile in
`~/.aws/config`, then `AWS_REGION` or `AWS_DEFAULT_REGION`. The `secrets.env`
reference is the secret resource name only, such as `name-a1b2c3`, not the full
ARN.

If you want to use Anthropic or OpenAI subscriptions, you can log in from
inside the VM.

Expand Down
84 changes: 84 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"vitest": "^4.0.0"
},
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.1050.0",
"@aws-sdk/credential-providers": "^3.1050.0",
"yargs": "18.0.0",
"zod": "^4.4.3"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../../../providers/macos-lima-user-v2-network.ts";
import type { ProviderBundle } from "../../../providers/types.ts";
import { preflightMacOsLimaUserV2Integration } from "./preflight.ts";
import { AwsSecretsManagerSecretProvider } from "../../../secrets/aws-secrets-manager.ts";
import { MacOsKeychainSecretProvider } from "../../../secrets/macos-keychain.ts";
import { StaticSecretProviderRegistry } from "../../../secrets/registry.ts";

Expand All @@ -43,6 +44,7 @@ export function createBundle(
vm: new LimaVmProvider(config, log),
secrets: new StaticSecretProviderRegistry([
new MacOsKeychainSecretProvider(),
...config.awsSecretsManagerProviders.map((providerConfig) => new AwsSecretsManagerSecretProvider(providerConfig)),
]),
};
}
Expand Down Expand Up @@ -142,6 +144,7 @@ function limaCleanupConfig(repoDir: string, instance: string, env: NodeJS.Proces
agentIp: "192.168.109.11",
networkPrefix: "24",
imageManifestUrl: "https://example.invalid/manifest.json",
awsSecretsManagerProviders: [],
};
}

Expand Down
2 changes: 2 additions & 0 deletions src/rootcell/providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { RootcellConfig } from "../types.ts";
import type { ProviderBundle } from "./types.ts";
import { LimaVmProvider } from "./lima.ts";
import { MacOsLimaUserV2NetworkProvider, type LimaUserV2NetworkAttachment } from "./macos-lima-user-v2-network.ts";
import { AwsSecretsManagerSecretProvider } from "../secrets/aws-secrets-manager.ts";
import { MacOsKeychainSecretProvider } from "../secrets/macos-keychain.ts";
import { StaticSecretProviderRegistry } from "../secrets/registry.ts";

Expand All @@ -14,6 +15,7 @@ export function createProviderBundle(
vm: new LimaVmProvider(config, log),
secrets: new StaticSecretProviderRegistry([
new MacOsKeychainSecretProvider(),
...config.awsSecretsManagerProviders.map((providerConfig) => new AwsSecretsManagerSecretProvider(providerConfig)),
]),
};
}
160 changes: 158 additions & 2 deletions src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ import {
import { MacOsKeychainSecretProvider } from "./secrets/macos-keychain.ts";
import { StaticSecretProviderRegistry } from "./secrets/registry.ts";
import { SecretEnvMappingSchema } from "./secrets/types.ts";
import { AwsSecretsManagerSecretProvider } from "./secrets/aws-secrets-manager.ts";
import {
AWS_SECRETS_MANAGER_PROVIDERS_ENV,
parseAwsSecretsManagerProviderConfigs,
resolveAwsSecretsManagerRegion,
} from "./secrets/aws-secrets-manager-config.ts";

const EmptyStringArraySchema = z.array(z.string()).length(0);
const DefaultSpyOptionsSchema = z.object({
Expand Down Expand Up @@ -211,7 +217,7 @@ describe("environment parsing", () => {
test("validates secret mappings", () => {
const mappings = parseSecretMappings([
"AWS_BEARER_TOKEN_BEDROCK=macos-keychain:aws-bedrock-api-key",
"AWS_SECRET_ACCESS_KEY=aws-prod:arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key",
"AWS_SECRET_ACCESS_KEY=aws-prod:prod-key-a1b2c3",
"ONEPASSWORD_TOKEN=1password:op://Private/token/password",
"",
].join("\n"));
Expand All @@ -225,7 +231,7 @@ describe("environment parsing", () => {
envName: "AWS_SECRET_ACCESS_KEY",
secret: {
providerId: "aws-prod",
reference: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key",
reference: "prod-key-a1b2c3",
},
},
{
Expand All @@ -252,6 +258,22 @@ describe("environment parsing", () => {
expect(config.firewallIp).toBe("192.168.109.10");
expect(config.agentIp).toBe("192.168.109.11");
expect(config.imageManifestUrl).toBe("https://github.com/rootcell-ai/rootcell/releases/latest/download/manifest.json");
expect(config.awsSecretsManagerProviders).toEqual([]);
});

test("builds config with AWS Secrets Manager providers", () => {
const config = buildConfig("/repo", {
[AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({
"aws-prod": { aws_profile: "prod", aws_region: "us-east-1" },
"aws-dev": { aws_profile: "dev" },
}),
}, fakeInstance("dev"));

expect(config).toEqual(expect.schemaMatching(RootcellConfigSchema));
expect(config.awsSecretsManagerProviders).toEqual([
{ id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" },
{ id: "aws-dev", awsProfile: "dev" },
]);
});
});

Expand Down Expand Up @@ -357,6 +379,128 @@ describe("secret providers", () => {
])).toThrow("duplicate secret provider id");
});

test("parses AWS Secrets Manager provider configuration", () => {
expect(parseAwsSecretsManagerProviderConfigs({})).toEqual([]);
expect(parseAwsSecretsManagerProviderConfigs({ [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: "" })).toEqual([]);
expect(parseAwsSecretsManagerProviderConfigs({
[AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({
"aws-prod": { aws_profile: "prod", aws_region: "us-east-1" },
"aws-dev": { aws_profile: "dev" },
}),
})).toEqual([
{ id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" },
{ id: "aws-dev", awsProfile: "dev" },
]);

expect(() => parseAwsSecretsManagerProviderConfigs({ [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: "[]" })).toThrow("must be a JSON object");
expect(() => parseAwsSecretsManagerProviderConfigs({ [AWS_SECRETS_MANAGER_PROVIDERS_ENV]: "{" })).toThrow("must be valid JSON");
expect(() => parseAwsSecretsManagerProviderConfigs({
[AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ "bad/id": { aws_profile: "prod" } }),
})).toThrow("invalid AWS Secrets Manager provider id");
expect(() => parseAwsSecretsManagerProviderConfigs({
[AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({ "aws-prod": { aws_region: "us-east-1" } }),
})).toThrow("aws_profile");
});

test("resolves AWS Secrets Manager regions from provider config, AWS config, and environment", () => {
const dir = mkdtempSync(join(tmpdir(), "rootcell-aws-"));
try {
const configPath = join(dir, "config");
const credentialsPath = join(dir, "credentials");
writeFileSync(configPath, [
"[default]",
"region = us-east-2",
"[profile prod]",
"region = us-west-2",
"",
].join("\n"), "utf8");
writeFileSync(credentialsPath, [
"[fallback]",
"region = ap-south-1",
"",
].join("\n"), "utf8");
const env: NodeJS.ProcessEnv = {
AWS_CONFIG_FILE: configPath,
AWS_SHARED_CREDENTIALS_FILE: credentialsPath,
};

expect(resolveAwsSecretsManagerRegion({ id: "aws-prod", awsProfile: "prod", awsRegion: "eu-central-1" }, env)).toBe("eu-central-1");
expect(resolveAwsSecretsManagerRegion({ id: "aws-prod", awsProfile: "prod" }, env)).toBe("us-west-2");
expect(resolveAwsSecretsManagerRegion({ id: "aws-default", awsProfile: "default" }, env)).toBe("us-east-2");
expect(resolveAwsSecretsManagerRegion({ id: "aws-fallback", awsProfile: "fallback" }, env)).toBe("ap-south-1");
expect(resolveAwsSecretsManagerRegion({ id: "aws-env", awsProfile: "missing" }, {
...env,
AWS_REGION: "ca-central-1",
})).toBe("ca-central-1");
expect(() => resolveAwsSecretsManagerRegion({ id: "aws-missing", awsProfile: "missing" }, env)).toThrow("has no region");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("AWS Secrets Manager provider reads SecretString by exact secret id", async () => {
const profiles: string[] = [];
const clientConfigs: { readonly region: string }[] = [];
const commands: unknown[] = [];
const provider = new AwsSecretsManagerSecretProvider(
{ id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" },
{
credentialFactory: (profile) => {
profiles.push(profile);
return { accessKeyId: "access", secretAccessKey: "secret" };
},
clientFactory: (clientConfig) => {
clientConfigs.push({ region: clientConfig.region });
return {
send: (command) => {
commands.push(command.input);
return Promise.resolve({ SecretString: "secret-value", $metadata: {} });
},
};
},
},
);

await expect(provider.read("bedrock-token-a1b2c3")).resolves.toBe("secret-value");
expect(profiles).toEqual(["prod"]);
expect(clientConfigs).toEqual([{ region: "us-east-1" }]);
expect(commands).toEqual([{ SecretId: "bedrock-token-a1b2c3" }]);
});

test("AWS Secrets Manager provider rejects ARN, binary, and missing string secrets", async () => {
const arnProvider = new AwsSecretsManagerSecretProvider(
{ id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" },
{
clientFactory: () => {
throw new Error("client should not be created for ARN references");
},
},
);
await expect(arnProvider.read("arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/key")).rejects.toThrow("not ARNs");

const binaryProvider = new AwsSecretsManagerSecretProvider(
{ id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" },
{
credentialFactory: () => ({ accessKeyId: "access", secretAccessKey: "secret" }),
clientFactory: () => ({
send: () => Promise.resolve({ SecretBinary: new Uint8Array([1]), $metadata: {} }),
}),
},
);
await expect(binaryProvider.read("binary-secret-a1b2c3")).rejects.toThrow("SecretBinary");

const missingProvider = new AwsSecretsManagerSecretProvider(
{ id: "aws-prod", awsProfile: "prod", awsRegion: "us-east-1" },
{
credentialFactory: () => ({ accessKeyId: "access", secretAccessKey: "secret" }),
clientFactory: () => ({
send: () => Promise.resolve({ $metadata: {} }),
}),
},
);
await expect(missingProvider.read("missing-secret-a1b2c3")).rejects.toThrow("returned no SecretString");
});

test("macOS Keychain provider reads generic passwords", async () => {
const calls: { command: string; args: readonly string[]; allowFailure: boolean | undefined }[] = [];
const provider = new MacOsKeychainSecretProvider("macos-keychain", (command, args, options) => {
Expand Down Expand Up @@ -394,6 +538,18 @@ describe("VM and network providers", () => {
expect(providers.secrets.ids).toEqual(["macos-keychain"]);
});

test("factory registers configured AWS Secrets Manager providers", () => {
const config = buildConfig("/repo", {
[AWS_SECRETS_MANAGER_PROVIDERS_ENV]: JSON.stringify({
"aws-prod": { aws_profile: "prod", aws_region: "us-east-1" },
"aws-dev": { aws_profile: "dev" },
}),
}, fakeInstance("dev"));
const providers = createProviderBundle(config, ignoreLog);

expect(providers.secrets.ids).toEqual(["macos-keychain", "aws-prod", "aws-dev"]);
});

test("macOS Lima user-v2 provider exposes egress firewall and private-only agent attachments", () => {
const config = buildConfig("/repo", {}, fakeInstance("dev"));
const plan = new MacOsLimaUserV2NetworkProvider(config, ignoreLog).plan();
Expand Down
2 changes: 2 additions & 0 deletions src/rootcell/rootcell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { commandExists, runCapture, runInherited } from "./process.ts";
import { createProviderBundle } from "./providers/factory.ts";
import type { NetworkPlan, ProviderBundle, VmNetworkAttachment, VmStatus } from "./providers/types.ts";
import { parseSchema } from "./schema.ts";
import { parseAwsSecretsManagerProviderConfigs } from "./secrets/aws-secrets-manager-config.ts";
import { RootcellConfigSchema, type RootcellConfig, type RootcellInstance, type SpyOptions, type VmFileSet } from "./types.ts";

const GUEST_USER = "luser";
Expand Down Expand Up @@ -114,6 +115,7 @@ export function buildConfig(repoDir: string, env: NodeJS.ProcessEnv, instance: R
networkPrefix: String(instance.state.networkPrefix),
imageManifestUrl: env.ROOTCELL_IMAGE_MANIFEST_URL ?? DEFAULT_IMAGE_MANIFEST_URL,
...(env.ROOTCELL_IMAGE_DIR === undefined || env.ROOTCELL_IMAGE_DIR.length === 0 ? {} : { imageDir: env.ROOTCELL_IMAGE_DIR }),
awsSecretsManagerProviders: parseAwsSecretsManagerProviderConfigs(env),
}, `invalid rootcell config for ${instance.name}`);
}

Expand Down
Loading