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
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,18 @@ non-interactively. `--help` on any command lists its subcommands and flags, and

Global flags, available on every command:

| Flag | Purpose |
| ---------- | ------------------------------------------------------------------------------------------------------------------- |
| `--region` | AWS region. Resolved from this flag, then `AWS_REGION`, `AWS_DEFAULT_REGION`, the active profile, then `us-east-1`. |
| `--json` | Emit JSON instead of opening the TUI. |
| `--debug` | Debug logging. |
| Flag | Purpose |
| ----------- | ------------------------------------------------------------------------------------------------------------------- |
| `--region` | AWS region. Resolved from this flag, then `AWS_REGION`, `AWS_DEFAULT_REGION`, the active profile, then `us-east-1`. |
| `--profile` | Named AWS shared configuration profile. Overrides `AWS_PROFILE` and ambient static credentials for this command. |
| `--json` | Emit JSON instead of opening the TUI. |
| `--debug` | Debug logging. |

For example, `agentcore --profile development harness list --json` uses the `development`
profile's credentials and region. `--region` and the region environment variables still take
precedence over the profile's region. Profile selection also applies to deployment and child
tools; it does not modify your shell environment. Without `--profile`, the usual AWS credential
chain is unchanged. Configure and sign in to SSO profiles with the AWS CLI before using them.

## Extending the CDK app

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions command.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ the platform for production AI agents
**Options**

- `--region <region>`: AWS region
- `--profile <profile>`: AWS shared configuration profile
- `--debug`: debug logging (default: false)
- `--json`: JSON output (default: false)
- `-V, --version`: display the CLI version
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@aws-sdk/client-s3": "^3.1121.0",
"@aws-sdk/client-sts": "^3.1092.0",
"@aws-sdk/client-xray": "^3.1092.0",
"@aws-sdk/credential-providers": "^3.1121.0",
"@aws-sdk/util-endpoints": "^3.996.44",
"@aws/agent-inspector": "0.6.2",
"@opentelemetry/api": "^1.9.1",
Expand Down
192 changes: 192 additions & 0 deletions src/awsProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { awsProfileEnvironment, selectedAwsProfile, withAwsProfile } from "./awsProfile";
import { CoreClient } from "./core";
import * as factories from "./core/factories";
import { createSilentLogger } from "./testing";
import { runProcess } from "./io";
import { createCdkIoHost, resolveCdkCredentials } from "./core/project/backends/cdk/toolkit";
import { resolveAwsAccount } from "./core/project/backends/cdk/environment";

const keys = [
"AWS_CONFIG_FILE",
"AWS_SHARED_CREDENTIALS_FILE",
"AWS_PROFILE",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
] as const;
let saved: NodeJS.ProcessEnv;
let directory: string;
beforeEach(async () => {
saved = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
directory = await mkdtemp(join(tmpdir(), "agentcore-profile-"));
process.env.AWS_CONFIG_FILE = join(directory, "config");
process.env.AWS_SHARED_CREDENTIALS_FILE = join(directory, "credentials");
process.env.AWS_PROFILE = "ambient";
process.env.AWS_ACCESS_KEY_ID = "environment-key";
process.env.AWS_SECRET_ACCESS_KEY = "environment-secret";
await writeFile(
process.env.AWS_CONFIG_FILE,
"[profile first]\nregion = eu-west-1\n[profile second]\nregion = ap-south-1\n",
);
await writeFile(
process.env.AWS_SHARED_CREDENTIALS_FILE,
"[first]\naws_access_key_id = first-key\naws_secret_access_key = first-secret\n" +
"[second]\naws_access_key_id = second-key\naws_secret_access_key = second-secret\n",
);
});
afterEach(async () => {
for (const key of keys) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
await rm(directory, { recursive: true, force: true });
});

