feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17
, '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

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth) - #2046

Merged
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials
Aug 21, 2026
Merged

feat(project): add 'project add credentials' (AgentCore Identity api-key + oauth)#2046
aidandaly24 merged 11 commits into
refactorfrom
feat/project-add-credentials

Conversation

@tejaskash

@tejaskashtejaskash commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

What

Adds the project-spec authoring path for AgentCore Identity credential providers:

agentcore project add credentials api-key --name <name> [--api-key -|file://p] [--api-key-secret-reference <json>]
agentcore project add credentials oauth --name <name> [--discovery-url u --client-id c --scopes ...]
[--vendor V --provider-configuration <json>]
[--client-secret -|file://p] [--client-secret-reference <json>]
  • Custom OAuth uses guided flags. Vendored OAuth uses --vendor (a free string, so new vendors need no CLI release) plus a complete --provider-configuration JSON.
  • Secret flags accept only stdin (-) or file:// sources. Inline values are rejected so keys never reach shell history. A single trailing newline is stripped. Multi-line values are rejected.
  • An omitted secret writes a commented placeholder to agentcore/.env.local (AGENTCORE_CREDENTIAL_<NAME>[_CLIENT_SECRET]) and prints a fill-before-deploy notice. Existing keys are never overwritten.
  • Secrets Manager references ({"secretId","jsonKey"}) are recorded in the spec (secretRef / clientSecretRef) and skip .env.local.
  • Schema reshape in projectSchemas/credential.ts: providerConfig (rejects nested clientSecret / apiKey), clientId, secretRef / clientSecretRef. The usage field is dropped. Deep validation lives in the schema so every writer path enforces it.

Structure

  • Each leaf command is its own file (add/credentials/api-key, add/credentials/oauth), matching the command tree. Shared helpers live in add/credentials/shared.ts.
  • .env.local writes go through an EnvLocalFile class with upsert and rollback, so a failed spec write reverses the secrets file in the same cleanup path as scaffolded files.

Reuse

SourceResolver (stdin and file sources), parseSecretReference, and the identity oauth2 config.ts helpers (parseProviderConfigFlags, validateProviderConfigMode). buildProviderConfigInput is not used here, because SDK input construction happens at deploy time.

Not in this PR

  • Deploy-side work. project deploy already throws NotImplementedError. The lifecycle design (CFN-owned providers via the new CfnApiKeyCredentialProvider / CfnOAuth2CredentialProvider L1s, convergent pre and post-deploy secret sync from .env.local, hash tracking in deployed state) lands with deploy.
  • The @aws/agentcore-cdk credential construct and the schema mirror in that repo (L3 PR 333).
  • README. The project command tree is undocumented there today, so no partial documentation was added.
  • Migration for providers created imperatively by the current CLI (a name collision on first CFN create) is a deploy-task edge case.

Testing

  • Command-seam tests drive the real router, handler, FsProjectManager, and filesystem across the three credential shapes and the failure paths. Schema tests cover the reshape. EnvLocalFile has unit tests for rollback (delete when created, restore prior content, no-op when nothing written). Full suite: 1605 pass.
  • E2E on the built artifact (bun run build, node dist/index.js): a real project, all three credential shapes, and the inline-secret and duplicate-name failures exit non-zero with no partial writes.

OAuth credentials now model the two authoring paths: guided custom OAuth
(clientId, discoveryUrl, scopes) and vendored providers (free-form vendor +
complete providerConfig). providerConfig rejects nested secret material so
secrets can only travel via .env.local or Secrets Manager references, which
both credential types record as secretRef/clientSecretRef. The unused usage
field is dropped. This shape is the shared contract with @aws/agentcore-cdk
(mirror change lands on that repo's refactor branch).
Credentials are the first spec-only resource in FsProjectManager.addResource:
no scaffolding, the spec entry lands in agentcore.json and secret values or
commented placeholders land in agentcore/.env.local (existing keys are never
overwritten, and a missing .gitignore guard for .env.local is restored).
Secret flags accept only stdin ('-') or file:// sources; inline values are
rejected so keys stay out of shell history. External Secrets Manager
references skip .env.local entirely and are recorded in the spec.
…path
The standalone newline test duplicated the file-secret pipe end to end; the
combined test feeds an echo-style newline-terminated file and asserts the
stored value, covering both behaviors in one test.
Inline the single-caller placeholder notice, extract the duplicated
secret-reference exclusivity parse shared by both leaves, and simplify the
envEntries guard.
@github-actionsgithub-actionsBot added the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
@codecov-commenter

codecov-commenter commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.90710% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.21%. Comparing base (b566b33) to head (7e67b71).

Files with missing linesPatch %Lines
src/core/project/manager.tsx88.57%4 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## refactor #2046 +/- ##
============================================
+ Coverage 97.19% 97.21% +0.02% 
============================================
Files 389 394 +5 Lines 23415 23757 +342 ============================================
+ Hits 22758 23096 +338 - Misses 657 661 +4 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

E2E verification of 'project build' against the published @aws/agentcore-cdk
found a contract drift: its ConfigIO requires credential names of 3-255
characters while the CLI allowed 1-128, so a 2-character name passed 'add'
and then broke synth. Tighten the CLI to the intersection (3-128, service
character set) until the L3 schema aligns with the service's 1-128 rule.
@tejaskash

Copy link
Copy Markdown
ContributorAuthor

Additional E2E verification: built the artifact and ran 'project build' (real npm install + CDK synth via published @aws/agentcore-cdk 0.1.0-alpha.45) with all three credential shapes in agentcore.json. Synth passes; the published ConfigIO strips the new fields harmlessly since nothing consumes them before deploy work lands.

One live contract drift found and contained in 1cd5d14: ConfigIO requires credential names of 3-255 chars while the CLI allowed 1-128, so a 2-char name passed 'add' and then broke 'build'. The CLI now enforces the intersection (3-128). The L3 refactor-branch follow-up should align its name rule with the service's 1-128 and add the new fields (providerConfig, clientId, secretRef, clientSecretRef).

@github-actionsgithub-actionsBot removed the agentcore-harness-reviewing AgentCore Harness review in progress label Aug 19, 2026
Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

@HweinstockHweinstockAug 19, 2026

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.

Is there a way we can handle this in the same pattern as scaffolding such that it:

  • rolls back entire process on failure.
  • avoids a second conditional branch in this function.

One potential way I see to do this would be to implement some interface:

interface LocalEnvAccessor {
upsert(entries: LocalEnvEntries[]): Promise<LocalEnvAccessor>;
rollback(silent?: boolean): Promise<void>
}

Then here, we instantiate it with the project, call upsert in the credential scaffold case, then call rollback where we rollback the scaffolding.

This would also move the envLocal.ts to a class implementation, which feels consistent with how we've done things like SourceResolver.

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

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

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. The whole-spec safeParse now runs inside the same try as the write, so a rejected spec runs the .env.local rollback together with the scaffolded-file cleanup rather than leaving the staged secret behind.

import type { AddProjectResourceConfig } from "../types";
import type { AddResourceInput, EnvLocalEntry } from "../../types";

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

Comment threadsrc/projectSchemas/credential.ts Outdated
@@ -1,8 +1,11 @@
import z from "zod";
import { PaymentProviderSchema } from "./payment";
// Min 3 keeps names inside what @aws/agentcore-cdk's ConfigIO accepts (3-255)

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.

i feel like this comment is at risk of becoming stale if the referenced implementation changes.

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

Comment threadsrc/core/project/manager.tsx Outdated
for (const key of skipped) {
yield { message: `'${key}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged` };
}
if (await ensureGitignoreCoversEnvLocal(project.rootPath)) {

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.

don't we already scaffold the project with it in the .gitignore here

? If the user removes it later, that feels like their own (wrong) choice.

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

Comment threadsrc/handlers/project/project.test.ts Outdated
async function run(args: string[], opts?: { core?: TestCoreClient; stdin?: string }) {
const io = testIO();
// testIO's stdin is a PassThrough; pre-filling and ending it simulates piped input.
if (opts?.stdin !== undefined) (io.io.stdin as unknown as PassThrough).end(opts.stdin);

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.

whats the difference between this pattern and

?

Maybe OOS here, but is there a way we can standardize on something in

exportfunctiontestIO({ isTTY =false}: TestIOOptions={}): TestIO{
so every consumer doesn't need to reinvent?

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

envLocal read/write now uses readTextFile and atomicWrite from src/io instead
of raw node:fs calls, matching how the rest of core does file IO and making
the secrets-file write atomic. Adds coverage for the create-when-missing
.env.local branch, which the scaffolded template previously masked. Also
drops a review-marker comment.
…drop redundant gitignore write, standardize testIO stdin
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
#	src/handlers/project/types.ts
Comment threadsrc/core/project/envLocal.ts Outdated
/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {
try {
return await readTextFile(path);

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.

I tried this through the built CLI with node dist/index.js, and it fails here with Bun is not defined because readTextFile() uses Bun.file(). I think this needs a Node-compatible read path, otherwise the Bun tests pass but the published artifact cannot add a credential that writes to .env.local.

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.

Good catch. The root cause was in readTextFile itself (src/io/fileRead.ts), which called Bun.file, a global that exists only under the Bun runtime. The published bundle targets node, so this broke every caller of that function, not only this path (eval.tsx reads files the same way). Fixed it there by reading through node:fs/promises, which keeps the AbortSignal support. Verified the built artifact with node dist/index.js.

Comment threadsrc/core/project/envLocal.ts Outdated
continue;
}
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
content += `${separator}# ${entry.comment}\n${entry.key}=${entry.value ?? ""}\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.

I think we need to quote or escape these values before writing them as dotenv. For example, left#right is written as-is, but parseEnv() reads it back as only left. Surrounding spaces are changed too. Silently changing credential material will probably make the provider fail later and be difficult to diagnose.

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. Values are single-quoted before they are written. node:utilparseEnv treats everything inside single quotes as literal, so left#right, leading and trailing spaces, $, and backslashes all read back byte for byte. The one value single quotes cannot represent is a value that itself contains a single quote, so that case is rejected with a message that points the caller at a Secrets Manager reference instead. A parseEnv round-trip test covers it.


/** Derives the .env.local variable name a credential's secret is stored under. */
export function credentialEnvVarName(credentialName: string, suffix = ""): string {
return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`;

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.

One edge case here: foo-bar and foo_bar are both valid credential names, but they both become AGENTCORE_CREDENTIAL_FOO_BAR. I tried adding both and the second command succeeds, but it reuses the first secret because the existing key is skipped. Maybe normalized-name collisions should be rejected, or the mapping should remain one-to-one? What do you think?

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. addCredentialToProject now rejects a new name that derives the same environment variable as an existing credential, and the error names both. Test added in project.test.ts.

Comment threadsrc/core/project/manager.tsx Outdated
throw err;
}

if (input.resourceType === "credential" && input.envEntries?.length) {

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.

I think there is still one rollback case here. .env.local is updated before the complete spec is validated, but rollback only runs when json.write fails. I tried the invalid name ab: the command failed validation and left agentcore.json unchanged, but the environment entry remained. Could validation happen before the env write, or be included in the same rollback boundary?

@jariy17jariy17 left a comment

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.

Pretty good beside the upsert Name function

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.

Comment threadsrc/core/project/envLocal.ts Outdated
}

/** Reads a file, returning null when it does not exist so callers can tell empty from absent. */
async function readOrNull(path: string): Promise<string | null> {

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.

private this function if no class uses.

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. readOrNull is now a private method on EnvLocalFile.

Comment threadsrc/core/project/envLocal.ts Outdated
* are left unchanged so user-managed values survive re-runs. Returns the keys
* written and those skipped.
*/
async upsert(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> {

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.

This doesn't update existing keys, it just skips. So the word upsert doesn't work here. Maybe insertIfNew...

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. Renamed to insertIfNew.

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.

@@ -0,0 +1,115 @@
import z from "zod";

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.

This would be a good example of using templates. We can provide default templates for Google Oauth, Okta and so on.

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.

Agreed that vendor templates for Google, Okta, and the rest would be a good addition. It is out of scope for this change, which lands the guided-custom and vendored paths. Noted as a follow-up.

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.

* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

I feel like this should be part of the SourceResolver named resolveSecretValueFlags because it could be use in other locations

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 resolveSecretFlag local for now. Its only callers today are the two credential handlers, which share it through shared.ts. Moving it onto SourceResolver would widen that class for a single use. When a second, unrelated caller needs the same stdin-or-file-only rule, that is the point to lift it.

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

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.

Done. Moved it onto SourceResolver as resolveSecret (commit a37b46d). It throws SourceResolutionError, which is already a subtype of InputValidationError, so the io package stays free of handler-level error types. Both credential handlers now call resolver.resolveSecret(...), and the runtime handler in #2035 can do the same.

- readTextFile: read via node:fs so the node-target bundle works (Bun.file
is a Bun-runtime global, absent under node); options.signal passes through
- .env.local values are single-quoted so node:util parseEnv round-trips them
byte-for-byte; reject values containing a single quote with a clear message
- move spec validation inside the rollback try so a rejected spec reverses the
staged .env.local write instead of leaving it behind
- reject two credential names that derive the same environment variable
- rename EnvLocalFile.upsert to insertIfNew (never overwrites)
jariy17
jariy17 previously approved these changes Aug 21, 2026
* into shell history and process listings, so only stdin and files are allowed.
* A single trailing newline is stripped (echo and editors add one).
*/
export async function resolveSecretFlag(

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.

It's should be used in runtime too now: https://github.com/aws/agentcore-cli/pull/2035/changes#r3833097791. Shift it into the SourceResolver when you rebase :)

The stdin-or-file-only rule now has a second caller (runtime, PR 2035), so
resolveSecretFlag moves off the credentials shared module and onto
SourceResolver as resolveSecret. It throws SourceResolutionError (already a
subtype of InputValidationError), which keeps the io package from depending on
handler-level error types.
Also bump the generated CDK app template's aws-cdk-lib to ~2.266.0 so it
satisfies the @aws/agentcore-cdk peer floor once the credential constructs
release.
…credentials
# Conflicts:
#	src/core/project/manager.tsx
#	src/handlers/project/add/index.ts
@aidandaly24
aidandaly24 merged commit 38c30ae into refactorAug 21, 2026
11 checks passed
@aidandaly24
aidandaly24 deleted the feat/project-add-credentials branch August 21, 2026 22:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

@tejaskash@codecov-commenter@Hweinstock@aidandaly24@jariy17