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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 src/assets/cdk/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,7 +24,7 @@
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"aws-cdk-lib": "~2.261.0",
"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
}
77 changes: 77 additions & 0 deletions src/core/project/envLocal.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { afterEach, expect, test } from "bun:test";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { parseEnv } from "node:util";
import { EnvLocalFile } from "./envLocal";

const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((r) => rm(r, { recursive: true, force: true })));
});

async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "envlocal-"));
roots.push(root);
// Real projects always have the agentcore/ dir; the class does not create it.
await mkdir(dirname(new EnvLocalFile(root).path), { recursive: true });
return root;
}

const ENTRY = { key: "SECRET", value: "v", comment: "c" };

test("rollback deletes the file it created", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([ENTRY]);
expect(existsSync(file.path)).toBe(true);

await file.rollback();
expect(existsSync(file.path)).toBe(false);
});

test("rollback restores the prior content of an existing file", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "EXISTING=1\n");

await file.insertIfNew([ENTRY]);
expect(await Bun.file(file.path).text()).toContain("SECRET='v'");

await file.rollback();
expect(await Bun.file(file.path).text()).toBe("EXISTING=1\n");
});

test("rollback is a no-op when insertIfNew wrote nothing", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await Bun.write(file.path, "SECRET=kept\n");

await file.insertIfNew([ENTRY]); // key already present, so nothing is written
await file.rollback();
expect(await Bun.file(file.path).text()).toBe("SECRET=kept\n");
});

test.each([
["left#right", "left#right"],
[" padded ", " padded "],
['has"double', 'has"double'],
["back\\slash", "back\\slash"],
["dollar$sign", "dollar$sign"],
])("a value with %p round-trips through parseEnv", async (value, expected) => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await file.insertIfNew([{ key: "SECRET", value, comment: "c" }]);

const parsed = parseEnv(await Bun.file(file.path).text()) as Record<string, string>;
expect(parsed.SECRET).toBe(expected);
});

test("rejects a value that contains a single quote", async () => {
const root = await tempRoot();
const file = new EnvLocalFile(root);
await expect(file.insertIfNew([{ key: "SECRET", value: "a'b", comment: "c" }])).rejects.toThrow(
/single quote/,
);
});
97 changes: 97 additions & 0 deletions src/core/project/envLocal.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { atomicWrite, readTextFile } from "../../io";
import { InputValidationError } from "../../errors";
import type { EnvLocalEntry } from "../../handlers/project/types";

/** The project-relative path of the local secrets file (read by `agentcore dev`). */
export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local");

const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;