describe("explicit AWS profiles", () => {
test("selects profile credentials over ambient profile and static environment keys", async () => {
await withAwsProfile("first", async () => {
expect((await selectedAwsProfile()!.credentials()).accessKeyId).toBe("first-key");
});
expect(process.env.AWS_PROFILE).toBe("ambient");
expect(process.env.AWS_ACCESS_KEY_ID).toBe("environment-key");
expect(selectedAwsProfile()).toBeUndefined();
});

test("an unknown profile cannot fall back to environment credentials", async () => {
await expect(
withAwsProfile("missing", () => selectedAwsProfile()!.credentials()),
).rejects.toThrow();
expect(selectedAwsProfile()).toBeUndefined();
});

test("loads temporary credentials from a credential_process profile", async () => {
const script = join(directory, "credentials.cjs");
await writeFile(
script,
`process.stdout.write(JSON.stringify({ Version: 1, AccessKeyId: "process-key", SecretAccessKey: "process-secret", SessionToken: "process-token", Expiration: "2099-01-01T00:00:00Z" }));`,
);
await writeFile(
process.env.AWS_CONFIG_FILE!,
`[profile process]\ncredential_process = "${process.execPath}" "${script}"\n`,
);
await withAwsProfile("process", async () => {
expect(await selectedAwsProfile()!.credentials()).toMatchObject({
accessKeyId: "process-key",
secretAccessKey: "process-secret",
sessionToken: "process-token",
});
});
});

test("STS account lookup signs with the selected profile and honors explicit credentials", async () => {
const authorization: string[] = [];
const server = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization") ?? "");
return new Response(
'<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/"><GetCallerIdentityResult><Account>123456789012</Account></GetCallerIdentityResult></GetCallerIdentityResponse>',
{ headers: { "content-type": "text/xml" } },
);
},
});
const savedEndpoint = process.env.AWS_ENDPOINT_URL_STS;
process.env.AWS_ENDPOINT_URL_STS = `http://localhost:${server.port}`;
try {
await withAwsProfile("first", async () => {
expect(await resolveAwsAccount("us-east-1")).toBe("123456789012");
expect(
await resolveAwsAccount("us-east-1", async () => ({
accessKeyId: "target-key",
secretAccessKey: "target-secret",
})),
).toBe("123456789012");
});
expect(authorization[0]).toContain("Credential=first-key/");
expect(authorization[1]).toContain("Credential=target-key/");
} finally {
if (savedEndpoint === undefined) delete process.env.AWS_ENDPOINT_URL_STS;
else process.env.AWS_ENDPOINT_URL_STS = savedEndpoint;
server.stop(true);
}
});

test("nested and concurrent commands retain their own selection", async () => {
await withAwsProfile("first", async () => {
await expect(
withAwsProfile("second", async () => {
expect(selectedAwsProfile()!.name).toBe("second");
throw new Error("command failed");
}),
).rejects.toThrow("command failed");
expect(selectedAwsProfile()!.name).toBe("first");
});
const credentials = await Promise.all(
["first", "second"].map((name) =>
withAwsProfile(name, async () => {
await Promise.resolve();
expect(selectedAwsProfile()!.name).toBe(name);
return (await selectedAwsProfile()!.credentials()).accessKeyId;
}),
),
);
expect(credentials).toEqual(["first-key", "second-key"]);
expect(selectedAwsProfile()).toBeUndefined();
});

test("SDK caches distinguish profiles and preserve explicit target credentials", async () => {
const core = new CoreClient({ ...factories, logger: createSilentLogger() });
const first = await withAwsProfile("first", async () => {
const client = core.control({ region: "us-east-1" });
expect(core.control({ region: "us-east-1" })).toBe(client);
expect((await client.config.credentials()).accessKeyId).toBe("first-key");
return client;
});
await withAwsProfile("second", async () => {
const client = core.control({ region: "us-east-1" });
expect(client).not.toBe(first);
expect((await client.config.credentials()).accessKeyId).toBe("second-key");
const target = core.control({
region: "us-east-1",
credentials: { accessKeyId: "target-key", secretAccessKey: "target-secret" },
});
expect((await target.config.credentials()).accessKeyId).toBe("target-key");
const deploymentProvider = await resolveCdkCredentials(
createCdkIoHost(createSilentLogger()),
"us-east-1",
);
expect(deploymentProvider).toBe(selectedAwsProfile()!.credentials);
expect((await deploymentProvider()).accessKeyId).toBe("second-key");
});
});

test("child tools receive the profile without inherited static credentials", async () => {
let output = "";
await withAwsProfile("first", () =>
runProcess(
[
process.execPath,
"-e",
"process.stdout.write(JSON.stringify({profile:process.env.AWS_PROFILE,defaultProfile:process.env.AWS_DEFAULT_PROFILE,key:process.env.AWS_ACCESS_KEY_ID,token:process.env.AWS_SESSION_TOKEN,marker:process.env.MARKER}))",
],
{
cwd: directory,
env: { ...process.env, AWS_SESSION_TOKEN: "ambient-token", MARKER: "retained" },
onOutput: (chunk) => {
output += chunk;
},
},
),
);
expect(JSON.parse(output)).toEqual({
profile: "first",
defaultProfile: "first",
marker: "retained",
});
const ambient = { AWS_PROFILE: "ambient", AWS_ACCESS_KEY_ID: "ambient-key" };
expect(awsProfileEnvironment(ambient)).toBe(ambient);
});
});
38 changes: 38 additions & 0 deletions src/awsProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { fromIni } from "@aws-sdk/credential-providers";
import type { AwsCredentialIdentityProvider } from "@smithy/types";

