diff --git a/.github/workflows/release-main-and-preview.yml b/.github/workflows/release-main-and-preview.yml
index c726518f2..6e0348e4b 100644
--- a/.github/workflows/release-main-and-preview.yml
+++ b/.github/workflows/release-main-and-preview.yml
@@ -1,10 +1,18 @@
-name: Release Both (Main + Preview)
+name: Release
on:
workflow_dispatch:
inputs:
+ release_target:
+ description: 'What to release'
+ required: true
+ type: choice
+ options:
+ - both
+ - main-only
+ - preview-only
main_bump_type:
- description: 'Main branch version bump'
+ description: 'Main version bump (ignored for preview-only)'
required: true
type: choice
options:
@@ -12,11 +20,13 @@ on:
- minor
- major
preview_bump_type:
- description: 'Preview branch version bump (prerelease with preview tag)'
+ description: 'Preview version bump (ignored for main-only)'
required: true
type: choice
options:
- prerelease
+ - minor
+ - major
main_changelog:
description: 'Main changelog entry (optional)'
required: false
@@ -26,7 +36,7 @@ on:
required: false
type: string
dry_run:
- description: 'Dry run — create PRs but skip npm publish'
+ description: 'Dry run — create PR but skip npm publish'
required: false
type: boolean
default: false
@@ -37,62 +47,22 @@ permissions:
jobs:
# ═══════════════════════════════════════════════════════════════════
- # Preflight — verify preview contains all of main
+ # Step 1 — Prepare release (bump both versions, single PR)
# ═══════════════════════════════════════════════════════════════════
- preflight:
- name: Preflight Checks
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - name: Verify running from main
- run: |
- if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then
- echo "❌ This workflow must be run from the main branch."
- exit 1
- fi
-
- - name: Verify preview contains all of main
- run: |
- git fetch origin preview
- MAIN_SHA=$(git rev-parse HEAD)
- MERGE_BASE=$(git merge-base HEAD origin/preview)
-
- if [[ "$MAIN_SHA" != "$MERGE_BASE" ]]; then
- echo "❌ preview branch does not contain all of main."
- echo ""
- echo "Main HEAD: $MAIN_SHA"
- echo "Merge base: $MERGE_BASE"
- echo ""
- echo "The sync-preview workflow should have merged automatically."
- echo "If it failed due to conflicts, resolve manually:"
- echo " git checkout preview && git merge main && git push origin preview"
- echo ""
- echo "Then re-run this workflow."
- exit 1
- fi
-
- echo "✅ preview contains all of main"
-
- # ═══════════════════════════════════════════════════════════════════
- # Step 1 — Prepare main release (bump, PR)
- # ═══════════════════════════════════════════════════════════════════
- prepare-main:
- name: Prepare Main Release
- needs: preflight
+ prepare-release:
+ name: Prepare Release
runs-on: ubuntu-latest
outputs:
- version: ${{ steps.bump.outputs.version }}
- branch: ${{ steps.bump.outputs.branch }}
+ main_version: ${{ steps.bump-main.outputs.version || steps.current-main.outputs.version }}
+ preview_version: ${{ steps.bump-preview.outputs.version }}
+ branch: ${{ steps.create-pr.outputs.branch }}
+ release_target: ${{ github.event.inputs.release_target }}
steps:
- name: Checkout main
uses: actions/checkout@v6
with:
- ref: main
+ ref: ${{ github.ref_name }}
fetch-depth: 0
- uses: actions/setup-node@v6
@@ -109,8 +79,9 @@ jobs:
- run: npm ci
- - name: Bump version
- id: bump
+ - name: Bump main version
+ id: bump-main
+ if: inputs.release_target != 'preview-only'
env:
BUMP_TYPE: ${{ github.event.inputs.main_bump_type }}
CHANGELOG_INPUT: ${{ github.event.inputs.main_changelog }}
@@ -123,98 +94,42 @@ jobs:
NEW_VERSION=$(node -p "require('./package.json').version")
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- echo "branch=release/v$NEW_VERSION" >> $GITHUB_OUTPUT
echo "📦 Main version: $NEW_VERSION"
- - name: Regenerate JSON schema
+ - name: Output current main version (preview-only)
+ id: current-main
+ if: inputs.release_target == 'preview-only'
run: |
- npm run build
- node scripts/generate-schema.mjs
- npx prettier --write schemas/
+ echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- - name: Update snapshots
- run: npm run test:update-snapshots
-
- - name: Generate GitHub App Token
- id: app-token
- uses: actions/create-github-app-token@v1
- with:
- app-id: ${{ vars.APP_ID }}
- private-key: ${{ secrets.APP_PRIVATE_KEY }}
-
- - name: Create release branch and PR
+ - name: Bump preview version
+ id: bump-preview
+ if: inputs.release_target != 'main-only'
env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- NEW_VERSION: ${{ steps.bump.outputs.version }}
+ BUMP_TYPE: ${{ github.event.inputs.preview_bump_type }}
run: |
- BRANCH_NAME="release/v$NEW_VERSION"
- git ls-remote --exit-code --heads origin $BRANCH_NAME && git push origin --delete $BRANCH_NAME || true
- git show-ref --verify --quiet refs/heads/$BRANCH_NAME && git branch -D $BRANCH_NAME || true
-
- git checkout -b $BRANCH_NAME
- git add -A
- git commit -m "chore: bump version to $NEW_VERSION"
- git push origin $BRANCH_NAME
-
- gh pr create \
- --base main \
- --head "$BRANCH_NAME" \
- --title "Release v$NEW_VERSION" \
- --body "## Release v$NEW_VERSION (main)
-
- Part of a coordinated main + preview release.
-
- ### Checklist
- - [ ] Review CHANGELOG.md
- - [ ] All CI checks passing
- - [ ] Merge this PR before approving the publish step"
+ CURRENT_VERSION=$(node -p "require('./preview-version.json').version")
+ echo "Current preview version: $CURRENT_VERSION"
+
+ NEW_VERSION=$(node -e "
+ const current = require('./preview-version.json').version;
+ const bumpType = process.env.BUMP_TYPE;
+ const parts = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-preview\.(\d+))?$/);
+ if (!parts) { console.error('Cannot parse version:', current); process.exit(1); }
+ let [, major, minor, patch, pre] = parts.map((v, i) => i > 0 && i < 5 ? parseInt(v || '0') : v);
+ if (bumpType === 'major') { major++; minor = 0; patch = 0; pre = 1; }
+ else if (bumpType === 'minor') { minor++; patch = 0; pre = 1; }
+ else { pre = (pre || 0) + 1; }
+ console.log(major + '.' + minor + '.' + patch + '-preview.' + pre);
+ ")
+
+ node -e "
+ const fs = require('fs');
+ const data = { version: '$NEW_VERSION' };
+ fs.writeFileSync('preview-version.json', JSON.stringify(data, null, 2) + '\n');
+ "
- # ═══════════════════════════════════════════════════════════════════
- # Step 2 — Prepare preview release (bump, PR)
- # ═══════════════════════════════════════════════════════════════════
- prepare-preview:
- name: Prepare Preview Release
- needs: preflight
- runs-on: ubuntu-latest
- outputs:
- version: ${{ steps.bump.outputs.version }}
- branch: ${{ steps.bump.outputs.branch }}
-
- steps:
- - name: Checkout preview
- uses: actions/checkout@v6
- with:
- ref: preview
- fetch-depth: 0
-
- - uses: actions/setup-node@v6
- with:
- node-version: 20.x
-
- - name: Install uv
- uses: astral-sh/setup-uv@v7
-
- - name: Configure git
- run: |
- git config --global user.name "github-actions[bot]"
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
-
- - run: npm ci
-
- - name: Bump version
- id: bump
- env:
- CHANGELOG_INPUT: ${{ github.event.inputs.preview_changelog }}
- run: |
- BUMP_CMD="npx tsx scripts/bump-version.ts prerelease --prerelease-tag preview"
- if [ -n "$CHANGELOG_INPUT" ]; then
- BUMP_CMD="$BUMP_CMD --changelog \"$CHANGELOG_INPUT\""
- fi
- eval $BUMP_CMD
-
- NEW_VERSION=$(node -p "require('./package.json').version")
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- echo "branch=release/v$NEW_VERSION" >> $GITHUB_OUTPUT
echo "📦 Preview version: $NEW_VERSION"
- name: Regenerate JSON schema
@@ -234,43 +149,76 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Create release branch and PR
+ id: create-pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
- NEW_VERSION: ${{ steps.bump.outputs.version }}
+ MAIN_VERSION: ${{ steps.bump-main.outputs.version || steps.current-main.outputs.version }}
+ PREVIEW_VERSION: ${{ steps.bump-preview.outputs.version }}
+ RELEASE_TARGET: ${{ github.event.inputs.release_target }}
run: |
- BRANCH_NAME="release/v$NEW_VERSION"
- git ls-remote --exit-code --heads origin $BRANCH_NAME && git push origin --delete $BRANCH_NAME || true
- git show-ref --verify --quiet refs/heads/$BRANCH_NAME && git branch -D $BRANCH_NAME || true
+ # Build branch name based on what we're releasing
+ if [ "$RELEASE_TARGET" = "main-only" ]; then
+ BRANCH_NAME="release/v${MAIN_VERSION}"
+ TITLE="Release v$MAIN_VERSION"
+ COMMIT_MSG="chore: bump main to $MAIN_VERSION"
+ elif [ "$RELEASE_TARGET" = "preview-only" ]; then
+ BRANCH_NAME="release/preview-v${PREVIEW_VERSION}"
+ TITLE="Release preview v$PREVIEW_VERSION"
+ COMMIT_MSG="chore: bump preview to $PREVIEW_VERSION"
+ else
+ BRANCH_NAME="release/v${MAIN_VERSION}+preview.${PREVIEW_VERSION}"
+ TITLE="Release v$MAIN_VERSION + preview v$PREVIEW_VERSION"
+ COMMIT_MSG="chore: bump main to $MAIN_VERSION, preview to $PREVIEW_VERSION"
+ fi
+ echo "branch=$BRANCH_NAME" >> $GITHUB_OUTPUT
+
+ git ls-remote --exit-code --heads origin "$BRANCH_NAME" && git push origin --delete "$BRANCH_NAME" || true
+ git show-ref --verify --quiet "refs/heads/$BRANCH_NAME" && git branch -D "$BRANCH_NAME" || true
- git checkout -b $BRANCH_NAME
+ git checkout -b "$BRANCH_NAME"
git add -A
- git commit -m "chore: bump version to $NEW_VERSION"
- git push origin $BRANCH_NAME
+ git commit -m "$COMMIT_MSG"
+ git push origin "$BRANCH_NAME"
- gh pr create \
- --base preview \
- --head "$BRANCH_NAME" \
- --title "Release v$NEW_VERSION (preview)" \
- --body "## Release v$NEW_VERSION (preview)
+ # Build PR body
+ BODY="## $TITLE
- Part of a coordinated main + preview release.
+ | Package | Version | npm Tag |
+ |---------|---------|---------|"
+ if [ "$RELEASE_TARGET" != "preview-only" ]; then
+ BODY="$BODY
+ | @aws/agentcore | $MAIN_VERSION | latest |"
+ fi
+ if [ "$RELEASE_TARGET" != "main-only" ]; then
+ BODY="$BODY
+ | @aws/agentcore | $PREVIEW_VERSION | preview |"
+ fi
+ BODY="$BODY
### Checklist
- [ ] Review CHANGELOG.md
- [ ] All CI checks passing
- [ ] Merge this PR before approving the publish step"
+ gh pr create \
+ --base "${{ github.ref_name }}" \
+ --head "$BRANCH_NAME" \
+ --label release \
+ --title "$TITLE" \
+ --body "$BODY"
+
# ═══════════════════════════════════════════════════════════════════
- # Step 3 — Build and test both
+ # Step 2 — Build and test both variants
# ═══════════════════════════════════════════════════════════════════
test-main:
- name: Test Main
- needs: prepare-main
+ name: Test Main Build
+ needs: prepare-release
+ if: inputs.release_target != 'preview-only'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
- ref: release/v${{ needs.prepare-main.outputs.version }}
+ ref: ${{ needs.prepare-release.outputs.branch }}
- uses: actions/setup-node@v6
with:
node-version: 20.x
@@ -286,13 +234,14 @@ jobs:
- run: npm run test:unit
test-preview:
- name: Test Preview
- needs: prepare-preview
+ name: Test Preview Build
+ needs: prepare-release
+ if: inputs.release_target != 'main-only'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
- ref: release/v${{ needs.prepare-preview.outputs.version }}
+ ref: ${{ needs.prepare-release.outputs.branch }}
- uses: actions/setup-node@v6
with:
node-version: 20.x
@@ -304,23 +253,27 @@ jobs:
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- - run: npm run build
+ - name: Build package
+ env:
+ BUILD_PREVIEW: '1'
+ run: npm run build
- run: npm run test:unit
# ═══════════════════════════════════════════════════════════════════
- # Step 4 — Manual approval gate
+ # Step 3 — Manual approval gate
# ═══════════════════════════════════════════════════════════════════
release-approval:
- name: Release Approval (Both)
- needs: [test-main, test-preview, prepare-main, prepare-preview]
+ name: Release Approval
+ needs: [test-main, test-preview, prepare-release]
+ if: always() && !failure() && !cancelled()
runs-on: ubuntu-latest
environment:
name: npm-publish-approval
steps:
- name: Approval checkpoint
env:
- MAIN_VERSION: ${{ needs.prepare-main.outputs.version }}
- PREVIEW_VERSION: ${{ needs.prepare-preview.outputs.version }}
+ MAIN_VERSION: ${{ needs.prepare-release.outputs.main_version }}
+ PREVIEW_VERSION: ${{ needs.prepare-release.outputs.preview_version }}
run: |
echo "✅ Both builds and tests passed"
echo ""
@@ -330,56 +283,58 @@ jobs:
echo "⚠️ MANUAL APPROVAL REQUIRED"
echo ""
echo "Before approving:"
- echo "1. Merge the main release PR (release/v$MAIN_VERSION → main)"
- echo "2. Merge the preview release PR (release/v$PREVIEW_VERSION → preview)"
- echo "3. Verify both PRs are merged"
+ echo "1. Merge the release PR to main"
+ echo "2. Verify the PR is merged"
# ═══════════════════════════════════════════════════════════════════
- # Step 5 — Verify both PRs merged before any publish
+ # Step 4 — Verify PR merged
# ═══════════════════════════════════════════════════════════════════
- verify-merges:
- name: Verify Both PRs Merged
- needs: [prepare-main, prepare-preview, release-approval]
+ verify-merge:
+ name: Verify PR Merged
+ needs: [prepare-release, release-approval]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
+ ref: ${{ github.ref_name }}
fetch-depth: 0
- name: Verify main version
+ if: needs.prepare-release.outputs.release_target != 'preview-only'
env:
- EXPECTED: ${{ needs.prepare-main.outputs.version }}
+ EXPECTED: ${{ needs.prepare-release.outputs.main_version }}
run: |
- git fetch origin main
- ACTUAL=$(git show origin/main:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
+ git fetch origin ${{ github.ref_name }}
+ ACTUAL=$(git show origin/${{ github.ref_name }}:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
if [ "$ACTUAL" != "$EXPECTED" ]; then
- echo "❌ Main release PR not merged yet!"
- echo "Expected: $EXPECTED, Got: $ACTUAL"
+ echo "❌ Release PR not merged yet!"
+ echo "Expected main version: $EXPECTED, Got: $ACTUAL"
exit 1
fi
echo "✅ Main version verified: $ACTUAL"
- name: Verify preview version
+ if: needs.prepare-release.outputs.release_target != 'main-only'
env:
- EXPECTED: ${{ needs.prepare-preview.outputs.version }}
+ EXPECTED: ${{ needs.prepare-release.outputs.preview_version }}
run: |
- git fetch origin preview
- ACTUAL=$(git show origin/preview:package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
+ ACTUAL=$(git show origin/${{ github.ref_name }}:preview-version.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
if [ "$ACTUAL" != "$EXPECTED" ]; then
- echo "❌ Preview release PR not merged yet!"
- echo "Expected: $EXPECTED, Got: $ACTUAL"
+ echo "❌ Release PR not merged yet!"
+ echo "Expected preview version: $EXPECTED, Got: $ACTUAL"
exit 1
fi
echo "✅ Preview version verified: $ACTUAL"
# ═══════════════════════════════════════════════════════════════════
- # Step 6a — Publish main to npm (tag: latest)
+ # Step 5a — Publish main to npm (tag: latest)
# ═══════════════════════════════════════════════════════════════════
publish-main:
name: Publish Main (@latest)
- needs: [prepare-main, verify-merges]
+ needs: [prepare-release, verify-merge]
+ if: inputs.release_target != 'preview-only'
runs-on: ubuntu-latest
environment:
name: npm-publish
@@ -392,7 +347,7 @@ jobs:
- name: Checkout main
uses: actions/checkout@v6
with:
- ref: main
+ ref: ${{ github.ref_name }}
fetch-depth: 0
- uses: actions/setup-node@v6
@@ -409,7 +364,7 @@ jobs:
- name: Tag and release
env:
- VERSION: ${{ needs.prepare-main.outputs.version }}
+ VERSION: ${{ needs.prepare-release.outputs.main_version }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
@@ -417,25 +372,26 @@ jobs:
git push origin "v$VERSION"
- name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
- tag_name: v${{ needs.prepare-main.outputs.version }}
- name: AgentCore CLI v${{ needs.prepare-main.outputs.version }}
+ tag_name: v${{ needs.prepare-release.outputs.main_version }}
+ name: AgentCore CLI v${{ needs.prepare-release.outputs.main_version }}
generate_release_notes: true
prerelease: false
body: |
## Installation
```bash
- npm install -g @aws/agentcore@${{ needs.prepare-main.outputs.version }}
+ npm install -g @aws/agentcore@${{ needs.prepare-release.outputs.main_version }}
```
# ═══════════════════════════════════════════════════════════════════
- # Step 6b — Publish preview to npm (tag: preview)
+ # Step 5b — Publish preview to npm (tag: preview)
# ═══════════════════════════════════════════════════════════════════
publish-preview:
name: Publish Preview (@preview)
- needs: [prepare-preview, verify-merges]
+ needs: [prepare-release, verify-merge]
+ if: inputs.release_target != 'main-only'
runs-on: ubuntu-latest
environment:
name: npm-publish
@@ -445,10 +401,10 @@ jobs:
contents: write
steps:
- - name: Checkout preview
+ - name: Checkout main
uses: actions/checkout@v6
with:
- ref: preview
+ ref: ${{ github.ref_name }}
fetch-depth: 0
- uses: actions/setup-node@v6
@@ -458,14 +414,30 @@ jobs:
- run: npm install -g npm@11.5.1
- run: npm ci
- - run: npm run build
+
+ - name: Set preview version in package.json
+ env:
+ VERSION: ${{ needs.prepare-release.outputs.preview_version }}
+ run: |
+ node -e "
+ const fs = require('fs');
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
+ pkg.version = process.env.VERSION;
+ fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+ "
+ echo "Set package.json version to $VERSION for preview publish"
+
+ - name: Build package
+ env:
+ BUILD_PREVIEW: '1'
+ run: npm run build
- name: Publish to npm
run: npm publish --access public --provenance --tag preview
- name: Tag and release
env:
- VERSION: ${{ needs.prepare-preview.outputs.version }}
+ VERSION: ${{ needs.prepare-release.outputs.preview_version }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
@@ -473,10 +445,10 @@ jobs:
git push origin "v$VERSION"
- name: Create GitHub Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
- tag_name: v${{ needs.prepare-preview.outputs.version }}
- name: AgentCore CLI v${{ needs.prepare-preview.outputs.version }} (Preview)
+ tag_name: v${{ needs.prepare-release.outputs.preview_version }}
+ name: AgentCore CLI v${{ needs.prepare-release.outputs.preview_version }} (Preview)
generate_release_notes: true
prerelease: true
body: |
@@ -491,14 +463,14 @@ jobs:
# ═══════════════════════════════════════════════════════════════════
summary:
name: Release Summary
- needs: [prepare-main, prepare-preview, publish-main, publish-preview]
- if: always()
+ needs: [prepare-release, publish-main, publish-preview]
+ if: always() && !cancelled()
runs-on: ubuntu-latest
steps:
- name: Summary
env:
- MAIN_VERSION: ${{ needs.prepare-main.outputs.version }}
- PREVIEW_VERSION: ${{ needs.prepare-preview.outputs.version }}
+ MAIN_VERSION: ${{ needs.prepare-release.outputs.main_version }}
+ PREVIEW_VERSION: ${{ needs.prepare-release.outputs.preview_version }}
MAIN_STATUS: ${{ needs.publish-main.result }}
PREVIEW_STATUS: ${{ needs.publish-preview.result }}
run: |
diff --git a/.github/workflows/sync-preview.yml b/.github/workflows/sync-preview.yml
deleted file mode 100644
index c6055fa24..000000000
--- a/.github/workflows/sync-preview.yml
+++ /dev/null
@@ -1,192 +0,0 @@
-name: Sync Preview with Main
-
-on:
- workflow_dispatch:
- push:
- branches: [main]
-
-concurrency:
- group: sync-preview
- cancel-in-progress: false
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- sync:
- name: Merge main into preview
- runs-on: ubuntu-latest
- steps:
- - name: Generate GitHub App Token
- id: app-token
- uses: actions/create-github-app-token@v1
- with:
- app-id: ${{ vars.APP_ID }}
- private-key: ${{ secrets.APP_PRIVATE_KEY }}
-
- - name: Checkout preview
- uses: actions/checkout@v6
- with:
- ref: preview
- fetch-depth: 0
- token: ${{ steps.app-token.outputs.token }}
-
- - name: Configure git
- run: |
- git config --global user.name "github-actions[bot]"
- git config --global user.email "github-actions[bot]@users.noreply.github.com"
-
- - name: Check if sync needed
- id: check
- run: |
- git fetch origin main
- MAIN_SHA=$(git rev-parse origin/main)
- MERGE_BASE=$(git merge-base HEAD origin/main)
-
- if [[ "$MAIN_SHA" == "$MERGE_BASE" ]]; then
- echo "✅ preview already contains all of main"
- echo "needed=false" >> $GITHUB_OUTPUT
- else
- echo "needed=true" >> $GITHUB_OUTPUT
- fi
-
- - name: Skip if already synced
- if: steps.check.outputs.needed == 'false'
- run: echo "Nothing to sync."
-
- - name: Merge main into preview
- if: steps.check.outputs.needed == 'true'
- id: merge
- run: |
- # Save preview's version before merge so we can restore it after
- PREVIEW_VERSION=$(node -p "require('./package.json').version")
- echo "preview_version=$PREVIEW_VERSION" >> $GITHUB_OUTPUT
-
- if git merge origin/main --no-edit -m "chore: merge main into preview"; then
- echo "status=clean" >> $GITHUB_OUTPUT
- else
- # preview carries a higher version string than main (e.g. 1.0.0-preview.X vs 0.13.X).
- # This means package.json/package-lock.json almost always conflict on the version field.
- # Accept main's content here; the version is restored in the next step.
- for f in package.json package-lock.json; do
- if git diff --name-only --diff-filter=U | grep -qx "$f"; then
- git checkout --theirs "$f"
- git add "$f"
- echo " ↳ resolved $f conflict (accepted main, will restore version)"
- fi
- done
-
- # Check if all conflicts are now resolved
- if [[ -z "$(git diff --name-only --diff-filter=U)" ]]; then
- git commit --no-edit -m "chore: merge main into preview"
- echo "status=clean" >> $GITHUB_OUTPUT
- else
- echo "status=conflict" >> $GITHUB_OUTPUT
- fi
- fi
-
- - name: Restore preview-owned files
- if: steps.merge.outputs.status == 'clean'
- run: |
- # These files are auto-generated during preview releases and must not
- # be overwritten by main's versions (schema-check CI will reject changes
- # to schemas/, and CHANGELOG.md tracks preview releases separately).
- PREVIEW_HEAD=$(git rev-parse HEAD^1)
- for f in schemas/agentcore.schema.v1.json CHANGELOG.md; do
- if git show "$PREVIEW_HEAD:$f" > /dev/null 2>&1; then
- git show "$PREVIEW_HEAD:$f" > "$f"
- git add "$f"
- echo " ↳ restored preview's $f"
- fi
- done
- if ! git diff --cached --quiet; then
- git commit -m "chore: restore preview-owned files (schema, changelog)"
- fi
-
- - name: Restore preview version and push
- if: steps.merge.outputs.status == 'clean'
- run: |
- PREVIEW_VERSION="${{ steps.merge.outputs.preview_version }}"
- CURRENT_VERSION=$(node -p "require('./package.json').version")
-
- if [[ "$CURRENT_VERSION" != "$PREVIEW_VERSION" ]]; then
- PREVIEW_VERSION="$PREVIEW_VERSION" node -e "
- const fs = require('fs');
- const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
- pkg.version = process.env.PREVIEW_VERSION;
- fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
- "
- if [[ -f package-lock.json ]]; then
- PREVIEW_VERSION="$PREVIEW_VERSION" node -e "
- const fs = require('fs');
- const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
- lock.version = process.env.PREVIEW_VERSION;
- if (lock.packages && lock.packages['']) {
- lock.packages[''].version = process.env.PREVIEW_VERSION;
- }
- fs.writeFileSync('package-lock.json', JSON.stringify(lock, null, 2) + '\n');
- "
- fi
- git add package.json
- [[ -f package-lock.json ]] && git add package-lock.json
- git commit -m "chore: restore preview version ($PREVIEW_VERSION)"
- fi
-
- git push origin HEAD:preview
- echo "✅ main merged into preview and pushed"
-
- - name: Create PR for conflict resolution
- if: steps.merge.outputs.status == 'conflict'
- env:
- GH_TOKEN: ${{ steps.app-token.outputs.token }}
- run: |
- # Check if there's already an open sync PR (match by branch prefix, not title search)
- COUNT=$(gh pr list --base preview --state open --json headRefName \
- --jq '[.[] | select(.headRefName | startswith("sync-preview/"))] | length')
- if [[ "$COUNT" != "0" ]]; then
- echo "ℹ️ Sync PR already open — skipping duplicate."
- exit 0
- fi
-
- # Abort the failed merge and redo on a branch for the PR
- git merge --abort
-
- BRANCH="sync-preview/merge-main-$(date +%Y%m%d-%H%M%S)"
- git checkout -b "$BRANCH"
- git merge origin/main --no-edit -m "chore: merge main into preview (conflicts need resolution)" || true
- git add -A
- git commit --no-edit -m "chore: merge main into preview (conflicts need resolution)" || true
- git push origin "$BRANCH"
-
- GH_USER=$(gh api "/repos/${{ github.repository }}/commits/$(git rev-parse origin/main)" --jq '.author.login // empty' 2>/dev/null || echo "")
- MENTION=""
- if [[ -n "$GH_USER" ]]; then
- MENTION="cc @${GH_USER}"
- fi
-
- gh pr create \
- --base preview \
- --head "$BRANCH" \
- --title "sync-preview: merge main into preview (conflicts)" \
- --body "$(cat <
- \`\`\`
- 2. Search for conflict markers and resolve them:
- \`\`\`bash
- grep -rn '<<<<<<< HEAD' .
- \`\`\`
- 3. Keep preview-specific values (package version, preview tests, etc.) — accept main's changes for everything else.
- 4. Commit and push, then merge this PR.
-
- ${MENTION}
-
- _Opened automatically by the sync-preview workflow._
- BODY
- )"
diff --git a/e2e-tests/harness-bedrock.test.ts b/e2e-tests/harness-bedrock.test.ts
new file mode 100644
index 000000000..7b53e18bb
--- /dev/null
+++ b/e2e-tests/harness-bedrock.test.ts
@@ -0,0 +1,3 @@
+import { createHarnessE2ESuite } from './harness-e2e-helper.js';
+
+createHarnessE2ESuite({ modelProvider: 'bedrock' });
diff --git a/e2e-tests/harness-e2e-helper.ts b/e2e-tests/harness-e2e-helper.ts
new file mode 100644
index 000000000..a0ee882c9
--- /dev/null
+++ b/e2e-tests/harness-e2e-helper.ts
@@ -0,0 +1,165 @@
+import { hasAwsCredentials, parseJsonOutput, prereqs, retry, spawnAndCollect } from '../src/test-utils/index.js';
+import {
+ cleanupStaleCredentialProviders,
+ installCdkTarball,
+ runAgentCoreCLI,
+ teardownE2EProject,
+ writeAwsTargets,
+} from './e2e-helper.js';
+import { randomUUID } from 'node:crypto';
+import { mkdir, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const hasAws = hasAwsCredentials();
+// Harness features are only available in preview builds (BUILD_PREVIEW=1).
+const isPreviewBuild = process.env.BUILD_PREVIEW === '1';
+const baseCanRun = prereqs.npm && prereqs.git && hasAws && isPreviewBuild;
+
+interface HarnessE2EConfig {
+ modelProvider: 'bedrock' | 'open_ai' | 'gemini';
+ requiredEnvVar?: string;
+ skipMemory?: boolean;
+}
+
+export function createHarnessE2ESuite(cfg: HarnessE2EConfig) {
+ const hasRequiredVar = !cfg.requiredEnvVar || !!process.env[cfg.requiredEnvVar];
+ const canRun = baseCanRun && hasRequiredVar;
+
+ const providerLabel =
+ cfg.modelProvider === 'open_ai' ? 'OpenAI' : cfg.modelProvider === 'gemini' ? 'Gemini' : 'Bedrock';
+
+ describe.sequential(`e2e: harness/${providerLabel} — create → deploy → invoke`, () => {
+ let testDir: string;
+ let projectPath: string;
+ let harnessName: string;
+
+ beforeAll(async () => {
+ if (!canRun) return;
+
+ await cleanupStaleCredentialProviders();
+
+ testDir = join(tmpdir(), `agentcore-e2e-harness-${randomUUID()}`);
+ await mkdir(testDir, { recursive: true });
+
+ const providerSlug = cfg.modelProvider.replace('_', '').slice(0, 4);
+ harnessName = `E2eHrns${providerSlug}${String(Date.now()).slice(-8)}`;
+
+ const createArgs = [
+ 'create',
+ '--name',
+ harnessName,
+ '--model-provider',
+ cfg.modelProvider,
+ '--json',
+ '--skip-git',
+ ];
+
+ if (cfg.requiredEnvVar && process.env[cfg.requiredEnvVar]) {
+ createArgs.push('--api-key-arn', process.env[cfg.requiredEnvVar]!);
+ }
+
+ if (cfg.skipMemory) {
+ createArgs.push('--no-harness-memory');
+ }
+
+ const result = await runAgentCoreCLI(createArgs, testDir);
+
+ expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0);
+ const json = parseJsonOutput(result.stdout) as { projectPath: string };
+ projectPath = json.projectPath;
+
+ await writeAwsTargets(projectPath);
+ installCdkTarball(projectPath);
+ }, 300000);
+
+ afterAll(async () => {
+ if (projectPath && hasAws) {
+ await teardownE2EProject(projectPath, harnessName, cfg.modelProvider);
+ }
+ if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 });
+ }, 600000);
+
+ it.skipIf(!canRun)(
+ 'deploys to AWS successfully',
+ async () => {
+ expect(projectPath, 'Project should have been created').toBeTruthy();
+
+ await retry(
+ async () => {
+ const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath);
+
+ if (result.exitCode !== 0) {
+ console.log('Deploy stdout:', result.stdout);
+ console.log('Deploy stderr:', result.stderr);
+ }
+
+ expect(result.exitCode, `Deploy failed (stderr: ${result.stderr}, stdout: ${result.stdout})`).toBe(0);
+
+ const json = parseJsonOutput(result.stdout) as { success: boolean };
+ expect(json.success, 'Deploy should report success').toBe(true);
+ },
+ 1,
+ 30000
+ );
+ },
+ 600000
+ );
+
+ it.skipIf(!canRun)(
+ 'invokes the deployed harness',
+ async () => {
+ expect(projectPath, 'Project should have been created').toBeTruthy();
+
+ await retry(
+ async () => {
+ const result = await runAgentCoreCLI(
+ ['invoke', '--harness', harnessName, '--prompt', 'Say hello', '--json'],
+ projectPath
+ );
+
+ if (result.exitCode !== 0) {
+ console.log('Invoke stdout:', result.stdout);
+ console.log('Invoke stderr:', result.stderr);
+ }
+
+ expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0);
+
+ const json = parseJsonOutput(result.stdout) as { success: boolean };
+ expect(json.success, 'Invoke should report success').toBe(true);
+ },
+ 3,
+ 15000
+ );
+ },
+ 180000
+ );
+
+ it.skipIf(!canRun)(
+ 'status shows the deployed harness',
+ async () => {
+ const statusResult = await spawnAndCollect('agentcore', ['status', '--json'], projectPath);
+
+ expect(statusResult.exitCode, `Status failed: ${statusResult.stderr}`).toBe(0);
+
+ const json = parseJsonOutput(statusResult.stdout) as {
+ success: boolean;
+ resources: {
+ resourceType: string;
+ name: string;
+ deploymentState: string;
+ identifier?: string;
+ }[];
+ };
+ expect(json.success).toBe(true);
+
+ const harness = json.resources.find(r => r.resourceType === 'harness' && r.name === harnessName);
+ expect(harness, `Harness "${harnessName}" should appear in status`).toBeDefined();
+ expect(harness!.deploymentState).toBe('deployed');
+ expect(harness!.identifier, 'Deployed harness should have a harnessArn').toBeTruthy();
+ },
+ 120000
+ );
+ });
+}
diff --git a/e2e-tests/harness-gemini.test.ts b/e2e-tests/harness-gemini.test.ts
new file mode 100644
index 000000000..8fd024147
--- /dev/null
+++ b/e2e-tests/harness-gemini.test.ts
@@ -0,0 +1,3 @@
+import { createHarnessE2ESuite } from './harness-e2e-helper.js';
+
+createHarnessE2ESuite({ modelProvider: 'gemini', requiredEnvVar: 'GEMINI_API_KEY_ARN', skipMemory: true });
diff --git a/e2e-tests/harness-openai.test.ts b/e2e-tests/harness-openai.test.ts
new file mode 100644
index 000000000..bdb9c3772
--- /dev/null
+++ b/e2e-tests/harness-openai.test.ts
@@ -0,0 +1,3 @@
+import { createHarnessE2ESuite } from './harness-e2e-helper.js';
+
+createHarnessE2ESuite({ modelProvider: 'open_ai', requiredEnvVar: 'OPENAI_API_KEY_ARN', skipMemory: true });
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index fc4e1d9ac..f01062b05 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -41,15 +41,20 @@ const textLoaderPlugin = {
},
};
+const outfile = process.env.ESBUILD_OUTFILE || './dist/cli/index.mjs';
+
await esbuild.build({
entryPoints: ['./src/cli/index.ts'],
- outfile: './dist/cli/index.mjs',
+ outfile,
bundle: true,
platform: 'node',
format: 'esm',
minify: true,
keepNames: true,
jsx: 'automatic',
+ define: {
+ __PREVIEW__: process.env.BUILD_PREVIEW === '1' ? 'true' : 'false',
+ },
// Inject require shim for ESM compatibility with CommonJS dependencies
banner: {
js: `import { createRequire } from 'module'; import { fileURLToPath as __ef } from 'url'; import { dirname as __ed } from 'path'; const require = createRequire(import.meta.url); const __filename = __ef(import.meta.url); const __dirname = __ed(__filename);`,
@@ -59,9 +64,9 @@ await esbuild.build({
});
// Make executable
-fs.chmodSync('./dist/cli/index.mjs', '755');
+fs.chmodSync(outfile, '755');
-console.log('CLI build complete: dist/cli/index.mjs');
+console.log(`CLI build complete: ${outfile}`);
// ---------------------------------------------------------------------------
// MCP harness build — opt-in via BUILD_HARNESS=1
diff --git a/integ-tests/add-remove-harness.test.ts b/integ-tests/add-remove-harness.test.ts
new file mode 100644
index 000000000..099f17959
--- /dev/null
+++ b/integ-tests/add-remove-harness.test.ts
@@ -0,0 +1,222 @@
+import { createTestProject, exists, readProjectConfig, runCLI } from '../src/test-utils/index.js';
+import type { TestProject } from '../src/test-utils/index.js';
+import { readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+// Harness features are only available in preview builds (BUILD_PREVIEW=1).
+// The standard CI build is GA, so skip these tests unless the preview bundle is present.
+const isPreviewBuild = process.env.BUILD_PREVIEW === '1';
+
+async function readHarnessSpec(projectPath: string, harnessName: string) {
+ return JSON.parse(await readFile(join(projectPath, `app/${harnessName}/harness.json`), 'utf-8'));
+}
+
+describe.skipIf(!isPreviewBuild)('integration: harness add/remove lifecycle', () => {
+ let project: TestProject;
+ const harnessName = 'TestHarness';
+
+ beforeAll(async () => {
+ project = await createTestProject({ noAgent: true });
+ });
+
+ afterAll(async () => {
+ await project.cleanup();
+ });
+
+ it('adds a harness with defaults', async () => {
+ const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+ const json = JSON.parse(result.stdout);
+ expect(json.success).toBe(true);
+
+ const config = await readProjectConfig(project.projectPath);
+ const harness = config.harnesses?.find((h: { name: string }) => h.name === harnessName);
+ expect(harness, `Harness "${harnessName}" should be in agentcore.json`).toBeTruthy();
+ expect(harness!.path).toBe(`app/${harnessName}`);
+ });
+
+ it('creates harness.json with correct model config', async () => {
+ const spec = await readHarnessSpec(project.projectPath, harnessName);
+ expect(spec.model).toBeDefined();
+ expect(spec.model.provider).toBe('bedrock');
+ expect(spec.model.modelId).toBeTruthy();
+ });
+
+ it('creates system-prompt.md', async () => {
+ const promptPath = join(project.projectPath, `app/${harnessName}/system-prompt.md`);
+ expect(await exists(promptPath), 'system-prompt.md should exist').toBe(true);
+ });
+
+ it('auto-creates memory resource', async () => {
+ const config = await readProjectConfig(project.projectPath);
+ const memories = config.memories ?? [];
+ expect(memories.length, 'Should have auto-created memory').toBeGreaterThan(0);
+ });
+
+ it('rejects duplicate harness name', async () => {
+ const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
+ expect(result.exitCode).not.toBe(0);
+ });
+
+ it('removes the harness', async () => {
+ const result = await runCLI(['remove', 'harness', '--name', harnessName, '--json'], project.projectPath);
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+ const json = JSON.parse(result.stdout);
+ expect(json.success).toBe(true);
+
+ const config = await readProjectConfig(project.projectPath);
+ const found = config.harnesses?.find((h: { name: string }) => h.name === harnessName);
+ expect(found, `Harness "${harnessName}" should be removed`).toBeFalsy();
+
+ const associatedMemory = (config.memories ?? []).find((m: { name: string }) => m.name === `${harnessName}Memory`);
+ expect(associatedMemory, 'Associated memory should be removed with harness').toBeFalsy();
+ });
+
+ it('re-adds harness after removal without duplicate memory error', async () => {
+ const result = await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+ const json = JSON.parse(result.stdout);
+ expect(json.success).toBe(true);
+
+ // Clean up for next tests
+ await runCLI(['remove', 'harness', '--name', harnessName, '--json'], project.projectPath);
+ });
+});
+
+describe.skipIf(!isPreviewBuild)('integration: harness configuration options', () => {
+ let project: TestProject;
+
+ beforeAll(async () => {
+ project = await createTestProject({ noAgent: true });
+ });
+
+ afterAll(async () => {
+ await project.cleanup();
+ });
+
+ it('adds harness with truncation strategy', async () => {
+ const name = 'TruncHarness';
+ const result = await runCLI(
+ ['add', 'harness', '--name', name, '--truncation-strategy', 'sliding_window', '--json'],
+ project.projectPath
+ );
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+
+ const spec = await readHarnessSpec(project.projectPath, name);
+ expect(spec.truncation?.strategy).toBe('sliding_window');
+ });
+
+ it('adds harness with lifecycle config', async () => {
+ const name = 'LifecycleHarness';
+ const result = await runCLI(
+ ['add', 'harness', '--name', name, '--idle-timeout', '300', '--max-lifetime', '3600', '--json'],
+ project.projectPath
+ );
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+
+ const spec = await readHarnessSpec(project.projectPath, name);
+ expect(spec.lifecycleConfig?.idleRuntimeSessionTimeout).toBe(300);
+ expect(spec.lifecycleConfig?.maxLifetime).toBe(3600);
+ });
+
+ it('adds harness without memory when --no-memory is set', async () => {
+ const name = 'NoMemHarness';
+ const configBefore = await readProjectConfig(project.projectPath);
+ const memoriesBefore = (configBefore.memories ?? []).length;
+
+ const result = await runCLI(['add', 'harness', '--name', name, '--no-memory', '--json'], project.projectPath);
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+
+ const configAfter = await readProjectConfig(project.projectPath);
+ const memoriesAfter = (configAfter.memories ?? []).length;
+ expect(memoriesAfter).toBe(memoriesBefore);
+ });
+
+ it('adds harness with non-bedrock model provider', async () => {
+ const name = 'OpenAIHarness';
+ const result = await runCLI(
+ [
+ 'add',
+ 'harness',
+ '--name',
+ name,
+ '--model-provider',
+ 'open_ai',
+ '--model-id',
+ 'gpt-5',
+ '--api-key-arn',
+ 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key',
+ '--json',
+ ],
+ project.projectPath
+ );
+
+ expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
+
+ const spec = await readHarnessSpec(project.projectPath, name);
+ expect(spec.model.provider).toBe('open_ai');
+ expect(spec.model.modelId).toBe('gpt-5');
+ expect(spec.model.apiKeyArn).toBe('arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key');
+ });
+});
+
+describe.skipIf(!isPreviewBuild)('integration: harness validation errors', () => {
+ let project: TestProject;
+
+ beforeAll(async () => {
+ project = await createTestProject({ noAgent: true });
+ });
+
+ afterAll(async () => {
+ await project.cleanup();
+ });
+
+ it('rejects invalid harness name with special characters', async () => {
+ const result = await runCLI(['add', 'harness', '--name', 'bad-name!', '--json'], project.projectPath);
+ expect(result.exitCode).not.toBe(0);
+ });
+
+ it('rejects harness name starting with a number', async () => {
+ const result = await runCLI(['add', 'harness', '--name', '1BadName', '--json'], project.projectPath);
+ expect(result.exitCode).not.toBe(0);
+ });
+
+ it('rejects add harness without --name when --json is passed', async () => {
+ const result = await runCLI(['add', 'harness', '--json'], project.projectPath);
+ expect(result.exitCode).not.toBe(0);
+ });
+});
+
+describe.skipIf(!isPreviewBuild)('integration: create project with harness', () => {
+ let project: TestProject;
+ const harnessName = 'CreateHarness';
+
+ beforeAll(async () => {
+ project = await createTestProject({ name: harnessName, noAgent: true });
+ await runCLI(['add', 'harness', '--name', harnessName, '--json'], project.projectPath);
+ });
+
+ afterAll(async () => {
+ await project.cleanup();
+ });
+
+ it('has correct project scaffolding', async () => {
+ expect(await exists(join(project.projectPath, 'agentcore/agentcore.json'))).toBe(true);
+ expect(await exists(join(project.projectPath, 'agentcore/cdk'))).toBe(true);
+ expect(await exists(join(project.projectPath, `app/${harnessName}/harness.json`))).toBe(true);
+ expect(await exists(join(project.projectPath, `app/${harnessName}/system-prompt.md`))).toBe(true);
+ });
+
+ it('has harness registered in project config', async () => {
+ const config = await readProjectConfig(project.projectPath);
+ const harness = config.harnesses?.find((h: { name: string }) => h.name === harnessName);
+ expect(harness).toBeTruthy();
+ });
+});
diff --git a/integ-tests/create-edge-cases.test.ts b/integ-tests/create-edge-cases.test.ts
index e001a7926..93849328c 100644
--- a/integ-tests/create-edge-cases.test.ts
+++ b/integ-tests/create-edge-cases.test.ts
@@ -1,4 +1,3 @@
-/* eslint-disable security/detect-non-literal-fs-filename */
import { exists, prereqs, runCLI } from '../src/test-utils/index.js';
import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js';
import { randomUUID } from 'node:crypto';
diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index ef7d47e04..b158d747a 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -1,12 +1,12 @@
{
"name": "@aws/agentcore",
- "version": "0.14.1",
+ "version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@aws/agentcore",
- "version": "0.14.1",
+ "version": "0.15.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
diff --git a/package.json b/package.json
index 98a834a6e..b82320d58 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"build:lib": "tsc -p tsconfig.build.json",
"build:cli": "node esbuild.config.mjs",
"build:assets": "node scripts/copy-assets.mjs",
+ "build:preview": "BUILD_PREVIEW=1 node esbuild.config.mjs",
"build:harness": "BUILD_HARNESS=1 node esbuild.config.mjs",
"cli": "npx tsx src/cli/index.ts",
"typecheck": "tsc --noEmit",
diff --git a/preview-version.json b/preview-version.json
new file mode 100644
index 000000000..d676b184a
--- /dev/null
+++ b/preview-version.json
@@ -0,0 +1,3 @@
+{
+ "version": "1.0.0-preview.9"
+}
diff --git a/scripts/bundle.mjs b/scripts/bundle.mjs
index 613258cf8..3ef1899f0 100644
--- a/scripts/bundle.mjs
+++ b/scripts/bundle.mjs
@@ -156,10 +156,53 @@ try {
const cliTarballName = `aws-agentcore-${cliVersionInfo.bumpedVersion}.tgz`;
const cliTarballPath = path.join(cliRoot, cliTarballName);
-if (fs.existsSync(cliTarballPath)) {
- log(`Done! Tarball: ${cliTarballPath}`);
- log(`Install with: npm install -g ${cliTarballPath}`);
- log('When you run agentcore create, the bundled CDK constructs will be installed automatically.');
-} else {
- log(`Done! Check ${cliRoot} for the .tgz file.`);
+if (!fs.existsSync(cliTarballPath)) {
+ console.error(`ERROR: Expected GA tarball at ${cliTarballPath} but not found.`);
+ process.exit(1);
+}
+log(`Done! GA Tarball: ${cliTarballPath}`);
+log(`Install with: npm install -g ${cliTarballPath}`);
+log('When you run agentcore create, the bundled CDK constructs will be installed automatically.');
+
+const gaTarballPath = cliTarballPath;
+
+// Step 6: Rebuild CLI with BUILD_PREVIEW=1
+log('Rebuilding CLI with BUILD_PREVIEW=1 for preview tarball...');
+run('npm', ['run', 'build:cli'], { cwd: cliRoot, env: { ...process.env, BUILD_PREVIEW: '1' } });
+
+// Step 7: Bump version to preview variant
+function bumpPreviewVersion(pkgDir) {
+ const pkgJsonPath = path.join(pkgDir, 'package.json');
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
+ const originalVersion = pkg.version;
+ const baseVersion = originalVersion.split('-')[0];
+ pkg.version = `${baseVersion}-preview-${timestamp}`;
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n');
+ log(`Bumped ${pkg.name} version: ${originalVersion} -> ${pkg.version}`);
+ return { pkgJsonPath, originalVersion, bumpedVersion: pkg.version };
}
+
+const previewVersionInfo = bumpPreviewVersion(cliRoot);
+
+// Step 8: Pack preview tarball
+try {
+ log('Packing CLI preview tarball...');
+ run('npm', ['pack'], { cwd: cliRoot });
+} finally {
+ restoreVersion(previewVersionInfo);
+}
+
+const previewTarballName = `aws-agentcore-${previewVersionInfo.bumpedVersion}.tgz`;
+const previewTarballPath = path.join(cliRoot, previewTarballName);
+
+if (!fs.existsSync(previewTarballPath)) {
+ console.error(`ERROR: Expected preview tarball at ${previewTarballPath} but not found.`);
+ process.exit(1);
+}
+
+// Final output
+log(`GA tarball: ${gaTarballPath}`);
+log(`Preview tarball: ${previewTarballPath}`);
+log(`Install GA: npm install -g ${gaTarballPath}`);
+log(`Install Preview: npm install -g ${previewTarballPath}`);
+log('When you run agentcore create, the bundled CDK constructs will be installed automatically.');
diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
index bec42f397..bc6e49330 100644
--- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
+++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
@@ -99,6 +99,42 @@ async function main() {
throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');
}
+ // Read harness configs for role creation.
+ const projectRoot = path.resolve(configRoot, '..');
+ const harnessConfigs: {
+ name: string;
+ executionRoleArn?: string;
+ memoryName?: string;
+ containerUri?: string;
+ hasDockerfile?: boolean;
+ dockerfile?: string;
+ codeLocation?: string;
+ tools?: { type: string; name: string }[];
+ apiKeyArn?: string;
+ }[] = [];
+ for (const entry of specAny.harnesses ?? []) {
+ const harnessDir = path.resolve(projectRoot, entry.path);
+ const harnessPath = path.resolve(harnessDir, 'harness.json');
+ try {
+ const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8'));
+ harnessConfigs.push({
+ name: entry.name,
+ executionRoleArn: harnessSpec.executionRoleArn,
+ memoryName: harnessSpec.memory?.name,
+ containerUri: harnessSpec.containerUri,
+ hasDockerfile: !!harnessSpec.dockerfile,
+ dockerfile: harnessSpec.dockerfile,
+ codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,
+ tools: harnessSpec.tools,
+ apiKeyArn: harnessSpec.model?.apiKeyArn,
+ });
+ } catch (err) {
+ throw new Error(
+ \`Could not read harness.json for "\${entry.name}" at \${harnessPath}: \${err instanceof Error ? err.message : err}\`
+ );
+ }
+ }
+
const app = new App();
for (const target of targets) {
@@ -118,6 +154,7 @@ async function main() {
spec,
mcpSpec,
credentials,
+ harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined,
env,
description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`,
tags: {
@@ -265,6 +302,18 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou
import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
+export interface HarnessConfig {
+ name: string;
+ executionRoleArn?: string;
+ memoryName?: string;
+ containerUri?: string;
+ hasDockerfile?: boolean;
+ dockerfile?: string;
+ codeLocation?: string;
+ tools?: { type: string; name: string }[];
+ apiKeyArn?: string;
+}
+
export interface AgentCoreStackProps extends StackProps {
/**
* The AgentCore project specification containing agents, memories, and credentials.
@@ -278,6 +327,10 @@ export interface AgentCoreStackProps extends StackProps {
* Credential provider ARNs from deployed state, keyed by credential name.
*/
credentials?: Record;
+ /**
+ * Harness role configurations.
+ */
+ harnesses?: HarnessConfig[];
}
/**
@@ -293,12 +346,15 @@ export class AgentCoreStack extends Stack {
constructor(scope: Construct, id: string, props: AgentCoreStackProps) {
super(scope, id, props);
- const { spec, mcpSpec, credentials } = props;
+ const { spec, mcpSpec, credentials, harnesses } = props;
- // Create AgentCoreApplication with all agents
- this.application = new AgentCoreApplication(this, 'Application', {
- spec,
- });
+ // Create AgentCoreApplication with all agents and harness roles
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const appProps: Record = { spec };
+ if (harnesses?.length) {
+ appProps.harnesses = harnesses;
+ }
+ this.application = new AgentCoreApplication(this, 'Application', appProps as any);
// Create AgentCoreMcp if there are gateways configured
if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) {
@@ -454,6 +510,7 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f
"evaluators/python-lambda/execution-role-policy.json",
"evaluators/python-lambda/lambda_function.py",
"evaluators/python-lambda/pyproject.toml",
+ "harness/invoke.py.template",
"mcp/python-lambda/README.md",
"mcp/python-lambda/handler.py",
"mcp/python-lambda/pyproject.toml",
diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts
index 7a78b71cd..7969869c3 100644
--- a/src/assets/cdk/bin/cdk.ts
+++ b/src/assets/cdk/bin/cdk.ts
@@ -54,6 +54,42 @@ async function main() {
throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json');
}
+ // Read harness configs for role creation.
+ const projectRoot = path.resolve(configRoot, '..');
+ const harnessConfigs: {
+ name: string;
+ executionRoleArn?: string;
+ memoryName?: string;
+ containerUri?: string;
+ hasDockerfile?: boolean;
+ dockerfile?: string;
+ codeLocation?: string;
+ tools?: { type: string; name: string }[];
+ apiKeyArn?: string;
+ }[] = [];
+ for (const entry of specAny.harnesses ?? []) {
+ const harnessDir = path.resolve(projectRoot, entry.path);
+ const harnessPath = path.resolve(harnessDir, 'harness.json');
+ try {
+ const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8'));
+ harnessConfigs.push({
+ name: entry.name,
+ executionRoleArn: harnessSpec.executionRoleArn,
+ memoryName: harnessSpec.memory?.name,
+ containerUri: harnessSpec.containerUri,
+ hasDockerfile: !!harnessSpec.dockerfile,
+ dockerfile: harnessSpec.dockerfile,
+ codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,
+ tools: harnessSpec.tools,
+ apiKeyArn: harnessSpec.model?.apiKeyArn,
+ });
+ } catch (err) {
+ throw new Error(
+ `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}`
+ );
+ }
+ }
+
const app = new App();
for (const target of targets) {
@@ -73,6 +109,7 @@ async function main() {
spec,
mcpSpec,
credentials,
+ harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined,
env,
description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`,
tags: {
diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts
index a4d277821..23b070896 100644
--- a/src/assets/cdk/lib/cdk-stack.ts
+++ b/src/assets/cdk/lib/cdk-stack.ts
@@ -7,6 +7,18 @@ import {
import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
+export interface HarnessConfig {
+ name: string;
+ executionRoleArn?: string;
+ memoryName?: string;
+ containerUri?: string;
+ hasDockerfile?: boolean;
+ dockerfile?: string;
+ codeLocation?: string;
+ tools?: { type: string; name: string }[];
+ apiKeyArn?: string;
+}
+
export interface AgentCoreStackProps extends StackProps {
/**
* The AgentCore project specification containing agents, memories, and credentials.
@@ -20,6 +32,10 @@ export interface AgentCoreStackProps extends StackProps {
* Credential provider ARNs from deployed state, keyed by credential name.
*/
credentials?: Record;
+ /**
+ * Harness role configurations.
+ */
+ harnesses?: HarnessConfig[];
}
/**
@@ -35,12 +51,15 @@ export class AgentCoreStack extends Stack {
constructor(scope: Construct, id: string, props: AgentCoreStackProps) {
super(scope, id, props);
- const { spec, mcpSpec, credentials } = props;
+ const { spec, mcpSpec, credentials, harnesses } = props;
- // Create AgentCoreApplication with all agents
- this.application = new AgentCoreApplication(this, 'Application', {
- spec,
- });
+ // Create AgentCoreApplication with all agents and harness roles
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const appProps: Record = { spec };
+ if (harnesses?.length) {
+ appProps.harnesses = harnesses;
+ }
+ this.application = new AgentCoreApplication(this, 'Application', appProps as any);
// Create AgentCoreMcp if there are gateways configured
if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) {
diff --git a/src/assets/harness/invoke.py.template b/src/assets/harness/invoke.py.template
new file mode 100644
index 000000000..cf2527f44
--- /dev/null
+++ b/src/assets/harness/invoke.py.template
@@ -0,0 +1,74 @@
+"""
+Standalone invoke script for AgentCore Harness.
+Generated by: agentcore create --with-invoke-script
+
+Usage:
+ pip install boto3
+ export HARNESS_ARN="arn:aws:bedrock-agentcore:::harness/"
+ python invoke.py "Hello, what can you do?"
+ python invoke.py --raw-events "Hello"
+"""
+
+import argparse
+import json
+import os
+import sys
+import uuid
+
+import boto3
+
+# --- Configuration ---
+HARNESS_ARN = os.environ.get("HARNESS_ARN", "{{HARNESS_ARN}}")
+REGION = os.environ.get("AWS_REGION", "{{REGION}}")
+SESSION_ID = os.environ.get("SESSION_ID", str(uuid.uuid4()))
+
+parser = argparse.ArgumentParser(description="Invoke an AgentCore Harness")
+parser.add_argument("prompt", nargs="?", default="Hello!", help="Prompt to send to the agent")
+parser.add_argument("--raw-events", action="store_true", help="Print raw streaming events as JSON")
+parser.add_argument("--session-id", default=SESSION_ID, help="Session ID for conversation continuity")
+args = parser.parse_args()
+
+client = boto3.client("bedrock-agentcore", region_name=REGION)
+
+response = client.invoke_harness(
+ harnessArn=HARNESS_ARN,
+ runtimeSessionId=args.session_id,
+ messages=[
+ {
+ "role": "user",
+ "content": [{"text": args.prompt}],
+ }
+ ],
+)
+
+for event in response["stream"]:
+ if args.raw_events:
+ print(json.dumps(event, default=str))
+ else:
+ if "contentBlockStart" in event:
+ start = event["contentBlockStart"].get("start", {})
+ if "toolUse" in start:
+ tool = start["toolUse"]
+ print(f"\n🔧 Tool: {tool.get('name', 'unknown')}", flush=True)
+ elif "contentBlockDelta" in event:
+ delta = event["contentBlockDelta"].get("delta", {})
+ if "text" in delta:
+ print(delta["text"], end="", flush=True)
+ elif "messageStop" in event:
+ stop_reason = event["messageStop"].get("stopReason", "")
+ if stop_reason == "end_turn":
+ print()
+ elif "metadata" in event:
+ usage = event["metadata"].get("usage", {})
+ metrics = event["metadata"].get("metrics", {})
+ latency = metrics.get("latencyMs", 0) / 1000
+ print(
+ f"\n⚡ {usage.get('inputTokens', 0)} in · "
+ f"{usage.get('outputTokens', 0)} out · "
+ f"{latency:.1f}s",
+ file=sys.stderr,
+ )
+ elif "internalServerException" in event:
+ print(f"\nError: {event['internalServerException']}", file=sys.stderr)
+
+print(f"\n🔗 Session: {args.session_id}", file=sys.stderr)
diff --git a/src/cli/__tests__/preview-flag.test.ts b/src/cli/__tests__/preview-flag.test.ts
new file mode 100644
index 000000000..d54fa3327
--- /dev/null
+++ b/src/cli/__tests__/preview-flag.test.ts
@@ -0,0 +1,49 @@
+import { execSync } from 'child_process';
+import { mkdtempSync, readFileSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { afterAll, beforeAll, describe, expect, test } from 'vitest';
+
+describe('Preview feature flag', () => {
+ test('isPreviewEnabled returns false when __PREVIEW__ is false', async () => {
+ const { isPreviewEnabled } = await import('../feature-flags');
+ expect(isPreviewEnabled()).toBe(false);
+ });
+
+ describe('dead code elimination', () => {
+ let tempDir: string;
+
+ beforeAll(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'preview-flag-test-'));
+ });
+
+ afterAll(() => {
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ test('GA build contains no harness code', () => {
+ const outfile = join(tempDir, 'ga-bundle.mjs');
+ execSync(`node esbuild.config.mjs`, {
+ cwd: process.cwd(),
+ env: { ...process.env, BUILD_PREVIEW: undefined, ESBUILD_OUTFILE: outfile },
+ stdio: 'pipe',
+ });
+ const bundle = readFileSync(outfile, 'utf-8');
+ // harness-deployer is a standalone module that should be fully eliminated
+ expect(bundle).not.toContain('harness-deployer');
+ // imperativeManager is only instantiated inside isPreviewEnabled() guards
+ expect(bundle).not.toContain('imperativeManager');
+ });
+
+ test('Preview build contains harness code', () => {
+ const outfile = join(tempDir, 'preview-bundle.mjs');
+ execSync(`node esbuild.config.mjs`, {
+ cwd: process.cwd(),
+ env: { ...process.env, BUILD_PREVIEW: '1', ESBUILD_OUTFILE: outfile },
+ stdio: 'pipe',
+ });
+ const bundle = readFileSync(outfile, 'utf-8');
+ expect(bundle).toContain('harness');
+ });
+ });
+});
diff --git a/src/cli/aws/__tests__/agentcore-harness.test.ts b/src/cli/aws/__tests__/agentcore-harness.test.ts
new file mode 100644
index 000000000..7d14d776c
--- /dev/null
+++ b/src/cli/aws/__tests__/agentcore-harness.test.ts
@@ -0,0 +1,451 @@
+import {
+ createHarness,
+ deleteHarness,
+ getHarness,
+ invokeHarness,
+ listAllHarnesses,
+ listHarnesses,
+ updateHarness,
+} from '../agentcore-harness.js';
+import { EventStreamCodec } from '@smithy/eventstream-codec';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { mockRequest, mockRequestRaw } = vi.hoisted(() => ({
+ mockRequest: vi.fn(),
+ mockRequestRaw: vi.fn(),
+}));
+
+vi.mock('../api-client', () => ({
+ AgentCoreApiClient: class {
+ request = mockRequest;
+ requestRaw = mockRequestRaw;
+ },
+ AgentCoreApiError: class extends Error {
+ statusCode: number;
+ requestId: string | undefined;
+ errorBody: string;
+ constructor(statusCode: number, errorBody: string, requestId?: string) {
+ super(`AgentCore API error (${statusCode}): ${errorBody}`);
+ this.statusCode = statusCode;
+ this.requestId = requestId;
+ this.errorBody = errorBody;
+ }
+ },
+}));
+
+describe('Harness control plane operations', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('createHarness', () => {
+ it('sends POST /harnesses with correct body', async () => {
+ const harness = { harnessId: 'h-123', harnessName: 'test', status: 'CREATING' };
+ mockRequest.mockResolvedValue({ harness });
+
+ const result = await createHarness({
+ region: 'us-west-2',
+ harnessName: 'test',
+ executionRoleArn: 'arn:aws:iam::123:role/TestRole',
+ model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } },
+ systemPrompt: [{ text: 'You are helpful.' }],
+ tools: [{ type: 'agentcore_browser', name: 'browser' }],
+ maxIterations: 75,
+ });
+
+ expect(result.harness.harnessId).toBe('h-123');
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'POST',
+ path: '/harnesses',
+ body: expect.objectContaining({
+ harnessName: 'test',
+ executionRoleArn: 'arn:aws:iam::123:role/TestRole',
+ clientToken: expect.any(String),
+ model: { bedrockModelConfig: { modelId: 'us.anthropic.claude-sonnet-4-6-20250514-v1:0' } },
+ systemPrompt: [{ text: 'You are helpful.' }],
+ tools: [{ type: 'agentcore_browser', name: 'browser' }],
+ maxIterations: 75,
+ }),
+ })
+ );
+ });
+
+ it('omits optional fields when not provided', async () => {
+ mockRequest.mockResolvedValue({ harness: { harnessId: 'h-1' } });
+
+ await createHarness({
+ region: 'us-west-2',
+ harnessName: 'minimal',
+ executionRoleArn: 'arn:aws:iam::123:role/R',
+ });
+
+ const body = mockRequest.mock.calls[0]![0].body;
+ expect(body.model).toBeUndefined();
+ expect(body.tools).toBeUndefined();
+ expect(body.memory).toBeUndefined();
+ expect(body.maxIterations).toBeUndefined();
+ });
+ });
+
+ describe('getHarness', () => {
+ it('sends GET /harnesses/{harnessId}', async () => {
+ const harness = { harnessId: 'h-123', status: 'READY' };
+ mockRequest.mockResolvedValue({ harness });
+
+ const result = await getHarness({ region: 'us-west-2', harnessId: 'h-123' });
+
+ expect(result.harness.status).toBe('READY');
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'GET',
+ path: '/harnesses/h-123',
+ })
+ );
+ });
+ });
+
+ describe('updateHarness', () => {
+ it('sends PATCH /harnesses/{harnessId}', async () => {
+ mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'UPDATING' } });
+
+ await updateHarness({
+ region: 'us-west-2',
+ harnessId: 'h-123',
+ model: { bedrockModelConfig: { modelId: 'new-model' } },
+ maxTokens: 4096,
+ });
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'PATCH',
+ path: '/harnesses/h-123',
+ body: expect.objectContaining({
+ clientToken: expect.any(String),
+ model: { bedrockModelConfig: { modelId: 'new-model' } },
+ maxTokens: 4096,
+ }),
+ })
+ );
+ });
+
+ it('passes nullable wrapper fields for memory and environmentArtifact', async () => {
+ mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123' } });
+
+ await updateHarness({
+ region: 'us-west-2',
+ harnessId: 'h-123',
+ memory: { optionalValue: null },
+ environmentArtifact: { optionalValue: null },
+ });
+
+ const body = mockRequest.mock.calls[0]![0].body;
+ expect(body.memory).toEqual({ optionalValue: null });
+ expect(body.environmentArtifact).toEqual({ optionalValue: null });
+ });
+ });
+
+ describe('deleteHarness', () => {
+ it('sends DELETE /harnesses/{harnessId} with clientToken query param', async () => {
+ mockRequest.mockResolvedValue({ harness: { harnessId: 'h-123', status: 'DELETING' } });
+
+ await deleteHarness({ region: 'us-west-2', harnessId: 'h-123' });
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'DELETE',
+ path: '/harnesses/h-123',
+ query: { clientToken: expect.any(String) },
+ })
+ );
+ });
+ });
+
+ describe('listHarnesses', () => {
+ it('sends GET /harnesses with query params', async () => {
+ mockRequest.mockResolvedValue({
+ harnesses: [{ harnessId: 'h-1', harnessName: 'one' }],
+ nextToken: undefined,
+ });
+
+ const result = await listHarnesses({ region: 'us-west-2', maxResults: 10 });
+
+ expect(result.harnesses).toHaveLength(1);
+ expect(mockRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'GET',
+ path: '/harnesses',
+ query: { maxResults: '10' },
+ })
+ );
+ });
+ });
+
+ describe('listAllHarnesses', () => {
+ it('auto-paginates across multiple pages', async () => {
+ mockRequest
+ .mockResolvedValueOnce({
+ harnesses: [{ harnessId: 'h-1' }],
+ nextToken: 'tok-1',
+ })
+ .mockResolvedValueOnce({
+ harnesses: [{ harnessId: 'h-2' }],
+ nextToken: undefined,
+ });
+
+ const all = await listAllHarnesses('us-west-2');
+
+ expect(all).toHaveLength(2);
+ expect(all[0]!.harnessId).toBe('h-1');
+ expect(all[1]!.harnessId).toBe('h-2');
+ expect(mockRequest).toHaveBeenCalledTimes(2);
+ });
+ });
+});
+
+describe('invokeHarness (streaming)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const toUtf8 = (input: Uint8Array) => new TextDecoder().decode(input);
+ const fromUtf8 = (input: string) => new TextEncoder().encode(input);
+ const codec = new EventStreamCodec(toUtf8, fromUtf8);
+
+ function encodeEvent(eventType: string, payload: Record): Uint8Array {
+ return codec.encode({
+ headers: {
+ ':event-type': { type: 'string', value: eventType },
+ ':content-type': { type: 'string', value: 'application/json' },
+ ':message-type': { type: 'string', value: 'event' },
+ },
+ body: fromUtf8(JSON.stringify(payload)),
+ });
+ }
+
+ function makeStreamResponse(frames: Uint8Array[]): Response {
+ let totalLen = 0;
+ for (const f of frames) totalLen += f.length;
+ const combined = new Uint8Array(totalLen);
+ let off = 0;
+ for (const f of frames) {
+ combined.set(f, off);
+ off += f.length;
+ }
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(combined);
+ controller.close();
+ },
+ });
+ return new Response(stream, { status: 200 });
+ }
+
+ it('yields messageStart events', async () => {
+ mockRequestRaw.mockResolvedValue(makeStreamResponse([encodeEvent('messageStart', { role: 'assistant' })]));
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:aws:bedrock-agentcore:us-west-2:123:harness/h-123',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hello' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events).toHaveLength(1);
+ expect(events[0]).toEqual({ type: 'messageStart', role: 'assistant' });
+ });
+
+ it('yields text deltas', async () => {
+ mockRequestRaw.mockResolvedValue(
+ makeStreamResponse([
+ encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: 'Hello' } }),
+ encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: ' world' } }),
+ ])
+ );
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hi' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events).toHaveLength(2);
+ expect(events[0]).toEqual({
+ type: 'contentBlockDelta',
+ contentBlockIndex: 0,
+ delta: { type: 'text', text: 'Hello' },
+ });
+ expect(events[1]).toEqual({
+ type: 'contentBlockDelta',
+ contentBlockIndex: 0,
+ delta: { type: 'text', text: ' world' },
+ });
+ });
+
+ it('yields tool use start events', async () => {
+ mockRequestRaw.mockResolvedValue(
+ makeStreamResponse([
+ encodeEvent('contentBlockStart', {
+ contentBlockIndex: 1,
+ start: { toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' } },
+ }),
+ ])
+ );
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'search' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events).toHaveLength(1);
+ expect(events[0]).toEqual({
+ type: 'contentBlockStart',
+ contentBlockIndex: 1,
+ start: {
+ type: 'toolUse',
+ toolUse: { toolUseId: 'tu-1', name: 'exa_search', type: 'remote_mcp', serverName: 'exa' },
+ },
+ });
+ });
+
+ it('yields messageStop with stopReason', async () => {
+ mockRequestRaw.mockResolvedValue(makeStreamResponse([encodeEvent('messageStop', { stopReason: 'end_turn' })]));
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hi' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({ type: 'messageStop', stopReason: 'end_turn' });
+ });
+
+ it('yields metadata with token usage', async () => {
+ mockRequestRaw.mockResolvedValue(
+ makeStreamResponse([
+ encodeEvent('metadata', {
+ usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
+ metrics: { latencyMs: 1200 },
+ }),
+ ])
+ );
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hi' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({
+ type: 'metadata',
+ usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
+ metrics: { latencyMs: 1200 },
+ });
+ });
+
+ it('yields error events for exception event types', async () => {
+ mockRequestRaw.mockResolvedValue(
+ makeStreamResponse([encodeEvent('internalServerException', { message: 'Something broke' })])
+ );
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hi' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events[0]).toEqual({
+ type: 'error',
+ errorType: 'internalServerException',
+ message: 'Something broke',
+ });
+ });
+
+ it('passes override options in request body', async () => {
+ mockRequestRaw.mockResolvedValue(makeStreamResponse([]));
+
+ for await (const _event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hi' }] }],
+ model: { bedrockModelConfig: { modelId: 'override-model' } },
+ maxIterations: 20,
+ skills: [{ path: './skills/research' }],
+ })) {
+ // drain
+ }
+
+ expect(mockRequestRaw).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'POST',
+ path: '/harnesses/invoke',
+ query: { harnessArn: 'arn:harness' },
+ headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-1' },
+ body: expect.objectContaining({
+ model: { bedrockModelConfig: { modelId: 'override-model' } },
+ maxIterations: 20,
+ skills: [{ path: './skills/research' }],
+ }),
+ })
+ );
+ });
+
+ it('handles multiple event types in sequence', async () => {
+ mockRequestRaw.mockResolvedValue(
+ makeStreamResponse([
+ encodeEvent('messageStart', { role: 'assistant' }),
+ encodeEvent('contentBlockDelta', { contentBlockIndex: 0, delta: { text: 'Hi' } }),
+ encodeEvent('contentBlockStop', { contentBlockIndex: 0 }),
+ encodeEvent('messageStop', { stopReason: 'end_turn' }),
+ encodeEvent('metadata', {
+ usage: { inputTokens: 10, outputTokens: 1, totalTokens: 11 },
+ metrics: { latencyMs: 100 },
+ }),
+ ])
+ );
+
+ const events = [];
+ for await (const event of invokeHarness({
+ region: 'us-west-2',
+ harnessArn: 'arn:harness',
+ runtimeSessionId: 'sess-1',
+ messages: [{ role: 'user', content: [{ text: 'hi' }] }],
+ })) {
+ events.push(event);
+ }
+
+ expect(events).toHaveLength(5);
+ expect(events.map(e => e.type)).toEqual([
+ 'messageStart',
+ 'contentBlockDelta',
+ 'contentBlockStop',
+ 'messageStop',
+ 'metadata',
+ ]);
+ });
+});
diff --git a/src/cli/aws/__tests__/api-client.test.ts b/src/cli/aws/__tests__/api-client.test.ts
new file mode 100644
index 000000000..5ebc1ebd3
--- /dev/null
+++ b/src/cli/aws/__tests__/api-client.test.ts
@@ -0,0 +1,185 @@
+import { AgentCoreApiClient, AgentCoreApiError } from '../api-client.js';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { mockSign } = vi.hoisted(() => ({
+ mockSign: vi.fn(),
+}));
+
+vi.mock('../account', () => ({
+ getCredentialProvider: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock('@smithy/signature-v4', () => ({
+ SignatureV4: class {
+ sign = mockSign;
+ },
+}));
+
+vi.mock('@smithy/protocol-http', () => ({
+ HttpRequest: class {
+ constructor(public opts: unknown) {}
+ },
+}));
+
+vi.mock('@aws-crypto/sha256-js', () => ({
+ Sha256: class {},
+}));
+
+vi.mock('@aws-sdk/credential-provider-node', () => ({
+ defaultProvider: vi.fn().mockReturnValue({}),
+}));
+
+const mockFetch = vi.fn();
+vi.stubGlobal('fetch', mockFetch);
+
+describe('AgentCoreApiClient', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ delete process.env.AGENTCORE_STAGE;
+ mockSign.mockResolvedValue({ headers: { host: 'example.com', 'content-type': 'application/json' } });
+ });
+
+ describe('endpoint resolution', () => {
+ it('uses control plane prod endpoint by default', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
+
+ await client.request({ method: 'GET', path: '/test' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining('bedrock-agentcore-control.us-west-2.amazonaws.com'),
+ expect.anything()
+ );
+ });
+
+ it('uses data plane prod endpoint', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-east-1', plane: 'data' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
+
+ await client.request({ method: 'GET', path: '/test' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining('bedrock-agentcore.us-east-1.amazonaws.com'),
+ expect.anything()
+ );
+ });
+
+ it('uses beta control plane endpoint when AGENTCORE_STAGE=beta', async () => {
+ process.env.AGENTCORE_STAGE = 'beta';
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
+
+ await client.request({ method: 'GET', path: '/test' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining('beta.us-west-2.elcapcp.genesis-primitives.aws.dev'),
+ expect.anything()
+ );
+ });
+
+ it('uses gamma data plane endpoint when AGENTCORE_STAGE=gamma', async () => {
+ process.env.AGENTCORE_STAGE = 'gamma';
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
+
+ await client.request({ method: 'GET', path: '/test' });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.stringContaining('gamma.us-west-2.elcapdp.genesis-primitives.aws.dev'),
+ expect.anything()
+ );
+ });
+ });
+
+ describe('request()', () => {
+ it('returns parsed JSON on success', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ harnessId: 'h-123' }), { status: 200 }));
+
+ const result = await client.request({ method: 'GET', path: '/harnesses/h-123' });
+
+ expect(result).toEqual({ harnessId: 'h-123' });
+ });
+
+ it('returns empty object on 204', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(new Response(null, { status: 204 }));
+
+ const result = await client.request({ method: 'DELETE', path: '/harnesses/h-123' });
+
+ expect(result).toEqual({});
+ });
+
+ it('throws AgentCoreApiError on non-2xx', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(
+ new Response('{"message":"Not found"}', {
+ status: 404,
+ headers: { 'x-amzn-requestid': 'req-abc' },
+ })
+ );
+
+ const err = await client.request({ method: 'GET', path: '/harnesses/bad' }).catch((e: unknown) => e);
+ expect(err).toBeInstanceOf(AgentCoreApiError);
+ const apiErr = err as AgentCoreApiError;
+ expect(apiErr.statusCode).toBe(404);
+ expect(apiErr.requestId).toBe('req-abc');
+ expect(apiErr.errorBody).toContain('Not found');
+ });
+
+ it('sends JSON body when provided', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 201 }));
+
+ await client.request({ method: 'POST', path: '/harnesses', body: { harnessName: 'test' } });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ body: JSON.stringify({ harnessName: 'test' }) })
+ );
+ });
+
+ it('appends query parameters to URL', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'control' });
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
+
+ await client.request({ method: 'GET', path: '/harnesses', query: { maxResults: '10' } });
+
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('maxResults=10'), expect.anything());
+ });
+ });
+
+ describe('requestRaw()', () => {
+ it('returns raw Response object', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' });
+ const mockResponse = new Response('streaming data', { status: 200 });
+ mockFetch.mockResolvedValue(mockResponse);
+
+ const response = await client.requestRaw({ method: 'POST', path: '/harnesses/invoke' });
+
+ expect(response).toBe(mockResponse);
+ expect(response.status).toBe(200);
+ });
+
+ it('passes custom headers through', async () => {
+ const client = new AgentCoreApiClient({ region: 'us-west-2', plane: 'data' });
+ mockFetch.mockResolvedValue(new Response('', { status: 200 }));
+
+ await client.requestRaw({
+ method: 'POST',
+ path: '/harnesses/invoke',
+ headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-123' },
+ });
+
+ expect(mockSign).toHaveBeenCalledWith(
+ expect.objectContaining({
+ opts: expect.objectContaining({
+ headers: expect.objectContaining({
+ 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': 'sess-123',
+ }),
+ }),
+ })
+ );
+ });
+ });
+});
diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts
index 54e89eb3e..832f44d70 100644
--- a/src/cli/aws/agentcore-control.ts
+++ b/src/cli/aws/agentcore-control.ts
@@ -409,7 +409,8 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise {
+ const rawKeys = memory.indexedKeys;
+ const indexedKeys = rawKeys?.flatMap(k => {
if (!k.key || !k.type) {
console.warn(`Warning: Skipping malformed indexed key from API response: ${JSON.stringify(k)}`);
return [];
diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts
new file mode 100644
index 000000000..608e52dde
--- /dev/null
+++ b/src/cli/aws/agentcore-harness.ts
@@ -0,0 +1,656 @@
+/**
+ * Typed client wrappers for Harness control plane and data plane operations.
+ *
+ * Control plane: CreateHarness, GetHarness, UpdateHarness, DeleteHarness, ListHarnesses
+ * Data plane: InvokeHarness (streaming)
+ * TODO InvokeAgentRuntimeCommand
+ *
+ * Built on AgentCoreApiClient (shared SigV4 HTTP client).
+ * Migrate to @aws-sdk/client-bedrock-agentcore-control when Harness commands land in the SDK.
+ */
+import { AgentCoreApiClient, AgentCoreApiError, resolveEndpoint } from './api-client';
+import { randomUUID } from 'node:crypto';
+
+// ============================================================================
+// Shared Types (from Smithy service model)
+// ============================================================================
+
+export type HarnessStatus = 'CREATING' | 'READY' | 'UPDATING' | 'DELETING' | 'DELETED' | 'FAILED';
+
+export interface BedrockModelConfig {
+ modelId: string;
+ temperature?: number;
+ topP?: number;
+ maxTokens?: number;
+}
+
+export interface OpenAiModelConfig {
+ modelId: string;
+ apiKeyArn?: string;
+ temperature?: number;
+ topP?: number;
+ maxTokens?: number;
+}
+
+export interface GeminiModelConfig {
+ modelId: string;
+ apiKeyArn?: string;
+ temperature?: number;
+ topP?: number;
+ topK?: number;
+ maxTokens?: number;
+}
+
+export interface HarnessModelConfiguration {
+ bedrockModelConfig?: BedrockModelConfig;
+ openAiModelConfig?: OpenAiModelConfig;
+ geminiModelConfig?: GeminiModelConfig;
+}
+
+export type HarnessSystemPrompt = { text: string }[];
+
+export interface HarnessTool {
+ type: string;
+ name: string;
+ browserArn?: string;
+ codeInterpreterArn?: string;
+ config?: Record;
+}
+
+export interface HarnessSkill {
+ path: string;
+}
+
+export interface HarnessAgentCoreMemoryConfiguration {
+ arn: string;
+ actorId?: string;
+ messagesCount?: number;
+ retrievalConfig?: Record;
+}
+
+export interface HarnessMemoryConfiguration {
+ agentCoreMemoryConfiguration: HarnessAgentCoreMemoryConfiguration;
+}
+
+export interface HarnessTruncationConfiguration {
+ strategy: string;
+ config: { slidingWindow?: { messagesCount: number } };
+}
+
+export interface HarnessEnvironmentArtifact {
+ containerConfiguration?: { containerUri: string };
+}
+
+export interface HarnessAgentCoreRuntimeEnvironment {
+ agentRuntimeArn?: string;
+ agentRuntimeId?: string;
+ agentRuntimeName?: string;
+ lifecycleConfiguration?: Record;
+ networkConfiguration?: Record;
+ filesystemConfigurations?: Record[];
+}
+
+export interface HarnessEnvironmentProvider {
+ agentCoreRuntimeEnvironment?: HarnessAgentCoreRuntimeEnvironment;
+}
+
+export interface Harness {
+ harnessId: string;
+ harnessName: string;
+ arn: string;
+ status: HarnessStatus;
+ executionRoleArn: string;
+ model?: HarnessModelConfiguration;
+ systemPrompt?: HarnessSystemPrompt;
+ tools?: HarnessTool[];
+ skills?: HarnessSkill[];
+ allowedTools?: string[];
+ memory?: HarnessMemoryConfiguration;
+ truncation?: HarnessTruncationConfiguration;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ environment?: HarnessEnvironmentProvider;
+ environmentArtifact?: HarnessEnvironmentArtifact;
+ environmentVariables?: Record;
+ authorizerConfiguration?: Record;
+ tags?: Record;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface HarnessSummary {
+ harnessId: string;
+ harnessName: string;
+ arn: string;
+ status: HarnessStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+// ============================================================================
+// CreateHarness
+// ============================================================================
+
+export interface CreateHarnessOptions {
+ region: string;
+ harnessName: string;
+ executionRoleArn: string;
+ environment?: HarnessEnvironmentProvider;
+ environmentArtifact?: HarnessEnvironmentArtifact;
+ environmentVariables?: Record;
+ authorizerConfiguration?: Record;
+ model?: HarnessModelConfiguration;
+ systemPrompt?: HarnessSystemPrompt;
+ tools?: HarnessTool[];
+ skills?: HarnessSkill[];
+ allowedTools?: string[];
+ memory?: HarnessMemoryConfiguration;
+ truncation?: HarnessTruncationConfiguration;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ tags?: Record;
+}
+
+export interface CreateHarnessResult {
+ harness: Harness;
+}
+
+export async function createHarness(options: CreateHarnessOptions): Promise {
+ const { region, ...rest } = options;
+ const client = new AgentCoreApiClient({ region, plane: 'control' });
+
+ const body: Record = {
+ harnessName: rest.harnessName,
+ clientToken: randomUUID(),
+ executionRoleArn: rest.executionRoleArn,
+ };
+
+ if (rest.environment) body.environment = rest.environment;
+ if (rest.environmentArtifact) body.environmentArtifact = rest.environmentArtifact;
+ if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables;
+ if (rest.authorizerConfiguration) body.authorizerConfiguration = rest.authorizerConfiguration;
+ if (rest.model) body.model = rest.model;
+ if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt;
+ if (rest.tools) body.tools = rest.tools;
+ if (rest.skills) body.skills = rest.skills;
+ if (rest.allowedTools) body.allowedTools = rest.allowedTools;
+ if (rest.memory) body.memory = rest.memory;
+ if (rest.truncation) body.truncation = rest.truncation;
+ if (rest.maxIterations != null) body.maxIterations = rest.maxIterations;
+ if (rest.maxTokens != null) body.maxTokens = rest.maxTokens;
+ if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds;
+ if (rest.tags) body.tags = rest.tags;
+
+ const result = await client.request({ method: 'POST', path: '/harnesses', body });
+ return result as CreateHarnessResult;
+}
+
+// ============================================================================
+// GetHarness
+// ============================================================================
+
+export interface GetHarnessOptions {
+ region: string;
+ harnessId: string;
+}
+
+export interface GetHarnessResult {
+ harness: Harness;
+}
+
+export async function getHarness(options: GetHarnessOptions): Promise {
+ const client = new AgentCoreApiClient({ region: options.region, plane: 'control' });
+ const result = await client.request({ method: 'GET', path: `/harnesses/${options.harnessId}` });
+ return result as GetHarnessResult;
+}
+
+// ============================================================================
+// UpdateHarness
+// ============================================================================
+
+export interface UpdateHarnessOptions {
+ region: string;
+ harnessId: string;
+ executionRoleArn?: string;
+ environment?: HarnessEnvironmentProvider;
+ environmentArtifact?: { optionalValue: HarnessEnvironmentArtifact | null };
+ environmentVariables?: Record;
+ authorizerConfiguration?: { optionalValue: Record | null };
+ model?: HarnessModelConfiguration;
+ systemPrompt?: HarnessSystemPrompt;
+ tools?: HarnessTool[];
+ skills?: HarnessSkill[];
+ allowedTools?: string[];
+ memory?: { optionalValue: HarnessMemoryConfiguration | null };
+ truncation?: HarnessTruncationConfiguration;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ tags?: Record;
+}
+
+export interface UpdateHarnessResult {
+ harness: Harness;
+}
+
+export async function updateHarness(options: UpdateHarnessOptions): Promise {
+ const { region, harnessId, ...rest } = options;
+ const client = new AgentCoreApiClient({ region, plane: 'control' });
+
+ const body: Record = {
+ clientToken: randomUUID(),
+ };
+
+ if (rest.executionRoleArn) body.executionRoleArn = rest.executionRoleArn;
+ if (rest.environment) body.environment = rest.environment;
+ if (rest.environmentArtifact !== undefined) body.environmentArtifact = rest.environmentArtifact;
+ if (rest.environmentVariables) body.environmentVariables = rest.environmentVariables;
+ if (rest.authorizerConfiguration !== undefined) body.authorizerConfiguration = rest.authorizerConfiguration;
+ if (rest.model) body.model = rest.model;
+ if (rest.systemPrompt) body.systemPrompt = rest.systemPrompt;
+ if (rest.tools) body.tools = rest.tools;
+ if (rest.skills) body.skills = rest.skills;
+ if (rest.allowedTools) body.allowedTools = rest.allowedTools;
+ if (rest.memory !== undefined) body.memory = rest.memory;
+ if (rest.truncation) body.truncation = rest.truncation;
+ if (rest.maxIterations != null) body.maxIterations = rest.maxIterations;
+ if (rest.maxTokens != null) body.maxTokens = rest.maxTokens;
+ if (rest.timeoutSeconds != null) body.timeoutSeconds = rest.timeoutSeconds;
+ if (rest.tags) body.tags = rest.tags;
+
+ const result = await client.request({ method: 'PATCH', path: `/harnesses/${harnessId}`, body });
+ return result as UpdateHarnessResult;
+}
+
+// ============================================================================
+// DeleteHarness
+// ============================================================================
+
+export interface DeleteHarnessOptions {
+ region: string;
+ harnessId: string;
+}
+
+export interface DeleteHarnessResult {
+ harness: Harness;
+}
+
+export async function deleteHarness(options: DeleteHarnessOptions): Promise {
+ const client = new AgentCoreApiClient({ region: options.region, plane: 'control' });
+ const result = await client.request({
+ method: 'DELETE',
+ path: `/harnesses/${options.harnessId}`,
+ query: { clientToken: randomUUID() },
+ });
+ return result as DeleteHarnessResult;
+}
+
+// ============================================================================
+// ListHarnesses
+// ============================================================================
+
+export interface ListHarnessesOptions {
+ region: string;
+ maxResults?: number;
+ nextToken?: string;
+}
+
+export interface ListHarnessesResult {
+ harnesses: HarnessSummary[];
+ nextToken?: string;
+}
+
+export async function listHarnesses(options: ListHarnessesOptions): Promise {
+ const client = new AgentCoreApiClient({ region: options.region, plane: 'control' });
+ const query: Record = {};
+ if (options.maxResults != null) query.maxResults = String(options.maxResults);
+ if (options.nextToken) query.nextToken = options.nextToken;
+
+ const result = await client.request({ method: 'GET', path: '/harnesses', query });
+ return result as ListHarnessesResult;
+}
+
+export async function listAllHarnesses(region: string): Promise {
+ const all: HarnessSummary[] = [];
+ let nextToken: string | undefined;
+
+ do {
+ const result = await listHarnesses({ region, maxResults: 100, nextToken });
+ all.push(...result.harnesses);
+ nextToken = result.nextToken;
+ } while (nextToken);
+
+ return all;
+}
+
+// ============================================================================
+// InvokeHarness (streaming, data plane)
+// ============================================================================
+
+export interface InvokeHarnessOptions {
+ region: string;
+ harnessArn: string;
+ runtimeSessionId: string;
+ messages: { role: string; content: Record[] }[];
+ model?: HarnessModelConfiguration;
+ systemPrompt?: HarnessSystemPrompt;
+ tools?: HarnessTool[];
+ skills?: HarnessSkill[];
+ allowedTools?: string[];
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ actorId?: string;
+ /** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */
+ bearerToken?: string;
+}
+
+// ── Stream event types ──────────────────────────────────────────────────────
+
+export type HarnessStopReason =
+ | 'end_turn'
+ | 'tool_use'
+ | 'tool_result'
+ | 'max_tokens'
+ | 'stop_sequence'
+ | 'content_filtered'
+ | 'malformed_model_output'
+ | 'malformed_tool_use'
+ | 'interrupted'
+ | 'partial_turn'
+ | 'model_context_window_exceeded'
+ | 'max_iterations_exceeded'
+ | 'max_output_tokens_exceeded'
+ | 'timeout_exceeded';
+
+export interface ToolUseBlockStart {
+ toolUseId: string;
+ name: string;
+ type?: string;
+ serverName?: string;
+}
+
+export interface ToolResultBlockStart {
+ toolUseId: string;
+ status?: string;
+}
+
+export type ContentBlockStart =
+ | { type: 'toolUse'; toolUse: ToolUseBlockStart }
+ | { type: 'toolResult'; toolResult: ToolResultBlockStart };
+
+export type ContentBlockDelta =
+ | { type: 'text'; text: string }
+ | { type: 'toolUse'; input: string }
+ | { type: 'toolResult'; results: Record[] }
+ | { type: 'reasoningContent'; text?: string; signature?: string };
+
+export interface TokenUsage {
+ inputTokens: number;
+ outputTokens: number;
+ totalTokens: number;
+ cacheReadInputTokens?: number;
+ cacheWriteInputTokens?: number;
+}
+
+export interface StreamMetrics {
+ latencyMs: number;
+}
+
+export type HarnessStreamEvent =
+ | { type: 'messageStart'; role: string }
+ | { type: 'contentBlockStart'; contentBlockIndex: number; start: ContentBlockStart }
+ | { type: 'contentBlockDelta'; contentBlockIndex: number; delta: ContentBlockDelta }
+ | { type: 'contentBlockStop'; contentBlockIndex: number }
+ | { type: 'messageStop'; stopReason: HarnessStopReason }
+ | { type: 'metadata'; usage: TokenUsage; metrics: StreamMetrics }
+ | { type: 'error'; errorType: string; message: string };
+
+export async function* invokeHarness(options: InvokeHarnessOptions): AsyncGenerator {
+ const { region, harnessArn, runtimeSessionId, messages, bearerToken, ...overrides } = options;
+
+ const body: Record = { messages };
+ if (overrides.model) body.model = overrides.model;
+ if (overrides.systemPrompt) body.systemPrompt = overrides.systemPrompt;
+ if (overrides.tools) body.tools = overrides.tools;
+ if (overrides.skills) body.skills = overrides.skills;
+ if (overrides.allowedTools) body.allowedTools = overrides.allowedTools;
+ if (overrides.maxIterations != null) body.maxIterations = overrides.maxIterations;
+ if (overrides.maxTokens != null) body.maxTokens = overrides.maxTokens;
+ if (overrides.timeoutSeconds != null) body.timeoutSeconds = overrides.timeoutSeconds;
+ if (overrides.actorId) body.actorId = overrides.actorId;
+
+ let response: Response;
+ if (bearerToken) {
+ response = await invokeHarnessWithBearerToken(region, harnessArn, runtimeSessionId, body, bearerToken);
+ } else {
+ const client = new AgentCoreApiClient({ region, plane: 'data' });
+ response = await client.requestRaw({
+ method: 'POST',
+ path: '/harnesses/invoke',
+ query: { harnessArn },
+ headers: { 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': runtimeSessionId },
+ body,
+ });
+ }
+
+ if (!response.ok) {
+ const errorBody = await response.text();
+ const requestId = response.headers.get('x-amzn-requestid') ?? undefined;
+ throw new AgentCoreApiError(response.status, errorBody, requestId);
+ }
+
+ if (!response.body) return;
+
+ yield* parseEventStream(response.body);
+}
+
+async function invokeHarnessWithBearerToken(
+ region: string,
+ harnessArn: string,
+ runtimeSessionId: string,
+ body: Record,
+ bearerToken: string
+): Promise {
+ const endpoint = resolveEndpoint(region, 'data');
+ const url = new URL('/harnesses/invoke', endpoint);
+ url.searchParams.set('harnessArn', harnessArn);
+
+ return fetch(url.toString(), {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${bearerToken}`,
+ 'Content-Type': 'application/json',
+ 'X-Amzn-Bedrock-AgentCore-Runtime-Session-Id': runtimeSessionId,
+ },
+ body: JSON.stringify(body),
+ });
+}
+
+async function* parseEventStream(body: ReadableStream): AsyncGenerator {
+ const { EventStreamCodec } = await import('@smithy/eventstream-codec');
+ const codec = new EventStreamCodec(toUtf8, fromUtf8);
+ const reader = body.getReader();
+ let buffer: Uint8Array = new Uint8Array(0);
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer = concatBuffers(buffer, new Uint8Array(value));
+
+ while (buffer.length >= 4) {
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
+ const totalLength = view.getUint32(0);
+ if (buffer.length < totalLength) break;
+
+ const frame = buffer.slice(0, totalLength);
+ buffer = buffer.slice(totalLength);
+
+ try {
+ const message = codec.decode(frame);
+ const headers: Record = {};
+ for (const [key, val] of Object.entries(message.headers)) {
+ headers[key] = String(val.value);
+ }
+
+ if (headers[':message-type'] === 'error') {
+ yield {
+ type: 'error',
+ errorType: headers[':error-code'] ?? 'unknown',
+ message: headers[':error-message'] ?? 'Unknown error',
+ };
+ continue;
+ }
+
+ if (headers[':message-type'] === 'exception') {
+ const exBody = new TextDecoder().decode(message.body);
+ let msg = exBody;
+ try {
+ const parsed = JSON.parse(exBody) as { message?: string };
+ msg = parsed.message ?? exBody;
+ } catch {
+ // use raw body
+ }
+ yield {
+ type: 'error',
+ errorType: headers[':exception-type'] ?? 'exception',
+ message: msg,
+ };
+ continue;
+ }
+
+ const eventType = headers[':event-type'];
+ if (!eventType) continue;
+
+ const bodyText = new TextDecoder().decode(message.body);
+ if (!bodyText) continue;
+
+ const event = parseEventPayload(eventType, bodyText);
+ if (event) yield event;
+ } catch {
+ // skip malformed frames
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+function toUtf8(input: Uint8Array): string {
+ return new TextDecoder().decode(input);
+}
+
+function fromUtf8(input: string): Uint8Array {
+ return new TextEncoder().encode(input);
+}
+
+function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
+ const result = new Uint8Array(a.length + b.length);
+ result.set(a, 0);
+ result.set(b, a.length);
+ return result;
+}
+
+function parseEventPayload(eventType: string, bodyText: string): HarnessStreamEvent | null {
+ let payload: Record;
+ try {
+ payload = JSON.parse(bodyText) as Record;
+ } catch {
+ return null;
+ }
+
+ switch (eventType) {
+ case 'messageStart':
+ return { type: 'messageStart', role: (payload.role as string) ?? 'assistant' };
+
+ case 'contentBlockStart': {
+ const start = (payload.start as Record) ?? payload;
+ return {
+ type: 'contentBlockStart',
+ contentBlockIndex: (payload.contentBlockIndex as number) ?? 0,
+ start: parseContentBlockStart(start),
+ };
+ }
+
+ case 'contentBlockDelta': {
+ const delta = (payload.delta as Record) ?? payload;
+ return {
+ type: 'contentBlockDelta',
+ contentBlockIndex: (payload.contentBlockIndex as number) ?? 0,
+ delta: parseContentBlockDelta(delta),
+ };
+ }
+
+ case 'contentBlockStop':
+ return { type: 'contentBlockStop', contentBlockIndex: (payload.contentBlockIndex as number) ?? 0 };
+
+ case 'messageStop':
+ return { type: 'messageStop', stopReason: (payload.stopReason as HarnessStopReason) ?? 'end_turn' };
+
+ case 'metadata':
+ return {
+ type: 'metadata',
+ usage: (payload.usage as TokenUsage) ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
+ metrics: (payload.metrics as StreamMetrics) ?? { latencyMs: 0 },
+ };
+
+ case 'internalServerException':
+ return {
+ type: 'error',
+ errorType: 'internalServerException',
+ message: (payload.message as string) ?? 'Internal server error',
+ };
+
+ case 'validationException':
+ return {
+ type: 'error',
+ errorType: 'validationException',
+ message: (payload.message as string) ?? 'Validation error',
+ };
+
+ case 'runtimeClientError':
+ return {
+ type: 'error',
+ errorType: 'runtimeClientError',
+ message: (payload.message as string) ?? 'Runtime client error',
+ };
+
+ default:
+ return null;
+ }
+}
+
+function parseContentBlockStart(start: Record): ContentBlockStart {
+ if ('toolUse' in start) {
+ const tu = start.toolUse as ToolUseBlockStart;
+ return { type: 'toolUse', toolUse: tu };
+ }
+ if ('toolResult' in start) {
+ const tr = start.toolResult as ToolResultBlockStart;
+ return { type: 'toolResult', toolResult: tr };
+ }
+ return { type: 'toolUse', toolUse: { toolUseId: '', name: 'unknown' } };
+}
+
+function parseContentBlockDelta(delta: Record): ContentBlockDelta {
+ if ('text' in delta) {
+ return { type: 'text', text: delta.text as string };
+ }
+ if ('toolUse' in delta) {
+ const tu = delta.toolUse as { input: string };
+ return { type: 'toolUse', input: tu.input };
+ }
+ if ('toolResult' in delta) {
+ return { type: 'toolResult', results: delta.toolResult as Record[] };
+ }
+ if ('reasoningContent' in delta) {
+ const rc = delta.reasoningContent as { text?: string; signature?: string };
+ return { type: 'reasoningContent', text: rc.text, signature: rc.signature };
+ }
+ return { type: 'text', text: '' };
+}
diff --git a/src/cli/aws/api-client.ts b/src/cli/aws/api-client.ts
new file mode 100644
index 000000000..1354615a3
--- /dev/null
+++ b/src/cli/aws/api-client.ts
@@ -0,0 +1,128 @@
+/**
+ * Shared SigV4-signed HTTP client for AgentCore control plane and data plane APIs.
+ * When the SDK adds native commands for new APIs, we will migrate callers to the SDK client.
+ */
+import { getCredentialProvider } from './account';
+import { dnsSuffix } from './partition';
+import { Sha256 } from '@aws-crypto/sha256-js';
+import { defaultProvider } from '@aws-sdk/credential-provider-node';
+import { HttpRequest } from '@smithy/protocol-http';
+import { SignatureV4 } from '@smithy/signature-v4';
+
+const SERVICE = 'bedrock-agentcore';
+
+export type ApiPlane = 'control' | 'data';
+
+export interface ApiClientOptions {
+ region: string;
+ plane: ApiPlane;
+}
+
+export interface RequestOptions {
+ method: string;
+ path: string;
+ body?: unknown;
+ query?: Record;
+ headers?: Record;
+}
+
+export class AgentCoreApiError extends Error {
+ readonly statusCode: number;
+ readonly requestId: string | undefined;
+ readonly errorBody: string;
+
+ constructor(statusCode: number, errorBody: string, requestId?: string) {
+ const reqIdSuffix = requestId ? ` [requestId: ${requestId}]` : '';
+ super(`AgentCore API error (${statusCode}): ${errorBody}${reqIdSuffix}`);
+ this.name = 'AgentCoreApiError';
+ this.statusCode = statusCode;
+ this.requestId = requestId;
+ this.errorBody = errorBody;
+ }
+}
+
+export class AgentCoreApiClient {
+ private readonly region: string;
+ private readonly endpoint: string;
+
+ constructor(options: ApiClientOptions) {
+ this.region = options.region;
+ this.endpoint = resolveEndpoint(options.region, options.plane);
+ }
+
+ async request(options: RequestOptions): Promise {
+ const response = await this.requestRaw(options);
+
+ if (!response.ok) {
+ const errorBody = await response.text();
+ const requestId = response.headers.get('x-amzn-requestid') ?? undefined;
+ throw new AgentCoreApiError(response.status, errorBody, requestId);
+ }
+
+ if (response.status === 204) return {};
+ return response.json();
+ }
+
+ async requestRaw(options: RequestOptions): Promise {
+ const { method, path, body, query, headers: extraHeaders } = options;
+
+ const url = new URL(path, this.endpoint);
+ if (query) {
+ for (const [key, value] of Object.entries(query)) {
+ url.searchParams.set(key, value);
+ }
+ }
+
+ const queryRecord: Record = {};
+ url.searchParams.forEach((value, key) => {
+ queryRecord[key] = value;
+ });
+
+ const serializedBody = body != null ? JSON.stringify(body) : undefined;
+
+ const httpRequest = new HttpRequest({
+ method,
+ protocol: 'https:',
+ hostname: url.hostname,
+ path: url.pathname,
+ ...(Object.keys(queryRecord).length > 0 && { query: queryRecord }),
+ headers: {
+ 'Content-Type': 'application/json',
+ host: url.hostname,
+ ...extraHeaders,
+ },
+ ...(serializedBody && { body: serializedBody }),
+ });
+
+ const credentials = getCredentialProvider() ?? defaultProvider();
+ const signer = new SignatureV4({
+ service: SERVICE,
+ region: this.region,
+ credentials,
+ sha256: Sha256,
+ });
+
+ const signed = await signer.sign(httpRequest);
+
+ const fullUrl = `${this.endpoint}${url.pathname}${url.search}`;
+ return fetch(fullUrl, {
+ method,
+ headers: signed.headers as Record,
+ ...(serializedBody && { body: serializedBody }),
+ });
+ }
+}
+
+export function resolveEndpoint(region: string, plane: ApiPlane): string {
+ const stage = process.env.AGENTCORE_STAGE?.toLowerCase();
+
+ if (plane === 'control') {
+ if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`;
+ if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`;
+ return `https://bedrock-agentcore-control.${region}.${dnsSuffix(region)}`;
+ }
+
+ if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`;
+ if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`;
+ return `https://bedrock-agentcore.${region}.${dnsSuffix(region)}`;
+}
diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts
index 7c87fec34..80d879044 100644
--- a/src/cli/aws/index.ts
+++ b/src/cli/aws/index.ts
@@ -26,6 +26,34 @@ export {
type GetPolicyGenerationOptions,
type GetPolicyGenerationResult,
} from './policy-generation';
+export { AgentCoreApiClient, AgentCoreApiError, type ApiClientOptions, type ApiPlane } from './api-client';
+export {
+ createHarness,
+ getHarness,
+ updateHarness,
+ deleteHarness,
+ listHarnesses,
+ listAllHarnesses,
+ invokeHarness,
+ type Harness,
+ type HarnessSummary,
+ type HarnessStatus,
+ type HarnessStreamEvent,
+ type HarnessStopReason,
+ type TokenUsage,
+ type StreamMetrics,
+ type CreateHarnessOptions,
+ type CreateHarnessResult,
+ type GetHarnessOptions,
+ type GetHarnessResult,
+ type UpdateHarnessOptions,
+ type UpdateHarnessResult,
+ type DeleteHarnessOptions,
+ type DeleteHarnessResult,
+ type ListHarnessesOptions,
+ type ListHarnessesResult,
+ type InvokeHarnessOptions,
+} from './agentcore-harness';
export {
DEFAULT_RUNTIME_USER_ID,
executeBashCommand,
diff --git a/src/cli/aws/policy-generation.ts b/src/cli/aws/policy-generation.ts
index fa75548c6..0c35db52f 100644
--- a/src/cli/aws/policy-generation.ts
+++ b/src/cli/aws/policy-generation.ts
@@ -68,6 +68,7 @@ export async function getPolicyGeneration(options: GetPolicyGenerationOptions):
{ policyGenerationId: options.generationId, policyEngineId: options.policyEngineId }
);
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (waiterResult.state !== WaiterState.SUCCESS) {
throw new Error(
`Policy generation did not complete within the timeout period (state: ${waiterResult.state}). ` +
diff --git a/src/cli/cli.ts b/src/cli/cli.ts
index 387a802ac..db18ebd83 100644
--- a/src/cli/cli.ts
+++ b/src/cli/cli.ts
@@ -1,6 +1,7 @@
import { getOrCreateInstallationId } from '../lib/schemas/io/global-config';
import { registerABTestCommand } from './commands/abtest';
import { registerAdd } from './commands/add';
+import { registerAddTool } from './commands/add/tool-command';
import { registerArchive } from './commands/archive';
import { registerConfigBundle } from './commands/config-bundle';
import { registerCreate } from './commands/create';
@@ -18,6 +19,7 @@ import { registerPackage } from './commands/package';
import { registerPause, registerPromote } from './commands/pause';
import { registerRecommendations } from './commands/recommendations';
import { registerRemove } from './commands/remove';
+import { registerRemoveTool } from './commands/remove/tool-command';
import { registerResume } from './commands/resume';
import { registerRun } from './commands/run';
import { registerStatus } from './commands/status';
@@ -27,6 +29,7 @@ import { registerTraces } from './commands/traces';
import { registerUpdate } from './commands/update';
import { registerValidate } from './commands/validate';
import { PACKAGE_VERSION } from './constants';
+import { isPreviewEnabled } from './feature-flags';
import { ALL_PRIMITIVES } from './primitives';
import { TelemetryClientAccessor } from './telemetry';
import { App } from './tui/App';
@@ -210,6 +213,12 @@ export function registerCommands(program: Command) {
primitive.registerCommands(addCmd, removeCmd);
}
+ // Register standalone add/remove subcommands (preview-only)
+ if (isPreviewEnabled()) {
+ registerAddTool(addCmd);
+ registerRemoveTool(removeCmd);
+ }
+
// Register AB test detail command
registerABTestCommand(program);
}
diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts
index 1009a31e3..f5b1173bb 100644
--- a/src/cli/cloudformation/outputs.ts
+++ b/src/cli/cloudformation/outputs.ts
@@ -421,6 +421,18 @@ export interface BuildDeployedStateOptions {
policyEngines?: Record;
policies?: Record;
runtimeEndpoints?: Record;
+ harnesses?: Record<
+ string,
+ {
+ harnessId: string;
+ harnessArn: string;
+ roleArn: string;
+ status: string;
+ agentRuntimeArn?: string;
+ memoryArn?: string;
+ configHash?: string;
+ }
+ >;
datasets?: Record;
}
@@ -442,6 +454,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta
policyEngines,
policies,
runtimeEndpoints,
+ harnesses,
datasets,
} = opts;
const targetState: TargetDeployedState = {
@@ -504,6 +517,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta
targetState.resources!.httpGateways = existingHttpGateways;
}
+ // Add harness state if harnesses exist
+ if (harnesses && Object.keys(harnesses).length > 0) {
+ targetState.resources!.harnesses = harnesses;
+ }
+
return {
targets: {
...existingState?.targets,
diff --git a/src/cli/commands/add/tool-action.ts b/src/cli/commands/add/tool-action.ts
new file mode 100644
index 000000000..cced82d81
--- /dev/null
+++ b/src/cli/commands/add/tool-action.ts
@@ -0,0 +1,177 @@
+import { ConfigIO } from '../../../lib';
+import type { HarnessGatewayOutboundAuth, HarnessSpec } from '../../../schema';
+import type { HarnessToolType } from '../../../schema/schemas/primitives/harness';
+
+export interface AddToolOptions {
+ harness: string;
+ type: string;
+ name: string;
+ url?: string;
+ browserArn?: string;
+ codeInterpreterArn?: string;
+ gatewayArn?: string;
+ gateway?: string;
+ outboundAuth?: string;
+ providerArn?: string;
+ scopes?: string;
+ grantType?: string;
+ json?: boolean;
+}
+
+const VALID_OUTBOUND_AUTH_TYPES = ['awsIam', 'none', 'oauth'] as const;
+const VALID_GRANT_TYPES = ['CLIENT_CREDENTIALS', 'USER_FEDERATION'] as const;
+const ARN_PATTERN = /^arn:[^:]+:/;
+
+export interface AddToolResult {
+ success: boolean;
+ error?: string;
+ harnessName?: string;
+ toolName?: string;
+}
+
+const VALID_TOOL_TYPES: HarnessToolType[] = [
+ 'agentcore_browser',
+ 'agentcore_code_interpreter',
+ 'remote_mcp',
+ 'agentcore_gateway',
+ 'inline_function',
+];
+
+export async function handleAddTool(options: AddToolOptions): Promise {
+ const { harness, type, name } = options;
+
+ if (!VALID_TOOL_TYPES.includes(type as HarnessToolType)) {
+ return {
+ success: false,
+ error: `Invalid tool type '${type}'. Valid types: ${VALID_TOOL_TYPES.join(', ')}`,
+ };
+ }
+
+ const toolType = type as HarnessToolType;
+
+ if (toolType === 'remote_mcp' && !options.url) {
+ return { success: false, error: '--url is required for remote_mcp tools' };
+ }
+
+ if (toolType === 'agentcore_gateway' && !options.gatewayArn && !options.gateway) {
+ return { success: false, error: '--gateway-arn or --gateway is required for agentcore_gateway tools' };
+ }
+
+ let outboundAuth: HarnessGatewayOutboundAuth | undefined;
+ if (options.outboundAuth !== undefined) {
+ if (toolType !== 'agentcore_gateway') {
+ return { success: false, error: '--outbound-auth is only valid for agentcore_gateway tools' };
+ }
+ if (!VALID_OUTBOUND_AUTH_TYPES.includes(options.outboundAuth as (typeof VALID_OUTBOUND_AUTH_TYPES)[number])) {
+ return {
+ success: false,
+ error: `Invalid --outbound-auth '${options.outboundAuth}'. Valid: ${VALID_OUTBOUND_AUTH_TYPES.join(', ')}`,
+ };
+ }
+ if (options.outboundAuth === 'awsIam' || options.outboundAuth === 'none') {
+ if (options.providerArn || options.scopes || options.grantType) {
+ return {
+ success: false,
+ error: '--provider-arn, --scopes, and --grant-type are only valid with --outbound-auth oauth',
+ };
+ }
+ outboundAuth = options.outboundAuth === 'awsIam' ? { awsIam: {} } : { none: {} };
+ } else {
+ if (!options.providerArn) {
+ return { success: false, error: '--provider-arn is required when --outbound-auth oauth' };
+ }
+ if (!ARN_PATTERN.test(options.providerArn)) {
+ return { success: false, error: `Invalid --provider-arn '${options.providerArn}': must be a valid ARN` };
+ }
+ if (!options.scopes) {
+ return { success: false, error: '--scopes is required when --outbound-auth oauth' };
+ }
+ const scopes = options.scopes
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean);
+ if (scopes.length === 0) {
+ return { success: false, error: '--scopes must contain at least one scope' };
+ }
+ if (
+ options.grantType !== undefined &&
+ !VALID_GRANT_TYPES.includes(options.grantType as (typeof VALID_GRANT_TYPES)[number])
+ ) {
+ return {
+ success: false,
+ error: `Invalid --grant-type '${options.grantType}'. Valid: ${VALID_GRANT_TYPES.join(', ')}`,
+ };
+ }
+ outboundAuth = {
+ oauth: {
+ providerArn: options.providerArn,
+ scopes,
+ ...(options.grantType && { grantType: options.grantType as (typeof VALID_GRANT_TYPES)[number] }),
+ },
+ };
+ }
+ }
+
+ const configIO = new ConfigIO();
+
+ // Resolve --gateway (project name) to ARN from deployed-state
+ let resolvedGatewayArn = options.gatewayArn;
+ if (toolType === 'agentcore_gateway' && options.gateway && !resolvedGatewayArn) {
+ try {
+ const deployedState = await configIO.readDeployedState();
+ const targetNames = Object.keys(deployedState.targets);
+ if (targetNames.length === 0) {
+ return { success: false, error: 'No deployed targets found. Deploy the gateway first.' };
+ }
+ const targetState = deployedState.targets[targetNames[0]!];
+ const gatewayState = targetState?.resources?.mcp?.gateways?.[options.gateway];
+ if (!gatewayState) {
+ return {
+ success: false,
+ error: `Gateway '${options.gateway}' not found in deployed state. Deploy it first or use --gateway-arn.`,
+ };
+ }
+ resolvedGatewayArn = gatewayState.gatewayArn;
+ } catch {
+ return { success: false, error: 'Could not read deployed state. Deploy the gateway first or use --gateway-arn.' };
+ }
+ }
+
+ let harnessSpec: HarnessSpec;
+ try {
+ harnessSpec = await configIO.readHarnessSpec(harness);
+ } catch {
+ return {
+ success: false,
+ error: `Harness '${harness}' not found. Check the name or run 'agentcore add harness' first.`,
+ };
+ }
+
+ const existingTool = harnessSpec.tools.find(t => t.name === name);
+ if (existingTool) {
+ return { success: false, error: `Tool '${name}' already exists in harness '${harness}'` };
+ }
+
+ const toolEntry: HarnessSpec['tools'][number] = { type: toolType, name };
+
+ if (toolType === 'remote_mcp') {
+ toolEntry.config = { remoteMcp: { url: options.url! } };
+ } else if (toolType === 'agentcore_browser' && options.browserArn) {
+ toolEntry.config = { agentCoreBrowser: { browserArn: options.browserArn } };
+ } else if (toolType === 'agentcore_code_interpreter' && options.codeInterpreterArn) {
+ toolEntry.config = { agentCoreCodeInterpreter: { codeInterpreterArn: options.codeInterpreterArn } };
+ } else if (toolType === 'agentcore_gateway') {
+ toolEntry.config = {
+ agentCoreGateway: {
+ gatewayArn: resolvedGatewayArn!,
+ ...(outboundAuth && { outboundAuth }),
+ },
+ };
+ }
+
+ harnessSpec.tools.push(toolEntry);
+
+ await configIO.writeHarnessSpec(harness, harnessSpec);
+
+ return { success: true, harnessName: harness, toolName: name };
+}
diff --git a/src/cli/commands/add/tool-command.ts b/src/cli/commands/add/tool-command.ts
new file mode 100644
index 000000000..252c6a286
--- /dev/null
+++ b/src/cli/commands/add/tool-command.ts
@@ -0,0 +1,82 @@
+import { findConfigRoot } from '../../../lib';
+import { getErrorMessage } from '../../errors';
+import { handleAddTool } from './tool-action';
+import type { Command } from '@commander-js/extra-typings';
+
+export function registerAddTool(addCmd: Command): void {
+ addCmd
+ .command('tool')
+ .description('Add a tool to a harness')
+ .requiredOption('--harness ', 'Target harness name')
+ .requiredOption(
+ '--type ',
+ 'Tool type: agentcore_browser, agentcore_code_interpreter, remote_mcp, agentcore_gateway, inline_function'
+ )
+ .requiredOption('--name ', 'Tool name')
+ .option('--url ', 'MCP server URL (required for remote_mcp)')
+ .option('--browser-arn ', 'Custom browser ARN (optional for agentcore_browser)')
+ .option('--code-interpreter-arn ', 'Custom code interpreter ARN (optional for agentcore_code_interpreter)')
+ .option('--gateway-arn ', 'Gateway ARN (for agentcore_gateway)')
+ .option('--gateway ', 'Project gateway name — resolves ARN from deployed state (for agentcore_gateway)')
+ .option(
+ '--outbound-auth ',
+ 'Gateway outbound auth: awsIam, none, or oauth (default: awsIam if omitted) [agentcore_gateway]'
+ )
+ .option('--provider-arn ', 'OAuth credential provider ARN (required when --outbound-auth oauth)')
+ .option(
+ '--scopes ',
+ 'Comma-separated OAuth scopes (required when --outbound-auth oauth), e.g. "openid,profile" or "https://api.example.com/read"'
+ )
+ .option(
+ '--grant-type ',
+ 'OAuth grant type: CLIENT_CREDENTIALS or USER_FEDERATION (for --outbound-auth oauth)'
+ )
+ .option('--json', 'Output as JSON')
+ .action(async cliOptions => {
+ if (!findConfigRoot()) {
+ console.error('No agentcore project found. Run `agentcore create` first.');
+ process.exit(1);
+ }
+
+ try {
+ const result = await handleAddTool({
+ harness: cliOptions.harness,
+ type: cliOptions.type,
+ name: cliOptions.name,
+ url: cliOptions.url,
+ browserArn: cliOptions.browserArn,
+ codeInterpreterArn: cliOptions.codeInterpreterArn,
+ gatewayArn: cliOptions.gatewayArn,
+ gateway: cliOptions.gateway,
+ outboundAuth: cliOptions.outboundAuth,
+ providerArn: cliOptions.providerArn,
+ scopes: cliOptions.scopes,
+ grantType: cliOptions.grantType,
+ json: cliOptions.json,
+ });
+
+ if (!result.success) {
+ if (cliOptions.json) {
+ console.log(JSON.stringify(result));
+ } else {
+ console.error(result.error);
+ }
+ process.exit(1);
+ }
+
+ if (cliOptions.json) {
+ console.log(JSON.stringify(result));
+ } else {
+ console.log(`Added tool '${result.toolName}' to harness '${result.harnessName}'.`);
+ console.log(`Run 'agentcore deploy' to apply changes.`);
+ }
+ } catch (error) {
+ if (cliOptions.json) {
+ console.log(JSON.stringify({ success: false, error: getErrorMessage(error) }));
+ } else {
+ console.error(getErrorMessage(error));
+ }
+ process.exit(1);
+ }
+ });
+}
diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts
index 111c3f851..c5bfdf38a 100644
--- a/src/cli/commands/add/types.ts
+++ b/src/cli/commands/add/types.ts
@@ -88,6 +88,44 @@ export interface AddGatewayTargetOptions {
json?: boolean;
}
+// Harness types
+export interface AddHarnessCliOptions {
+ name?: string;
+ modelProvider?: string;
+ modelId?: string;
+ apiKeyArn?: string;
+ container?: string;
+ memory?: boolean;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeout?: number;
+ truncationStrategy?: string;
+ networkMode?: string;
+ subnets?: string;
+ securityGroups?: string;
+ idleTimeout?: number;
+ maxLifetime?: number;
+ sessionStorage?: string;
+ withInvokeScript?: boolean;
+ systemPrompt?: string;
+ tools?: string;
+ mcpName?: string;
+ mcpUrl?: string;
+ gatewayArn?: string;
+ gatewayOutboundAuth?: string;
+ gatewayProviderArn?: string;
+ gatewayScopes?: string;
+ authorizerType?: RuntimeAuthorizerType;
+ discoveryUrl?: string;
+ allowedAudience?: string;
+ allowedClients?: string;
+ allowedScopes?: string;
+ customClaims?: string;
+ clientId?: string;
+ clientSecret?: string;
+ json?: boolean;
+}
+
// Memory types (v2: no owner/user concept)
export interface AddMemoryOptions {
name?: string;
diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts
index 9a4d75928..3590a899d 100644
--- a/src/cli/commands/add/validate.ts
+++ b/src/cli/commands/add/validate.ts
@@ -31,6 +31,7 @@ import type {
AddDatasetOptions,
AddGatewayOptions,
AddGatewayTargetOptions,
+ AddHarnessCliOptions,
AddMemoryOptions,
} from './types';
import { existsSync, readFileSync } from 'fs';
@@ -871,3 +872,79 @@ export function validateAddCredentialOptions(options: AddCredentialOptions): Val
return { valid: true };
}
+
+const VALID_HARNESS_TOOLS = [
+ 'agentcore_browser',
+ 'agentcore_code_interpreter',
+ 'remote_mcp',
+ 'agentcore_gateway',
+] as const;
+
+const VALID_GATEWAY_OUTBOUND_AUTH = ['awsIam', 'none', 'oauth'] as const;
+
+export function validateAddHarnessOptions(options: AddHarnessCliOptions): ValidationResult {
+ if (options.tools) {
+ const toolNames = options.tools.split(',').map(s => s.trim());
+ for (const tool of toolNames) {
+ if (!VALID_HARNESS_TOOLS.includes(tool as (typeof VALID_HARNESS_TOOLS)[number])) {
+ return {
+ valid: false,
+ error: `Unknown tool '${tool}'. Valid tools: ${VALID_HARNESS_TOOLS.join(', ')}`,
+ };
+ }
+ }
+
+ if (toolNames.includes('remote_mcp')) {
+ if (!options.mcpName) {
+ return { valid: false, error: '--mcp-name is required when --tools includes remote_mcp' };
+ }
+ if (!options.mcpUrl) {
+ return { valid: false, error: '--mcp-url is required when --tools includes remote_mcp' };
+ }
+ }
+
+ if (toolNames.includes('agentcore_gateway')) {
+ if (!options.gatewayArn) {
+ return { valid: false, error: '--gateway-arn is required when --tools includes agentcore_gateway' };
+ }
+ }
+ }
+
+ if (options.gatewayOutboundAuth) {
+ if (
+ !VALID_GATEWAY_OUTBOUND_AUTH.includes(options.gatewayOutboundAuth as (typeof VALID_GATEWAY_OUTBOUND_AUTH)[number])
+ ) {
+ return {
+ valid: false,
+ error: `Invalid --gateway-outbound-auth '${options.gatewayOutboundAuth}'. Use: ${VALID_GATEWAY_OUTBOUND_AUTH.join(', ')}`,
+ };
+ }
+
+ if (options.gatewayOutboundAuth === 'oauth') {
+ if (!options.gatewayProviderArn) {
+ return { valid: false, error: '--gateway-provider-arn is required when --gateway-outbound-auth is oauth' };
+ }
+ if (!options.gatewayScopes) {
+ return { valid: false, error: '--gateway-scopes is required when --gateway-outbound-auth is oauth' };
+ }
+ }
+ }
+
+ if (options.authorizerType) {
+ const authResult = RuntimeAuthorizerTypeSchema.safeParse(options.authorizerType);
+ if (!authResult.success) {
+ return { valid: false, error: 'Invalid authorizer type. Use AWS_IAM or CUSTOM_JWT' };
+ }
+
+ if (options.authorizerType === 'CUSTOM_JWT') {
+ const jwtResult = validateJwtAuthorizerOptions(options);
+ if (!jwtResult.valid) return jwtResult;
+ }
+ }
+
+ if (options.clientId && options.authorizerType !== 'CUSTOM_JWT') {
+ return { valid: false, error: 'OAuth client credentials are only valid with CUSTOM_JWT authorizer' };
+ }
+
+ return { valid: true };
+}
diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx
index e9a58f520..03bde089c 100644
--- a/src/cli/commands/create/command.tsx
+++ b/src/cli/commands/create/command.tsx
@@ -1,6 +1,7 @@
import { ValidationError, getWorkingDirectory, serializeResult } from '../../../lib';
import type {
BuildType,
+ HarnessModelProvider,
ModelProvider,
NetworkMode,
ProtocolMode,
@@ -9,6 +10,8 @@ import type {
} from '../../../schema';
import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema';
import { getErrorMessage } from '../../errors';
+import { isPreviewEnabled } from '../../feature-flags';
+import { harnessPrimitive } from '../../primitives/registry';
import { runCliCommand } from '../../telemetry/cli-command-run.js';
import {
AgentFramework,
@@ -26,6 +29,8 @@ import { requireTTY } from '../../tui/guards';
import { CreateScreen } from '../../tui/screens/create';
import { parseCommaSeparatedList } from '../shared/vpc-utils';
import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action';
+import { createProjectWithHarness } from './harness-action';
+import { normalizeHarnessModelProvider, validateCreateHarnessOptions } from './harness-validate';
import type { CreateOptions } from './types';
import { validateCreateOptions } from './validate';
import type { Command } from '@commander-js/extra-typings';
@@ -85,6 +90,136 @@ function printCreateSummary(
console.log('');
}
+/** Flags that trigger the agent/runtime path (preview mode) */
+const AGENT_PATH_FLAGS = ['framework', 'language', 'build', 'protocol', 'type', 'agentId', 'agentAliasId'] as const;
+
+/** Flags that are harness-only (preview mode) */
+const HARNESS_ONLY_FLAGS = [
+ 'modelId',
+ 'apiKeyArn',
+ 'maxIterations',
+ 'maxTokens',
+ 'timeout',
+ 'truncationStrategy',
+] as const;
+
+/** Determines if the agent path should be taken based on provided flags (preview mode) */
+function isAgentPath(options: CreateOptions): boolean {
+ return AGENT_PATH_FLAGS.some(flag => options[flag] !== undefined);
+}
+
+/** Determines if any harness-only flags are present (preview mode) */
+function hasHarnessOnlyFlags(options: CreateOptions): boolean {
+ return HARNESS_ONLY_FLAGS.some(flag => options[flag] !== undefined);
+}
+
+/** Print completion summary after successful harness create (preview mode) */
+function printCreateHarnessSummary(projectName: string, harnessName: string): void {
+ const green = '\x1b[32m';
+ const cyan = '\x1b[36m';
+ const dim = '\x1b[2m';
+ const reset = '\x1b[0m';
+
+ console.log('');
+
+ // Created summary
+ console.log(`${dim}Created:${reset}`);
+ console.log(` ${projectName}/`);
+ console.log(` agentcore/ ${dim}Config and CDK project${reset}`);
+ console.log(` app/${harnessName}/ ${dim}Harness config${reset}`);
+ console.log('');
+
+ // Success and next steps
+ console.log(`${green}Harness project created successfully!${reset}`);
+ console.log('');
+ console.log('To continue:');
+ console.log(` ${cyan}cd ${projectName}${reset}`);
+ console.log(` ${cyan}agentcore deploy${reset}`);
+ console.log('');
+}
+
+/** Handle CLI mode for the harness path (preview mode) */
+async function handleCreateHarnessCLI(options: CreateOptions): Promise {
+ const cwd = options.outputDir ?? getWorkingDirectory();
+ const name = options.name ?? options.projectName;
+ const projectName = options.projectName ?? name;
+
+ const validation = validateCreateHarnessOptions(
+ {
+ name,
+ projectName,
+ modelProvider: options.modelProvider,
+ modelId: options.modelId,
+ apiKeyArn: options.apiKeyArn,
+ },
+ cwd
+ );
+ if (!validation.valid) {
+ if (options.json) {
+ console.log(JSON.stringify({ success: false, error: validation.error }));
+ } else {
+ console.error(validation.error);
+ }
+ process.exit(1);
+ }
+
+ // Progress callback
+ const green = '\x1b[32m';
+ const reset = '\x1b[0m';
+ const onProgress: ProgressCallback | undefined = options.json
+ ? undefined
+ : (step, status) => {
+ if (status === 'done') console.log(`${green}[done]${reset} ${step}`);
+ else if (status === 'error') console.log(`\x1b[31m[error]${reset} ${step}`);
+ };
+
+ const provider = (
+ options.modelProvider ? normalizeHarnessModelProvider(options.modelProvider) : 'bedrock'
+ ) as HarnessModelProvider;
+ const defaultModelIds: Record = {
+ bedrock: 'global.anthropic.claude-sonnet-4-6',
+ open_ai: 'gpt-5',
+ gemini: 'gemini-2.5-flash',
+ };
+ const modelId = options.modelId ?? defaultModelIds[provider] ?? 'global.anthropic.claude-sonnet-4-6';
+
+ const containerOption = harnessPrimitive!.parseContainerFlag(options.container);
+
+ const result = await createProjectWithHarness({
+ name: name!,
+ projectName: projectName!,
+ cwd,
+ modelProvider: provider,
+ modelId,
+ apiKeyArn: options.apiKeyArn,
+ containerUri: containerOption.containerUri,
+ dockerfilePath: containerOption.dockerfilePath,
+ skipMemory: options.harnessMemory === false,
+ maxIterations: options.maxIterations ? Number(options.maxIterations) : undefined,
+ maxTokens: options.maxTokens ? Number(options.maxTokens) : undefined,
+ timeoutSeconds: options.timeout ? Number(options.timeout) : undefined,
+ truncationStrategy: options.truncationStrategy as 'sliding_window' | 'summarization' | undefined,
+ networkMode: options.networkMode as NetworkMode | undefined,
+ subnets: parseCommaSeparatedList(options.subnets),
+ securityGroups: parseCommaSeparatedList(options.securityGroups),
+ idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined,
+ maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined,
+ sessionStoragePath: options.sessionStorageMountPath,
+ skipGit: options.skipGit,
+ skipInstall: options.skipInstall,
+ onProgress,
+ });
+
+ if (options.json) {
+ console.log(JSON.stringify(result));
+ } else if (result.success) {
+ printCreateHarnessSummary(projectName!, name!);
+ } else {
+ console.error(result.error);
+ }
+ process.exit(result.success ? 0 : 1);
+}
+
/** Handle CLI mode with progress output */
async function handleCreateCLI(options: CreateOptions): Promise {
const cwd = options.outputDir ?? getWorkingDirectory();
@@ -210,10 +345,10 @@ async function handleCreateCLI(options: CreateOptions): Promise {
}
export const registerCreate = (program: Command) => {
- program
+ const createCmd = program
.command('create')
.description(COMMAND_DESCRIPTIONS.create)
- .option('--name ', 'Resource name (agent or harness) [non-interactive]')
+ .option('--name ', 'Resource name [non-interactive]')
.option(
'--project-name ',
'Project name (start with letter, alphanumeric only, max 23 chars) [non-interactive]'
@@ -255,9 +390,152 @@ export const registerCreate = (program: Command) => {
.option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]')
.option('--skip-install', 'Skip all dependency installation (npm install, uv sync) [non-interactive]')
.option('--dry-run', 'Preview what would be created without making changes [non-interactive]')
- .option('--json', 'Output as JSON [non-interactive]')
- .action(async options => {
- try {
+ .option('--json', 'Output as JSON [non-interactive]');
+
+ if (isPreviewEnabled()) {
+ createCmd
+ .option('--model-id ', 'Model ID for harness [non-interactive] [preview]')
+ .option('--api-key-arn ', 'API key ARN for non-Bedrock harness providers [non-interactive] [preview]')
+ .option('--no-harness-memory', 'Skip auto-creating memory for harness [non-interactive] [preview]')
+ .option('--max-iterations ', 'Max agent loop iterations (harness) [non-interactive] [preview]')
+ .option('--max-tokens ', 'Max tokens per iteration (harness) [non-interactive] [preview]')
+ .option('--timeout ', 'Max execution duration in seconds (harness) [non-interactive] [preview]')
+ .option(
+ '--truncation-strategy ',
+ 'Truncation strategy: sliding_window or summarization (harness) [non-interactive] [preview]'
+ )
+ .option(
+ '--container ',
+ 'Container image URI or Dockerfile path (harness) [non-interactive] [preview]'
+ );
+ }
+
+ createCmd.action(async (rawOptions: Record) => {
+ const options = rawOptions as Record & {
+ name?: string;
+ projectName?: string;
+ agent: boolean;
+ defaults?: true;
+ build?: string;
+ language?: string;
+ framework?: string;
+ modelProvider?: string;
+ apiKey?: string;
+ memory?: string;
+ protocol?: string;
+ type?: string;
+ agentId?: string;
+ agentAliasId?: string;
+ region?: string;
+ networkMode?: string;
+ subnets?: string;
+ securityGroups?: string;
+ idleTimeout?: string;
+ maxLifetime?: string;
+ sessionStorageMountPath?: string;
+ withConfigBundle?: true;
+ outputDir?: string;
+ skipGit?: true;
+ skipPythonSetup?: true;
+ skipInstall?: true;
+ dryRun?: true;
+ json?: true;
+ modelId?: string;
+ apiKeyArn?: string;
+ harnessMemory?: boolean;
+ maxIterations?: string;
+ maxTokens?: string;
+ timeout?: string;
+ truncationStrategy?: string;
+ container?: string;
+ };
+ try {
+ if (isPreviewEnabled()) {
+ // Preview mode: fork between harness and agent paths
+ const hasAnyFlag = Boolean(
+ options.name ??
+ options.projectName ??
+ (options.agent === false ? true : null) ??
+ options.defaults ??
+ options.build ??
+ options.language ??
+ options.framework ??
+ options.modelProvider ??
+ options.apiKey ??
+ options.memory ??
+ options.protocol ??
+ options.type ??
+ options.agentId ??
+ options.agentAliasId ??
+ options.region ??
+ options.networkMode ??
+ options.subnets ??
+ options.securityGroups ??
+ options.idleTimeout ??
+ options.maxLifetime ??
+ options.outputDir ??
+ options.skipGit ??
+ options.skipPythonSetup ??
+ options.skipInstall ??
+ options.dryRun ??
+ options.json ??
+ options.modelId ??
+ options.apiKeyArn ??
+ (options.harnessMemory === false ? true : null) ??
+ options.maxIterations ??
+ options.maxTokens ??
+ options.timeout ??
+ options.truncationStrategy
+ );
+
+ if (!hasAnyFlag) {
+ requireTTY();
+ handleCreateTUI();
+ return;
+ }
+
+ const opts = options as CreateOptions;
+
+ // Conflict detection: agent-path flags + harness-only flags
+ if (isAgentPath(opts) && hasHarnessOnlyFlags(opts)) {
+ const error =
+ 'Cannot mix agent-path flags (--framework, --language, etc.) with harness-only flags (--model-id, --max-iterations, etc.)';
+ if (opts.json) {
+ console.log(JSON.stringify({ success: false, error }));
+ } else {
+ console.error(error);
+ }
+ process.exit(1);
+ }
+
+ // --no-agent: bare project (no harness, no agent)
+ if (opts.agent === false) {
+ opts.language = opts.language ?? 'Python';
+ await handleCreateCLI(opts);
+ return;
+ }
+
+ // Agent path: any agent-specific flag triggers it
+ if (isAgentPath(opts)) {
+ if (opts.defaults) {
+ opts.language = opts.language ?? 'Python';
+ opts.build = opts.build ?? 'CodeZip';
+ opts.framework = opts.framework ?? 'Strands';
+ opts.modelProvider = opts.modelProvider ?? 'Bedrock';
+ opts.memory = opts.memory ?? 'none';
+ }
+ opts.language = opts.language ?? 'Python';
+ await handleCreateCLI(opts);
+ return;
+ }
+
+ // Harness path (default in preview mode)
+ if (!opts.json && !opts.modelProvider && !hasHarnessOnlyFlags(opts)) {
+ console.log('Creating a harness project (pass --framework to create an agent project instead).');
+ }
+ await handleCreateHarnessCLI(opts);
+ } else {
+ // GA mode: original behavior
// Apply defaults if --defaults flag is set
if (options.defaults) {
options.language = options.language ?? 'Python';
@@ -295,9 +573,10 @@ export const registerCreate = (program: Command) => {
requireTTY();
handleCreateTUI();
}
- } catch (error) {
- render(Error: {getErrorMessage(error)});
- process.exit(1);
}
- });
+ } catch (error) {
+ render(Error: {getErrorMessage(error)});
+ process.exit(1);
+ }
+ });
};
diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts
new file mode 100644
index 000000000..28fdda684
--- /dev/null
+++ b/src/cli/commands/create/harness-action.ts
@@ -0,0 +1,100 @@
+import { CONFIG_DIR } from '../../../lib';
+import type { HarnessModelProvider, NetworkMode } from '../../../schema';
+import { harnessPrimitive } from '../../primitives/registry';
+import { type ProgressCallback, createProject } from './action';
+import type { CreateResult } from './types';
+import { toError } from '@/lib/errors/types';
+import { join } from 'path';
+
+export interface CreateHarnessProjectOptions {
+ name: string;
+ projectName?: string;
+ cwd: string;
+ modelProvider: HarnessModelProvider;
+ modelId: string;
+ apiKeyArn?: string;
+ skipMemory?: boolean;
+ containerUri?: string;
+ dockerfilePath?: string;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ truncationStrategy?: 'sliding_window' | 'summarization';
+ networkMode?: NetworkMode;
+ subnets?: string[];
+ securityGroups?: string[];
+ idleTimeout?: number;
+ maxLifetime?: number;
+ sessionStoragePath?: string;
+ skipGit?: boolean;
+ skipInstall?: boolean;
+ onProgress?: ProgressCallback;
+}
+
+export async function createProjectWithHarness(options: CreateHarnessProjectOptions): Promise {
+ const { name, projectName: explicitProjectName, cwd, skipGit, skipInstall, onProgress } = options;
+ const projectName = explicitProjectName ?? name;
+
+ const projectResult = await createProject({
+ name: projectName,
+ cwd,
+ skipGit,
+ skipInstall,
+ onProgress,
+ });
+
+ if (!projectResult.success) {
+ return projectResult;
+ }
+
+ const projectRoot = projectResult.projectPath!;
+ const configBaseDir = join(projectRoot, CONFIG_DIR);
+
+ try {
+ onProgress?.('Add harness to project', 'start');
+
+ const harnessResult = await harnessPrimitive!.add({
+ name: options.name,
+ modelProvider: options.modelProvider,
+ modelId: options.modelId,
+ apiKeyArn: options.apiKeyArn,
+ containerUri: options.containerUri,
+ dockerfilePath: options.dockerfilePath,
+ skipMemory: options.skipMemory,
+ maxIterations: options.maxIterations,
+ maxTokens: options.maxTokens,
+ timeoutSeconds: options.timeoutSeconds,
+ truncationStrategy: options.truncationStrategy,
+ networkMode: options.networkMode,
+ subnets: options.subnets,
+ securityGroups: options.securityGroups,
+ idleTimeout: options.idleTimeout,
+ maxLifetime: options.maxLifetime,
+ sessionStoragePath: options.sessionStoragePath,
+ configBaseDir,
+ });
+
+ if (!harnessResult.success) {
+ onProgress?.('Add harness to project', 'error');
+ return {
+ success: false,
+ error: harnessResult.error,
+ warnings: projectResult.warnings,
+ };
+ }
+
+ onProgress?.('Add harness to project', 'done');
+
+ return {
+ success: true,
+ projectPath: projectRoot,
+ warnings: projectResult.warnings,
+ };
+ } catch (err) {
+ return {
+ success: false,
+ error: toError(err),
+ warnings: projectResult.warnings,
+ };
+ }
+}
diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts
new file mode 100644
index 000000000..52f84d6e2
--- /dev/null
+++ b/src/cli/commands/create/harness-validate.ts
@@ -0,0 +1,94 @@
+import { HarnessNameSchema, ProjectNameSchema } from '../../../schema';
+import { validateFolderNotExists } from './validate';
+
+export interface CreateHarnessCliOptions {
+ name?: string;
+ projectName?: string;
+ modelProvider?: string;
+ modelId?: string;
+ apiKeyArn?: string;
+ container?: string;
+ noMemory?: boolean;
+ maxIterations?: string;
+ maxTokens?: string;
+ timeout?: string;
+ truncationStrategy?: string;
+ networkMode?: string;
+ subnets?: string;
+ securityGroups?: string;
+ idleTimeout?: string;
+ maxLifetime?: string;
+ outputDir?: string;
+ skipGit?: boolean;
+ skipInstall?: boolean;
+ dryRun?: boolean;
+ json?: boolean;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ error?: string;
+}
+
+const MODEL_PROVIDER_MAPPING: Record = {
+ bedrock: 'bedrock',
+ Bedrock: 'bedrock',
+ open_ai: 'open_ai',
+ openai: 'open_ai',
+ OpenAI: 'open_ai',
+ anthropic: 'bedrock',
+ Anthropic: 'bedrock',
+ gemini: 'gemini',
+ Gemini: 'gemini',
+};
+
+export function normalizeHarnessModelProvider(raw: string): string | undefined {
+ return MODEL_PROVIDER_MAPPING[raw];
+}
+
+export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, cwd?: string): ValidationResult {
+ if (!options.name) {
+ return { valid: false, error: '--name is required' };
+ }
+
+ const projectName = options.projectName ?? options.name;
+ const projectNameResult = ProjectNameSchema.safeParse(projectName);
+ if (!projectNameResult.success) {
+ return { valid: false, error: projectNameResult.error.issues[0]?.message ?? 'Invalid project name' };
+ }
+
+ const nameResult = HarnessNameSchema.safeParse(options.name);
+ if (!nameResult.success) {
+ return { valid: false, error: nameResult.error.issues[0]?.message ?? 'Invalid harness name' };
+ }
+
+ const folderCheck = validateFolderNotExists(projectName, cwd ?? process.cwd());
+ if (folderCheck !== true) {
+ return { valid: false, error: folderCheck };
+ }
+
+ if (options.modelProvider) {
+ const normalized = normalizeHarnessModelProvider(options.modelProvider);
+ if (!normalized) {
+ return {
+ valid: false,
+ error: `Invalid model provider: ${options.modelProvider}. Use bedrock, open_ai, or gemini`,
+ };
+ }
+ options.modelProvider = normalized;
+ }
+ options.modelProvider ??= 'bedrock';
+
+ const defaultModelIds: Record = {
+ bedrock: 'global.anthropic.claude-sonnet-4-6',
+ open_ai: 'gpt-5',
+ gemini: 'gemini-2.5-flash',
+ };
+ options.modelId ??= defaultModelIds[options.modelProvider] ?? 'global.anthropic.claude-sonnet-4-6';
+
+ if (options.modelProvider !== 'bedrock' && !options.apiKeyArn) {
+ return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` };
+ }
+
+ return { valid: true };
+}
diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts
index cd42c545b..814b06287 100644
--- a/src/cli/commands/create/types.ts
+++ b/src/cli/commands/create/types.ts
@@ -27,6 +27,15 @@ export interface CreateOptions extends VpcOptions {
skipInstall?: boolean;
dryRun?: boolean;
json?: boolean;
+ // Harness-specific (preview only)
+ modelId?: string;
+ apiKeyArn?: string;
+ container?: string;
+ harnessMemory?: boolean;
+ maxIterations?: string;
+ maxTokens?: string;
+ timeout?: string;
+ truncationStrategy?: string;
}
export type CreateResult = Result<{
diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts
index 4422ebe2c..13f696587 100644
--- a/src/cli/commands/deploy/actions.ts
+++ b/src/cli/commands/deploy/actions.ts
@@ -1,9 +1,9 @@
import { ConfigIO, ResourceNotFoundError, SecureCredentials, ValidationError, toError } from '../../../lib';
-import type { AgentCoreMcpSpec, DeployedState } from '../../../schema';
+import type { AgentCoreMcpSpec, DeployedState, HarnessDeployedState } from '../../../schema';
import { applyTargetRegionToEnv } from '../../aws';
import { validateAwsCredentials } from '../../aws/account';
import { CdkToolkitWrapper, createSwitchableIoHost } from '../../cdk/toolkit-lib';
-import type { SwitchableIoHost } from '../../cdk/toolkit-lib';
+import type { DeployMessage, SwitchableIoHost } from '../../cdk/toolkit-lib';
import {
buildDeployedState,
getStackOutputs,
@@ -18,6 +18,7 @@ import {
parseRuntimeEndpointOutputs,
} from '../../cloudformation';
import { getErrorMessage } from '../../errors';
+import { isPreviewEnabled } from '../../feature-flags';
import { ExecLogger } from '../../logging';
import {
bootstrapEnvironment,
@@ -34,7 +35,9 @@ import {
synthesizeCdk,
validateProject,
} from '../../operations/deploy';
+import { computeProjectDeployHash } from '../../operations/deploy/change-detection';
import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status';
+import { type ImperativeDeployContext, createDeploymentManager } from '../../operations/deploy/imperative';
import { deleteOrphanedABTests, setupABTests } from '../../operations/deploy/post-deploy-ab-tests';
import {
resolveConfigBundleComponentKeys,
@@ -55,6 +58,7 @@ export interface ValidatedDeployOptions {
diff?: boolean;
onProgress?: (step: string, status: 'start' | 'success' | 'error') => void;
onResourceEvent?: (message: string) => void;
+ onDeployMessage?: (message: DeployMessage) => void;
}
const AGENT_NEXT_STEPS = ['agentcore invoke', 'agentcore status'];
@@ -270,7 +274,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise {
- options.onResourceEvent!(msg.message);
+ options.onResourceEvent?.(msg.message);
+ options.onDeployMessage?.(msg);
});
switchableIoHost.setVerbose(true);
}
@@ -378,7 +383,37 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ targets: {} }) as DeployedState);
+ const teardownContext: ImperativeDeployContext = {
+ projectSpec: context.projectSpec,
+ target,
+ configIO,
+ deployedState: existingTeardownState,
+ onProgress: (step: string, status: 'start' | 'done' | 'error') => {
+ logger.log(`${step}: ${status}`);
+ },
+ };
+
+ if (imperativeManager.hasDeployersForPhase('post-cdk', teardownContext)) {
+ startStep('Tear down imperative resources');
+ const imperativeTeardown = await imperativeManager.teardownAll(teardownContext);
+ if (!imperativeTeardown.success) {
+ endStep('error', imperativeTeardown.error);
+ logger.finalize(false);
+ return {
+ success: false,
+ error: new Error(`Imperative teardown failed: ${imperativeTeardown.error}`),
+ logPath: logger.getRelativeLogPath(),
+ };
+ }
+ endStep('success');
+ }
+ }
+
startStep('Tear down stack');
const teardown = await performStackTeardown(target.name);
if (!teardown.success) {
@@ -469,6 +504,57 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise d.name);
const datasets = parseDatasetOutputs(outputs, datasetNames);
+ endStep('success');
+
+ // Post-CDK: deploy imperative resources (harness) — preview mode only
+ let deployedHarnesses: Record | undefined;
+ if (isPreviewEnabled()) {
+ const imperativeManager = createDeploymentManager();
+ const existingImperativeState: DeployedState = await configIO.readDeployedState().catch(() => ({ targets: {} }));
+ const imperativeContext = {
+ projectSpec: context.projectSpec,
+ target,
+ configIO,
+ deployedState: existingImperativeState,
+ cdkOutputs: outputs,
+ onProgress: (step: string, status: 'start' | 'done' | 'error') => {
+ logger.log(`${step}: ${status}`);
+ },
+ };
+
+ let harnessDeployError: string | undefined;
+ if (imperativeManager.hasDeployersForPhase('post-cdk', imperativeContext)) {
+ startStep('Deploy harnesses');
+ const postCdkResult = await imperativeManager.runPhase('post-cdk', imperativeContext);
+ const harnessResult = postCdkResult.results.get('harness');
+ if (harnessResult?.state) {
+ deployedHarnesses = harnessResult.state as Record;
+ }
+ if (!postCdkResult.success) {
+ endStep('error', postCdkResult.error);
+ harnessDeployError = postCdkResult.error;
+ } else {
+ endStep('success');
+ }
+ }
+
+ if (harnessDeployError) {
+ logger.finalize(false);
+ return {
+ success: false,
+ error: new Error(`Harness deployment failed: ${harnessDeployError}`),
+ logPath: logger.getRelativeLogPath(),
+ };
+ }
+ }
+
+ let deployHash: string | undefined;
+ try {
+ deployHash = await computeProjectDeployHash(configIO);
+ } catch {
+ // hash computation is best-effort
+ }
+
const existingState = await configIO.readDeployedState().catch(() => undefined);
let deployedState = buildDeployedState({
targetName: target.name,
@@ -483,9 +569,18 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS];
+ const hasHarnesses = isPreviewEnabled() && (context.projectSpec.harnesses ?? []).length > 0;
+ const hasInvokable = agentNames.length > 0 || hasHarnesses;
+ const nextSteps = hasInvokable ? [...AGENT_NEXT_STEPS] : [...MEMORY_ONLY_NEXT_STEPS];
const notes: string[] = [];
const hasPythonAgent =
context.projectSpec.runtimes?.some(a => a.entrypoint?.endsWith('.py') || a.entrypoint?.includes('.py:')) ?? false;
diff --git a/src/cli/commands/deploy/progress.ts b/src/cli/commands/deploy/progress.ts
new file mode 100644
index 000000000..a8dff1cd0
--- /dev/null
+++ b/src/cli/commands/deploy/progress.ts
@@ -0,0 +1,104 @@
+import { ConfigIO } from '../../../lib';
+import { detectAwsContext } from '../../aws/aws-context';
+import { getErrorMessage } from '../../errors';
+import { canSkipDeploy } from '../../operations/deploy/change-detection';
+import { handleDeploy } from './actions';
+
+export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
+
+export interface SpinnerProgress {
+ onProgress: (step: string, status: 'start' | 'success' | 'error') => void;
+ cleanup: () => void;
+}
+
+export function createSpinnerProgress(): SpinnerProgress {
+ let spinner: NodeJS.Timeout | undefined;
+
+ const clearSpinner = () => {
+ if (spinner) {
+ clearInterval(spinner);
+ spinner = undefined;
+ process.stdout.write('\r\x1b[K');
+ }
+ };
+
+ const onProgress = (step: string, status: 'start' | 'success' | 'error') => {
+ clearSpinner();
+
+ if (status === 'start') {
+ let i = 0;
+ process.stdout.write(`${SPINNER_FRAMES[0]} ${step}...`);
+ spinner = setInterval(() => {
+ i = (i + 1) % SPINNER_FRAMES.length;
+ process.stdout.write(`\r${SPINNER_FRAMES[i]} ${step}...`);
+ }, 80);
+ } else if (status === 'success') {
+ console.log(`✓ ${step}`);
+ } else {
+ console.log(`✗ ${step}`);
+ }
+ };
+
+ return { onProgress, cleanup: clearSpinner };
+}
+
+export async function runCliDeploy(): Promise {
+ console.log('Deploying project resources...');
+ const { onProgress, cleanup } = createSpinnerProgress();
+
+ try {
+ // Auto-populate aws-targets.json if empty
+ const configIO = new ConfigIO();
+ try {
+ const targets = await configIO.readAWSDeploymentTargets();
+ if (targets.length === 0) {
+ const ctx = await detectAwsContext();
+ if (ctx.accountId) {
+ await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]);
+ }
+ }
+ } catch {
+ // aws-targets.json doesn't exist — try to create it
+ try {
+ const ctx = await detectAwsContext();
+ if (ctx.accountId) {
+ await configIO.writeAWSDeploymentTargets([{ name: 'default', account: ctx.accountId, region: ctx.region }]);
+ }
+ } catch {
+ // Can't detect — let handleDeploy fail with a clear error
+ }
+ }
+
+ const noChanges = await canSkipDeploy(configIO);
+ if (noChanges) {
+ onProgress('No changes detected — skipping deploy', 'success');
+ cleanup();
+ console.log('');
+ return;
+ }
+
+ const result = await handleDeploy({
+ target: 'default',
+ autoConfirm: true,
+ onProgress,
+ });
+ cleanup();
+
+ if (result.success) {
+ console.log('Deploy complete.');
+ if (result.logPath) {
+ console.log(`Deploy log: ${result.logPath}`);
+ }
+ console.log('');
+ } else {
+ console.warn(`\x1b[33mDeploy failed: ${result.error}. Starting dev server anyway...\x1b[0m`);
+ if (result.logPath) {
+ console.warn(`Deploy log: ${result.logPath}`);
+ }
+ console.log('');
+ }
+ } catch (deployErr) {
+ cleanup();
+ console.warn(`\x1b[33mDeploy failed: ${getErrorMessage(deployErr)}. Starting dev server anyway...\x1b[0m\n`);
+ }
+}
diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts
index 9bdcd6987..0a6b0885d 100644
--- a/src/cli/commands/dev/browser-mode.ts
+++ b/src/cli/commands/dev/browser-mode.ts
@@ -1,5 +1,6 @@
import { ConfigIO, findConfigRoot, getWorkingDirectory } from '../../../lib';
import type { AgentCoreProjectSpec } from '../../../schema';
+import { isPreviewEnabled } from '../../feature-flags';
import { getDevConfig, getDevSupportedAgents, loadDevEnv, loadProjectConfig } from '../../operations/dev';
import { type OtelCollector, startOtelCollector } from '../../operations/dev/otel';
import {
@@ -8,10 +9,15 @@ import {
type RetrieveMemoryRecordsHandler,
runWebUI,
} from '../../operations/dev/web-ui';
+import type { HarnessInfo } from '../../operations/dev/web-ui/constants';
import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memory';
-import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent';
+import { loadDeployedProjectConfig, resolveAgentOrHarness } from '../../operations/resolve-agent';
import { fetchTraceRecords, listTraces } from '../../operations/traces';
+import { LayoutProvider } from '../../tui/context';
+import { runCliDeploy } from '../deploy/progress';
+import { render } from 'ink';
import path from 'node:path';
+import React from 'react';
interface DeployedHandlers {
onListMemoryRecords?: ListMemoryRecordsHandler;
@@ -98,6 +104,7 @@ export interface BrowserModeOptions {
project: AgentCoreProjectSpec;
port: number;
agentName?: string;
+ harnessName?: string;
/** OTEL env vars to pass to dev servers (set by the dev command when collector is active) */
otelEnvVars?: Record;
/** OTEL collector instance for local trace collection */
@@ -112,11 +119,23 @@ export async function launchBrowserDev(): Promise {
const workingDir = getWorkingDirectory();
const project = await loadProjectConfig(workingDir);
- if (!project?.runtimes || project.runtimes.length === 0) {
- console.error('Error: No agents defined in project.');
+ if (!project) {
+ console.error('Error: No agents or harnesses defined in project.');
process.exit(1);
}
+ const hasRuntimes = project.runtimes.length > 0;
+ const hasHarnesses = isPreviewEnabled() && (project.harnesses ?? []).length > 0;
+
+ if (!hasRuntimes && !hasHarnesses) {
+ console.error('Error: No agents or harnesses defined in project.');
+ process.exit(1);
+ }
+
+ if (hasHarnesses) {
+ await runCliDeploy();
+ }
+
const configRoot = findConfigRoot(workingDir);
const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces');
const { collector, otelEnvVars } = await startOtelCollector(persistTracesDir);
@@ -131,14 +150,15 @@ export async function launchBrowserDev(): Promise {
}
export async function runBrowserMode(opts: BrowserModeOptions): Promise {
- const { workingDir, project, agentName, otelEnvVars = {}, collector } = opts;
+ const { workingDir, project, agentName, harnessName, otelEnvVars = {}, collector } = opts;
const configRoot = findConfigRoot(workingDir);
const { envVars } = await loadDevEnv(workingDir);
const supportedAgents = getDevSupportedAgents(project);
+ const projectHasHarnesses = isPreviewEnabled() && (project.harnesses ?? []).length > 0;
- if (supportedAgents.length === 0) {
+ if (supportedAgents.length === 0 && !projectHasHarnesses) {
console.error('Error: No dev-supported agents found.');
process.exit(1);
}
@@ -165,13 +185,52 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise {
// Handlers re-resolve on each call so newly deployed memories are picked up.
const baseDir = configRoot ?? workingDir;
+ // Discover deployed harnesses from project config + deployed state (preview mode)
+ const harnessInfoList: HarnessInfo[] = [];
+ if (isPreviewEnabled()) {
+ try {
+ const configIO = new ConfigIO({ baseDir });
+ if (configIO.configExists('state') && configIO.configExists('awsTargets')) {
+ const deployedState = await configIO.readDeployedState();
+ const awsTargets = await configIO.readAWSDeploymentTargets();
+ const targetName = Object.keys(deployedState.targets)[0];
+ if (targetName) {
+ const targetState = deployedState.targets[targetName];
+ const targetConfig = awsTargets.find(t => t.name === targetName);
+ if (targetConfig) {
+ for (const harness of project.harnesses ?? []) {
+ const state = targetState?.resources?.harnesses?.[harness.name];
+ if (state) {
+ harnessInfoList.push({
+ name: harness.name,
+ harnessArn: state.harnessArn,
+ region: targetConfig.region,
+ });
+ }
+ }
+ if (harnessInfoList.length > 0) {
+ onLog(
+ 'info',
+ `Found ${harnessInfoList.length} deployed harness(es): ${harnessInfoList.map(h => h.name).join(', ')}`
+ );
+ }
+ }
+ }
+ }
+ } catch {
+ // Harness discovery is best-effort — local dev works without it
+ }
+ }
+
await runWebUI({
logLabel: 'dev',
onLog,
serverOptions: {
mode: 'dev',
agents: agentInfoList,
+ harnesses: harnessInfoList,
selectedAgent: agentName,
+ selectedHarness: harnessName,
envVars: mergedEnvVars,
getEnvVars: async () => {
const { envVars: freshEnvVars } = await loadDevEnv(workingDir);
@@ -196,11 +255,11 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise {
? (agentNameParam, startTime, endTime) => collector.listTraces(agentNameParam, startTime, endTime)
: undefined,
onGetTrace: collector ? (agentNameParam, traceId) => collector.getTraceSpans(agentNameParam, traceId) : undefined,
- onListCloudWatchTraces: async (agentName, _harnessName, startTime, endTime) => {
+ onListCloudWatchTraces: async (agentName, harnessName, startTime, endTime) => {
try {
const configIO = new ConfigIO({ baseDir });
const context = await loadDeployedProjectConfig(configIO);
- const resolved = resolveAgent(context, { runtime: agentName });
+ const resolved = await resolveAgentOrHarness(context, { runtime: agentName, harness: harnessName });
if (!resolved.success) return { success: false, error: resolved.error };
const res = await listTraces({
region: resolved.agent.region,
@@ -217,11 +276,11 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise {
};
}
},
- onGetCloudWatchTrace: async (agentName, _harnessName, traceId, startTime, endTime) => {
+ onGetCloudWatchTrace: async (agentName, harnessName, traceId, startTime, endTime) => {
try {
const configIO = new ConfigIO({ baseDir });
const context = await loadDeployedProjectConfig(configIO);
- const resolved = resolveAgent(context, { runtime: agentName });
+ const resolved = await resolveAgentOrHarness(context, { runtime: agentName, harness: harnessName });
if (!resolved.success) return { success: false, error: resolved.error };
const res = await fetchTraceRecords({
region: resolved.agent.region,
@@ -252,3 +311,51 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise {
},
});
}
+
+const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H';
+const EXIT_ALT_SCREEN = '\x1B[?1049l';
+const SHOW_CURSOR = '\x1B[?25h';
+
+interface TuiPickerResult {
+ agentName?: string;
+ harnessName?: string;
+}
+
+export async function launchTuiDevScreenWithPicker(
+ workingDir: string,
+ options?: { skipDeploy?: boolean }
+): Promise {
+ process.stdout.write(ENTER_ALT_SCREEN);
+
+ const exitAltScreen = () => {
+ process.stdout.write(EXIT_ALT_SCREEN);
+ process.stdout.write(SHOW_CURSOR);
+ };
+
+ let pickerResult: TuiPickerResult | undefined;
+ const { DevScreen } = await import('../../tui/screens/dev/DevScreen');
+ const { unmount, waitUntilExit } = render(
+ React.createElement(
+ LayoutProvider,
+ null,
+ React.createElement(DevScreen, {
+ onBack: () => {
+ exitAltScreen();
+ unmount();
+ process.exit(0);
+ },
+ workingDir,
+ skipDeploy: options?.skipDeploy,
+ onLaunchBrowser: (selection?: { agentName?: string; harnessName?: string }) => {
+ pickerResult = selection ?? {};
+ exitAltScreen();
+ unmount();
+ },
+ })
+ )
+ );
+
+ await waitUntilExit();
+ exitAltScreen();
+ return pickerResult;
+}
diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx
index 49411646d..b935692f3 100644
--- a/src/cli/commands/dev/command.tsx
+++ b/src/cli/commands/dev/command.tsx
@@ -8,6 +8,7 @@ import {
} from '../../../lib';
import { getErrorMessage } from '../../errors';
import { detectContainerRuntime } from '../../external-requirements';
+import { isPreviewEnabled } from '../../feature-flags';
import { ExecLogger } from '../../logging';
import {
callMcpTool,
@@ -29,9 +30,10 @@ import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
import { AgentProtocol, standardize } from '../../telemetry/schemas/common-shapes.js';
import { LayoutProvider } from '../../tui/context';
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
-import { requireTTY } from '../../tui/guards';
+import { requireProject, requireTTY } from '../../tui/guards';
+import { runCliDeploy } from '../deploy/progress';
import { parseHeaderFlags } from '../shared/header-utils';
-import { runBrowserMode } from './browser-mode';
+import { launchTuiDevScreenWithPicker, runBrowserMode } from './browser-mode';
import type { Command } from '@commander-js/extra-typings';
import { spawn } from 'child_process';
import { render } from 'ink';
@@ -174,6 +176,7 @@ export const registerDev = (program: Command) => {
.option('--exec', 'Execute a shell command in the running dev container (Container agents only) [non-interactive]')
.option('--tool ', 'MCP tool name (used with "call-tool" prompt) [non-interactive]')
.option('--input ', 'MCP tool arguments as JSON (used with --tool) [non-interactive]')
+ .option('--skip-deploy', 'Skip automatic resource deployment before starting dev server [preview]')
.option(
'-H, --header ',
'Custom header to forward to the agent (format: "Name: Value", repeatable) [non-interactive]',
@@ -291,6 +294,8 @@ export const registerDev = (program: Command) => {
return;
}
+ requireProject();
+
const workingDir = getWorkingDirectory();
const serverResult = await withCommandRunTelemetry(
@@ -307,8 +312,14 @@ export const registerDev = (program: Command) => {
if (!project) {
throw new NoProjectError();
}
- if (!project.runtimes || project.runtimes.length === 0) {
- throw new ValidationError('No agents defined in project. Run `agentcore add agent` to fix this.');
+
+ const hasRuntimes = project.runtimes && project.runtimes.length > 0;
+ const hasHarnesses = isPreviewEnabled() && project.harnesses && project.harnesses.length > 0;
+
+ if (!hasRuntimes && !hasHarnesses) {
+ throw new ValidationError(
+ 'No agents or harnesses defined in project. Run `agentcore add agent` to fix this.'
+ );
}
const targetDevAgent = opts.runtime
@@ -321,8 +332,10 @@ export const registerDev = (program: Command) => {
}
const supportedAgents = getDevSupportedAgents(project);
- if (supportedAgents.length === 0) {
- throw new ValidationError('No agents support dev mode. Dev mode requires an agent with an entrypoint.');
+ if (supportedAgents.length === 0 && !hasHarnesses) {
+ throw new ValidationError(
+ 'No agents support dev mode. Dev mode requires an agent with an entrypoint or a harness.'
+ );
}
const configRoot = findConfigRoot(workingDir);
@@ -338,6 +351,22 @@ export const registerDev = (program: Command) => {
// --logs: non-interactive server mode
if (opts.logs) {
+ // Preview: harness-only projects need deploy then print invoke instructions
+ if (isPreviewEnabled() && supportedAgents.length === 0 && hasHarnesses) {
+ if (!opts.skipDeploy) {
+ await runCliDeploy();
+ }
+ const harnessNames = (project.harnesses ?? []).map(h => h.name);
+ console.log('Harness dev runs against the deployed service (no local server).');
+ console.log(`If you changed the harness config, redeploy to pick up changes: agentcore deploy`);
+ console.log(`\nInvoke your harness:`);
+ for (const name of harnessNames) {
+ console.log(` agentcore invoke --harness ${name} "your prompt"`);
+ }
+ console.log(`\nOr use the interactive TUI: agentcore dev`);
+ return { success: true as const, blockingPromise: Promise.resolve() };
+ }
+
if (project.runtimes.length > 1 && !opts.runtime) {
const names = project.runtimes.map(a => a.name).join(', ');
throw new ValidationError(
@@ -368,6 +397,11 @@ export const registerDev = (program: Command) => {
);
}
+ // Deploy resources before starting dev server (preview mode with harnesses)
+ if (isPreviewEnabled() && !opts.skipDeploy && hasHarnesses) {
+ await runCliDeploy();
+ }
+
const logger = new ExecLogger({ command: 'dev' });
if (actualPort !== fixedPort) {
@@ -446,6 +480,7 @@ export const registerDev = (program: Command) => {
port={port}
agentName={opts.runtime}
headers={headers}
+ skipDeploy={opts.skipDeploy}
/>
);
@@ -459,6 +494,30 @@ export const registerDev = (program: Command) => {
};
}
+ // Preview: show TUI deploy progress, then launch Agent Inspector in the browser
+ if (isPreviewEnabled()) {
+ const pickerResult = await launchTuiDevScreenWithPicker(workingDir, {
+ skipDeploy: opts.skipDeploy,
+ });
+
+ if (pickerResult != null) {
+ recorder.set({ ui_mode: 'browser' as const });
+ return {
+ success: true as const,
+ blockingPromise: runBrowserMode({
+ workingDir,
+ project,
+ port,
+ agentName: pickerResult.agentName,
+ harnessName: pickerResult.harnessName,
+ otelEnvVars,
+ collector,
+ }),
+ };
+ }
+ return { success: true as const, blockingPromise: Promise.resolve() };
+ }
+
// Default: browser mode (blocks forever)
recorder.set({ ui_mode: 'browser' as const });
return {
diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts
index ad21c7113..ce3e4b68d 100644
--- a/src/cli/commands/invoke/action.ts
+++ b/src/cli/commands/invoke/action.ts
@@ -1,5 +1,5 @@
import { ConfigIO, ResourceNotFoundError, ValidationError } from '../../../lib';
-import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema';
+import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState, HarnessModel } from '../../../schema';
import {
buildAguiRunInput,
executeBashCommand,
@@ -11,11 +11,19 @@ import {
mcpInitSession,
mcpListTools,
} from '../../aws';
+import { invokeHarness } from '../../aws/agentcore-harness';
+import { isPreviewEnabled } from '../../feature-flags';
import { InvokeLogger } from '../../logging';
import { formatMcpToolList } from '../../operations/dev/utils';
-import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access';
+import {
+ canFetchHarnessToken,
+ canFetchRuntimeToken,
+ fetchHarnessToken,
+ fetchRuntimeToken,
+} from '../../operations/fetch-access';
import { generateSessionId } from '../../operations/session';
import type { InvokeOptions, InvokeResult } from './types';
+import { randomUUID } from 'node:crypto';
export interface InvokeContext {
project: AgentCoreProjectSpec;
@@ -70,6 +78,29 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption
};
}
+ // Preview: route to harness or runtime
+ if (isPreviewEnabled()) {
+ const harnessEntries = project.harnesses ?? [];
+ const isHarnessInvoke = options.harnessName != null || (harnessEntries.length > 0 && project.runtimes.length === 0);
+
+ if (isHarnessInvoke) {
+ return handleHarnessInvoke(project, targetState, targetConfig, selectedTargetName, options);
+ }
+
+ if (harnessEntries.length > 0 && project.runtimes.length > 0 && !options.agentName) {
+ const runtimeNames = project.runtimes.map(a => a.name);
+ const harnessNames = harnessEntries.map(h => h.name);
+ return {
+ success: false,
+ error: new ValidationError(
+ `Project has both runtimes and harnesses. Specify one:\n` +
+ ` --runtime: ${runtimeNames.join(', ')}\n` +
+ ` --harness: ${harnessNames.join(', ')}`
+ ),
+ };
+ }
+ }
+
if (project.runtimes.length === 0) {
return { success: false, error: new ValidationError('No agents defined in configuration') };
}
@@ -532,3 +563,263 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption
logFilePath: logger.logFilePath,
};
}
+
+// ============================================================================
+// Harness Invoke (preview mode)
+// ============================================================================
+
+export function buildHarnessBaseOpts(
+ options: InvokeOptions,
+ harnessSpec?: Partial
+): Partial {
+ const baseOpts: Partial = {};
+ if (options.modelId || options.modelProvider || options.apiKeyArn) {
+ const provider = options.modelProvider ?? harnessSpec?.provider;
+ const modelId = options.modelId ?? harnessSpec?.modelId ?? '';
+ const apiKeyArn = options.apiKeyArn ?? harnessSpec?.apiKeyArn;
+ switch (provider) {
+ case 'open_ai':
+ baseOpts.model = {
+ openAiModelConfig: { modelId, ...(apiKeyArn && { apiKeyArn }) },
+ };
+ break;
+ case 'gemini':
+ baseOpts.model = {
+ geminiModelConfig: { modelId, ...(apiKeyArn && { apiKeyArn }) },
+ };
+ break;
+ default:
+ baseOpts.model = {
+ bedrockModelConfig: { modelId },
+ };
+ break;
+ }
+ }
+ if (options.tools) {
+ baseOpts.tools = options.tools.split(',').map(t => {
+ const type = t.trim();
+ return { type, name: type };
+ });
+ }
+ if (options.maxIterations != null) baseOpts.maxIterations = options.maxIterations;
+ if (options.maxTokens != null) baseOpts.maxTokens = options.maxTokens;
+ if (options.harnessTimeout != null) baseOpts.timeoutSeconds = options.harnessTimeout;
+ if (options.systemPrompt) baseOpts.systemPrompt = [{ text: options.systemPrompt }];
+ if (options.allowedTools) baseOpts.allowedTools = options.allowedTools.split(',').map(t => t.trim());
+ if (options.actorId) baseOpts.actorId = options.actorId;
+ return baseOpts;
+}
+
+export async function handleHarnessInvokeByArn(
+ harnessArn: string,
+ region: string,
+ options: InvokeOptions
+): Promise {
+ if (!options.prompt) {
+ return {
+ success: false,
+ error: new ValidationError(
+ 'No prompt provided. Usage: agentcore invoke --harness-arn --region "your prompt"'
+ ),
+ };
+ }
+
+ const sessionId = options.sessionId ?? randomUUID();
+ const logger = new InvokeLogger({ agentName: 'external-harness', runtimeArn: harnessArn, region, sessionId });
+ logger.logPrompt(options.prompt, sessionId, options.userId);
+
+ const baseOpts = buildHarnessBaseOpts(options);
+ return streamHarnessInvoke({ region, harnessArn, sessionId, prompt: options.prompt, options, logger, baseOpts });
+}
+
+interface StreamHarnessParams {
+ region: string;
+ harnessArn: string;
+ sessionId: string;
+ prompt: string;
+ options: InvokeOptions;
+ logger: InvokeLogger;
+ baseOpts: Partial;
+}
+
+async function streamHarnessInvoke(params: StreamHarnessParams): Promise {
+ const { region, harnessArn, sessionId, prompt, options, logger, baseOpts } = params;
+ let fullResponse = '';
+
+ try {
+ const messages: { role: string; content: Record[] }[] = [
+ { role: 'user', content: [{ text: prompt }] },
+ ];
+
+ const stream = invokeHarness({
+ region,
+ harnessArn,
+ runtimeSessionId: sessionId,
+ messages,
+ bearerToken: options.bearerToken,
+ ...baseOpts,
+ });
+
+ for await (const event of stream) {
+ if (options.verbose) {
+ console.log(JSON.stringify(event));
+ continue;
+ }
+
+ switch (event.type) {
+ case 'contentBlockDelta':
+ if (event.delta.type === 'text') {
+ fullResponse += event.delta.text;
+ if (!options.json) {
+ process.stdout.write(event.delta.text);
+ }
+ }
+ break;
+ case 'messageStop':
+ if (!options.json && event.stopReason !== 'tool_use' && event.stopReason !== 'tool_result') {
+ process.stdout.write('\n');
+ }
+ break;
+ case 'error':
+ logger.logError(new Error(`${event.errorType}: ${event.message}`), 'stream error');
+ if (options.json) {
+ return { success: false, error: new Error(`${event.errorType}: ${event.message}`) };
+ }
+ process.stderr.write(`\nError: ${event.message}\n`);
+ break;
+ }
+ }
+
+ logger.logResponse(fullResponse);
+
+ if (options.json) {
+ return {
+ success: true,
+ response: JSON.stringify({ text: fullResponse, sessionId }),
+ sessionId,
+ logFilePath: logger.logFilePath,
+ };
+ }
+
+ return { success: true, sessionId, logFilePath: logger.logFilePath };
+ } catch (err) {
+ logger.logError(err, 'harness invoke failed');
+ return {
+ success: false,
+ error: new Error(`Harness invoke failed: ${err instanceof Error ? err.message : String(err)}`),
+ logFilePath: logger.logFilePath,
+ };
+ }
+}
+
+async function handleHarnessInvoke(
+ project: AgentCoreProjectSpec,
+ targetState: DeployedState['targets'][string] | undefined,
+ targetConfig: { region: string; name: string },
+ selectedTargetName: string,
+ options: InvokeOptions
+): Promise {
+ const harnessEntries = project.harnesses ?? [];
+
+ if (harnessEntries.length === 0) {
+ return { success: false, error: new ValidationError('No harnesses defined in configuration') };
+ }
+
+ let harnessName = options.harnessName;
+ if (!harnessName) {
+ if (harnessEntries.length > 1) {
+ const names = harnessEntries.map(h => h.name);
+ return {
+ success: false,
+ error: new ValidationError(`Multiple harnesses found. Use --harness to specify one: ${names.join(', ')}`),
+ };
+ }
+ harnessName = harnessEntries[0]!.name;
+ }
+
+ const harnessEntry = harnessEntries.find(h => h.name === harnessName);
+ if (!harnessEntry) {
+ const names = harnessEntries.map(h => h.name);
+ return {
+ success: false,
+ error: new ResourceNotFoundError(`Harness '${harnessName}' not found. Available: ${names.join(', ')}`),
+ };
+ }
+
+ const harnessState = targetState?.resources?.harnesses?.[harnessName];
+ if (!harnessState) {
+ return {
+ success: false,
+ error: new ValidationError(
+ `Harness '${harnessName}' is not deployed to target '${selectedTargetName}'. Run \`agentcore deploy\` first.`
+ ),
+ };
+ }
+
+ const sessionId = options.sessionId ?? randomUUID();
+ const region = targetConfig.region;
+
+ const logger = new InvokeLogger({
+ agentName: harnessName,
+ runtimeArn: harnessState.harnessArn,
+ region,
+ sessionId,
+ });
+
+ // Read harness spec for auth config
+ const configIO = new ConfigIO();
+ let harnessSpec;
+ try {
+ harnessSpec = await configIO.readHarnessSpec(harnessName);
+ } catch {
+ // spec read is best-effort
+ }
+
+ // Auto-fetch bearer token for CUSTOM_JWT harnesses
+ if (harnessSpec?.authorizerType === 'CUSTOM_JWT' && !options.bearerToken) {
+ const canFetch = await canFetchHarnessToken(harnessName);
+ if (canFetch) {
+ try {
+ const tokenResult = await fetchHarnessToken(harnessName, { deployTarget: selectedTargetName });
+ options = { ...options, bearerToken: tokenResult.token };
+ } catch (err) {
+ return {
+ success: false,
+ error: new ValidationError(
+ `CUSTOM_JWT harness requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`
+ ),
+ };
+ }
+ } else {
+ return {
+ success: false,
+ error: new ValidationError(
+ `Harness '${harnessName}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the harness with --client-id and --client-secret to enable auto-fetch.`
+ ),
+ };
+ }
+ }
+
+ if (!options.prompt) {
+ return {
+ success: false,
+ error: new ValidationError('No prompt provided. Usage: agentcore invoke --harness "your prompt"'),
+ };
+ }
+
+ logger.logPrompt(options.prompt, sessionId, options.userId);
+
+ const baseOpts = buildHarnessBaseOpts(options, harnessSpec?.model);
+
+ const result = await streamHarnessInvoke({
+ region,
+ harnessArn: harnessState.harnessArn,
+ sessionId,
+ prompt: options.prompt,
+ options,
+ logger,
+ baseOpts,
+ });
+
+ return { ...result, targetName: selectedTargetName };
+}
diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx
index 1359232c3..20d9bc40c 100644
--- a/src/cli/commands/invoke/command.tsx
+++ b/src/cli/commands/invoke/command.tsx
@@ -1,12 +1,13 @@
import { type Result, ValidationError, serializeResult } from '../../../lib';
import { getErrorMessage } from '../../errors';
+import { isPreviewEnabled } from '../../feature-flags';
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js';
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
import { requireProject, requireTTY } from '../../tui/guards';
import { InvokeScreen } from '../../tui/screens/invoke';
import { parseHeaderFlags } from '../shared/header-utils';
-import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action';
+import { type InvokeContext, handleHarnessInvokeByArn, handleInvoke, loadInvokeConfig } from './action';
import { resolvePrompt } from './resolve-prompt';
import type { InvokeOptions, InvokeResult } from './types';
import { validateInvokeOptions } from './validate';
@@ -45,10 +46,30 @@ async function handleInvokeCLI(options: InvokeOptions, preloadedContext?: Invoke
let spinner: NodeJS.Timeout | undefined;
try {
+ // Preview: direct harness invoke by ARN (no project required)
+ if (isPreviewEnabled() && options.harnessArn) {
+ const region = options.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
+ if (!region) {
+ const msg = '--region is required with --harness-arn (or set AWS_REGION)';
+ if (options.json) {
+ console.log(JSON.stringify({ success: false, error: msg }));
+ } else {
+ console.error(msg);
+ }
+ process.exit(1);
+ }
+ return handleHarnessInvokeByArn(options.harnessArn, region, options);
+ }
+
const context = preloadedContext ?? (await loadInvokeConfig());
// Show spinner for non-streaming, non-json, non-exec invocations
- if (!options.stream && !options.json && !options.exec) {
+ // Harness invoke always streams directly to stdout, so skip spinner for harness
+ const isHarness =
+ isPreviewEnabled() &&
+ (options.harnessName != null ||
+ ((context.project.harnesses ?? []).length > 0 && context.project.runtimes.length === 0));
+ if (!options.stream && !options.json && !options.exec && !isHarness) {
spinner = startSpinner('Invoking agent...');
}
@@ -97,7 +118,7 @@ function printInvokeResult(result: InvokeResult, options: InvokeOptions): void {
}
export const registerInvoke = (program: Command) => {
- program
+ const invokeCmd = program
.command('invoke')
.alias('i')
.description(COMMAND_DESCRIPTIONS.invoke)
@@ -126,157 +147,226 @@ export const registerInvoke = (program: Command) => {
(val: string, prev: string[]) => [...prev, val],
[] as string[]
)
- .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]')
- .action(
- async (
- positionalPrompt: string | undefined,
- cliOptions: {
- prompt?: string;
- promptFile?: string;
- runtime?: string;
- target?: string;
- sessionId?: string;
- userId?: string;
- json?: boolean;
- stream?: boolean;
- tool?: string;
- input?: string;
- exec?: boolean;
- timeout?: number;
- header?: string[];
- bearerToken?: string;
- }
- ) => {
- try {
- requireProject();
+ .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]');
- // Load config once for protocol resolution and to pass into handleInvokeCLI
- let invokeContext: InvokeContext | undefined;
- let agentProtocol: string | undefined;
- try {
- invokeContext = await loadInvokeConfig();
- const agent = cliOptions.runtime
- ? invokeContext.project.runtimes.find(a => a.name === cliOptions.runtime)
- : invokeContext.project.runtimes[0];
- agentProtocol = agent?.protocol;
- } catch {
- // Config load failure will be caught again inside handleInvokeCLI
- }
+ if (isPreviewEnabled()) {
+ invokeCmd
+ .option('--harness ', 'Select specific harness to invoke [non-interactive] [preview]')
+ .option('--harness-arn ', 'Invoke a harness by ARN (no project required) [non-interactive] [preview]')
+ .option(
+ '--region ',
+ 'AWS region (required with --harness-arn when no project) [non-interactive] [preview]'
+ )
+ .option('--verbose', 'Print verbose streaming JSON events (harness only) [non-interactive] [preview]')
+ .option('--model-id ', 'Override model for this invocation (harness only) [non-interactive] [preview]')
+ .option(
+ '--model-provider ',
+ 'Override model provider: bedrock, open_ai, gemini (harness only) [non-interactive] [preview]'
+ )
+ .option(
+ '--api-key-arn ',
+ 'Override API key ARN for open_ai/gemini (harness only) [non-interactive] [preview]'
+ )
+ .option('--tools ', 'Override tools, comma-separated (harness only) [non-interactive] [preview]')
+ .option('--max-iterations ', 'Override max iterations (harness only) [non-interactive] [preview]', parseInt)
+ .option('--max-tokens ', 'Override max tokens (harness only) [non-interactive] [preview]', parseInt)
+ .option(
+ '--harness-timeout ',
+ 'Override timeout seconds (harness only) [non-interactive] [preview]',
+ parseInt
+ )
+ .option('--system-prompt ', 'Override system prompt (harness only) [non-interactive] [preview]')
+ .option(
+ '--allowed-tools ',
+ 'Override allowed tools, comma-separated (harness only) [non-interactive] [preview]'
+ )
+ .option('--actor-id ', 'Override memory actor ID (harness only) [non-interactive] [preview]');
+ }
- // Resolve prompt from flag / positional / --prompt-file / stdin
- const resolved = await resolvePrompt({
- flag: cliOptions.prompt,
- positional: positionalPrompt,
- file: cliOptions.promptFile,
- stdinPiped: !process.stdin.isTTY,
- });
+ invokeCmd.action(
+ async (
+ positionalPrompt: string | undefined,
+ cliOptions: {
+ prompt?: string;
+ promptFile?: string;
+ runtime?: string;
+ target?: string;
+ sessionId?: string;
+ userId?: string;
+ json?: boolean;
+ stream?: boolean;
+ tool?: string;
+ input?: string;
+ exec?: boolean;
+ timeout?: number;
+ header?: string[];
+ bearerToken?: string;
+ harness?: string;
+ harnessArn?: string;
+ region?: string;
+ verbose?: boolean;
+ modelId?: string;
+ modelProvider?: string;
+ apiKeyArn?: string;
+ tools?: string;
+ maxIterations?: number;
+ maxTokens?: number;
+ harnessTimeout?: number;
+ systemPrompt?: string;
+ allowedTools?: string;
+ actorId?: string;
+ }
+ ) => {
+ try {
+ // Skip requireProject when --harness-arn provided (preview mode)
+ if (!(isPreviewEnabled() && cliOptions.harnessArn)) {
+ requireProject();
+ }
- // CLI mode if any CLI-specific options provided, prompt resolved, or prompt resolution failed
- // (follows deploy command pattern)
- if (
- !resolved.success ||
- resolved.prompt !== undefined ||
- cliOptions.json ||
- cliOptions.target ||
- cliOptions.stream ||
- cliOptions.runtime ||
- cliOptions.tool ||
- cliOptions.exec ||
- cliOptions.bearerToken
- ) {
- const result = await withCommandRunTelemetry(
- 'invoke',
- {
- has_stream: cliOptions.stream ?? false,
- has_session_id: !!cliOptions.sessionId,
- auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'),
- agent_protocol: standardize(
- AgentProtocol,
- resolveProtocol({ tool: cliOptions.tool } as InvokeOptions, agentProtocol)
- ),
- },
- async (): Promise => {
- if (!resolved.success) {
- return { success: false, error: new ValidationError(resolved.error ?? 'Prompt resolution failed') };
- }
+ // Load config once for protocol resolution and to pass into handleInvokeCLI
+ let invokeContext: InvokeContext | undefined;
+ let agentProtocol: string | undefined;
+ try {
+ invokeContext = await loadInvokeConfig();
+ const agent = cliOptions.runtime
+ ? invokeContext.project.runtimes.find(a => a.name === cliOptions.runtime)
+ : invokeContext.project.runtimes[0];
+ agentProtocol = agent?.protocol;
+ } catch {
+ // Config load failure will be caught again inside handleInvokeCLI
+ }
- // Parse custom headers
- let headers: Record | undefined;
- if (cliOptions.header && cliOptions.header.length > 0) {
- headers = parseHeaderFlags(cliOptions.header);
- }
+ // Resolve prompt from flag / positional / --prompt-file / stdin
+ const resolved = await resolvePrompt({
+ flag: cliOptions.prompt,
+ positional: positionalPrompt,
+ file: cliOptions.promptFile,
+ stdinPiped: !process.stdin.isTTY,
+ });
- const options: InvokeOptions = {
- prompt: resolved.prompt,
- agentName: cliOptions.runtime,
- targetName: cliOptions.target ?? 'default',
- sessionId: cliOptions.sessionId,
- userId: cliOptions.userId,
- json: cliOptions.json,
- stream: cliOptions.stream,
- tool: cliOptions.tool,
- input: cliOptions.input,
- exec: cliOptions.exec,
- timeout: cliOptions.timeout,
- headers,
- bearerToken: cliOptions.bearerToken,
- };
+ // CLI mode if any CLI-specific options provided, prompt resolved, or prompt resolution failed
+ // (follows deploy command pattern)
+ if (
+ !resolved.success ||
+ resolved.prompt !== undefined ||
+ cliOptions.json ||
+ cliOptions.target ||
+ cliOptions.stream ||
+ cliOptions.runtime ||
+ cliOptions.tool ||
+ cliOptions.exec ||
+ cliOptions.bearerToken ||
+ cliOptions.harness ||
+ cliOptions.harnessArn ||
+ cliOptions.verbose
+ ) {
+ const result = await withCommandRunTelemetry(
+ 'invoke',
+ {
+ has_stream: cliOptions.stream ?? false,
+ has_session_id: !!cliOptions.sessionId,
+ auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'),
+ agent_protocol: standardize(
+ AgentProtocol,
+ resolveProtocol({ tool: cliOptions.tool } as InvokeOptions, agentProtocol)
+ ),
+ },
+ async (): Promise => {
+ if (!resolved.success) {
+ return { success: false, error: new ValidationError(resolved.error ?? 'Prompt resolution failed') };
+ }
- return handleInvokeCLI(options, invokeContext);
+ // Parse custom headers
+ let headers: Record | undefined;
+ if (cliOptions.header && cliOptions.header.length > 0) {
+ headers = parseHeaderFlags(cliOptions.header);
}
- );
- printInvokeResult(result, {
- json: cliOptions.json,
- stream: cliOptions.stream,
- });
- process.exit(result.success ? 0 : 1);
- } else {
- // No CLI options - interactive TUI mode (headers still passed if provided)
- requireTTY();
+ const options: InvokeOptions = {
+ prompt: resolved.prompt,
+ agentName: cliOptions.runtime,
+ targetName: cliOptions.target ?? 'default',
+ sessionId: cliOptions.sessionId,
+ userId: cliOptions.userId,
+ json: cliOptions.json,
+ stream: cliOptions.stream,
+ tool: cliOptions.tool,
+ input: cliOptions.input,
+ exec: cliOptions.exec,
+ timeout: cliOptions.timeout,
+ headers,
+ bearerToken: cliOptions.bearerToken,
+ harnessName: cliOptions.harness,
+ harnessArn: cliOptions.harnessArn,
+ region: cliOptions.region,
+ verbose: cliOptions.verbose,
+ modelId: cliOptions.modelId,
+ modelProvider: cliOptions.modelProvider,
+ apiKeyArn: cliOptions.apiKeyArn,
+ tools: cliOptions.tools,
+ maxIterations: cliOptions.maxIterations,
+ maxTokens: cliOptions.maxTokens,
+ harnessTimeout: cliOptions.harnessTimeout,
+ systemPrompt: cliOptions.systemPrompt,
+ allowedTools: cliOptions.allowedTools,
+ actorId: cliOptions.actorId,
+ };
- // Parse custom headers for TUI mode
- let headers: Record | undefined;
- if (cliOptions.header && cliOptions.header.length > 0) {
- headers = parseHeaderFlags(cliOptions.header);
+ return handleInvokeCLI(options, invokeContext);
}
+ );
- const tuiResult = await withCommandRunTelemetry(
- 'invoke',
- {
- has_stream: true,
- has_session_id: !!cliOptions.sessionId,
- auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'),
- agent_protocol: standardize(AgentProtocol, resolveProtocol({}, agentProtocol)),
- },
- async (): Promise => {
- const { waitUntilExit, unmount } = render(
- unmount()}
- initialSessionId={cliOptions.sessionId}
- initialUserId={cliOptions.userId}
- initialHeaders={headers}
- initialBearerToken={cliOptions.bearerToken}
- />
- );
- await waitUntilExit();
- return { success: true };
- }
- );
- if (!tuiResult.success) {
- render(Error: {getErrorMessage(tuiResult.error)});
- process.exit(1);
- }
+ printInvokeResult(result, {
+ json: cliOptions.json,
+ stream: cliOptions.stream,
+ });
+ process.exit(result.success ? 0 : 1);
+ } else {
+ // No CLI options - interactive TUI mode (headers still passed if provided)
+ requireTTY();
+
+ // Parse custom headers for TUI mode
+ let headers: Record | undefined;
+ if (cliOptions.header && cliOptions.header.length > 0) {
+ headers = parseHeaderFlags(cliOptions.header);
}
- } catch (error) {
- if (cliOptions.json) {
- console.log(JSON.stringify({ success: false, error: getErrorMessage(error) }));
- } else {
- render(Error: {getErrorMessage(error)});
+
+ const tuiResult = await withCommandRunTelemetry(
+ 'invoke',
+ {
+ has_stream: true,
+ has_session_id: !!cliOptions.sessionId,
+ auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'),
+ agent_protocol: standardize(AgentProtocol, resolveProtocol({}, agentProtocol)),
+ },
+ async (): Promise => {
+ const { waitUntilExit, unmount } = render(
+ unmount()}
+ initialSessionId={cliOptions.sessionId}
+ initialUserId={cliOptions.userId}
+ initialHeaders={headers}
+ initialBearerToken={cliOptions.bearerToken}
+ />
+ );
+ await waitUntilExit();
+ return { success: true };
+ }
+ );
+ if (!tuiResult.success) {
+ render(Error: {getErrorMessage(tuiResult.error)});
+ process.exit(1);
}
- process.exit(1);
}
+ } catch (error) {
+ if (cliOptions.json) {
+ console.log(JSON.stringify({ success: false, error: getErrorMessage(error) }));
+ } else {
+ render(Error: {getErrorMessage(error)});
+ }
+ process.exit(1);
}
- );
+ }
+ );
};
diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts
index 86411214c..31798b3de 100644
--- a/src/cli/commands/invoke/types.ts
+++ b/src/cli/commands/invoke/types.ts
@@ -2,6 +2,11 @@ import type { Result } from '../../../lib/result';
export interface InvokeOptions {
agentName?: string;
+ harnessName?: string;
+ /** Direct harness ARN — bypasses project config and deployed state resolution */
+ harnessArn?: string;
+ /** AWS region (used with --harness-arn) */
+ region?: string;
targetName?: string;
prompt?: string;
/** Path to a file containing the prompt (alternative to --prompt / positional) */
@@ -22,6 +27,30 @@ export interface InvokeOptions {
headers?: Record;
/** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */
bearerToken?: string;
+ /** Print verbose streaming JSON events instead of formatted text (harness only) */
+ verbose?: boolean;
+ /** Override model ID for this invocation (harness only) */
+ modelId?: string;
+ /** Override model provider for this invocation (harness only): bedrock, open_ai, gemini */
+ modelProvider?: string;
+ /** Override API key ARN for this invocation (harness only, open_ai/gemini) */
+ apiKeyArn?: string;
+ /** Override tools for this invocation (harness only, comma-separated) */
+ tools?: string;
+ /** Override max iterations (harness only) */
+ maxIterations?: number;
+ /** Override timeout seconds (harness only) */
+ harnessTimeout?: number;
+ /** Override max tokens (harness only) */
+ maxTokens?: number;
+ /** Skills to use (harness only, comma-separated paths) */
+ skills?: string;
+ /** Override system prompt (harness only) */
+ systemPrompt?: string;
+ /** Override allowed tools (harness only, comma-separated) */
+ allowedTools?: string;
+ /** Override memory actor ID (harness only) */
+ actorId?: string;
}
export type InvokeResult = Result & {
diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts
index 807fb87de..f7fcabc3c 100644
--- a/src/cli/commands/logs/__tests__/action.test.ts
+++ b/src/cli/commands/logs/__tests__/action.test.ts
@@ -63,6 +63,7 @@ describe('resolveAgentContext', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
},
deployedState: {
@@ -128,6 +129,7 @@ describe('resolveAgentContext', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
},
});
@@ -173,6 +175,7 @@ describe('resolveAgentContext', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
},
deployedState: {
@@ -228,6 +231,7 @@ describe('resolveAgentContext', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
},
});
diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx
index c4b296089..953f6f248 100644
--- a/src/cli/commands/remove/command.tsx
+++ b/src/cli/commands/remove/command.tsx
@@ -38,6 +38,7 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise', 'Target harness name')
+ .requiredOption('--name ', 'Tool name to remove')
+ .option('--json', 'Output as JSON')
+ .action(async cliOptions => {
+ if (!findConfigRoot()) {
+ console.error('No agentcore project found. Run `agentcore create` first.');
+ process.exit(1);
+ }
+
+ try {
+ const configIO = new ConfigIO();
+ let harnessSpec;
+ try {
+ harnessSpec = await configIO.readHarnessSpec(cliOptions.harness);
+ } catch {
+ const error = `Harness '${cliOptions.harness}' not found.`;
+ if (cliOptions.json) {
+ console.log(JSON.stringify({ success: false, error }));
+ } else {
+ console.error(error);
+ }
+ process.exit(1);
+ return;
+ }
+
+ const toolIndex = harnessSpec.tools.findIndex(t => t.name === cliOptions.name);
+ if (toolIndex === -1) {
+ const error = `Tool '${cliOptions.name}' not found in harness '${cliOptions.harness}'`;
+ if (cliOptions.json) {
+ console.log(JSON.stringify({ success: false, error }));
+ } else {
+ console.error(error);
+ }
+ process.exit(1);
+ return;
+ }
+
+ harnessSpec.tools.splice(toolIndex, 1);
+ await configIO.writeHarnessSpec(cliOptions.harness, harnessSpec);
+
+ const result = { success: true, harnessName: cliOptions.harness, toolName: cliOptions.name };
+ if (cliOptions.json) {
+ console.log(JSON.stringify(result));
+ } else {
+ console.log(`Removed tool '${cliOptions.name}' from harness '${cliOptions.harness}'.`);
+ console.log(`Run 'agentcore deploy' to apply changes.`);
+ }
+ } catch (error) {
+ if (cliOptions.json) {
+ console.log(JSON.stringify({ success: false, error: getErrorMessage(error) }));
+ } else {
+ console.error(getErrorMessage(error));
+ }
+ process.exit(1);
+ }
+ });
+}
diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts
index dafcbee3c..afd6de173 100644
--- a/src/cli/commands/remove/types.ts
+++ b/src/cli/commands/remove/types.ts
@@ -2,6 +2,7 @@ import type { Result } from '../../../lib/result';
export type ResourceType =
| 'agent'
+ | 'harness'
| 'gateway'
| 'gateway-target'
| 'runtime-endpoint'
diff --git a/src/cli/constants.ts b/src/cli/constants.ts
index 48c3e0375..85d053817 100644
--- a/src/cli/constants.ts
+++ b/src/cli/constants.ts
@@ -30,11 +30,21 @@ export const DISTRO_CONFIG = {
},
} as const;
+export function getNpmDistTag(): string {
+ return PACKAGE_VERSION.includes('-') ? 'preview' : 'latest';
+}
+
/**
* Get the current distribution configuration.
*/
export function getDistroConfig() {
- return DISTRO_CONFIG[DISTRO_MODE];
+ const base = DISTRO_CONFIG[DISTRO_MODE];
+ const distTag = getNpmDistTag();
+ return {
+ ...base,
+ distTag,
+ installCommand: base.installCommand.replace('@latest', `@${distTag}`),
+ };
}
/**
diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts
index 6ee6a2a90..9a8caa5d9 100644
--- a/src/cli/external-requirements/__tests__/checks-extended.test.ts
+++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts
@@ -56,6 +56,7 @@ describe('requiresUv', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresUv(project)).toBe(true);
@@ -85,6 +86,7 @@ describe('requiresUv', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresUv(project)).toBe(false);
@@ -105,6 +107,7 @@ describe('requiresUv', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresUv(project)).toBe(false);
@@ -136,6 +139,7 @@ describe('requiresContainerRuntime', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresContainerRuntime(project)).toBe(true);
@@ -165,6 +169,7 @@ describe('requiresContainerRuntime', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresContainerRuntime(project)).toBe(false);
@@ -185,6 +190,7 @@ describe('requiresContainerRuntime', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresContainerRuntime(project)).toBe(false);
@@ -222,6 +228,7 @@ describe('requiresContainerRuntime', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
expect(requiresContainerRuntime(project)).toBe(true);
@@ -293,6 +300,7 @@ describe('checkDependencyVersions', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -317,6 +325,7 @@ describe('checkDependencyVersions', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -349,6 +358,7 @@ describe('checkDependencyVersions', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
diff --git a/src/cli/feature-flags.ts b/src/cli/feature-flags.ts
new file mode 100644
index 000000000..f6dce4f86
--- /dev/null
+++ b/src/cli/feature-flags.ts
@@ -0,0 +1,3 @@
+declare const __PREVIEW__: boolean;
+
+export const isPreviewEnabled = (): boolean => __PREVIEW__;
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 9006973ee..33d64012b 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -2,6 +2,9 @@
import { main } from './cli.js';
import { getErrorMessage } from './errors.js';
+// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
+(globalThis as any).__PREVIEW__ ??= process.env.BUILD_PREVIEW === '1';
+
// Global safety net — prevent raw stack traces from reaching the user
process.on('uncaughtException', err => {
console.error(`Error: ${getErrorMessage(err)}`);
diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts
index fc5dbe8bc..dc3119d2e 100644
--- a/src/cli/logging/remove-logger.ts
+++ b/src/cli/logging/remove-logger.ts
@@ -9,6 +9,7 @@ export interface RemoveLoggerOptions {
/** Type of resource being removed */
resourceType:
| 'agent'
+ | 'harness'
| 'memory'
| 'credential'
| 'gateway'
diff --git a/src/cli/operations/__tests__/resolve-agent.test.ts b/src/cli/operations/__tests__/resolve-agent.test.ts
new file mode 100644
index 000000000..423282f28
--- /dev/null
+++ b/src/cli/operations/__tests__/resolve-agent.test.ts
@@ -0,0 +1,273 @@
+import type { DeployedProjectConfig } from '../resolve-agent';
+import { resolveAgent, resolveAgentOrHarness, resolveHarness } from '../resolve-agent';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../aws/agentcore-harness', () => ({
+ getHarness: vi.fn().mockResolvedValue({ harnessId: 'h-123' }),
+}));
+
+function makeContext(overrides: Partial = {}): DeployedProjectConfig {
+ return {
+ project: {
+ name: 'test-project',
+ runtimes: [{ name: 'my-agent', path: 'agents/my-agent', type: 'strands' }],
+ harnesses: [],
+ memories: [],
+ credentials: [],
+ evaluators: [],
+ onlineEvalConfigs: [],
+ gateways: [],
+ policyEngines: [],
+ ...overrides.project,
+ } as DeployedProjectConfig['project'],
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ runtimes: {
+ 'my-agent': { runtimeId: 'rt-abc123' },
+ },
+ },
+ },
+ },
+ ...overrides.deployedState,
+ } as DeployedProjectConfig['deployedState'],
+ awsTargets: [{ name: 'dev', region: 'us-east-1', account: '111111111111' }],
+ ...overrides,
+ };
+}
+
+describe('resolveAgent', () => {
+ it('resolves a single runtime', () => {
+ const result = resolveAgent(makeContext(), {});
+ expect(result).toEqual({
+ success: true,
+ agent: {
+ agentName: 'my-agent',
+ targetName: 'dev',
+ region: 'us-east-1',
+ accountId: '111111111111',
+ runtimeId: 'rt-abc123',
+ },
+ });
+ });
+
+ it('returns error when no runtimes defined', () => {
+ const ctx = makeContext({ project: { runtimes: [], harnesses: [] } as any });
+ const result = resolveAgent(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('No runtimes defined');
+ });
+
+ it('returns error when multiple runtimes and none specified', () => {
+ const ctx = makeContext({
+ project: {
+ runtimes: [
+ { name: 'agent-a', path: 'a', type: 'strands' },
+ { name: 'agent-b', path: 'b', type: 'strands' },
+ ],
+ } as any,
+ });
+ const result = resolveAgent(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('Multiple runtimes');
+ });
+
+ it('resolves named runtime from multiple', () => {
+ const ctx = makeContext({
+ project: {
+ runtimes: [
+ { name: 'agent-a', path: 'a', type: 'strands' },
+ { name: 'agent-b', path: 'b', type: 'strands' },
+ ],
+ } as any,
+ deployedState: {
+ targets: {
+ dev: { resources: { runtimes: { 'agent-b': { runtimeId: 'rt-bbb' } } } },
+ },
+ } as any,
+ });
+ const result = resolveAgent(ctx, { runtime: 'agent-b' });
+ expect(result.success).toBe(true);
+ expect((result as any).agent.runtimeId).toBe('rt-bbb');
+ });
+
+ it('returns error when specified runtime not found', () => {
+ const result = resolveAgent(makeContext(), { runtime: 'nonexistent' });
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('not found');
+ });
+
+ it('returns error when no deployed targets', () => {
+ const ctx = makeContext({ deployedState: { targets: {} } as any });
+ const result = resolveAgent(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('No deployed targets');
+ });
+
+ it('returns error when runtime not deployed', () => {
+ const ctx = makeContext({
+ deployedState: { targets: { dev: { resources: { runtimes: {} } } } } as any,
+ });
+ const result = resolveAgent(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('is not deployed');
+ });
+
+ it('returns error when target config missing from aws-targets', () => {
+ const ctx = makeContext();
+ ctx.awsTargets = [];
+ const result = resolveAgent(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('not found in aws-targets');
+ });
+});
+
+describe('resolveHarness', () => {
+ function harnessContext(): DeployedProjectConfig {
+ return {
+ project: {
+ name: 'test-project',
+ runtimes: [],
+ harnesses: [{ name: 'my-harness', path: 'harnesses/my-harness' }],
+ memories: [],
+ credentials: [],
+ evaluators: [],
+ onlineEvalConfigs: [],
+ gateways: [],
+ policyEngines: [],
+ } as unknown as DeployedProjectConfig['project'],
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ 'my-harness': {
+ harnessId: 'h-123',
+ agentRuntimeArn: 'arn:aws:bedrock:us-east-1:111:agent-runtime/rt-harness1',
+ },
+ },
+ },
+ },
+ },
+ } as any,
+ awsTargets: [{ name: 'dev', region: 'us-east-1', account: '111111111111' }],
+ };
+ }
+
+ it('resolves harness with agentRuntimeArn', async () => {
+ const result = await resolveHarness(harnessContext(), 'my-harness');
+ expect(result.success).toBe(true);
+ expect((result as any).agent.runtimeId).toBe('rt-harness1');
+ });
+
+ it('returns error when harness not in config', async () => {
+ const result = await resolveHarness(harnessContext(), 'unknown');
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('not found');
+ });
+
+ it('returns error when no harnesses defined', async () => {
+ const ctx = harnessContext();
+ (ctx.project as any).harnesses = [];
+ const result = await resolveHarness(ctx, 'my-harness');
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('No harnesses defined');
+ });
+
+ it('returns error when harness not deployed', async () => {
+ const ctx = harnessContext();
+ (ctx.deployedState.targets.dev as any).resources.harnesses = {};
+ const result = await resolveHarness(ctx, 'my-harness');
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('is not deployed');
+ });
+
+ it('falls back to getHarness API when no agentRuntimeArn', async () => {
+ const ctx = harnessContext();
+ (ctx.deployedState.targets.dev as any).resources.harnesses['my-harness'] = {
+ harnessId: 'h-123',
+ };
+ const result = await resolveHarness(ctx, 'my-harness');
+ expect(result.success).toBe(true);
+ expect((result as any).agent.runtimeId).toBe('h-123');
+ });
+});
+
+describe('resolveAgentOrHarness', () => {
+ it('returns error when both --harness and --runtime specified', async () => {
+ const result = await resolveAgentOrHarness(makeContext(), { harness: 'h', runtime: 'r' });
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('Cannot specify both');
+ });
+
+ it('routes to resolveHarness when --harness specified', async () => {
+ const ctx: DeployedProjectConfig = {
+ project: { runtimes: [], harnesses: [{ name: 'h1', path: 'h' }] } as any,
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ h1: { harnessId: 'hid', agentRuntimeArn: 'arn:aws:bedrock:us-east-1:111:agent-runtime/rt-x' },
+ },
+ },
+ },
+ },
+ } as any,
+ awsTargets: [{ name: 'dev', region: 'us-east-1', account: '111111111111' }],
+ };
+ const result = await resolveAgentOrHarness(ctx, { harness: 'h1' });
+ expect(result.success).toBe(true);
+ expect((result as any).agent.runtimeId).toBe('rt-x');
+ });
+
+ it('auto-selects single harness when no runtimes exist', async () => {
+ const ctx: DeployedProjectConfig = {
+ project: { runtimes: [], harnesses: [{ name: 'solo', path: 'h' }] } as any,
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ solo: { harnessId: 'hid', agentRuntimeArn: 'arn:aws:bedrock:us-east-1:111:agent-runtime/rt-solo' },
+ },
+ },
+ },
+ },
+ } as any,
+ awsTargets: [{ name: 'dev', region: 'us-east-1', account: '111111111111' }],
+ };
+ const result = await resolveAgentOrHarness(ctx, {});
+ expect(result.success).toBe(true);
+ expect((result as any).agent.runtimeId).toBe('rt-solo');
+ });
+
+ it('returns error when multiple harnesses and none specified', async () => {
+ const ctx: DeployedProjectConfig = {
+ project: {
+ runtimes: [],
+ harnesses: [
+ { name: 'h1', path: 'a' },
+ { name: 'h2', path: 'b' },
+ ],
+ } as any,
+ deployedState: { targets: { dev: { resources: {} } } } as any,
+ awsTargets: [{ name: 'dev', region: 'us-east-1', account: '111' }],
+ };
+ const result = await resolveAgentOrHarness(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('Multiple harnesses');
+ });
+
+ it('returns error when no runtimes or harnesses', async () => {
+ const ctx: DeployedProjectConfig = {
+ project: { runtimes: [], harnesses: [] } as any,
+ deployedState: { targets: {} } as any,
+ awsTargets: [],
+ };
+ const result = await resolveAgentOrHarness(ctx, {});
+ expect(result.success).toBe(false);
+ expect((result as any).error).toContain('No runtimes or harnesses');
+ });
+});
diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts
index 8bf810ea3..8fef363c0 100644
--- a/src/cli/operations/agent/generate/write-agent-to-project.ts
+++ b/src/cli/operations/agent/generate/write-agent-to-project.ts
@@ -74,6 +74,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
diff --git a/src/cli/operations/deploy/__tests__/change-detection.test.ts b/src/cli/operations/deploy/__tests__/change-detection.test.ts
new file mode 100644
index 000000000..028411d11
--- /dev/null
+++ b/src/cli/operations/deploy/__tests__/change-detection.test.ts
@@ -0,0 +1,102 @@
+import { canSkipDeploy, computeProjectDeployHash } from '../change-detection';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('node:fs/promises', () => ({
+ readFile: vi.fn().mockImplementation((path: string) => {
+ if (path.includes('harness.json'))
+ return Promise.resolve('{"name":"h1","model":{"provider":"bedrock","modelId":"anthropic.claude-3"}}');
+ if (path.includes('system-prompt.md')) return Promise.resolve('You are a helpful assistant.');
+ return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
+ }),
+}));
+
+function mockConfigIO(opts: {
+ runtimes?: any[];
+ harnesses?: any[];
+ targets?: Record;
+ awsTargets?: any[];
+}) {
+ return {
+ readProjectSpec: vi.fn().mockResolvedValue({
+ name: 'test-project',
+ runtimes: opts.runtimes ?? [],
+ harnesses: opts.harnesses ?? [{ name: 'h1', path: 'harnesses/h1' }],
+ }),
+ readDeployedState: vi.fn().mockResolvedValue({
+ targets: opts.targets ?? {},
+ }),
+ readAWSDeploymentTargets: vi
+ .fn()
+ .mockResolvedValue(opts.awsTargets ?? [{ name: 'dev', region: 'us-east-1', account: '111' }]),
+ getConfigRoot: vi.fn().mockReturnValue('/project/agentcore'),
+ } as any;
+}
+
+describe('computeProjectDeployHash', () => {
+ it('returns a 16-character hex string', async () => {
+ const hash = await computeProjectDeployHash(mockConfigIO({}));
+ expect(hash).toMatch(/^[0-9a-f]{16}$/);
+ });
+
+ it('returns same hash for same inputs', async () => {
+ const io = mockConfigIO({});
+ const hash1 = await computeProjectDeployHash(io);
+ const hash2 = await computeProjectDeployHash(io);
+ expect(hash1).toBe(hash2);
+ });
+
+ it('returns different hash when aws-targets change', async () => {
+ const io1 = mockConfigIO({ awsTargets: [{ name: 'dev', region: 'us-east-1', account: '111' }] });
+ const io2 = mockConfigIO({ awsTargets: [{ name: 'prod', region: 'us-west-2', account: '222' }] });
+ const hash1 = await computeProjectDeployHash(io1);
+ const hash2 = await computeProjectDeployHash(io2);
+ expect(hash1).not.toBe(hash2);
+ });
+});
+
+describe('canSkipDeploy', () => {
+ it('returns false when runtimes exist', async () => {
+ const io = mockConfigIO({ runtimes: [{ name: 'agent', path: 'agents/a', type: 'strands' }] });
+ expect(await canSkipDeploy(io)).toBe(false);
+ });
+
+ it('returns false when no targets deployed', async () => {
+ const io = mockConfigIO({ targets: {} });
+ expect(await canSkipDeploy(io)).toBe(false);
+ });
+
+ it('returns true when hash matches stored hash', async () => {
+ const io = mockConfigIO({});
+ const hash = await computeProjectDeployHash(io);
+ const io2 = mockConfigIO({
+ targets: { dev: { resources: { deployHash: hash } } },
+ });
+ expect(await canSkipDeploy(io2)).toBe(true);
+ });
+
+ it('returns false when hash differs from stored hash', async () => {
+ const io = mockConfigIO({
+ targets: { dev: { resources: { deployHash: 'stale0000000000' } } },
+ });
+ expect(await canSkipDeploy(io)).toBe(false);
+ });
+
+ it('returns false when any target has mismatched hash', async () => {
+ const io = mockConfigIO({});
+ const hash = await computeProjectDeployHash(io);
+ const io2 = mockConfigIO({
+ targets: {
+ dev: { resources: { deployHash: hash } },
+ prod: { resources: { deployHash: 'different0000000' } },
+ },
+ });
+ expect(await canSkipDeploy(io2)).toBe(false);
+ });
+
+ it('returns false on error (graceful degradation)', async () => {
+ const io = {
+ readProjectSpec: vi.fn().mockRejectedValue(new Error('file not found')),
+ } as any;
+ expect(await canSkipDeploy(io)).toBe(false);
+ });
+});
diff --git a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts
index 9d0b67492..c5e28c6e8 100644
--- a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts
+++ b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts
@@ -70,6 +70,7 @@ function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCo
httpGateways: [],
datasets: [],
abTests,
+ harnesses: [],
};
}
diff --git a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts
index f916a89e3..2a3a5a55d 100644
--- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts
+++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts
@@ -508,6 +508,7 @@ describe('resolveConfigBundleComponentKeys', () => {
httpGateways: [],
datasets: [],
abTests: [],
+ harnesses: [],
};
}
diff --git a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts
index afb43bc9e..232eee0a0 100644
--- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts
+++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts
@@ -81,6 +81,7 @@ function makeProjectSpec(httpGateways: AgentCoreProjectSpec['httpGateways'] = []
configBundles: [],
abTests: [],
httpGateways,
+ harnesses: [],
datasets: [],
};
}
diff --git a/src/cli/operations/deploy/change-detection.ts b/src/cli/operations/deploy/change-detection.ts
new file mode 100644
index 000000000..65a513142
--- /dev/null
+++ b/src/cli/operations/deploy/change-detection.ts
@@ -0,0 +1,75 @@
+import { ConfigIO } from '../../../lib';
+import { createHash } from 'node:crypto';
+import { readFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+
+/**
+ * Computes a hash of the project configuration relevant to deploy.
+ * Includes agentcore.json, all harness.json files, system-prompt.md files,
+ * and aws-targets.json.
+ *
+ * Only used for harness-only projects — runtime projects always need full
+ * deploy since source code changes aren't tracked here.
+ */
+export async function computeProjectDeployHash(configIO: ConfigIO): Promise {
+ const hash = createHash('sha256');
+
+ const projectSpec = await configIO.readProjectSpec();
+ hash.update(JSON.stringify(projectSpec));
+
+ const configRoot = configIO.getConfigRoot();
+ const projectRoot = dirname(configRoot);
+
+ for (const harness of projectSpec.harnesses ?? []) {
+ const harnessDir = join(projectRoot, harness.path);
+ try {
+ const harnessJson = await readFile(join(harnessDir, 'harness.json'), 'utf-8');
+ hash.update(harnessJson);
+ } catch {
+ // harness.json missing — hash will differ from last deploy
+ }
+ try {
+ const prompt = await readFile(join(harnessDir, 'system-prompt.md'), 'utf-8');
+ hash.update(prompt);
+ } catch {
+ // no system prompt
+ }
+ }
+
+ const awsTargets = await configIO.readAWSDeploymentTargets();
+ hash.update(JSON.stringify(awsTargets));
+
+ return hash.digest('hex').slice(0, 16);
+}
+
+/**
+ * Checks if the project has changed since the last deploy.
+ * Returns true if deploy can be skipped.
+ *
+ * Only applies to harness-only projects. Projects with runtimes always
+ * need full deploy since source code changes aren't tracked by hash.
+ */
+export async function canSkipDeploy(configIO: ConfigIO): Promise {
+ try {
+ const projectSpec = await configIO.readProjectSpec();
+
+ if (projectSpec.runtimes.length > 0) {
+ return false;
+ }
+
+ const currentHash = await computeProjectDeployHash(configIO);
+ const deployedState = await configIO.readDeployedState();
+ const targetNames = Object.keys(deployedState.targets);
+ if (targetNames.length === 0) return false;
+
+ for (const targetName of targetNames) {
+ const targetState = deployedState.targets[targetName];
+ const storedHash = targetState?.resources?.deployHash;
+ if (storedHash !== currentHash) return false;
+ }
+
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts
new file mode 100644
index 000000000..091ddcfda
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-deployer.test.ts
@@ -0,0 +1,360 @@
+import { createHarness, deleteHarness, getHarness, updateHarness } from '../../../../../aws/agentcore-harness';
+import { AgentCoreApiError } from '../../../../../aws/api-client';
+import type { ImperativeDeployContext } from '../../types';
+import { HarnessDeployer } from '../harness-deployer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('fs/promises', () => ({
+ readFile: vi.fn().mockImplementation((path: string) => {
+ if (path.includes('harness.json')) {
+ return Promise.resolve(
+ JSON.stringify({
+ name: 'my_harness',
+ model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet' },
+ tools: [],
+ skills: [],
+ })
+ );
+ }
+ if (path.includes('system-prompt.md')) return Promise.resolve('You are helpful.');
+ return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
+ }),
+}));
+
+vi.mock('../harness-mapper', () => ({
+ mapHarnessSpecToCreateOptions: vi.fn().mockResolvedValue({
+ harnessName: 'proj_my-harness',
+ region: 'us-east-1',
+ executionRoleArn: 'arn:aws:iam::111:role/HarnessRole',
+ model: { bedrockModelConfig: { modelId: 'anthropic.claude-3-5-sonnet' } },
+ systemPrompt: [{ text: 'You are helpful.' }],
+ }),
+}));
+
+vi.mock('../../../../../aws/agentcore-harness', () => ({
+ createHarness: vi.fn().mockResolvedValue({
+ harness: {
+ harnessId: 'h-123',
+ arn: 'arn:aws:bedrock:us-east-1:111:harness/h-123',
+ status: 'READY',
+ environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:runtime' } },
+ },
+ }),
+ updateHarness: vi.fn().mockResolvedValue({
+ harness: {
+ harnessId: 'h-existing',
+ arn: 'arn:aws:bedrock:us-east-1:111:harness/h-existing',
+ status: 'READY',
+ environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:runtime' } },
+ },
+ }),
+ deleteHarness: vi.fn().mockResolvedValue({}),
+ getHarness: vi.fn().mockResolvedValue({
+ harness: {
+ harnessId: 'h-123',
+ arn: 'arn:aws:bedrock:us-east-1:111:harness/h-123',
+ status: 'READY',
+ environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:runtime' } },
+ },
+ }),
+}));
+
+function makeContext(overrides: Partial = {}): ImperativeDeployContext {
+ return {
+ projectSpec: {
+ name: 'proj',
+ harnesses: [{ name: 'my_harness', path: 'harnesses/my_harness' }],
+ } as any,
+ target: { name: 'dev', region: 'us-east-1' } as any,
+ configIO: { getConfigRoot: () => '/project/agentcore' } as any,
+ deployedState: { targets: {} } as any,
+ cdkOutputs: { ApplicationHarnessMyHarnessRoleArnOutput123: 'arn:aws:iam::111:role/HarnessRole' },
+ ...overrides,
+ };
+}
+
+describe('HarnessDeployer', () => {
+ let deployer: HarnessDeployer;
+
+ beforeEach(() => {
+ deployer = new HarnessDeployer();
+ vi.clearAllMocks();
+ });
+
+ describe('shouldRun', () => {
+ it('returns true when project has harnesses', () => {
+ expect(deployer.shouldRun(makeContext())).toBe(true);
+ });
+
+ it('returns true when deployed state has harnesses', () => {
+ const ctx = makeContext({
+ projectSpec: { name: 'proj', harnesses: [] } as any,
+ deployedState: {
+ targets: { dev: { resources: { harnesses: { old: { harnessId: 'h-old' } } } } },
+ } as any,
+ });
+ expect(deployer.shouldRun(ctx)).toBe(true);
+ });
+
+ it('returns false when no harnesses anywhere', () => {
+ const ctx = makeContext({
+ projectSpec: { name: 'proj' } as any,
+ deployedState: { targets: {} } as any,
+ });
+ expect(deployer.shouldRun(ctx)).toBe(false);
+ });
+ });
+
+ describe('deploy - create path', () => {
+ it('calls createHarness and returns state on success', async () => {
+ const result = await deployer.deploy(makeContext());
+ expect(result.success).toBe(true);
+ expect(createHarness).toHaveBeenCalled();
+ expect(result.state!.my_harness).toMatchObject({
+ harnessId: 'h-123',
+ status: 'READY',
+ });
+ });
+
+ it('throws when harness enters FAILED state after create', async () => {
+ vi.mocked(createHarness).mockResolvedValueOnce({
+ harness: { harnessId: 'h-fail', arn: 'arn:fail', status: 'CREATING' },
+ } as any);
+ vi.mocked(getHarness).mockResolvedValueOnce({
+ harness: { harnessId: 'h-fail', arn: 'arn:fail', status: 'FAILED' },
+ } as any);
+
+ const result = await deployer.deploy(makeContext());
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('FAILED state');
+ });
+ });
+
+ describe('deploy - update path', () => {
+ it('calls updateHarness when existing harness has different configHash', async () => {
+ const ctx = makeContext({
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ my_harness: {
+ harnessId: 'h-existing',
+ configHash: 'old-hash',
+ harnessArn: 'arn:old',
+ roleArn: 'arn:role',
+ status: 'READY',
+ },
+ },
+ },
+ },
+ },
+ } as any,
+ });
+
+ const result = await deployer.deploy(ctx);
+ expect(result.success).toBe(true);
+ expect(updateHarness).toHaveBeenCalled();
+ expect(createHarness).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('deploy - skip path', () => {
+ it('skips when configHash matches', async () => {
+ // We need to compute the actual hash. Instead, mock readFile to produce deterministic content
+ // and set the deployed hash to match. Easiest: just set configHash to what will be computed.
+ // Since we can't easily predict the hash, test the logic by verifying no API calls.
+ const ctx = makeContext({
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ my_harness: {
+ harnessId: 'h-existing',
+ configHash: 'WILL_NOT_MATCH',
+ harnessArn: 'arn:x',
+ roleArn: 'arn:role',
+ status: 'READY',
+ },
+ },
+ },
+ },
+ },
+ } as any,
+ });
+
+ // To truly test skip, we'd need to know the hash. Let's just verify that when
+ // configHash matches, it skips. We'll run once to get the hash, then use it.
+ const firstResult = await deployer.deploy(ctx);
+ // It will have updated because hash doesn't match
+ expect(updateHarness).toHaveBeenCalledTimes(1);
+
+ // Now use the actual computed hash
+ vi.clearAllMocks();
+ const computedHash = firstResult.state!.my_harness!.configHash;
+ const ctx2 = makeContext({
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ my_harness: {
+ harnessId: 'h-existing',
+ configHash: computedHash,
+ harnessArn: 'arn:x',
+ roleArn: 'arn:role',
+ status: 'READY',
+ },
+ },
+ },
+ },
+ },
+ } as any,
+ });
+
+ const result = await deployer.deploy(ctx2);
+ expect(result.success).toBe(true);
+ expect(createHarness).not.toHaveBeenCalled();
+ expect(updateHarness).not.toHaveBeenCalled();
+ expect(result.notes).toContain('Harness "my_harness" unchanged, skipped');
+ });
+ });
+
+ describe('deploy - delete orphaned harnesses', () => {
+ it('deletes harnesses not in project spec', async () => {
+ const ctx = makeContext({
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ 'removed-harness': {
+ harnessId: 'h-removed',
+ configHash: 'x',
+ harnessArn: 'arn:r',
+ roleArn: 'arn:role',
+ status: 'READY',
+ },
+ },
+ },
+ },
+ },
+ } as any,
+ });
+
+ const result = await deployer.deploy(ctx);
+ expect(result.success).toBe(true);
+ expect(deleteHarness).toHaveBeenCalledWith({ region: 'us-east-1', harnessId: 'h-removed' });
+ expect(result.state!['removed-harness']).toBeUndefined();
+ });
+ });
+
+ describe('deploy - role resolution', () => {
+ it('fails when CDK outputs missing role ARN', async () => {
+ const ctx = makeContext({ cdkOutputs: {} });
+ const result = await deployer.deploy(ctx);
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Could not find role ARN');
+ });
+
+ it('resolves role from RoleRoleArn output key pattern', async () => {
+ const ctx = makeContext({
+ cdkOutputs: { ApplicationHarnessMyHarnessRoleArnSomeSuffix: 'arn:aws:iam::111:role/NewRole' },
+ });
+ const result = await deployer.deploy(ctx);
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('deploy - retry logic', () => {
+ it('retries on role validation error then succeeds', async () => {
+ const roleError = new AgentCoreApiError(400, 'Role validation failed for the given role');
+ vi.mocked(createHarness)
+ .mockRejectedValueOnce(roleError)
+ .mockResolvedValueOnce({
+ harness: { harnessId: 'h-retry', arn: 'arn:retry', status: 'READY', environment: {} },
+ } as any);
+
+ const result = await deployer.deploy(makeContext());
+ expect(result.success).toBe(true);
+ expect(createHarness).toHaveBeenCalledTimes(2);
+ }, 30_000);
+
+ it('throws non-role-validation errors immediately', async () => {
+ vi.mocked(createHarness).mockRejectedValueOnce(new Error('Network failure'));
+
+ const result = await deployer.deploy(makeContext());
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Network failure');
+ expect(createHarness).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('deploy - polling (waitForReady)', () => {
+ it('polls getHarness until READY', async () => {
+ vi.mocked(createHarness).mockResolvedValueOnce({
+ harness: { harnessId: 'h-poll', arn: 'arn:poll', status: 'CREATING' },
+ } as any);
+ vi.mocked(getHarness)
+ .mockResolvedValueOnce({ harness: { harnessId: 'h-poll', arn: 'arn:poll', status: 'CREATING' } } as any)
+ .mockResolvedValueOnce({
+ harness: {
+ harnessId: 'h-poll',
+ arn: 'arn:poll',
+ status: 'READY',
+ environment: { agentCoreRuntimeEnvironment: { agentRuntimeArn: 'arn:rt' } },
+ },
+ } as any);
+
+ const result = await deployer.deploy(makeContext());
+ expect(result.success).toBe(true);
+ expect(getHarness).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('teardown', () => {
+ it('deletes all deployed harnesses', async () => {
+ const ctx = makeContext({
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ h1: { harnessId: 'id-1', configHash: 'x', harnessArn: 'arn:1', roleArn: 'arn:r', status: 'READY' },
+ h2: { harnessId: 'id-2', configHash: 'y', harnessArn: 'arn:2', roleArn: 'arn:r', status: 'READY' },
+ },
+ },
+ },
+ },
+ } as any,
+ });
+
+ const result = await deployer.teardown(ctx);
+ expect(result.success).toBe(true);
+ expect(deleteHarness).toHaveBeenCalledTimes(2);
+ expect(result.state).toEqual({});
+ });
+
+ it('returns error if delete fails', async () => {
+ vi.mocked(deleteHarness).mockRejectedValueOnce(new Error('Access denied'));
+ const ctx = makeContext({
+ deployedState: {
+ targets: {
+ dev: {
+ resources: {
+ harnesses: {
+ h1: { harnessId: 'id-1', configHash: 'x', harnessArn: 'arn:1', roleArn: 'arn:r', status: 'READY' },
+ },
+ },
+ },
+ },
+ } as any,
+ });
+
+ const result = await deployer.teardown(ctx);
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Access denied');
+ });
+ });
+});
diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts
new file mode 100644
index 000000000..b3e4faf4d
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts
@@ -0,0 +1,577 @@
+import type { DeployedResourceState, Memory } from '../../../../../../schema';
+import type { MapHarnessOptions } from '../harness-mapper';
+import { mapHarnessSpecToCreateOptions } from '../harness-mapper';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('fs/promises', () => ({
+ readFile: vi.fn().mockImplementation((path: string) => {
+ if (path.includes('system-prompt.md')) return Promise.resolve('You are helpful.');
+ if (path.includes('custom-prompt.md')) return Promise.resolve('Custom prompt content.');
+ return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
+ }),
+ stat: vi.fn().mockImplementation((path: string) => {
+ if (path.includes('too-large.md')) return Promise.resolve({ size: 2 * 1024 * 1024 });
+ return Promise.resolve({ size: 100 });
+ }),
+}));
+
+function baseOptions(overrides: Partial = {}): MapHarnessOptions {
+ return {
+ harnessSpec: {
+ name: 'test-harness',
+ model: { provider: 'bedrock', modelId: 'anthropic.claude-3-5-sonnet' },
+ tools: [],
+ skills: [],
+ } as any,
+ harnessDir: '/project/harnesses/test-harness',
+ executionRoleArn: 'arn:aws:iam::111:role/HarnessRole',
+ region: 'us-east-1',
+ projectName: 'my-project',
+ ...overrides,
+ };
+}
+
+describe('mapHarnessSpecToCreateOptions', () => {
+ describe('basic mapping', () => {
+ it('sets harnessName as projectName_specName', async () => {
+ const result = await mapHarnessSpecToCreateOptions(baseOptions());
+ expect(result.harnessName).toBe('my-project_test-harness');
+ });
+
+ it('passes region and executionRoleArn', async () => {
+ const result = await mapHarnessSpecToCreateOptions(baseOptions());
+ expect(result.region).toBe('us-east-1');
+ expect(result.executionRoleArn).toBe('arn:aws:iam::111:role/HarnessRole');
+ });
+ });
+
+ describe('model mapping', () => {
+ it('maps bedrock provider', async () => {
+ const result = await mapHarnessSpecToCreateOptions(baseOptions());
+ expect(result.model).toEqual({
+ bedrockModelConfig: { modelId: 'anthropic.claude-3-5-sonnet' },
+ });
+ });
+
+ it('maps open_ai provider with apiKeyArn', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'oai',
+ model: {
+ provider: 'open_ai',
+ modelId: 'gpt-4o',
+ apiKeyArn: 'arn:aws:secretsmanager:us-east-1:111:secret:key',
+ },
+ tools: [],
+ skills: [],
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.model).toEqual({
+ openAiModelConfig: { modelId: 'gpt-4o', apiKeyArn: 'arn:aws:secretsmanager:us-east-1:111:secret:key' },
+ });
+ });
+
+ it('maps gemini provider with topK', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'gem',
+ model: { provider: 'gemini', modelId: 'gemini-2.0-flash', topK: 0.5 },
+ tools: [],
+ skills: [],
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.model).toEqual({
+ geminiModelConfig: { modelId: 'gemini-2.0-flash', topK: 0.5 },
+ });
+ });
+
+ it('includes optional model params when set', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude', temperature: 0.7, topP: 0.9, maxTokens: 2048 },
+ tools: [],
+ skills: [],
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.model).toEqual({
+ bedrockModelConfig: { modelId: 'claude', temperature: 0.7, topP: 0.9, maxTokens: 2048 },
+ });
+ });
+ });
+
+ describe('system prompt', () => {
+ it('auto-discovers system-prompt.md when no systemPrompt in spec', async () => {
+ const result = await mapHarnessSpecToCreateOptions(baseOptions());
+ expect(result.systemPrompt).toEqual([{ text: 'You are helpful.' }]);
+ });
+
+ it('loads from file path when systemPrompt is a relative path', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ systemPrompt: './custom-prompt.md',
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.systemPrompt).toEqual([{ text: 'Custom prompt content.' }]);
+ });
+
+ it('uses inline text when systemPrompt is not a file path', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ systemPrompt: 'Inline prompt text here',
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.systemPrompt).toEqual([{ text: 'Inline prompt text here' }]);
+ });
+
+ it('throws when prompt file exceeds max size', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ systemPrompt: './too-large.md',
+ } as any,
+ });
+ await expect(mapHarnessSpecToCreateOptions(opts)).rejects.toThrow('too large');
+ });
+ });
+
+ describe('tools mapping', () => {
+ it('maps tools with type, name, and config', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [
+ { type: 'remote_mcp', name: 'my-mcp', config: { remoteMcp: { url: 'https://example.com' } } },
+ { type: 'agentcore_code_interpreter', name: 'code-interp' },
+ ],
+ skills: [],
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.tools).toEqual([
+ { type: 'remote_mcp', name: 'my-mcp', config: { remoteMcp: { url: 'https://example.com' } } },
+ { type: 'agentcore_code_interpreter', name: 'code-interp' },
+ ]);
+ });
+
+ it('omits tools when empty array', async () => {
+ const result = await mapHarnessSpecToCreateOptions(baseOptions());
+ expect(result.tools).toBeUndefined();
+ });
+ });
+
+ describe('skills mapping', () => {
+ it('maps skills as path objects', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: ['path/to/skill1', 'path/to/skill2'],
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.skills).toEqual([{ path: 'path/to/skill1' }, { path: 'path/to/skill2' }]);
+ });
+ });
+
+ describe('memory mapping', () => {
+ it('maps memory with direct ARN', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { arn: 'arn:aws:bedrock:us-east-1:111:memory/mem-123' },
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.memory).toEqual({
+ agentCoreMemoryConfiguration: { arn: 'arn:aws:bedrock:us-east-1:111:memory/mem-123' },
+ });
+ });
+
+ it('resolves memory by name from deployed state', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'my-memory' },
+ } as any,
+ deployedResources: {
+ memories: { 'my-memory': { memoryArn: 'arn:aws:bedrock:us-east-1:111:memory/mem-resolved' } },
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.memory).toEqual({
+ agentCoreMemoryConfiguration: { arn: 'arn:aws:bedrock:us-east-1:111:memory/mem-resolved' },
+ });
+ });
+
+ it('throws when memory name cannot be resolved', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'missing-memory' },
+ } as any,
+ });
+ await expect(mapHarnessSpecToCreateOptions(opts)).rejects.toThrow('not in deployed state');
+ });
+
+ it('includes retrievalConfig derived from memory strategy namespaces', async () => {
+ const deployedResources: DeployedResourceState = {
+ memories: {
+ my_memory: {
+ memoryId: 'mem-123',
+ memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ },
+ },
+ };
+ const memorySpec: Memory = {
+ name: 'my_memory',
+ eventExpiryDuration: 30,
+ strategies: [
+ { type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] },
+ { type: 'USER_PREFERENCE', namespaces: ['/users/{actorId}/preferences'] },
+ { type: 'SUMMARIZATION', namespaces: ['/summaries/{actorId}/{sessionId}'] },
+ {
+ type: 'EPISODIC',
+ namespaces: ['/episodes/{actorId}/{sessionId}'],
+ reflectionNamespaces: ['/episodes/{actorId}'],
+ },
+ ],
+ };
+
+ const result = await mapHarnessSpecToCreateOptions(
+ baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'my_memory' },
+ } as any,
+ deployedResources,
+ memorySpec,
+ })
+ );
+
+ expect(result.memory).toEqual({
+ agentCoreMemoryConfiguration: {
+ arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ retrievalConfig: {
+ '/users/{actorId}/facts': {},
+ '/users/{actorId}/preferences': {},
+ '/summaries/{actorId}/{sessionId}': {},
+ '/episodes/{actorId}/{sessionId}': {},
+ '/episodes/{actorId}': {},
+ },
+ },
+ });
+ });
+
+ it('includes EPISODIC reflectionNamespaces in retrievalConfig even without namespaces', async () => {
+ const deployedResources: DeployedResourceState = {
+ memories: {
+ my_memory: {
+ memoryId: 'mem-123',
+ memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ },
+ },
+ };
+ const memorySpec: Memory = {
+ name: 'my_memory',
+ eventExpiryDuration: 30,
+ strategies: [
+ { type: 'SEMANTIC' },
+ {
+ type: 'EPISODIC',
+ reflectionNamespaces: ['/episodes/{actorId}'],
+ },
+ ],
+ };
+
+ const result = await mapHarnessSpecToCreateOptions(
+ baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'my_memory' },
+ } as any,
+ deployedResources,
+ memorySpec,
+ })
+ );
+
+ expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toEqual({
+ '/episodes/{actorId}': {},
+ });
+ });
+
+ it('omits retrievalConfig when strategies have no namespaces or reflectionNamespaces', async () => {
+ const deployedResources: DeployedResourceState = {
+ memories: {
+ my_memory: {
+ memoryId: 'mem-123',
+ memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ },
+ },
+ };
+ const memorySpec: Memory = {
+ name: 'my_memory',
+ eventExpiryDuration: 30,
+ strategies: [{ type: 'SEMANTIC' }, { type: 'SUMMARIZATION' }],
+ };
+
+ const result = await mapHarnessSpecToCreateOptions(
+ baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'my_memory' },
+ } as any,
+ deployedResources,
+ memorySpec,
+ })
+ );
+
+ expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toBeUndefined();
+ });
+
+ it('omits retrievalConfig when memorySpec not provided', async () => {
+ const deployedResources: DeployedResourceState = {
+ memories: {
+ my_memory: {
+ memoryId: 'mem-123',
+ memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ },
+ },
+ };
+
+ const result = await mapHarnessSpecToCreateOptions(
+ baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'my_memory' },
+ } as any,
+ deployedResources,
+ })
+ );
+
+ expect(result.memory?.agentCoreMemoryConfiguration.retrievalConfig).toBeUndefined();
+ });
+
+ it('includes both actorId and retrievalConfig when both are set', async () => {
+ const deployedResources: DeployedResourceState = {
+ memories: {
+ my_memory: {
+ memoryId: 'mem-123',
+ memoryArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ },
+ },
+ };
+ const memorySpec: Memory = {
+ name: 'my_memory',
+ eventExpiryDuration: 30,
+ strategies: [{ type: 'SEMANTIC', namespaces: ['/users/{actorId}/facts'] }],
+ };
+
+ const result = await mapHarnessSpecToCreateOptions(
+ baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ memory: { name: 'my_memory', actorId: 'alice' },
+ } as any,
+ deployedResources,
+ memorySpec,
+ })
+ );
+
+ expect(result.memory).toEqual({
+ agentCoreMemoryConfiguration: {
+ arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/mem-123',
+ actorId: 'alice',
+ retrievalConfig: {
+ '/users/{actorId}/facts': {},
+ },
+ },
+ });
+ });
+ });
+
+ describe('execution limits', () => {
+ it('passes through maxIterations, maxTokens, timeoutSeconds', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ maxIterations: 10,
+ maxTokens: 4096,
+ timeoutSeconds: 120,
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.maxIterations).toBe(10);
+ expect(result.maxTokens).toBe(4096);
+ expect(result.timeoutSeconds).toBe(120);
+ });
+ });
+
+ describe('container artifact', () => {
+ it('maps direct containerUri', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ containerUri: '111.dkr.ecr.us-east-1.amazonaws.com/repo:tag',
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.environmentArtifact).toEqual({
+ containerConfiguration: { containerUri: '111.dkr.ecr.us-east-1.amazonaws.com/repo:tag' },
+ });
+ });
+
+ it('resolves container URI from CDK outputs for dockerfile', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'my-env',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ dockerfile: 'Dockerfile',
+ } as any,
+ cdkOutputs: { ApplicationHarnessMyEnvImageUriOutput123: '111.dkr.ecr.us-east-1.amazonaws.com/built:latest' },
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.environmentArtifact).toEqual({
+ containerConfiguration: { containerUri: '111.dkr.ecr.us-east-1.amazonaws.com/built:latest' },
+ });
+ });
+
+ it('throws when dockerfile specified but no CDK output found', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ dockerfile: 'Dockerfile',
+ } as any,
+ cdkOutputs: {},
+ });
+ await expect(mapHarnessSpecToCreateOptions(opts)).rejects.toThrow('no container URI was found');
+ });
+ });
+
+ describe('environment provider', () => {
+ it('maps network config', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ networkConfig: { subnets: ['subnet-1'], securityGroups: ['sg-1'] },
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.environment).toEqual({
+ agentCoreRuntimeEnvironment: {
+ networkConfiguration: {
+ networkMode: 'VPC',
+ networkModeConfig: { subnets: ['subnet-1'], securityGroups: ['sg-1'] },
+ },
+ },
+ });
+ });
+
+ it('maps sessionStoragePath', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ sessionStoragePath: '/mnt/storage',
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.environment).toEqual({
+ agentCoreRuntimeEnvironment: {
+ filesystemConfigurations: [{ sessionStorage: { mountPath: '/mnt/storage' } }],
+ },
+ });
+ });
+
+ it('returns no environment when no network/lifecycle/storage', async () => {
+ const result = await mapHarnessSpecToCreateOptions(baseOptions());
+ expect(result.environment).toBeUndefined();
+ });
+ });
+
+ describe('authorizer configuration', () => {
+ it('maps custom JWT authorizer', async () => {
+ const opts = baseOptions({
+ harnessSpec: {
+ name: 'h',
+ model: { provider: 'bedrock', modelId: 'claude' },
+ tools: [],
+ skills: [],
+ authorizerConfiguration: {
+ customJwtAuthorizer: {
+ discoveryUrl: 'https://example.com/.well-known/openid-configuration',
+ allowedAudience: ['aud1'],
+ allowedClients: ['client1'],
+ },
+ },
+ } as any,
+ });
+ const result = await mapHarnessSpecToCreateOptions(opts);
+ expect(result.authorizerConfiguration).toEqual({
+ customJWTAuthorizer: {
+ discoveryUrl: 'https://example.com/.well-known/openid-configuration',
+ allowedAudience: ['aud1'],
+ allowedClients: ['client1'],
+ },
+ });
+ });
+ });
+});
diff --git a/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts
new file mode 100644
index 000000000..102a53765
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/deployers/harness-deployer.ts
@@ -0,0 +1,376 @@
+/**
+ * HarnessDeployer - Post-CDK imperative deployer for Harness resources.
+ *
+ * Runs after CDK deploy to create, update, or delete harness resources
+ * via the SigV4 API client. Harness role ARNs are resolved from CDK
+ * stack outputs, and harness specs are read from disk (harness.json).
+ */
+import type { HarnessDeployedState, HarnessSpec, Memory } from '../../../../../schema';
+import { HarnessSpecSchema } from '../../../../../schema';
+import type {
+ CreateHarnessResult,
+ Harness,
+ UpdateHarnessOptions,
+ UpdateHarnessResult,
+} from '../../../../aws/agentcore-harness';
+import { createHarness, deleteHarness, getHarness, updateHarness } from '../../../../aws/agentcore-harness';
+import { AgentCoreApiError } from '../../../../aws/api-client';
+import { toPascalId } from '../../../../cloudformation/logical-ids';
+import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from '../types';
+import { mapHarnessSpecToCreateOptions } from './harness-mapper';
+import { readFile } from 'fs/promises';
+import { createHash } from 'node:crypto';
+import { dirname, join } from 'path';
+
+const ROLE_VALIDATION_RETRY_DELAYS_MS = [5_000, 10_000, 15_000, 20_000, 30_000];
+const READY_POLL_INTERVAL_MS = 3_000;
+const READY_POLL_MAX_ATTEMPTS = 40; // 2 minutes max
+
+// ============================================================================
+// Types
+// ============================================================================
+
+type HarnessDeployedStateMap = Record;
+
+async function computeHarnessHash(
+ harnessDir: string,
+ harnessSpec: HarnessSpec,
+ roleArn: string,
+ memorySpec?: Memory
+): Promise {
+ const hash = createHash('sha256');
+ hash.update(JSON.stringify(harnessSpec));
+ hash.update(roleArn);
+ if (memorySpec) {
+ hash.update(JSON.stringify(memorySpec));
+ }
+ try {
+ const promptContent = await readFile(join(harnessDir, 'system-prompt.md'), 'utf-8');
+ hash.update(promptContent);
+ } catch {
+ // no system-prompt.md
+ }
+ if (harnessSpec.dockerfile) {
+ try {
+ const dockerfileContent = await readFile(join(harnessDir, harnessSpec.dockerfile), 'utf-8');
+ hash.update(dockerfileContent);
+ } catch {
+ // Dockerfile missing — preflight already validates existence before deploy
+ }
+ }
+ return hash.digest('hex').slice(0, 16);
+}
+
+// ============================================================================
+// Deployer
+// ============================================================================
+
+export class HarnessDeployer implements ImperativeDeployer {
+ readonly name = 'harness';
+ readonly label = 'Harnesses';
+ readonly phase: DeployPhase = 'post-cdk';
+
+ shouldRun(context: ImperativeDeployContext): boolean {
+ const projectHarnesses = context.projectSpec.harnesses;
+ const hasProjectHarnesses = !!projectHarnesses && projectHarnesses.length > 0;
+
+ const targetName = context.target.name;
+ const deployedHarnesses = context.deployedState.targets?.[targetName]?.resources?.harnesses;
+ const hasDeployedHarnesses = !!deployedHarnesses && Object.keys(deployedHarnesses).length > 0;
+
+ return hasProjectHarnesses || hasDeployedHarnesses;
+ }
+
+ async deploy(context: ImperativeDeployContext): Promise> {
+ const { projectSpec, target, configIO, deployedState, cdkOutputs } = context;
+ const region = target.region;
+ const targetName = target.name;
+ const projectName = projectSpec.name;
+ const configRoot = configIO.getConfigRoot();
+ const projectRoot = dirname(configRoot);
+
+ const projectHarnesses = projectSpec.harnesses ?? [];
+ const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {};
+ const resultState: HarnessDeployedStateMap = { ...deployedHarnesses };
+ const notes: string[] = [];
+
+ // Build set of harness names in current project spec
+ const projectHarnessNames = new Set(projectHarnesses.map(h => h.name));
+
+ // Create or update each harness in the project spec
+ for (const entry of projectHarnesses) {
+ // Harness path is relative to project root (like agent codeLocation)
+ const harnessDir = join(projectRoot, entry.path);
+
+ // Read harness.json from disk and validate
+ let harnessSpec: HarnessSpec;
+ try {
+ const raw = await readFile(join(harnessDir, 'harness.json'), 'utf-8');
+ const parsed: unknown = JSON.parse(raw);
+ const validated = HarnessSpecSchema.safeParse(parsed);
+ if (!validated.success) {
+ return {
+ success: false,
+ error: `Invalid harness.json for "${entry.name}": ${validated.error.message}`,
+ state: resultState,
+ };
+ }
+ harnessSpec = validated.data;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ success: false,
+ error: `Failed to read harness.json for "${entry.name}": ${message}`,
+ state: resultState,
+ };
+ }
+
+ // Resolve role ARN from CDK outputs
+ const roleArn = resolveRoleArn(entry.name, cdkOutputs);
+ if (!roleArn) {
+ return {
+ success: false,
+ error: `Could not find role ARN in CDK outputs for harness "${entry.name}". Expected output key starting with "ApplicationHarness${toPascalId(entry.name)}RoleArn" or "ApplicationHarness${toPascalId(entry.name)}RoleRoleArn".`,
+ state: resultState,
+ };
+ }
+
+ // Use executionRoleArn from harness spec if provided, otherwise use CDK output
+ const executionRoleArn = harnessSpec.executionRoleArn ?? roleArn;
+
+ const deployedResources = deployedState.targets?.[targetName]?.resources;
+ const existingHarness = deployedHarnesses[entry.name];
+ const memorySpec = projectSpec.memories?.find(m => m.name === harnessSpec.memory?.name);
+
+ const configHash = await computeHarnessHash(harnessDir, harnessSpec, executionRoleArn, memorySpec);
+
+ if (existingHarness?.configHash === configHash) {
+ resultState[entry.name] = existingHarness;
+ notes.push(`Harness "${entry.name}" unchanged, skipped`);
+ context.onProgress?.(`Harness "${entry.name}": no changes`, 'done');
+ continue;
+ }
+
+ try {
+ if (existingHarness) {
+ // Update existing harness
+ const createOptions = await mapHarnessSpecToCreateOptions({
+ harnessSpec,
+ harnessDir,
+ executionRoleArn,
+ region,
+ projectName,
+ deployedResources,
+ cdkOutputs,
+ memorySpec,
+ });
+
+ // Memory uses { optionalValue: null } to explicitly clear it when removed from config,
+ // since the API treats an absent field as "no change" but null as "remove".
+ // environmentArtifact uses undefined (omit) because container config is immutable
+ // after creation — it cannot be cleared via update, only set on create.
+ const updateOptions: UpdateHarnessOptions = {
+ region,
+ harnessId: existingHarness.harnessId,
+ executionRoleArn: createOptions.executionRoleArn,
+ model: createOptions.model,
+ systemPrompt: createOptions.systemPrompt,
+ tools: createOptions.tools,
+ skills: createOptions.skills,
+ allowedTools: createOptions.allowedTools,
+ memory: createOptions.memory ? { optionalValue: createOptions.memory } : { optionalValue: null },
+ truncation: createOptions.truncation,
+ maxIterations: createOptions.maxIterations,
+ maxTokens: createOptions.maxTokens,
+ timeoutSeconds: createOptions.timeoutSeconds,
+ environment: createOptions.environment,
+ environmentArtifact: createOptions.environmentArtifact
+ ? { optionalValue: createOptions.environmentArtifact }
+ : undefined,
+ environmentVariables: createOptions.environmentVariables,
+ tags: createOptions.tags,
+ authorizerConfiguration: createOptions.authorizerConfiguration
+ ? { optionalValue: createOptions.authorizerConfiguration }
+ : { optionalValue: null },
+ };
+
+ const updateResult: UpdateHarnessResult = await updateHarness(updateOptions);
+ const finalHarness = await waitForReady(region, updateResult.harness);
+ if (finalHarness.status === 'FAILED') {
+ throw new Error(`Harness "${entry.name}" entered FAILED state`);
+ }
+ resultState[entry.name] = {
+ harnessId: finalHarness.harnessId,
+ harnessArn: finalHarness.arn,
+ roleArn: executionRoleArn,
+ status: finalHarness.status,
+ agentRuntimeArn: extractRuntimeArn(finalHarness),
+ memoryArn: createOptions.memory?.agentCoreMemoryConfiguration?.arn,
+ configHash,
+ };
+ notes.push(`Updated harness "${entry.name}"`);
+ } else {
+ // Create new harness (with retry for IAM role propagation delay)
+ const createOptions = await mapHarnessSpecToCreateOptions({
+ harnessSpec,
+ harnessDir,
+ executionRoleArn,
+ region,
+ projectName,
+ deployedResources,
+ cdkOutputs,
+ memorySpec,
+ });
+
+ const createResult: CreateHarnessResult = await createWithRetry(createOptions);
+ const finalHarness = await waitForReady(region, createResult.harness);
+ if (finalHarness.status === 'FAILED') {
+ throw new Error(`Harness "${entry.name}" entered FAILED state`);
+ }
+ resultState[entry.name] = {
+ harnessId: finalHarness.harnessId,
+ harnessArn: finalHarness.arn,
+ roleArn: executionRoleArn,
+ status: finalHarness.status,
+ agentRuntimeArn: extractRuntimeArn(finalHarness),
+ memoryArn: createOptions.memory?.agentCoreMemoryConfiguration?.arn,
+ configHash,
+ };
+ notes.push(`Created harness "${entry.name}"`);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const hint = getDeployErrorHint(err, region);
+ const errorMsg = hint
+ ? `Failed to deploy harness "${entry.name}": ${message}\n${hint}`
+ : `Failed to deploy harness "${entry.name}": ${message}`;
+ return { success: false, error: errorMsg, state: resultState };
+ }
+ }
+
+ // Delete harnesses that exist in deployed state but not in project spec
+ for (const [name, state] of Object.entries(deployedHarnesses)) {
+ if (!projectHarnessNames.has(name)) {
+ try {
+ await deleteHarness({ region, harnessId: state.harnessId });
+ delete resultState[name];
+ notes.push(`Deleted harness "${name}"`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: `Failed to delete harness "${name}": ${message}`, state: resultState };
+ }
+ }
+ }
+
+ return { success: true, state: resultState, notes };
+ }
+
+ async teardown(context: ImperativeDeployContext): Promise> {
+ const { target, deployedState } = context;
+ const region = target.region;
+ const targetName = target.name;
+
+ const deployedHarnesses = deployedState.targets?.[targetName]?.resources?.harnesses ?? {};
+ const notes: string[] = [];
+
+ for (const [name, state] of Object.entries(deployedHarnesses)) {
+ try {
+ await deleteHarness({ region, harnessId: state.harnessId });
+ notes.push(`Deleted harness "${name}"`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return { success: false, error: `Failed to delete harness "${name}": ${message}` };
+ }
+ }
+
+ return { success: true, state: {}, notes };
+ }
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/**
+ * Resolve the IAM role ARN for a harness from CDK stack outputs.
+ *
+ * Supports two construct tree layouts:
+ * Old (AgentCoreHarnessRole directly under Application):
+ * ApplicationHarness{PascalName}RoleArnOutput...
+ * New (AgentCoreHarnessEnvironment wrapping AgentCoreHarnessRole):
+ * ApplicationHarness{PascalName}RoleRoleArnOutput...
+ */
+function resolveRoleArn(harnessName: string, cdkOutputs?: Record): string | undefined {
+ if (!cdkOutputs) return undefined;
+
+ const pascalName = toPascalId(harnessName);
+ // Longer prefix first — RoleArn is a substring of RoleRoleArn, so checking it first would match both.
+ const prefixes = [`ApplicationHarness${pascalName}RoleRoleArn`, `ApplicationHarness${pascalName}RoleArn`];
+
+ for (const [key, value] of Object.entries(cdkOutputs)) {
+ if (prefixes.some(p => key.startsWith(p))) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+function isRoleValidationError(err: unknown): boolean {
+ return err instanceof AgentCoreApiError && err.statusCode === 400 && err.errorBody.includes('Role validation failed');
+}
+
+async function createWithRetry(options: Parameters[0]): Promise {
+ let lastError: unknown;
+ for (let attempt = 0; attempt <= ROLE_VALIDATION_RETRY_DELAYS_MS.length; attempt++) {
+ try {
+ return await createHarness(options);
+ } catch (err) {
+ if (!isRoleValidationError(err) || attempt === ROLE_VALIDATION_RETRY_DELAYS_MS.length) {
+ throw err;
+ }
+ lastError = err;
+ await sleep(ROLE_VALIDATION_RETRY_DELAYS_MS[attempt]!);
+ }
+ }
+ throw lastError;
+}
+
+async function waitForReady(region: string, harness: Harness): Promise {
+ if (harness.status === 'READY' || harness.status === 'FAILED') return harness;
+
+ for (let i = 0; i < READY_POLL_MAX_ATTEMPTS; i++) {
+ await sleep(READY_POLL_INTERVAL_MS);
+ const result = await getHarness({ region, harnessId: harness.harnessId });
+ if (result.harness.status === 'READY' || result.harness.status === 'FAILED') return result.harness;
+ }
+
+ return harness;
+}
+
+function extractRuntimeArn(harness: Harness): string | undefined {
+ return harness.environment?.agentCoreRuntimeEnvironment?.agentRuntimeArn;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function getDeployErrorHint(err: unknown, region: string): string | undefined {
+ if (!(err instanceof AgentCoreApiError)) return undefined;
+ const body = err.errorBody.toLowerCase();
+
+ if (err.statusCode === 403) {
+ return 'Check that your AWS credentials have permission to call the AgentCore Harness API.';
+ }
+ if (body.includes('not available') || body.includes('not supported') || body.includes('endpoint')) {
+ return `Harness may not be available in ${region}. Try a different region (e.g., us-east-1, us-west-2).`;
+ }
+ if (err.statusCode === 429) {
+ return 'Too many requests. Wait a moment and try again.';
+ }
+ if (err.statusCode >= 500) {
+ return 'This looks like a service-side issue. Wait a moment and redeploy.';
+ }
+ return undefined;
+}
diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts
new file mode 100644
index 000000000..273951a91
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts
@@ -0,0 +1,429 @@
+/**
+ * Maps user-facing HarnessSpec (harness.json) to the CreateHarness API wire format.
+ *
+ * Each transformation is a pure function that converts a section of the spec
+ * into the corresponding API field. The top-level mapHarnessSpecToCreateOptions
+ * orchestrates them and returns a complete CreateHarnessOptions object.
+ */
+import type { DeployedResourceState, HarnessSpec, Memory } from '../../../../../schema';
+import type {
+ CreateHarnessOptions,
+ HarnessEnvironmentArtifact,
+ HarnessEnvironmentProvider,
+ HarnessMemoryConfiguration,
+ HarnessModelConfiguration,
+ HarnessSkill,
+ HarnessSystemPrompt,
+ HarnessTool,
+ HarnessTruncationConfiguration,
+} from '../../../../aws/agentcore-harness';
+import { toPascalId } from '../../../../cloudformation/logical-ids';
+import { readFile, stat } from 'fs/promises';
+import { join } from 'path';
+
+const MAX_PROMPT_FILE_SIZE = 1024 * 1024; // 1 MB
+
+// ============================================================================
+// Public Interface
+// ============================================================================
+
+export interface MapHarnessOptions {
+ harnessSpec: HarnessSpec;
+ harnessDir: string;
+ executionRoleArn: string;
+ region: string;
+ projectName: string;
+ deployedResources?: DeployedResourceState;
+ cdkOutputs?: Record;
+ /** The memory spec for the memory this harness references, used to derive retrievalConfig namespaces. */
+ memorySpec?: Memory;
+}
+
+/**
+ * Transform a HarnessSpec into CreateHarnessOptions for the control plane API.
+ */
+export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): Promise {
+ const { harnessSpec, harnessDir, executionRoleArn, region, projectName, deployedResources, cdkOutputs, memorySpec } =
+ options;
+
+ const result: CreateHarnessOptions = {
+ region,
+ harnessName: `${projectName}_${harnessSpec.name}`,
+ executionRoleArn,
+ };
+
+ // Model
+ result.model = mapModel(harnessSpec.model);
+
+ // System prompt (may read from disk or auto-discover system-prompt.md)
+ if (harnessSpec.systemPrompt !== undefined) {
+ result.systemPrompt = await mapSystemPrompt(harnessSpec.systemPrompt, harnessDir);
+ } else {
+ // Auto-discover system-prompt.md if it exists
+ result.systemPrompt = await tryLoadSystemPromptFile(harnessDir);
+ }
+
+ // Tools
+ if (harnessSpec.tools.length > 0) {
+ result.tools = mapTools(harnessSpec.tools);
+ }
+
+ // Skills
+ if (harnessSpec.skills.length > 0) {
+ result.skills = mapSkills(harnessSpec.skills);
+ }
+
+ // Allowed tools
+ if (harnessSpec.allowedTools) {
+ result.allowedTools = harnessSpec.allowedTools;
+ }
+
+ // Memory
+ if (harnessSpec.memory) {
+ result.memory = mapMemory(harnessSpec.memory, deployedResources, cdkOutputs, memorySpec);
+ }
+
+ // Truncation
+ if (harnessSpec.truncation) {
+ result.truncation = mapTruncation(harnessSpec.truncation);
+ }
+
+ // Execution limits
+ if (harnessSpec.maxIterations !== undefined) {
+ result.maxIterations = harnessSpec.maxIterations;
+ }
+ if (harnessSpec.maxTokens !== undefined) {
+ result.maxTokens = harnessSpec.maxTokens;
+ }
+ if (harnessSpec.timeoutSeconds !== undefined) {
+ result.timeoutSeconds = harnessSpec.timeoutSeconds;
+ }
+
+ // Container artifact
+ if (harnessSpec.containerUri) {
+ result.environmentArtifact = mapEnvironmentArtifact(harnessSpec.containerUri);
+ } else if (harnessSpec.dockerfile) {
+ const builtUri = resolveContainerUriFromOutputs(harnessSpec.name, cdkOutputs);
+ if (!builtUri) {
+ throw new Error(
+ `Harness "${harnessSpec.name}" specifies "dockerfile" but no container URI was found in CDK outputs. ` +
+ `Expected a CDK output key starting with "ApplicationHarness${toPascalId(harnessSpec.name)}ImageUri" or "Harness${toPascalId(harnessSpec.name)}ContainerUri".`
+ );
+ }
+ result.environmentArtifact = mapEnvironmentArtifact(builtUri);
+ }
+
+ // Environment provider (network + lifecycle)
+ const environmentProvider = mapEnvironmentProvider(harnessSpec);
+ if (environmentProvider) {
+ result.environment = environmentProvider;
+ }
+
+ // Environment variables
+ if (harnessSpec.environmentVariables) {
+ result.environmentVariables = harnessSpec.environmentVariables;
+ }
+
+ // Tags
+ if (harnessSpec.tags) {
+ result.tags = harnessSpec.tags;
+ }
+
+ // Authorizer configuration — authorizerType is inferred by the API from the
+ // presence of authorizerConfiguration, so only the configuration is forwarded.
+ if (harnessSpec.authorizerConfiguration?.customJwtAuthorizer) {
+ const jwt = harnessSpec.authorizerConfiguration.customJwtAuthorizer;
+ result.authorizerConfiguration = {
+ customJWTAuthorizer: {
+ discoveryUrl: jwt.discoveryUrl,
+ ...(jwt.allowedAudience && { allowedAudience: jwt.allowedAudience }),
+ ...(jwt.allowedClients && { allowedClients: jwt.allowedClients }),
+ ...(jwt.allowedScopes && { allowedScopes: jwt.allowedScopes }),
+ ...(jwt.customClaims && { customClaims: jwt.customClaims }),
+ },
+ };
+ }
+
+ return result;
+}
+
+// ============================================================================
+// Model Mapping
+// ============================================================================
+
+function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration {
+ const { provider, modelId, apiKeyArn, temperature, topP, topK, maxTokens } = model;
+
+ switch (provider) {
+ case 'bedrock':
+ return {
+ bedrockModelConfig: {
+ modelId,
+ ...(temperature !== undefined && { temperature }),
+ ...(topP !== undefined && { topP }),
+ ...(maxTokens !== undefined && { maxTokens }),
+ },
+ };
+ case 'open_ai':
+ return {
+ openAiModelConfig: {
+ modelId,
+ ...(apiKeyArn && { apiKeyArn }),
+ ...(temperature !== undefined && { temperature }),
+ ...(topP !== undefined && { topP }),
+ ...(maxTokens !== undefined && { maxTokens }),
+ },
+ };
+ case 'gemini':
+ return {
+ geminiModelConfig: {
+ modelId,
+ ...(apiKeyArn && { apiKeyArn }),
+ ...(temperature !== undefined && { temperature }),
+ ...(topP !== undefined && { topP }),
+ ...(topK !== undefined && { topK }),
+ ...(maxTokens !== undefined && { maxTokens }),
+ },
+ };
+ }
+}
+
+// ============================================================================
+// System Prompt Mapping
+// ============================================================================
+
+const FILE_PATH_PATTERN = /^\.\.?\//;
+const FILE_EXTENSION_PATTERN = /\.(md|txt)$/;
+
+function isFilePath(value: string): boolean {
+ return FILE_PATH_PATTERN.test(value) || FILE_EXTENSION_PATTERN.test(value);
+}
+
+async function mapSystemPrompt(prompt: string, harnessDir: string): Promise {
+ let text: string;
+
+ if (isFilePath(prompt)) {
+ const filePath = join(harnessDir, prompt);
+ const fileStats = await stat(filePath);
+ if (fileStats.size > MAX_PROMPT_FILE_SIZE) {
+ throw new Error(
+ `System prompt file "${prompt}" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.`
+ );
+ }
+ text = await readFile(filePath, 'utf-8');
+ } else {
+ text = prompt;
+ }
+
+ return [{ text }];
+}
+
+/**
+ * Try to load system-prompt.md from harness directory.
+ * Returns undefined if file doesn't exist (harness will have no system prompt).
+ */
+async function tryLoadSystemPromptFile(harnessDir: string): Promise {
+ const promptPath = join(harnessDir, 'system-prompt.md');
+
+ try {
+ const fileStats = await stat(promptPath);
+ if (fileStats.size > MAX_PROMPT_FILE_SIZE) {
+ throw new Error(
+ `System prompt file "system-prompt.md" is too large (${fileStats.size} bytes). Maximum size is ${MAX_PROMPT_FILE_SIZE} bytes.`
+ );
+ }
+ const text = await readFile(promptPath, 'utf-8');
+ return [{ text }];
+ } catch (err) {
+ // File doesn't exist - return undefined (no system prompt)
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+ return undefined;
+ }
+ // Other errors (permissions, etc.) should be thrown
+ throw err;
+ }
+}
+
+// ============================================================================
+// Tools Mapping
+// ============================================================================
+
+function mapTools(tools: HarnessSpec['tools']): HarnessTool[] {
+ return tools.map(tool => ({
+ type: tool.type,
+ name: tool.name,
+ ...(tool.config && { config: tool.config }),
+ }));
+}
+
+// ============================================================================
+// Skills Mapping
+// ============================================================================
+
+function mapSkills(skills: string[]): HarnessSkill[] {
+ return skills.map(path => ({ path }));
+}
+
+// ============================================================================
+// Memory Mapping
+// ============================================================================
+
+function mapMemory(
+ memory: NonNullable,
+ deployedResources?: DeployedResourceState,
+ cdkOutputs?: Record,
+ memorySpec?: Memory
+): HarnessMemoryConfiguration | undefined {
+ let arn: string | undefined;
+
+ // Direct ARN takes precedence
+ if (memory.arn) {
+ arn = memory.arn;
+ } else if (memory.name) {
+ // Resolve by name from deployed state or CDK outputs
+ const deployedMemory = deployedResources?.memories?.[memory.name];
+ if (deployedMemory) {
+ arn = deployedMemory.memoryArn;
+ } else if (cdkOutputs) {
+ arn = resolveMemoryArnFromOutputs(memory.name, cdkOutputs);
+ }
+
+ if (!arn) {
+ throw new Error(
+ `Memory "${memory.name}" referenced by harness is not in deployed state. Ensure the memory is defined in agentcore.json and has been deployed.`
+ );
+ }
+ }
+
+ if (!arn) {
+ return undefined;
+ }
+
+ // Build retrievalConfig from the memory's strategy namespaces so the harness
+ // runtime knows which namespaces to search at inference time.
+ const retrievalConfig = buildRetrievalConfig(memorySpec);
+
+ return {
+ agentCoreMemoryConfiguration: {
+ arn,
+ ...(memory.actorId && { actorId: memory.actorId }),
+ ...(retrievalConfig && { retrievalConfig }),
+ },
+ };
+}
+
+function buildRetrievalConfig(
+ memorySpec: Memory | undefined
+): Record | undefined {
+ if (!memorySpec?.strategies?.length) return undefined;
+
+ const namespaces = memorySpec.strategies.flatMap(s => [
+ ...(s.namespaces ?? []),
+ ...(s.type === 'EPISODIC' ? (s.reflectionNamespaces ?? []) : []),
+ ]);
+
+ return namespaces.length > 0 ? Object.fromEntries(namespaces.map(ns => [ns, {}])) : undefined;
+}
+
+/**
+ * Resolve memory ARN from CDK stack outputs.
+ * The CDK construct exports memory ARNs with keys matching:
+ * ApplicationMemory{PascalName}ArnOutput...
+ */
+function resolveMemoryArnFromOutputs(memoryName: string, cdkOutputs: Record): string | undefined {
+ const pascalName = toPascalId(memoryName);
+ const prefix = `ApplicationMemory${pascalName}ArnOutput`;
+
+ for (const [key, value] of Object.entries(cdkOutputs)) {
+ if (key.startsWith(prefix)) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+// ============================================================================
+// Truncation Mapping
+// ============================================================================
+
+function mapTruncation(truncation: NonNullable): HarnessTruncationConfiguration {
+ return {
+ strategy: truncation.strategy,
+ config: truncation.config as HarnessTruncationConfiguration['config'],
+ };
+}
+
+// ============================================================================
+// Container URI Resolution (from CDK outputs for dockerfile-based harnesses)
+// ============================================================================
+
+/**
+ * Supports two construct tree layouts:
+ * Old (CfnOutput on stack root):
+ * Harness{PascalName}ContainerUri...
+ * New (CfnOutput inside AgentCoreHarnessEnvironment):
+ * ApplicationHarness{PascalName}ImageUriOutput...
+ */
+function resolveContainerUriFromOutputs(harnessName: string, cdkOutputs?: Record): string | undefined {
+ if (!cdkOutputs) return undefined;
+
+ const pascalName = toPascalId(harnessName);
+ const prefixes = [`ApplicationHarness${pascalName}ImageUri`, `Harness${pascalName}ContainerUri`];
+
+ for (const [key, value] of Object.entries(cdkOutputs)) {
+ if (prefixes.some(p => key.startsWith(p))) {
+ return value;
+ }
+ }
+
+ return undefined;
+}
+
+// ============================================================================
+// Container / Environment Artifact Mapping
+// ============================================================================
+
+function mapEnvironmentArtifact(containerUri: string): HarnessEnvironmentArtifact {
+ return {
+ containerConfiguration: { containerUri },
+ };
+}
+
+// ============================================================================
+// Environment Provider (Network + Lifecycle) Mapping
+// ============================================================================
+
+function mapEnvironmentProvider(spec: HarnessSpec): HarnessEnvironmentProvider | undefined {
+ const hasNetwork = !!spec.networkConfig;
+ const hasLifecycle = !!spec.lifecycleConfig;
+ const hasSessionStorage = !!spec.sessionStoragePath;
+
+ if (!hasNetwork && !hasLifecycle && !hasSessionStorage) {
+ return undefined;
+ }
+
+ const agentCoreRuntimeEnvironment: Record = {};
+
+ if (spec.networkConfig) {
+ agentCoreRuntimeEnvironment.networkConfiguration = {
+ networkMode: 'VPC',
+ networkModeConfig: {
+ subnets: spec.networkConfig.subnets,
+ securityGroups: spec.networkConfig.securityGroups,
+ },
+ };
+ }
+
+ if (spec.lifecycleConfig) {
+ agentCoreRuntimeEnvironment.lifecycleConfiguration = spec.lifecycleConfig;
+ }
+
+ if (spec.sessionStoragePath) {
+ agentCoreRuntimeEnvironment.filesystemConfigurations = [{ sessionStorage: { mountPath: spec.sessionStoragePath } }];
+ }
+
+ return {
+ agentCoreRuntimeEnvironment,
+ };
+}
diff --git a/src/cli/operations/deploy/imperative/deployers/index.ts b/src/cli/operations/deploy/imperative/deployers/index.ts
new file mode 100644
index 000000000..655785b10
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/deployers/index.ts
@@ -0,0 +1,2 @@
+export { HarnessDeployer } from './harness-deployer';
+export { mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './harness-mapper';
diff --git a/src/cli/operations/deploy/imperative/index.ts b/src/cli/operations/deploy/imperative/index.ts
new file mode 100644
index 000000000..930dfe094
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/index.ts
@@ -0,0 +1,18 @@
+import { HarnessDeployer } from './deployers';
+import { ImperativeDeploymentManager } from './manager';
+
+export type {
+ DeployPhase,
+ DeployProgress,
+ ImperativeDeployContext,
+ ImperativeDeployResult,
+ ImperativeDeployer,
+} from './types';
+
+export { ImperativeDeploymentManager, type ImperativePhaseResult } from './manager';
+
+export { HarnessDeployer, mapHarnessSpecToCreateOptions, type MapHarnessOptions } from './deployers';
+
+export function createDeploymentManager(): ImperativeDeploymentManager {
+ return new ImperativeDeploymentManager().register(new HarnessDeployer());
+}
diff --git a/src/cli/operations/deploy/imperative/manager.ts b/src/cli/operations/deploy/imperative/manager.ts
new file mode 100644
index 000000000..b7e22ecda
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/manager.ts
@@ -0,0 +1,110 @@
+import type { DeployPhase, ImperativeDeployContext, ImperativeDeployResult, ImperativeDeployer } from './types';
+
+export interface ImperativePhaseResult {
+ success: boolean;
+ results: Map;
+ error?: string;
+ notes: string[];
+}
+
+export class ImperativeDeploymentManager {
+ private readonly deployers: ImperativeDeployer[] = [];
+
+ register(deployer: ImperativeDeployer): this {
+ this.deployers.push(deployer);
+ return this;
+ }
+
+ async runPhase(phase: DeployPhase, context: ImperativeDeployContext): Promise {
+ const results = new Map();
+ const notes: string[] = [];
+
+ const applicable = this.deployers.filter(d => d.phase === phase && d.shouldRun(context));
+
+ for (const deployer of applicable) {
+ context.onProgress?.(deployer.label, 'start');
+
+ try {
+ const result = await deployer.deploy(context);
+ results.set(deployer.name, result);
+
+ if (result.notes) {
+ notes.push(...result.notes);
+ }
+
+ if (!result.success) {
+ context.onProgress?.(deployer.label, 'error');
+ return {
+ success: false,
+ results,
+ error: result.error ?? `Deployer '${deployer.name}' failed`,
+ notes,
+ };
+ }
+
+ context.onProgress?.(deployer.label, 'done');
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ results.set(deployer.name, { success: false, error: errorMessage });
+ context.onProgress?.(deployer.label, 'error');
+ return {
+ success: false,
+ results,
+ error: errorMessage,
+ notes,
+ };
+ }
+ }
+
+ return { success: true, results, notes };
+ }
+
+ async teardownAll(context: ImperativeDeployContext): Promise {
+ const results = new Map();
+ const notes: string[] = [];
+ const errors: string[] = [];
+
+ const applicable = this.deployers.filter(d => d.shouldRun(context)).reverse();
+
+ for (const deployer of applicable) {
+ context.onProgress?.(deployer.label, 'start');
+
+ try {
+ const result = await deployer.teardown(context);
+ results.set(deployer.name, result);
+
+ if (result.notes) {
+ notes.push(...result.notes);
+ }
+
+ if (!result.success) {
+ context.onProgress?.(deployer.label, 'error');
+ errors.push(result.error ?? `Teardown of '${deployer.name}' failed`);
+ continue;
+ }
+
+ context.onProgress?.(deployer.label, 'done');
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ results.set(deployer.name, { success: false, error: errorMessage });
+ context.onProgress?.(deployer.label, 'error');
+ errors.push(errorMessage);
+ }
+ }
+
+ if (errors.length > 0) {
+ return {
+ success: false,
+ results,
+ error: errors.join('; '),
+ notes,
+ };
+ }
+
+ return { success: true, results, notes };
+ }
+
+ hasDeployersForPhase(phase: DeployPhase, context: ImperativeDeployContext): boolean {
+ return this.deployers.some(d => d.phase === phase && d.shouldRun(context));
+ }
+}
diff --git a/src/cli/operations/deploy/imperative/types.ts b/src/cli/operations/deploy/imperative/types.ts
new file mode 100644
index 000000000..7efa13e7a
--- /dev/null
+++ b/src/cli/operations/deploy/imperative/types.ts
@@ -0,0 +1,32 @@
+import type { ConfigIO } from '../../../../lib';
+import type { AgentCoreProjectSpec, AwsDeploymentTarget, DeployedState } from '../../../../schema';
+
+export type DeployPhase = 'pre-cdk' | 'post-cdk' | 'standalone';
+
+export type DeployProgress = (step: string, status: 'start' | 'done' | 'error') => void;
+
+export interface ImperativeDeployContext {
+ projectSpec: AgentCoreProjectSpec;
+ target: AwsDeploymentTarget;
+ configIO: ConfigIO;
+ deployedState: DeployedState;
+ onProgress?: DeployProgress;
+ cdkOutputs?: Record;
+ autoConfirm?: boolean;
+}
+
+export interface ImperativeDeployResult> {
+ success: boolean;
+ state?: TState;
+ notes?: string[];
+ error?: string;
+}
+
+export interface ImperativeDeployer> {
+ readonly name: string;
+ readonly label: string;
+ readonly phase: DeployPhase;
+ shouldRun(context: ImperativeDeployContext): boolean;
+ deploy(context: ImperativeDeployContext): Promise>;
+ teardown(context: ImperativeDeployContext): Promise>;
+}
diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts
index 4124dea3f..6577eb529 100644
--- a/src/cli/operations/deploy/preflight.ts
+++ b/src/cli/operations/deploy/preflight.ts
@@ -86,12 +86,21 @@ export async function validateProject(): Promise {
const hasMemories = projectSpec.memories && projectSpec.memories.length > 0;
const hasEvaluators = projectSpec.evaluators && projectSpec.evaluators.length > 0;
const hasPolicyEngines = projectSpec.policyEngines && projectSpec.policyEngines.length > 0;
+ const hasHarnesses = projectSpec.harnesses && projectSpec.harnesses.length > 0;
const hasDatasets = projectSpec.datasets && projectSpec.datasets.length > 0;
// Check for gateways in agentcore.json
const hasGateways = projectSpec.agentCoreGateways && projectSpec.agentCoreGateways.length > 0;
- if (!hasAgents && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines && !hasDatasets) {
+ if (
+ !hasAgents &&
+ !hasGateways &&
+ !hasMemories &&
+ !hasEvaluators &&
+ !hasPolicyEngines &&
+ !hasHarnesses &&
+ !hasDatasets
+ ) {
let hasExistingStack = false;
try {
const deployedState = await configIO.readDeployedState();
diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts
index 6ba805506..b6bf54837 100644
--- a/src/cli/operations/dev/__tests__/config.test.ts
+++ b/src/cli/operations/dev/__tests__/config.test.ts
@@ -24,6 +24,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -56,6 +57,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -87,6 +89,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -124,6 +127,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -156,6 +160,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -189,6 +194,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -222,6 +228,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -255,6 +262,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -288,6 +296,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -320,6 +329,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -352,6 +362,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -384,6 +395,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -416,6 +428,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -449,6 +462,7 @@ describe('getDevConfig', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -495,6 +509,7 @@ describe('getAgentPort', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -517,6 +532,7 @@ describe('getAgentPort', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -544,6 +560,7 @@ describe('getDevSupportedAgents', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -574,6 +591,7 @@ describe('getDevSupportedAgents', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -614,6 +632,7 @@ describe('getDevSupportedAgents', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
};
const supported = getDevSupportedAgents(project);
@@ -644,6 +663,7 @@ describe('getDevSupportedAgents', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
@@ -684,6 +704,7 @@ describe('getDevSupportedAgents', () => {
configBundles: [],
abTests: [],
httpGateways: [],
+ harnesses: [],
datasets: [],
};
diff --git a/src/cli/operations/dev/web-ui/__tests__/harness-invocation.test.ts b/src/cli/operations/dev/web-ui/__tests__/harness-invocation.test.ts
new file mode 100644
index 000000000..df9175a7b
--- /dev/null
+++ b/src/cli/operations/dev/web-ui/__tests__/harness-invocation.test.ts
@@ -0,0 +1,183 @@
+/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-empty-function, require-yield, @typescript-eslint/unbound-method */
+import { invokeHarness } from '../../../../aws/agentcore-harness';
+import { handleHarnessInvocation } from '../handlers/harness-invocation';
+import type { RouteContext } from '../handlers/route-context';
+import type { ServerResponse } from 'http';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../../aws/agentcore-harness', () => ({
+ invokeHarness: vi.fn(),
+}));
+
+function mockRes(): ServerResponse & {
+ _status: number;
+ _headers: Record;
+ _body: string;
+ _chunks: string[];
+} {
+ const res = {
+ _status: 0,
+ _headers: {} as Record,
+ _body: '',
+ _chunks: [] as string[],
+ writeHead(status: number, headers?: Record) {
+ res._status = status;
+ if (headers) Object.assign(res._headers, headers);
+ return res;
+ },
+ write(chunk: string) {
+ res._chunks.push(chunk);
+ return true;
+ },
+ end(body?: string) {
+ if (body) res._body = body;
+ },
+ };
+ return res as unknown as ServerResponse & {
+ _status: number;
+ _headers: Record;
+ _body: string;
+ _chunks: string[];
+ };
+}
+
+function mockCtx(overrides: Partial = {}): RouteContext {
+ return {
+ options: {
+ mode: 'dev',
+ harnesses: [
+ { name: 'MyHarness', harnessArn: 'arn:aws:bedrock:us-west-2:123:harness/h-123', region: 'us-west-2' },
+ ],
+ ...overrides,
+ } as RouteContext['options'],
+ runningAgents: new Map(),
+ startingAgents: new Map(),
+ agentErrors: new Map(),
+ setCorsHeaders: vi.fn(),
+ readBody: vi.fn(),
+ } as unknown as RouteContext;
+}
+
+describe('handleHarnessInvocation', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns 400 when harnessName is missing', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ await handleHarnessInvocation(ctx, {}, res, undefined);
+
+ expect(res._status).toBe(400);
+ expect(JSON.parse(res._body)).toEqual({ success: false, error: 'harnessName is required' });
+ });
+
+ it('returns 400 when prompt is missing', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ await handleHarnessInvocation(ctx, { harnessName: 'MyHarness' }, res, undefined);
+
+ expect(res._status).toBe(400);
+ expect(JSON.parse(res._body)).toEqual({ success: false, error: 'prompt is required' });
+ });
+
+ it('returns 404 when harness not found', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ await handleHarnessInvocation(ctx, { harnessName: 'NonExistent', prompt: 'hello' }, res, undefined);
+
+ expect(res._status).toBe(404);
+ expect(JSON.parse(res._body)).toEqual({ success: false, error: 'Harness "NonExistent" not found' });
+ });
+
+ it('returns 404 when no harnesses configured', async () => {
+ const ctx = mockCtx({ harnesses: undefined });
+ const res = mockRes();
+
+ await handleHarnessInvocation(ctx, { harnessName: 'MyHarness', prompt: 'hello' }, res, undefined);
+
+ expect(res._status).toBe(404);
+ });
+
+ it('streams SSE events on successful invocation', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ const events = [
+ { type: 'text', text: 'Hello' },
+ { type: 'text', text: ' world' },
+ ];
+ vi.mocked(invokeHarness).mockReturnValue(
+ (async function* () {
+ for (const e of events) yield e;
+ })() as any
+ );
+
+ await handleHarnessInvocation(ctx, { harnessName: 'MyHarness', prompt: 'hello' }, res, undefined);
+
+ expect(res._status).toBe(200);
+ expect(res._headers['Content-Type']).toBe('text/event-stream');
+ expect(res._chunks).toHaveLength(2);
+ expect(res._chunks[0]!).toContain('data: ');
+ expect(JSON.parse(res._chunks[0]!.replace('data: ', '').trim())).toEqual(events[0]);
+ });
+
+ it('streams error event on invocation failure', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ vi.mocked(invokeHarness).mockReturnValue(
+ (async function* () {
+ throw new Error('Service unavailable');
+ })() as any
+ );
+
+ await handleHarnessInvocation(ctx, { harnessName: 'MyHarness', prompt: 'hello' }, res, undefined);
+
+ expect(res._status).toBe(200);
+ expect(res._chunks).toHaveLength(1);
+ const errorEvent = JSON.parse(res._chunks[0]!.replace('data: ', '').trim());
+ expect(errorEvent.type).toBe('error');
+ expect(errorEvent.message).toBe('Service unavailable');
+ });
+
+ it('sets x-session-id header with provided sessionId', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ vi.mocked(invokeHarness).mockReturnValue((async function* () {})() as any);
+
+ await handleHarnessInvocation(
+ ctx,
+ { harnessName: 'MyHarness', prompt: 'hello', sessionId: 'my-session-123' },
+ res,
+ undefined
+ );
+
+ expect(res._headers['x-session-id']).toBe('my-session-123');
+ });
+
+ it('generates sessionId when not provided', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ vi.mocked(invokeHarness).mockReturnValue((async function* () {})() as any);
+
+ await handleHarnessInvocation(ctx, { harnessName: 'MyHarness', prompt: 'hello' }, res, undefined);
+
+ expect(res._headers['x-session-id']).toBeDefined();
+ expect(res._headers['x-session-id']!.length).toBeGreaterThan(0);
+ });
+
+ it('sets CORS headers', async () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ await handleHarnessInvocation(ctx, { harnessName: 'MyHarness' }, res, 'http://localhost:3000');
+
+ expect(ctx.setCorsHeaders).toHaveBeenCalledWith(res, 'http://localhost:3000');
+ });
+});
diff --git a/src/cli/operations/dev/web-ui/__tests__/status-harness.test.ts b/src/cli/operations/dev/web-ui/__tests__/status-harness.test.ts
new file mode 100644
index 000000000..4120229c3
--- /dev/null
+++ b/src/cli/operations/dev/web-ui/__tests__/status-harness.test.ts
@@ -0,0 +1,81 @@
+import type { RouteContext } from '../handlers/route-context';
+import { handleStatus } from '../handlers/status';
+import type { ServerResponse } from 'http';
+import { describe, expect, it, vi } from 'vitest';
+
+function mockRes(): ServerResponse & { _status: number; _headers: Record; _body: string } {
+ const res = {
+ _status: 0,
+ _headers: {} as Record,
+ _body: '',
+ writeHead(status: number, headers?: Record) {
+ res._status = status;
+ if (headers) Object.assign(res._headers, headers);
+ return res;
+ },
+ end(body?: string) {
+ if (body) res._body = body;
+ },
+ };
+ return res as unknown as ServerResponse & { _status: number; _headers: Record; _body: string };
+}
+
+function mockCtx(overrides: Partial = {}): RouteContext {
+ return {
+ options: {
+ mode: 'dev',
+ agents: [],
+ harnesses: [{ name: 'MyHarness' }],
+ selectedAgent: undefined,
+ selectedHarness: 'MyHarness',
+ ...overrides,
+ } as RouteContext['options'],
+ runningAgents: new Map(),
+ startingAgents: new Map(),
+ agentErrors: new Map(),
+ setCorsHeaders: vi.fn(),
+ readBody: vi.fn(),
+ } as unknown as RouteContext;
+}
+
+describe('handleStatus - harness fields', () => {
+ it('includes harnesses array in response', () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ handleStatus(ctx, res, undefined);
+
+ const body = JSON.parse(res._body);
+ expect(body.harnesses).toEqual([{ name: 'MyHarness' }]);
+ });
+
+ it('includes selectedHarness in response', () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ handleStatus(ctx, res, undefined);
+
+ const body = JSON.parse(res._body);
+ expect(body.selectedHarness).toBe('MyHarness');
+ });
+
+ it('returns empty harnesses when none configured', () => {
+ const ctx = mockCtx({ harnesses: undefined });
+ const res = mockRes();
+
+ handleStatus(ctx, res, undefined);
+
+ const body = JSON.parse(res._body);
+ expect(body.harnesses).toEqual([]);
+ });
+
+ it('includes mode in response', () => {
+ const ctx = mockCtx();
+ const res = mockRes();
+
+ handleStatus(ctx, res, undefined);
+
+ const body = JSON.parse(res._body);
+ expect(body.mode).toBe('dev');
+ });
+});
diff --git a/src/cli/operations/dev/web-ui/api-types.ts b/src/cli/operations/dev/web-ui/api-types.ts
index 5d4cc2d41..d5994c0a3 100644
--- a/src/cli/operations/dev/web-ui/api-types.ts
+++ b/src/cli/operations/dev/web-ui/api-types.ts
@@ -8,6 +8,7 @@
* TODO: Extract these types into a shared package so both repos import
* from a single source of truth instead of manually duplicating.
*/
+import type { HarnessModelConfiguration, HarnessTool } from '../../../aws/agentcore-harness';
import type { CloudWatchSpanRecord, CloudWatchTraceRecord } from '../../traces/types';
// ---------------------------------------------------------------------------
@@ -423,3 +424,43 @@ export interface A2AAgentCardResponse {
success: true;
card: A2AAgentCard;
}
+
+// ---------------------------------------------------------------------------
+// Harness invocation types
+// ---------------------------------------------------------------------------
+
+export interface HarnessInvocationOverrides {
+ model?: HarnessModelConfiguration;
+ systemPrompt?: string;
+ skills?: { path: string }[];
+ actorId?: string;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ allowedTools?: string[];
+ tools?: HarnessTool[];
+}
+
+export interface HarnessToolResponseRequest {
+ harnessName: string;
+ sessionId: string;
+ messages: { role: string; content: Record[] }[];
+ harnessOverrides?: HarnessInvocationOverrides;
+}
+
+export interface StatusHarness {
+ name: string;
+}
+
+export interface ResourceHarness {
+ name: string;
+ model: string;
+ tools: string[];
+ deploymentStatus?: ResourceDeploymentStatus;
+ deployed?: DeployedHarnessState;
+}
+
+export interface DeployedHarnessState {
+ harnessId: string;
+ harnessArn: string;
+}
diff --git a/src/cli/operations/dev/web-ui/constants.ts b/src/cli/operations/dev/web-ui/constants.ts
index 1ff6d9361..d3eafb498 100644
--- a/src/cli/operations/dev/web-ui/constants.ts
+++ b/src/cli/operations/dev/web-ui/constants.ts
@@ -16,3 +16,9 @@ export interface AgentError {
message: string;
timestamp: number;
}
+
+export interface HarnessInfo {
+ name: string;
+ harnessArn: string;
+ region: string;
+}
diff --git a/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts b/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts
new file mode 100644
index 000000000..4e4c464f1
--- /dev/null
+++ b/src/cli/operations/dev/web-ui/handlers/harness-invocation.ts
@@ -0,0 +1,87 @@
+import { invokeHarness } from '../../../../aws/agentcore-harness';
+import type { InvokeHarnessOptions } from '../../../../aws/agentcore-harness';
+import type { HarnessInvocationOverrides } from '../api-types';
+import { buildInvokeOptions } from './harness-utils';
+import type { RouteContext } from './route-context';
+import { randomUUID } from 'node:crypto';
+import type { ServerResponse } from 'node:http';
+
+interface ParsedHarnessRequest {
+ harnessName: string;
+ prompt: string;
+ sessionId: string;
+ userId?: string;
+ overrides?: HarnessInvocationOverrides;
+}
+
+function parseRequest(raw: Record): { parsed?: ParsedHarnessRequest; error?: string } {
+ const harnessName = raw.harnessName as string | undefined;
+ if (!harnessName) return { error: 'harnessName is required' };
+
+ const prompt = raw.prompt as string | undefined;
+ if (!prompt) return { error: 'prompt is required' };
+
+ return {
+ parsed: {
+ harnessName,
+ prompt,
+ sessionId: (raw.sessionId as string) || randomUUID(),
+ userId: raw.userId as string | undefined,
+ overrides: raw.harnessOverrides as HarnessInvocationOverrides | undefined,
+ },
+ };
+}
+
+export async function handleHarnessInvocation(
+ ctx: RouteContext,
+ body: Record,
+ res: ServerResponse,
+ origin?: string
+): Promise {
+ const { parsed, error } = parseRequest(body);
+ if (!parsed) {
+ ctx.setCorsHeaders(res, origin);
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: false, error }));
+ return;
+ }
+
+ const harness = (ctx.options.harnesses ?? []).find(h => h.name === parsed.harnessName);
+ if (!harness) {
+ ctx.setCorsHeaders(res, origin);
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: false, error: `Harness "${parsed.harnessName}" not found` }));
+ return;
+ }
+
+ const messages: InvokeHarnessOptions['messages'] = [{ role: 'user', content: [{ text: parsed.prompt }] }];
+
+ const invokeOpts = buildInvokeOptions(
+ harness.harnessArn,
+ harness.region,
+ parsed.sessionId,
+ messages,
+ parsed.overrides
+ );
+
+ ctx.setCorsHeaders(res, origin);
+ const sseHeaders: Record = {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'x-session-id': parsed.sessionId,
+ };
+ res.writeHead(200, sseHeaders);
+
+ try {
+ const stream = invokeHarness(invokeOpts);
+ for await (const event of stream) {
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ res.write(`data: ${JSON.stringify({ type: 'error', errorType: 'invocationError', message })}\n\n`);
+ }
+
+ res.end();
+}
diff --git a/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts b/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts
new file mode 100644
index 000000000..369ad45d7
--- /dev/null
+++ b/src/cli/operations/dev/web-ui/handlers/harness-tool-response.ts
@@ -0,0 +1,92 @@
+import { invokeHarness } from '../../../../aws/agentcore-harness';
+import type { HarnessInvocationOverrides } from '../api-types';
+import { buildInvokeOptions } from './harness-utils';
+import type { RouteContext } from './route-context';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+
+interface ParsedToolResponseRequest {
+ harnessName: string;
+ sessionId: string;
+ messages: { role: string; content: Record[] }[];
+ harnessOverrides?: HarnessInvocationOverrides;
+}
+
+function parseToolResponseRequest(body: string): {
+ parsed?: ParsedToolResponseRequest;
+ error?: string;
+ status?: number;
+} {
+ let raw: Record;
+ try {
+ raw = JSON.parse(body) as Record;
+ } catch {
+ return { error: 'Invalid JSON', status: 400 };
+ }
+
+ if (!raw.harnessName) return { error: 'harnessName is required', status: 400 };
+ if (!raw.messages || !Array.isArray(raw.messages)) return { error: 'messages array is required', status: 400 };
+ if (!raw.sessionId) return { error: 'sessionId is required', status: 400 };
+
+ return {
+ parsed: {
+ harnessName: raw.harnessName as string,
+ sessionId: raw.sessionId as string,
+ messages: raw.messages as ParsedToolResponseRequest['messages'],
+ harnessOverrides: raw.harnessOverrides as HarnessInvocationOverrides | undefined,
+ },
+ };
+}
+
+export async function handleHarnessToolResponse(
+ ctx: RouteContext,
+ req: IncomingMessage,
+ res: ServerResponse,
+ origin?: string
+): Promise {
+ const body = await ctx.readBody(req);
+
+ const { parsed, error, status } = parseToolResponseRequest(body);
+ if (!parsed) {
+ ctx.setCorsHeaders(res, origin);
+ res.writeHead(status ?? 400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: false, error }));
+ return;
+ }
+
+ const harness = (ctx.options.harnesses ?? []).find(h => h.name === parsed.harnessName);
+ if (!harness) {
+ ctx.setCorsHeaders(res, origin);
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ success: false, error: `Harness "${parsed.harnessName}" not found` }));
+ return;
+ }
+
+ const invokeOpts = buildInvokeOptions(
+ harness.harnessArn,
+ harness.region,
+ parsed.sessionId,
+ parsed.messages,
+ parsed.harnessOverrides
+ );
+
+ ctx.setCorsHeaders(res, origin);
+ const sseHeaders: Record = {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'x-session-id': parsed.sessionId,
+ };
+ res.writeHead(200, sseHeaders);
+
+ try {
+ const stream = invokeHarness(invokeOpts);
+ for await (const event of stream) {
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ res.write(`data: ${JSON.stringify({ type: 'error', errorType: 'invocationError', message })}\n\n`);
+ }
+
+ res.end();
+}
diff --git a/src/cli/operations/dev/web-ui/handlers/harness-utils.ts b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts
new file mode 100644
index 000000000..4a2947e9e
--- /dev/null
+++ b/src/cli/operations/dev/web-ui/handlers/harness-utils.ts
@@ -0,0 +1,31 @@
+import type { HarnessSystemPrompt, InvokeHarnessOptions } from '../../../../aws/agentcore-harness';
+import type { HarnessInvocationOverrides } from '../api-types';
+
+const DEFAULT_MAX_ITERATIONS = 75;
+
+export function buildInvokeOptions(
+ harnessArn: string,
+ region: string,
+ sessionId: string,
+ messages: InvokeHarnessOptions['messages'],
+ overrides?: HarnessInvocationOverrides
+): InvokeHarnessOptions {
+ const opts: InvokeHarnessOptions = {
+ region,
+ harnessArn,
+ runtimeSessionId: sessionId,
+ messages,
+ };
+
+ if (overrides?.model) opts.model = overrides.model;
+ if (overrides?.systemPrompt) opts.systemPrompt = [{ text: overrides.systemPrompt }] as HarnessSystemPrompt;
+ if (overrides?.skills) opts.skills = overrides.skills;
+ if (overrides?.actorId) opts.actorId = overrides.actorId;
+ opts.maxIterations = overrides?.maxIterations ?? DEFAULT_MAX_ITERATIONS;
+ if (overrides?.maxTokens != null) opts.maxTokens = overrides.maxTokens;
+ if (overrides?.timeoutSeconds != null) opts.timeoutSeconds = overrides.timeoutSeconds;
+ if (overrides?.allowedTools) opts.allowedTools = overrides.allowedTools;
+ if (overrides?.tools) opts.tools = overrides.tools;
+
+ return opts;
+}
diff --git a/src/cli/operations/dev/web-ui/handlers/index.ts b/src/cli/operations/dev/web-ui/handlers/index.ts
index 0ae7b4f67..8f45106e2 100644
--- a/src/cli/operations/dev/web-ui/handlers/index.ts
+++ b/src/cli/operations/dev/web-ui/handlers/index.ts
@@ -8,3 +8,5 @@ export { handleListCloudWatchTraces, handleGetCloudWatchTrace } from './cloudwat
export { handleListMemoryRecords, handleRetrieveMemoryRecords } from './memory';
export { handleMcpProxy } from './mcp-proxy';
export { handleA2AAgentCard } from './a2a-proxy';
+export { handleHarnessInvocation } from './harness-invocation';
+export { handleHarnessToolResponse } from './harness-tool-response';
diff --git a/src/cli/operations/dev/web-ui/handlers/invocations.ts b/src/cli/operations/dev/web-ui/handlers/invocations.ts
index 3a6b70ed9..8271fef6d 100644
--- a/src/cli/operations/dev/web-ui/handlers/invocations.ts
+++ b/src/cli/operations/dev/web-ui/handlers/invocations.ts
@@ -1,4 +1,5 @@
import { extractSSEEventText, extractTaskText, isStatusUpdateEvent } from '../../invoke-a2a';
+import { handleHarnessInvocation } from './harness-invocation';
import type { RouteContext } from './route-context';
import { randomUUID } from 'node:crypto';
import { type IncomingMessage, type ServerResponse, request as httpRequest } from 'node:http';
@@ -17,6 +18,16 @@ export async function handleInvocations(
): Promise {
const body = await ctx.readBody(req);
+ // Route to harness handler if harnessName is present
+ try {
+ const parsedBody = JSON.parse(body) as Record;
+ if (parsedBody.harnessName) {
+ return handleHarnessInvocation(ctx, parsedBody, res, origin);
+ }
+ } catch {
+ // fall through to agent routing
+ }
+
let agentPort: number | undefined;
let agentName: string | undefined;
let agentProtocol: string | undefined;
diff --git a/src/cli/operations/dev/web-ui/handlers/resources.ts b/src/cli/operations/dev/web-ui/handlers/resources.ts
index 47c4e00ef..ffaa73d08 100644
--- a/src/cli/operations/dev/web-ui/handlers/resources.ts
+++ b/src/cli/operations/dev/web-ui/handlers/resources.ts
@@ -8,6 +8,7 @@ import type {
ResourceDeploymentStatus,
ResourceEvaluator,
ResourceGateway,
+ ResourceHarness,
ResourceMemory,
ResourceOnlineEvalConfig,
ResourcePolicyEngine,
@@ -106,6 +107,42 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or
}
}
+ // Build harnesses from local config
+ const localHarnessNames = new Set((project.harnesses ?? []).map(h => h.name));
+ const harnesses: ResourceHarness[] = [];
+ for (const h of project.harnesses ?? []) {
+ let model = '';
+ let tools: string[] = [];
+ try {
+ const spec = await configIO.readHarnessSpec(h.name);
+ model = `${spec.model.provider}/${spec.model.modelId}`;
+ tools = spec.tools.map(t => t.name);
+ } catch {
+ // harness spec may be unreadable — show what we can
+ }
+ const deployed = targetResources?.harnesses?.[h.name];
+ harnesses.push({
+ name: h.name,
+ model,
+ tools,
+ deploymentStatus: statusByTypeAndName.get(`harness:${h.name}`),
+ deployed: deployed ? { harnessId: deployed.harnessId, harnessArn: deployed.harnessArn } : undefined,
+ });
+ }
+
+ // Add pending-removal harnesses
+ for (const [name, deployed] of Object.entries(targetResources?.harnesses ?? {})) {
+ if (!localHarnessNames.has(name)) {
+ harnesses.push({
+ name,
+ model: '',
+ tools: [],
+ deploymentStatus: 'pending-removal' as ResourceDeploymentStatus,
+ deployed: { harnessId: deployed.harnessId, harnessArn: deployed.harnessArn },
+ });
+ }
+ }
+
// Build memories from local config
const localMemoryNames = new Set(project.memories.map(m => m.name));
const memories: ResourceMemory[] = project.memories.map(m => ({
@@ -274,6 +311,7 @@ export async function handleResources(ctx: RouteContext, res: ServerResponse, or
success: true,
project: project.name,
agents,
+ harnesses,
memories,
credentials,
gateways,
diff --git a/src/cli/operations/dev/web-ui/handlers/status.ts b/src/cli/operations/dev/web-ui/handlers/status.ts
index 0fdb05500..b6fe0d1fc 100644
--- a/src/cli/operations/dev/web-ui/handlers/status.ts
+++ b/src/cli/operations/dev/web-ui/handlers/status.ts
@@ -1,8 +1,8 @@
-import type { StatusAgentError, StatusRunningAgent } from '../api-types';
+import type { StatusAgentError, StatusHarness, StatusRunningAgent } from '../api-types';
import type { RouteContext } from './route-context';
import type { ServerResponse } from 'node:http';
-/** GET /api/status — returns available agents, which ones are running, and any errors */
+/** GET /api/status — returns available agents, harnesses, which agents are running, and any errors */
export function handleStatus(ctx: RouteContext, res: ServerResponse, origin?: string): void {
const { agents } = ctx.options;
const running: StatusRunningAgent[] = [];
@@ -11,15 +11,24 @@ export function handleStatus(ctx: RouteContext, res: ServerResponse, origin?: st
running.push({ name, port });
}
- // Collect per-agent errors
const errors: StatusAgentError[] = [];
for (const [name, agentError] of ctx.agentErrors) {
errors.push({ name, message: agentError.message });
}
+ const harnesses: StatusHarness[] = (ctx.options.harnesses ?? []).map(h => ({ name: h.name }));
+
ctx.setCorsHeaders(res, origin);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
- JSON.stringify({ mode: ctx.options.mode, agents, running, errors, selectedAgent: ctx.options.selectedAgent })
+ JSON.stringify({
+ mode: ctx.options.mode,
+ agents,
+ harnesses,
+ running,
+ errors,
+ selectedAgent: ctx.options.selectedAgent,
+ selectedHarness: ctx.options.selectedHarness,
+ })
);
}
diff --git a/src/cli/operations/dev/web-ui/web-server.ts b/src/cli/operations/dev/web-ui/web-server.ts
index fe845194f..ef706c803 100644
--- a/src/cli/operations/dev/web-ui/web-server.ts
+++ b/src/cli/operations/dev/web-ui/web-server.ts
@@ -1,11 +1,12 @@
import type { DevConfig } from '../config';
import type { DevServer } from '../server';
-import { type AgentError, type AgentInfo, WEB_UI_LOCAL_URL } from './constants';
+import { type AgentError, type AgentInfo, type HarnessInfo, WEB_UI_LOCAL_URL } from './constants';
import {
type RouteContext,
handleA2AAgentCard,
handleGetCloudWatchTrace,
handleGetTrace,
+ handleHarnessToolResponse,
handleInvocations,
handleListCloudWatchTraces,
handleListMemoryRecords,
@@ -145,6 +146,8 @@ export interface WebUIOptions {
uiPort: number;
/** Available agents (metadata only — servers are started on demand) */
agents: AgentInfo[];
+ /** Deployed harnesses available for invocation (metadata only — no local server needed) */
+ harnesses?: HarnessInfo[];
/** Dev config factory — called when an agent needs to be started. Required for dev mode, unused when onStart is provided. */
getDevConfig?: (agentName: string) => DevConfig | null | Promise;
/** Env vars to pass to started agent servers */
@@ -173,6 +176,8 @@ export interface WebUIOptions {
onRetrieveMemoryRecords?: RetrieveMemoryRecordsHandler;
/** Agent to pre-select in the UI dropdown (set when --runtime is specified) */
selectedAgent?: string;
+ /** Harness to pre-select in the UI */
+ selectedHarness?: string;
/** Callback to reload the agents list from config. When provided, the server watches agentcore.json and calls this on change. */
reloadAgents?: () => Promise;
}
@@ -340,6 +345,8 @@ export class WebUIServer {
await handleListCloudWatchTraces(ctx, req, res, origin);
} else if (req.method === 'POST' && req.url === '/api/start') {
await handleStart(ctx, req, res, origin);
+ } else if (req.method === 'POST' && req.url === '/api/harness/tool-response') {
+ await handleHarnessToolResponse(ctx, req, res, origin);
} else if (req.method === 'POST' && req.url === '/invocations') {
await handleInvocations(ctx, req, res, origin);
} else if (req.method === 'POST' && req.url === '/api/mcp') {
diff --git a/src/cli/operations/fetch-access/fetch-harness-token.ts b/src/cli/operations/fetch-access/fetch-harness-token.ts
new file mode 100644
index 000000000..654bc5c36
--- /dev/null
+++ b/src/cli/operations/fetch-access/fetch-harness-token.ts
@@ -0,0 +1,83 @@
+import { ConfigIO } from '../../../lib';
+import { readEnvFile } from '../../../lib/utils/env';
+import {
+ computeDefaultCredentialEnvVarName,
+ computeManagedOAuthCredentialName,
+} from '../../primitives/credential-utils';
+import { fetchOAuthToken } from './oauth-token';
+import type { OAuthTokenResult } from './oauth-token';
+
+/**
+ * Check whether auto-fetch is possible for a CUSTOM_JWT harness.
+ * Returns true only if the managed OAuth credential exists in the project
+ * spec AND the client secret is available in .env.local.
+ */
+export async function canFetchHarnessToken(
+ harnessName: string,
+ options: { configIO?: ConfigIO } = {}
+): Promise {
+ try {
+ const configIO = options.configIO ?? new ConfigIO();
+ const harnessSpec = await configIO.readHarnessSpec(harnessName);
+
+ if (harnessSpec.authorizerType !== 'CUSTOM_JWT') return false;
+ if (!harnessSpec.authorizerConfiguration?.customJwtAuthorizer) return false;
+
+ const projectSpec = await configIO.readProjectSpec();
+ const credName = computeManagedOAuthCredentialName(harnessName);
+ const hasCredential = projectSpec.credentials.some(
+ c => c.authorizerType === 'OAuthCredentialProvider' && c.name === credName
+ );
+ if (!hasCredential) return false;
+
+ const envVarPrefix = computeDefaultCredentialEnvVarName(credName);
+ const envVars = await readEnvFile();
+ return !!envVars[`${envVarPrefix}_CLIENT_SECRET`];
+ } catch (err) {
+ if (process.env.DEBUG) console.error('[canFetchHarnessToken]', err);
+ return false;
+ }
+}
+
+/**
+ * Fetch an OAuth access token for a CUSTOM_JWT harness.
+ *
+ * Performs OIDC discovery and client_credentials token fetch using the
+ * managed OAuth credential created during harness setup.
+ */
+export async function fetchHarnessToken(
+ harnessName: string,
+ options: { configIO?: ConfigIO; deployTarget?: string } = {}
+): Promise {
+ const configIO = options.configIO ?? new ConfigIO();
+
+ const deployedState = await configIO.readDeployedState();
+ const projectSpec = await configIO.readProjectSpec();
+ const harnessSpec = await configIO.readHarnessSpec(harnessName);
+
+ const targetNames = Object.keys(deployedState.targets);
+ if (targetNames.length === 0) {
+ throw new Error('No deployed targets found. Run `agentcore deploy` first.');
+ }
+
+ const targetName = options.deployTarget ?? targetNames[0]!;
+
+ if (harnessSpec.authorizerType !== 'CUSTOM_JWT') {
+ throw new Error(`Harness '${harnessName}' uses ${harnessSpec.authorizerType ?? 'AWS_IAM'} auth, not CUSTOM_JWT.`);
+ }
+
+ const jwtConfig = harnessSpec.authorizerConfiguration?.customJwtAuthorizer;
+ if (!jwtConfig) {
+ throw new Error(
+ `Harness '${harnessName}' is configured as CUSTOM_JWT but has no customJwtAuthorizer configuration.`
+ );
+ }
+
+ return fetchOAuthToken({
+ resourceName: harnessName,
+ jwtConfig,
+ deployedState,
+ targetName,
+ credentials: projectSpec.credentials,
+ });
+}
diff --git a/src/cli/operations/fetch-access/index.ts b/src/cli/operations/fetch-access/index.ts
index 06b7807a7..cbccf9c45 100644
--- a/src/cli/operations/fetch-access/index.ts
+++ b/src/cli/operations/fetch-access/index.ts
@@ -1,4 +1,5 @@
export { fetchGatewayToken } from './fetch-gateway-token';
+export { canFetchHarnessToken, fetchHarnessToken } from './fetch-harness-token';
export { canFetchRuntimeToken, fetchRuntimeToken } from './fetch-runtime-token';
export { fetchOAuthToken } from './oauth-token';
export type { OAuthTokenResult } from './oauth-token';
diff --git a/src/cli/operations/resolve-agent.ts b/src/cli/operations/resolve-agent.ts
index 8f8ee6ba3..6b865f2c3 100644
--- a/src/cli/operations/resolve-agent.ts
+++ b/src/cli/operations/resolve-agent.ts
@@ -1,5 +1,6 @@
import { ConfigIO } from '../../lib';
import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../schema';
+import { getHarness } from '../aws/agentcore-harness';
export interface DeployedProjectConfig {
project: AgentCoreProjectSpec;
@@ -97,3 +98,126 @@ export function resolveAgent(
},
};
}
+
+/**
+ * Resolves a harness to a ResolvedAgent by looking up deployed state and
+ * fetching the underlying agentRuntimeArn via the GetHarness API.
+ */
+export async function resolveHarness(
+ context: DeployedProjectConfig,
+ harnessName: string
+): Promise<{ success: true; agent: ResolvedAgent } | { success: false; error: string }> {
+ const { project, deployedState, awsTargets } = context;
+
+ const harnesses = project.harnesses ?? [];
+ const harnessSpec = harnesses.find(h => h.name === harnessName);
+ if (!harnessSpec) {
+ const available = harnesses.map(h => h.name);
+ return {
+ success: false,
+ error:
+ available.length > 0
+ ? `Harness '${harnessName}' not found. Available: ${available.join(', ')}`
+ : 'No harnesses defined in agentcore.json',
+ };
+ }
+
+ const targetNames = Object.keys(deployedState.targets);
+ if (targetNames.length === 0) {
+ return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' };
+ }
+ const selectedTargetName = targetNames[0]!;
+
+ const targetState = deployedState.targets[selectedTargetName];
+ const targetConfig = awsTargets.find(t => t.name === selectedTargetName);
+
+ if (!targetConfig) {
+ return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` };
+ }
+
+ const harnessState = targetState?.resources?.harnesses?.[harnessName];
+ if (!harnessState) {
+ return {
+ success: false,
+ error: `Harness '${harnessName}' is not deployed to target '${selectedTargetName}'. Run 'agentcore deploy' first.`,
+ };
+ }
+
+ let runtimeId: string | undefined;
+
+ if (harnessState.agentRuntimeArn) {
+ const arnMatch = /runtime\/([^/]+)/.exec(harnessState.agentRuntimeArn);
+ if (arnMatch) {
+ runtimeId = arnMatch[1];
+ }
+ }
+
+ if (!runtimeId) {
+ try {
+ await getHarness({ region: targetConfig.region, harnessId: harnessState.harnessId });
+ runtimeId = harnessState.harnessId;
+ } catch (err) {
+ return {
+ success: false,
+ error: `Failed to resolve runtime for harness '${harnessName}': ${(err as Error).message}`,
+ };
+ }
+ }
+
+ if (!runtimeId) {
+ return {
+ success: false,
+ error: `Could not resolve runtime ID for harness '${harnessName}'. Re-deploy to populate agentRuntimeArn.`,
+ };
+ }
+
+ return {
+ success: true,
+ agent: {
+ agentName: harnessName,
+ targetName: selectedTargetName,
+ region: targetConfig.region,
+ accountId: targetConfig.account,
+ runtimeId,
+ },
+ };
+}
+
+/**
+ * Resolves either an agent runtime or a harness to a ResolvedAgent.
+ * - If --harness is specified, resolves that harness.
+ * - If --runtime is specified, resolves that runtime.
+ * - If neither is specified, auto-selects: single runtime wins, or if no runtimes
+ * but harnesses exist, auto-selects the single harness.
+ */
+export async function resolveAgentOrHarness(
+ context: DeployedProjectConfig,
+ options: { runtime?: string; harness?: string }
+): Promise<{ success: true; agent: ResolvedAgent } | { success: false; error: string }> {
+ if (options.harness && options.runtime) {
+ return { success: false, error: 'Cannot specify both --harness and --runtime' };
+ }
+
+ if (options.harness) {
+ return resolveHarness(context, options.harness);
+ }
+
+ if (options.runtime || context.project.runtimes.length > 0) {
+ return resolveAgent(context, options);
+ }
+
+ const harnesses = context.project.harnesses ?? [];
+ if (harnesses.length === 0) {
+ return { success: false, error: 'No runtimes or harnesses defined in agentcore.json' };
+ }
+
+ if (harnesses.length > 1) {
+ const names = harnesses.map(h => h.name);
+ return {
+ success: false,
+ error: `Multiple harnesses found. Use --harness to specify one: ${names.join(', ')}`,
+ };
+ }
+
+ return resolveHarness(context, harnesses[0]!.name);
+}
diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts
new file mode 100644
index 000000000..0c78a0612
--- /dev/null
+++ b/src/cli/primitives/HarnessPrimitive.ts
@@ -0,0 +1,582 @@
+import { APP_DIR, ConfigIO, type Result, findConfigRoot } from '../../lib';
+import type {
+ HarnessGatewayOutboundAuth,
+ HarnessModelProvider,
+ HarnessSpec,
+ MemoryStrategy,
+ MemoryStrategyType,
+ NetworkMode,
+ RuntimeAuthorizerType,
+} from '../../schema';
+import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES, HarnessSpecSchema } from '../../schema';
+import { deleteHarness } from '../aws/agentcore-harness';
+import { getErrorMessage } from '../errors';
+import type { RemovalPreview, SchemaChange } from '../operations/remove/types';
+import { getTemplatePath } from '../templates/templateRoot';
+import { DEFAULT_MEMORY_EXPIRY_DAYS } from '../tui/screens/generate/defaults';
+import { BasePrimitive } from './BasePrimitive';
+import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from './auth-utils';
+import type { JwtConfigOptions } from './auth-utils';
+import type { AddScreenComponent, RemovableResource } from './types';
+import { ResourceNotFoundError, toError } from '@/lib/errors/types';
+import type { Command } from '@commander-js/extra-typings';
+import { access, copyFile, mkdir, readFile, rm, writeFile } from 'fs/promises';
+import { basename, dirname, isAbsolute, join, resolve } from 'path';
+
+export interface AddHarnessOptions {
+ name: string;
+ modelProvider: HarnessModelProvider;
+ modelId: string;
+ apiKeyArn?: string;
+ systemPrompt?: string;
+ skipMemory?: boolean;
+ containerUri?: string;
+ dockerfilePath?: string;
+ maxIterations?: number;
+ maxTokens?: number;
+ timeoutSeconds?: number;
+ truncationStrategy?: 'sliding_window' | 'summarization';
+ networkMode?: NetworkMode;
+ subnets?: string[];
+ securityGroups?: string[];
+ idleTimeout?: number;
+ maxLifetime?: number;
+ sessionStoragePath?: string;
+ withInvokeScript?: boolean;
+ selectedTools?: string[];
+ mcpName?: string;
+ mcpUrl?: string;
+ gatewayArn?: string;
+ gatewayOutboundAuth?: 'awsIam' | 'none' | 'oauth';
+ gatewayProviderArn?: string;
+ gatewayScopes?: string[];
+ authorizerType?: RuntimeAuthorizerType;
+ jwtConfig?: JwtConfigOptions;
+ configBaseDir?: string;
+}
+
+export type RemovableHarness = RemovableResource;
+
+export class HarnessPrimitive extends BasePrimitive {
+ readonly kind = 'harness' as const;
+ readonly label = 'Harness';
+ readonly primitiveSchema = HarnessSpecSchema;
+
+ async add(options: AddHarnessOptions): Promise> {
+ try {
+ const configBaseDir = options.configBaseDir ?? findConfigRoot();
+ if (!configBaseDir) {
+ return {
+ success: false,
+ error: new ResourceNotFoundError('No agentcore project found. Run `agentcore create` first.'),
+ };
+ }
+
+ const configIO = new ConfigIO({ baseDir: configBaseDir });
+ const project = await this.readProjectSpec(configIO);
+
+ const harnesses = project.harnesses ?? [];
+ this.checkDuplicate(harnesses, options.name);
+
+ const memoryName = options.skipMemory ? undefined : `${options.name}Memory`;
+
+ let dockerfile: string | undefined;
+ if (options.dockerfilePath) {
+ const projectRoot = dirname(configBaseDir);
+ const srcPath = isAbsolute(options.dockerfilePath)
+ ? options.dockerfilePath
+ : resolve(projectRoot, options.dockerfilePath);
+ try {
+ await access(srcPath);
+ } catch {
+ return { success: false, error: new ResourceNotFoundError(`Dockerfile not found at: ${srcPath}`) };
+ }
+ const appDir = join(projectRoot, APP_DIR, options.name);
+ await mkdir(appDir, { recursive: true });
+ const destFilename = basename(srcPath);
+ await copyFile(srcPath, join(appDir, destFilename));
+ dockerfile = destFilename;
+ }
+
+ const tools: HarnessSpec['tools'] = [];
+ if (options.selectedTools) {
+ for (const toolType of options.selectedTools) {
+ if (toolType === 'agentcore_browser') {
+ tools.push({ type: 'agentcore_browser', name: 'browser' });
+ } else if (toolType === 'agentcore_code_interpreter') {
+ tools.push({ type: 'agentcore_code_interpreter', name: 'code-interpreter' });
+ } else if (toolType === 'remote_mcp' && options.mcpName && options.mcpUrl) {
+ tools.push({
+ type: 'remote_mcp',
+ name: options.mcpName,
+ config: { remoteMcp: { url: options.mcpUrl } },
+ });
+ } else if (toolType === 'agentcore_gateway' && options.gatewayArn) {
+ let outboundAuth: HarnessGatewayOutboundAuth | undefined;
+ if (options.gatewayOutboundAuth === 'awsIam') {
+ outboundAuth = { awsIam: {} };
+ } else if (options.gatewayOutboundAuth === 'none') {
+ outboundAuth = { none: {} };
+ } else if (
+ options.gatewayOutboundAuth === 'oauth' &&
+ options.gatewayProviderArn &&
+ options.gatewayScopes &&
+ options.gatewayScopes.length > 0
+ ) {
+ outboundAuth = {
+ oauth: {
+ providerArn: options.gatewayProviderArn,
+ scopes: options.gatewayScopes,
+ },
+ };
+ }
+ tools.push({
+ type: 'agentcore_gateway',
+ name: 'gateway',
+ config: {
+ agentCoreGateway: {
+ gatewayArn: options.gatewayArn,
+ ...(outboundAuth && { outboundAuth }),
+ },
+ },
+ });
+ }
+ }
+ }
+
+ const harnessSpec: HarnessSpec = {
+ name: options.name,
+ model: {
+ provider: options.modelProvider,
+ modelId: options.modelId,
+ ...(options.apiKeyArn && { apiKeyArn: options.apiKeyArn }),
+ },
+ tools,
+ skills: [],
+ ...(options.systemPrompt && { systemPrompt: options.systemPrompt }),
+ ...(memoryName && { memory: { name: memoryName } }),
+ ...(options.containerUri && { containerUri: options.containerUri }),
+ ...(dockerfile && { dockerfile }),
+ ...(options.maxIterations !== undefined && { maxIterations: options.maxIterations }),
+ ...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }),
+ ...(options.timeoutSeconds !== undefined && { timeoutSeconds: options.timeoutSeconds }),
+ ...(options.truncationStrategy && { truncation: { strategy: options.truncationStrategy } }),
+ ...(options.networkMode && { networkMode: options.networkMode }),
+ ...(options.networkMode === 'VPC' &&
+ options.subnets &&
+ options.securityGroups && {
+ networkConfig: {
+ subnets: options.subnets,
+ securityGroups: options.securityGroups,
+ },
+ }),
+ ...(this.buildLifecycleConfig(options) && { lifecycleConfig: this.buildLifecycleConfig(options) }),
+ ...(options.sessionStoragePath && { sessionStoragePath: options.sessionStoragePath }),
+ ...(options.authorizerType && { authorizerType: options.authorizerType }),
+ ...(options.authorizerType === 'CUSTOM_JWT' && options.jwtConfig
+ ? { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(options.jwtConfig) }
+ : {}),
+ };
+
+ await configIO.writeHarnessSpec(options.name, harnessSpec);
+
+ const pathResolver = configIO.getPathResolver();
+ const harnessDir = pathResolver.getHarnessDir(options.name);
+ const systemPromptPath = join(harnessDir, 'system-prompt.md');
+ const systemPromptContent = options.systemPrompt ?? 'You are a helpful assistant';
+ await writeFile(systemPromptPath, systemPromptContent, 'utf-8');
+
+ if (options.withInvokeScript) {
+ const templatePath = getTemplatePath('harness', 'invoke.py.template');
+ const invokeScriptPath = join(harnessDir, 'invoke.py');
+ let template = await readFile(templatePath, 'utf-8');
+ template = template.replace('{{HARNESS_ARN}}', '');
+ template = template.replace('{{REGION}}', '');
+ await writeFile(invokeScriptPath, template, 'utf-8');
+ }
+
+ if (memoryName) {
+ const strategyTypes: MemoryStrategyType[] = ['SEMANTIC', 'USER_PREFERENCE', 'SUMMARIZATION', 'EPISODIC'];
+ const strategies: MemoryStrategy[] = strategyTypes.map(type => ({
+ type,
+ ...(DEFAULT_STRATEGY_NAMESPACES[type] && { namespaces: DEFAULT_STRATEGY_NAMESPACES[type] }),
+ ...(type === 'EPISODIC' && { reflectionNamespaces: DEFAULT_EPISODIC_REFLECTION_NAMESPACES }),
+ }));
+
+ project.memories.push({
+ name: memoryName,
+ eventExpiryDuration: DEFAULT_MEMORY_EXPIRY_DAYS,
+ strategies,
+ });
+ }
+
+ project.harnesses = [
+ ...harnesses,
+ {
+ name: options.name,
+ path: `app/${options.name}`,
+ },
+ ];
+
+ await this.writeProjectSpec(project, configIO);
+
+ if (options.jwtConfig?.clientId && options.jwtConfig?.clientSecret) {
+ await createManagedOAuthCredential(
+ options.name,
+ options.jwtConfig,
+ spec => this.writeProjectSpec(spec, configIO),
+ () => this.readProjectSpec(configIO)
+ );
+ }
+
+ return { success: true, harnessName: options.name };
+ } catch (err) {
+ return { success: false, error: toError(err) };
+ }
+ }
+
+ async remove(harnessName: string): Promise {
+ try {
+ const configRoot = findConfigRoot();
+ if (!configRoot) {
+ return { success: false, error: new ResourceNotFoundError('No agentcore project found.') };
+ }
+
+ const configIO = new ConfigIO({ baseDir: configRoot });
+ const project = await this.readProjectSpec(configIO);
+
+ const harnesses = project.harnesses ?? [];
+ const harnessIndex = harnesses.findIndex(h => h.name === harnessName);
+
+ if (harnessIndex === -1) {
+ return { success: false, error: new ResourceNotFoundError(`Harness "${harnessName}" not found.`) };
+ }
+
+ // Delete harness from AWS if it's deployed
+ try {
+ const deployedState = await configIO.readDeployedState();
+ for (const target of Object.values(deployedState.targets)) {
+ const deployedHarness = target.resources?.harnesses?.[harnessName];
+ if (deployedHarness) {
+ const targets = await configIO.resolveAWSDeploymentTargets();
+ const region = targets[0]?.region;
+ if (region) {
+ await deleteHarness({ region, harnessId: deployedHarness.harnessId });
+ }
+ delete target.resources!.harnesses![harnessName];
+ await configIO.writeDeployedState(deployedState);
+ break;
+ }
+ }
+ } catch {
+ // AWS deletion is best-effort; next deploy will clean up
+ }
+
+ harnesses.splice(harnessIndex, 1);
+ project.harnesses = harnesses;
+
+ // Remove the associated memory (convention: Memory)
+ const associatedMemoryName = `${harnessName}Memory`;
+ if (project.memories) {
+ project.memories = project.memories.filter(m => m.name !== associatedMemoryName);
+ }
+
+ await this.writeProjectSpec(project, configIO);
+
+ const pathResolver = configIO.getPathResolver();
+ const harnessDir = pathResolver.getHarnessDir(harnessName);
+ await rm(harnessDir, { recursive: true, force: true });
+
+ return { success: true };
+ } catch (err) {
+ return { success: false, error: toError(err) };
+ }
+ }
+
+ async previewRemove(harnessName: string): Promise {
+ const project = await this.readProjectSpec();
+
+ const harnesses = project.harnesses ?? [];
+ const harness = harnesses.find(h => h.name === harnessName);
+
+ if (!harness) {
+ throw new Error(`Harness "${harnessName}" not found.`);
+ }
+
+ const associatedMemoryName = `${harnessName}Memory`;
+ const hasAssociatedMemory = (project.memories ?? []).some(m => m.name === associatedMemoryName);
+
+ const summary: string[] = [`Removing harness: ${harnessName}`];
+ if (hasAssociatedMemory) {
+ summary.push(`Removing associated memory: ${associatedMemoryName}`);
+ }
+ const directoriesToDelete: string[] = [`app/${harnessName}`];
+ const schemaChanges: SchemaChange[] = [];
+
+ const afterSpec = {
+ ...project,
+ harnesses: harnesses.filter(h => h.name !== harnessName),
+ ...(hasAssociatedMemory && { memories: (project.memories ?? []).filter(m => m.name !== associatedMemoryName) }),
+ };
+
+ schemaChanges.push({
+ file: 'agentcore/agentcore.json',
+ before: project,
+ after: afterSpec,
+ });
+
+ return { summary, directoriesToDelete, schemaChanges };
+ }
+
+ async getRemovable(): Promise {
+ try {
+ const project = await this.readProjectSpec();
+ const harnesses = project.harnesses ?? [];
+ return harnesses.map(h => ({ name: h.name }));
+ } catch {
+ return [];
+ }
+ }
+
+ registerCommands(addCmd: Command, removeCmd: Command): void {
+ addCmd
+ .command('harness')
+ .description('Add a harness to the project')
+ .option('--name ', 'Harness name (start with letter, alphanumeric + underscores, max 48 chars)')
+ .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini')
+ .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)')
+ .option('--api-key-arn ', 'API key ARN for non-Bedrock providers')
+ .option('--container ', 'Container image URI or path to a Dockerfile')
+ .option('--no-memory', 'Skip auto-creating memory')
+ .option('--max-iterations ', 'Max iterations', parseInt)
+ .option('--max-tokens ', 'Max tokens', parseInt)
+ .option('--timeout ', 'Timeout in seconds', parseInt)
+ .option('--truncation-strategy ', 'Truncation strategy: sliding_window or summarization')
+ .option('--network-mode ', 'Network mode: PUBLIC or VPC')
+ .option('--subnets ', 'Comma-separated subnet IDs (for VPC mode)')
+ .option('--security-groups ', 'Comma-separated security group IDs (for VPC mode)')
+ .option('--idle-timeout ', 'Idle timeout in seconds', parseInt)
+ .option('--max-lifetime ', 'Max lifetime in seconds', parseInt)
+ .option('--session-storage ', 'Mount path for persistent session storage (e.g., /mnt/data/)')
+ .option('--with-invoke-script', 'Generate a standalone Python invoke script')
+ .option(
+ '--system-prompt ',
+ 'System prompt text (written to system-prompt.md; defaults to "You are a helpful assistant")'
+ )
+ .option(
+ '--tools ',
+ 'Comma-separated tools: agentcore_browser, agentcore_code_interpreter, remote_mcp, agentcore_gateway'
+ )
+ .option('--mcp-name ', 'Remote MCP tool name (required when --tools includes remote_mcp)')
+ .option('--mcp-url ', 'Remote MCP endpoint URL (required when --tools includes remote_mcp)')
+ .option('--gateway-arn ', 'Gateway ARN (required when --tools includes agentcore_gateway)')
+ .option(
+ '--gateway-outbound-auth ',
+ 'Gateway outbound auth: awsIam, none, oauth (requires --gateway-provider-arn and --gateway-scopes)'
+ )
+ .option('--gateway-provider-arn ', 'OAuth provider ARN for gateway outbound auth')
+ .option('--gateway-scopes ', 'Comma-separated OAuth scopes for gateway outbound auth')
+ .option('--authorizer-type ', 'Authorizer type: AWS_IAM or CUSTOM_JWT')
+ .option('--discovery-url ', 'OIDC discovery URL (for CUSTOM_JWT)')
+ .option('--allowed-audience ', 'Comma-separated allowed audiences (for CUSTOM_JWT)')
+ .option('--allowed-clients ', 'Comma-separated allowed client IDs (for CUSTOM_JWT)')
+ .option('--allowed-scopes ', 'Comma-separated allowed scopes (for CUSTOM_JWT)')
+ .option('--custom-claims ', 'Custom claims JSON array (for CUSTOM_JWT)')
+ .option('--client-id