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
2 changes: 1 addition & 1 deletion .github/workflows/release-main-and-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:
registry-url: https://registry.npmjs.org
# npm trusted publishing needs npm 11.5.1 or newer, Node 20 bundles npm 10.
- run: npm install -g npm@11
- run: bun install --frozen-lockfile
- run: bun install --frozen-lockfile --os="*" --cpu="*"

- name: Resolve release
id: release
Expand Down
29 changes: 29 additions & 0 deletions bun.lock

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

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@
"yaml": "^2.8.1",
"zod": "^4.4.3"
},
"optionalDependencies": {
"@napi-rs/keyring": "2.1.1"
},
"overrides": {
"@opentelemetry/core": "^2.10.0",
"@aws-cdk/toolkit-lib": {
Expand Down
5 changes: 3 additions & 2 deletions scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
import { chmod } from "node:fs/promises";
import { join, resolve } from "node:path";
import { runWithExitCode } from "../src/runnable";
import { nativeKeyringPlugin } from "./keyring";

const REPO_ROOT = resolve(import.meta.dir, "..");
const ASSETS_DIR = join(REPO_ROOT, "src", "assets");
Expand All @@ -23,7 +24,7 @@ const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]";
// __dirname to the *build machine's* path, which resolves on the builder and
// fails everywhere else. Binaries therefore have to hand the Toolkit an
// explicit copy of anything it would otherwise read from its package.
const EXTERNAL = ["@aws-cdk/toolkit-lib"];
const EXTERNAL = ["@aws-cdk/toolkit-lib", "@napi-rs/keyring"];

const BOOTSTRAP_TEMPLATE = ["lib", "api", "bootstrap", "bootstrap-template.yaml"];

Expand Down Expand Up @@ -202,7 +203,7 @@ async function compile(target: string): Promise<void> {
define: DEFINE,
root: REPO_ROOT,
naming: { asset: ASSET_NAMING },
plugins: [assetLoaderPlugin(), runtimeShellSdkPlugin()],
plugins: [assetLoaderPlugin(), runtimeShellSdkPlugin(), nativeKeyringPlugin(target)],
});
await assertTemplateIsEmbedded(outfile, template);
console.log(
Expand Down
44 changes: 44 additions & 0 deletions scripts/keyring.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { expect, test } from "bun:test";
import { mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { keyringBindingPath, nativeKeyringPlugin } from "./keyring";

test("compiled keyring loads without an adjacent node_modules directory", async () => {
const root = await mkdtemp(join(tmpdir(), "agentcore-keyring-binary-"));
try {
const entry = join(root, "entry.ts"),
binary = join(root, process.platform === "win32" ? "probe.exe" : "probe");
const modulePath = join(import.meta.dir, "..", "src", "io", "keyring.ts");
await writeFile(
entry,
"import { loadKeyring } from " +
JSON.stringify(modulePath) +
';\nif (typeof loadKeyring().Entry !== "function") throw new Error("Missing native Entry");\nconsole.log("embedded-keyring");\n',
);
const platform = process.platform === "win32" ? "windows" : process.platform;
const target = "bun-" + platform + "-" + process.arch;
await Bun.build({
entrypoints: [entry],
compile: { outfile: binary },
plugins: [nativeKeyringPlugin(target)],
});
const binding = keyringBindingPath(target);
const hidden = binding + ".probe-hidden";
await rename(binding, hidden);
try {
const child = Bun.spawn([binary], { cwd: root, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, status] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
expect({ status, stderr }).toEqual({ status: 0, stderr: "" });
expect(stdout.trim()).toBe("embedded-keyring");
} finally {
await rename(hidden, binding);
}
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30000);
38 changes: 38 additions & 0 deletions scripts/keyring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { dirname, join } from "node:path";

const REPO_ROOT = join(import.meta.dir, "..");

const TARGETS: Record<string, string> = {
"bun-darwin-x64": "darwin-x64",
"bun-darwin-arm64": "darwin-arm64",
"bun-linux-x64": "linux-x64-gnu",
"bun-linux-arm64": "linux-arm64-gnu",
"bun-windows-x64": "win32-x64-msvc",
"bun-windows-arm64": "win32-arm64-msvc",
};

export function keyringBindingPath(target: string): string {
const platform = TARGETS[target];
if (!platform) throw new Error("Unsupported keyring target: " + target);
try {
const manifest = Bun.resolveSync("@napi-rs/keyring-" + platform + "/package.json", REPO_ROOT);
return join(dirname(manifest), "keyring." + platform + ".node");
} catch (cause) {
throw new Error("Missing keyring binding for " + target + ". Run bun install --os=* --cpu=*.", {
cause,
});
}
}

export function nativeKeyringPlugin(target: string): Bun.BunPlugin {
const path = keyringBindingPath(target);
return {
name: "embedded-keyring",
setup(build) {
build.onLoad({ filter: /[/\\]io[/\\]keyring\.ts$/ }, () => ({
contents: "export function loadKeyring() { return require(" + JSON.stringify(path) + "); }",
loader: "js",
}));
},
};
}
23 changes: 21 additions & 2 deletions src/core/project/backends/cdk/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,16 @@ describe("createCredentialProvisioner", () => {

test("creates an API key provider under the target-scoped name, recorded under the spec name", async () => {
const subject = account();
const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n");
const input = await project([API_KEY]);
const file = new EnvLocalFile(input.rootPath);
await file.insertIfNew([
{ key: "AGENTCORE_CREDENTIAL_OPENAI_KEY", value: "sk-live", comment: "API key" },
]);
expect(await Bun.file(file.path).text()).not.toContain("sk-live");
await writeFile(
file.path,
(await Bun.file(file.path).text()) + "SOME_API_KEY=enc:v1:unrelated\n",
);

const { events, result } = await run(subject.provision, input);

Expand All @@ -359,6 +368,13 @@ describe("createCredentialProvisioner", () => {
});
});

test("refuses an unreadable encrypted secret before calling Identity", async () => {
const subject = account();
const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='enc:v1:broken'\n");
await expect(run(subject.provision, input)).rejects.toThrow(/Could not decrypt/);
expect(subject.optionsSeen).toEqual([]);
});

test("creates an API key provider from a Secrets Manager reference", async () => {
const secretRef = { secretId: "prod/openai", jsonKey: "apiKey" };
const subject = account();
Expand Down Expand Up @@ -391,7 +407,10 @@ describe("createCredentialProvisioner", () => {
{},
{ AGENTCORE_CREDENTIAL_OPENAI_KEY: "sk-from-env", HOME: "/should/not/matter" },
);
const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-from-file'\n");
const input = await project(
[API_KEY],
"AGENTCORE_CREDENTIAL_OPENAI_KEY=enc:v1:broken\nSOME_API_KEY=enc:v1:unrelated\n",
);

await run(subject.provision, input);

Expand Down
18 changes: 14 additions & 4 deletions src/core/project/backends/cdk/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,20 @@ export function createCredentialProvisioner(

// Credential variables set in the process environment win over the file, so a
// deploy can be handed its secrets without writing them to disk first.
const env = {
...(await new EnvLocalFile(project.rootPath).read()),
...credentialEnvironment(processEnv),
};
const needed = new Set(declared.flatMap(credentialEnvironmentVariableNames));
for (const credential of declared) {
if (
credential.authorizerType === "OAuthCredentialProvider" &&
credential.clientId === undefined &&
credential.providerConfig === undefined
) {
needed.add(credentialEnvVarName(credential.name, "_CLIENT_ID"));
}
}
const env = await new EnvLocalFile(project.rootPath).read({
select: (name) => needed.has(name),
overrides: credentialEnvironment(processEnv),
});
// Every Identity call runs against the deployment target's own credentials
// rather than the default chain, in the region the target deploys to.
const options: CoreOptions = { region, credentials };
Expand Down
75 changes: 75 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";
import { ENC_PREFIX } from "../../secrets/cipher";

const roots: string[] = [];
afterEach(async () => {
Expand Down Expand Up @@ -176,3 +177,77 @@ testPosix("removeKeys keeps owner-only permissions on the rewritten file", async

expect((await stat(file.path)).mode & 0o777).toBe(0o600);
});

test("encrypts API, OAuth and payment secrets while leaving identifiers and placeholders readable", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
const values = {
AGENTCORE_CREDENTIAL_MODEL: "model-value",
AGENTCORE_CREDENTIAL_OAUTH_CLIENT_SECRET: "oauth-value",
AGENTCORE_CREDENTIAL_PAY_API_KEY_SECRET: "payment-value",
AGENTCORE_CREDENTIAL_PAY_WALLET_SECRET: "wallet-value",
AGENTCORE_CREDENTIAL_PAY_AUTHORIZATION_PRIVATE_KEY: "private-value",
AGENTCORE_CREDENTIAL_PAY_APP_SECRET: "app-value",
AGENTCORE_CREDENTIAL_PAY_API_KEY_ID: "identifier",
EMPTY_API_KEY: "",
};
await file.insertIfNew(
Object.entries(values).map(([key, value]) => ({ key, value, comment: key })),
);
const raw = await Bun.file(file.path).text();
const parsed = parseEnv(raw);
expect(await file.read()).toEqual(values);
for (const [key, value] of Object.entries(values)) {
if (key.endsWith("_ID") || value === "") expect(parsed[key]).toBe(value);
else {
expect(parsed[key]?.startsWith(ENC_PREFIX)).toBe(true);
expect(raw).not.toContain(value);
}
}
});

test("migrates legacy plaintext including multiline values without losing comments or unrelated content", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
const before =
"# user comment\nexport LEGACY_API_KEY = 'first\nsecond' # trailing comment\n" +
"OTHER=keep # other comment\n";
await Bun.write(file.path, before);
expect(await file.read()).toEqual({ LEGACY_API_KEY: "first\nsecond", OTHER: "keep" });
await file.insertIfNew([{ key: "ADDED", value: "yes", comment: "new entry" }]);
const raw = await Bun.file(file.path).text();
expect(raw).not.toContain("first\nsecond");
expect(raw).toContain("# user comment\n");
expect(raw).toContain("# trailing comment\nOTHER=keep # other comment\n");
expect(await file.read()).toEqual({
LEGACY_API_KEY: "first\nsecond",
OTHER: "keep",
ADDED: "yes",
});
await file.rollback();
expect(await Bun.file(file.path).text()).toBe(before);
});

test("preserves an existing secret and envelope when the key is skipped or a neighbor is removed", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([
{ key: "MODEL_API_KEY", value: "old-value", comment: "secret" },
{ key: "REMOVE", value: "other", comment: "neighbor" },
]);
const token = parseEnv(await Bun.file(file.path).text()).MODEL_API_KEY;
await file.insertIfNew([{ key: "MODEL_API_KEY", value: "new-value", comment: "ignored" }]);
await file.removeKeys(["REMOVE"]);
expect(parseEnv(await Bun.file(file.path).text()).MODEL_API_KEY).toBe(token);
expect(await file.read()).toEqual({ MODEL_API_KEY: "old-value" });
});

test("encrypts quote-bearing secrets before serializing them and migrates remaining secrets on removal", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "LEGACY_API_KEY=legacy-value\nREMOVE=1\n");
await file.removeKeys(["REMOVE"]);
expect((await Bun.file(file.path).text()).includes("legacy-value")).toBe(false);
await file.insertIfNew([{ key: "MODEL_API_KEY", value: "a'b\"c", comment: "secret" }]);
expect(await file.read()).toEqual({ LEGACY_API_KEY: "legacy-value", MODEL_API_KEY: "a'b\"c" });
});
Loading
Loading