/**
* The project's `.env.local` secrets file, edited transactionally. `insertIfNew`
* appends entries (never overwriting an existing key) and snapshots the prior
* state so `rollback` can undo the write if a later step in the same operation
* fails. Mirrors the class shape of {@link SourceResolver} so callers hold one
* object and reverse its effect, rather than tracking loose paths.
*/
export class EnvLocalFile {
// undefined: no write yet; null: file did not exist before the write;
// string: the file's content before the write.
private snapshot?: string | null;

constructor(private readonly rootPath: string) {}

/** The absolute path of the secrets file. */
get path(): string {
return join(this.rootPath, ENV_LOCAL_RELATIVE_PATH);
}

/**
* Appends entries, creating the file when missing. Keys that already exist
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {
const existing = await this.readOrNull();
const existingKeys = new Set(
(existing ?? "")
.split("\n")
.map((line) => KEY_LINE.exec(line)?.[1])
.filter((key) => key !== undefined),
);

const written: string[] = [];
const skipped: string[] = [];
let content = existing ?? "";
for (const entry of entries) {
if (existingKeys.has(entry.key)) {
skipped.push(entry.key);
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Leave a code comment show the format if you can.
// # <entry.comment>
<entry.key>=<entry.value>

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// Each entry is two lines: # <comment>\n<key>=<value>
content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`;
written.push(entry.key);
}

if (written.length > 0) {
this.snapshot = existing;
await atomicWrite(this.path, content);
}
return { written, skipped };
}

/** Restores the file to its pre-write state; a no-op when nothing was written. */
async rollback(): Promise<void> {
if (this.snapshot === undefined) return;
if (this.snapshot === null) await rm(this.path, { force: true });
else await atomicWrite(this.path, this.snapshot);
}

private async readOrNull(): Promise<string | null> {
try {
return await readTextFile(this.path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
}

/**
* Single-quotes a value so `node:util`'s `parseEnv` reads it back byte-for-byte.
* Single quotes are literal in that parser, so no character needs escaping,
* except a single quote itself, which the format cannot represent.
*/
function formatValue(value?: string): string {
if (!value) return "";
if (value.includes("'")) {
throw new InputValidationError(
"a secret value that contains a single quote (') cannot be written to " +
".env.local; supply it with a Secrets Manager reference instead",
);
}
return `'${value}'`;
}
56 changes: 43 additions & 13 deletions src/core/project/manager.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@ import {
type ReadWriteJson,
} from "../../io";
import { defaultSource, type AssetSource } from "./source";
import { ENV_LOCAL_RELATIVE_PATH, EnvLocalFile } from "./envLocal";
import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates";
import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project";
import { enclosingProjectRoot } from "./fsUtils";
Expand DownExpand Up@@ -151,8 +152,11 @@ export class FsProjectManager implements ProjectManager {
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

// Widened: arms push their own shapes; the whole-spec safeParse below validates.
const newResources: unknown[] = [...existingResources];
const scaffoldedPaths: string[] = [];
// Non-file work that a failed spec write must also reverse.
let envFile: EnvLocalFile | undefined;

switch (resourceType) {
case "harness": {
Expand All@@ -172,6 +176,22 @@ export class FsProjectManager implements ProjectManager {
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
case "credential": {
// No file scaffolding; the secret placeholder is staged into .env.local
// and reversed with the spec write if that commit fails.
newResources.push(input.resourceConfig);
if (input.envEntries?.length) {
envFile = new EnvLocalFile(project.rootPath);
yield { message: `Updating secrets file at '${envFile.path}'` };
const { skipped } = await envFile.insertIfNew(input.envEntries);
for (const key of skipped) {
yield {
message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`,
};
}
}
break;
}
case "config-bundle":
case "online-eval":
case "online-insight":
Expand All@@ -186,37 +206,45 @@ export class FsProjectManager implements ProjectManager {

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };

// Validate and write inside the same boundary so a rejected spec rolls back
// staged side effects (.env.local, scaffolded files) rather than leaving them.
let newProjectSpec: z.infer<typeof ProjectSpecSchema>;
try {
const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources };
const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec);

if (!newSpecParseResult.success)
throw new InputValidationError(z.prettifyError(newSpecParseResult.error), {
cause: newSpecParseResult.error,
});
const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);

return {
...project,
spec: newProjectSpec,
};
newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data);
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
`could not commit the spec update to ${agentCoreSpecPath}; attempting best-effort cleanup of staged changes`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
await Promise.all([
...scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
envFile?.rollback().catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to roll back ${ENV_LOCAL_RELATIVE_PATH}`);
}),
]);
throw err;
}

return {
...project,
spec: newProjectSpec,
};
}

private getProjectSpecPath(project: Project): string {
Expand DownExpand Up@@ -306,6 +334,8 @@ function toProjectSpecKey(resourceType: ProjectResource) {
return "harnesses";
case "runtime":
return "runtimes";
case "credential":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side Note: I really want to abstract this.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it for now. The switch is exhaustive over ProjectResource, and the inferred return type is what keeps unrelated spec keys (name, managedBy) from leaking in, which the doc comment above it calls out. An abstraction here would need to preserve that per-case return typing to earn its place, so I would rather keep the direct mapping until a second use appears.

return "credentials";
case "config-bundle":
return "configBundles";
case "online-eval":
Expand Down
56 changes: 56 additions & 0 deletions src/handlers/project/add/credentials/api-key/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
import z from "zod";
import { createHandler, flag } from "../../../../../router";
import { InputValidationError } from "../../../../../errors";
import { SourceResolver } from "../../../../../io";
import type { AddProjectResourceConfig } from "../../types";
import type { EnvLocalEntry } from "../../../types";
import { addCredentialToProject, credentialEnvVarName, parseExclusiveSecretRef } from "../shared";

export const createAddApiKeyCredentialHandler = (config: AddProjectResourceConfig) =>
createHandler({
name: "api-key",
description: "add an API key credential provider to the current project",
flags: [
flag("name", "the name of the credential provider", z.string().optional()),
flag(
"api-key",
"the API key (file://path or - for stdin; inline values are rejected)",
z.string().optional(),
{ sensitive: true },
),
flag(
"api-key-secret-reference",
'external secret reference JSON: {"secretId":"<arn>","jsonKey":"<key>"}',
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
throw new InputValidationError("required option '--name <name>' not specified");

const secretRef = parseExclusiveSecretRef(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we combine these flags because they do the same thing and only one of them can be used?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two flags carry different meanings, so I kept them apart. --api-key takes the secret value itself (from stdin or a file) and the CLI stores it in .env.local. --api-key-secret-reference names a secret the caller already keeps in Secrets Manager and stores no value. parseExclusiveSecretRef makes sure only one is given. Folding them into one flag would force the CLI to guess whether the argument is a reference or a raw secret, and that ambiguity is how a real secret ends up read as a reference or the reverse.

"api-key-secret-reference",
flags["api-key-secret-reference"],
"api-key",
flags["api-key"],
);

const resolver = new SourceResolver({ stdin: config.io.stdin });
const apiKey = await resolver.resolveSecret("api-key", flags["api-key"]);

const envEntries: EnvLocalEntry[] = secretRef
? []
: [
{
key: credentialEnvVarName(flags.name),
value: apiKey,
comment: `API key for credential provider '${flags.name}' (set before deploy)`,
},
];

await addCredentialToProject(ctx, config, {
resourceConfig: { authorizerType: "ApiKeyCredentialProvider", name: flags.name, secretRef },
envEntries,
});
},
});
14 changes: 14 additions & 0 deletions src/handlers/project/add/credentials/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
import { Router } from "../../../../router";
import type { AddProjectResourceConfig } from "../types";
import { createAddApiKeyCredentialHandler } from "./api-key";
import { createAddOauthCredentialHandler } from "./oauth";

export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we continue to match the command tree with the file tree + 1:1 of file to handler pattern?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

const credentials = new Router(
"credentials",
"add AgentCore Identity credential providers to the current project",
);
credentials.handler(createAddApiKeyCredentialHandler(config));
credentials.handler(createAddOauthCredentialHandler(config));
return credentials;
}
Loading
Loading