type SelectedProfile = { name: string; credentials: AwsCredentialIdentityProvider };
const profiles = new AsyncLocalStorage<SelectedProfile>();

/** Keep profile selection local to a command, including its asynchronous work. */
export function withAwsProfile<T>(name: string, action: () => T, region?: string): T {
return profiles.run(
{ name, credentials: fromIni({ profile: name, clientConfig: { region } }) },
action,
);
}

export function selectedAwsProfile(): SelectedProfile | undefined {
return profiles.getStore();
}

/** Child tools use the selected profile instead of inherited static credentials. */
export function awsProfileEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const profile = selectedAwsProfile();
if (!profile) return env;
const selected: NodeJS.ProcessEnv = {
...env,
AWS_PROFILE: profile.name,
AWS_DEFAULT_PROFILE: profile.name,
};
for (const key of [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_SECURITY_TOKEN",
]) {
delete selected[key];
}
return selected;
}
64 changes: 64 additions & 0 deletions src/core/dev/container.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { withAwsProfile } from "../../awsProfile";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -268,6 +269,69 @@ describe("ContainerDevRunner", () => {
expect(missing.calls).toHaveLength(0);
});

test("an explicit profile mounts custom shared files outside the AWS directory", async () => {
const projectRuntime = runtime();
const root = await projectRoot(projectRuntime);
const config = join(root, "custom-config");
const credentials = join(root, "custom-credentials");
await writeFile(config, "[profile selected]\nregion = eu-west-1\n");
await writeFile(
credentials,
"[selected]\naws_access_key_id = selected-key\naws_secret_access_key = selected-secret\n",
);
const { calls, runner } = harness({
processEnv: { AWS_CONFIG_FILE: config, AWS_SHARED_CREDENTIALS_FILE: credentials },
});
await withAwsProfile("selected", () => collect(runner.run(input(root, projectRuntime))));
const run = calls.find((call) => call.command[1] === "run")!;
expect(run.command).toContain(`${config}:/aws-profile/config:ro`);
expect(run.command).toContain(`${credentials}:/aws-profile/credentials:ro`);
expect(parseEnv(run.envFile!.contents)).toMatchObject({
AWS_PROFILE: "selected",
AWS_CONFIG_FILE: "/aws-profile/config",
AWS_SHARED_CREDENTIALS_FILE: "/aws-profile/credentials",
});
await expect(
withAwsProfile("selected", () =>
collect(
harness({ processEnv: { AWS_CONFIG_FILE: join(root, "missing") } }).runner.run(
input(root, projectRuntime),
),
),
),
).rejects.toThrow("AWS_CONFIG_FILE does not reference");
});

test("an explicit profile overrides ambient keys and profile in the container", async () => {
const projectRuntime = runtime();
const root = await projectRoot(projectRuntime);
const awsDirectory = join(root, ".aws");
await mkdir(awsDirectory);
const { calls, runner } = harness({
awsDirectory,
processEnv: {
AWS_PROFILE: "ambient",
AWS_ACCESS_KEY_ID: "ambient-key",
AWS_SECRET_ACCESS_KEY: "ambient-secret",
},
});
await withAwsProfile("selected", () => collect(runner.run(input(root, projectRuntime))));
expect(parseEnv(commandCall(calls, "run").envFile!.contents)).toMatchObject({
AWS_PROFILE: "selected",
});
expect(parseEnv(commandCall(calls, "run").envFile!.contents)).not.toHaveProperty(
"AWS_ACCESS_KEY_ID",
);
expect(parseEnv(commandCall(calls, "run").envFile!.contents)).not.toHaveProperty(
"AWS_SECRET_ACCESS_KEY",
);
const missing = harness({ awsDirectory: join(root, "missing") });
await expect(
withAwsProfile("selected", () => collect(missing.runner.run(input(root, projectRuntime)))),
).rejects.toBeInstanceOf(InvalidEnvironmentError);
expect(missing.calls).toHaveLength(0);
});

test("preserves an existing build context .dockerignore", async () => {
const projectRuntime = runtime({ buildContextPath: "." });
const root = await projectRoot(projectRuntime);
Expand Down
Loading
Loading