fix(cli): depend on the published harness in exported projects (#4478)

Co-authored-by: Mackenzie Zastrow <zastrowm@users.noreply.github.com>
This commit is contained in:
Mackenzie Zastrow
2026-09-21 19:10:29 -04:00
committed by GitHub
co-authored by Mackenzie Zastrow
parent 4d376d8e8f
commit 8ecf1ebc18
7 changed files with 38 additions and 85 deletions
@@ -3,9 +3,9 @@ name: "Harness CLI: Test Package Pack"
# End-to-end package install smoke test for the Strands CLI (`@strands-agents/cli`).
# Reusable; called by release-harness-cli.yml. Reproduces a real user's install:
# `npm pack` the CLI, install the tarball in a tempdir OUTSIDE the repo tree (so
# nothing hoists), run the `strands` bin, and confirm the bundled harness-py is
# present. The CLI's @strands-agents/harness / @strands-agents/sdk deps resolve
# from the registry (ranged, not pinned), so only the CLI tarball is packed.
# nothing hoists), and run the `strands` bin. The CLI's @strands-agents/harness /
# @strands-agents/sdk deps resolve from the registry (ranged, not pinned), so only
# the CLI tarball is packed.
on:
workflow_call:
@@ -57,9 +57,7 @@ jobs:
fi
- name: Pack, install in a tmpdir, run the bin
# npm pack runs prepack (clean + build = tsc + bundle-python.js), so the
# tarball carries the bundled harness-py under dist/python. The tmpdir
# MUST live outside the monorepo so nothing hoists into resolution.
# The tmpdir MUST live outside the monorepo so nothing hoists into resolution.
run: |
set -euo pipefail
TARBALL=$(npm pack --silent)
@@ -73,17 +71,6 @@ jobs:
npm install --ignore-scripts --no-audit --no-fund "$TARBALL_PATH"
./node_modules/.bin/strands --help
- name: Assert the tarball bundles harness-py
# `strands --help` never touches the Python sidecar, so a silent
# bundle-python.js failure would pass the smoke test above.
run: |
set -euo pipefail
shopt -s nullglob
tgzs=(./*.tgz)
tar tzf "${tgzs[0]}" > contents.txt
grep -q '^package/dist/python/pyproject.toml$' contents.txt \
|| { echo "::error::CLI tarball has no bundled Python (dist/python missing)."; exit 1; }
- name: Upload tarball for downstream inspect/publish
uses: actions/upload-artifact@v7.0.1
with:
+3 -5
View File
@@ -9,8 +9,8 @@ name: "Harness CLI: Release"
#
# The CLI depends on @strands-agents/harness and @strands-agents/sdk as version
# RANGES (< 2.0.0 / < 1.18.0), resolved from the registry at install time -- so
# there is no exact-pin or check-library-published gate. The tarball bundles the
# harness-py source (scripts/bundle-python.js), so no PyPI dependency either.
# there is no exact-pin or check-library-published gate. Exported projects pull
# the harness from npm / PyPI too, so the tarball carries no vendored library.
#
# One-time setup before the first non-dry run:
# - GitHub environments `release-gate` and `npm` (already used by other releases).
@@ -243,9 +243,7 @@ jobs:
echo "::error::${tgzs[0]}: is $name@$version, expected $EXPECTED_NAME@$EXPECTED_VERSION."
exit 1
fi
tar tzf "${tgzs[0]}" | grep -q '^package/dist/python/pyproject.toml$' \
|| { echo "::error::${tgzs[0]}: missing bundled harness-py (dist/python)."; exit 1; }
echo "${tgzs[0]} is $name@$version and bundles harness-py"
echo "${tgzs[0]} is $name@$version"
- name: Upload verified tarball for publish
uses: actions/upload-artifact@v7.0.1
+1 -1
View File
@@ -19,7 +19,7 @@
"NOTICE"
],
"scripts": {
"build": "tsc --project src/tsconfig.json && node scripts/bundle-python.js",
"build": "tsc --project src/tsconfig.json && node scripts/copy-python-runtime.js",
"clean:dist": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"prepack": "npm run clean:dist && npm run build",
"clean": "rm -rf dist node_modules",
-19
View File
@@ -1,19 +0,0 @@
import { cpSync, mkdirSync, rmSync } from 'node:fs'
import { basename, join } from 'node:path'
import { URL, fileURLToPath } from 'node:url'
const source = fileURLToPath(new URL('../../harness-py', import.meta.url))
const destination = fileURLToPath(new URL('../dist/python', import.meta.url))
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
for (const name of ['pyproject.toml', 'README.md', 'LICENSE', 'NOTICE']) {
cpSync(join(source, name), join(destination, name))
}
cpSync(join(source, 'src'), join(destination, 'src'), {
recursive: true,
filter: (path) => basename(path) !== '__pycache__',
})
const worker = fileURLToPath(new URL('../src/tui/project/worker.py', import.meta.url))
const workerDestination = fileURLToPath(new URL('../dist/src/tui/project/worker.py', import.meta.url))
cpSync(worker, workerDestination)
@@ -0,0 +1,9 @@
import { cpSync } from 'node:fs'
import { fileURLToPath, URL } from 'node:url'
// tsc emits only the .ts modules, so the CLI's own Python runtime file has to be copied next to its
// compiled sibling by hand. python.ts loads it as `new URL('./worker.py', import.meta.url)`, i.e.
// dist/src/tui/project/worker.py. (sidecar.py ships via package.json `files` with a src fallback.)
const worker = fileURLToPath(new URL('../src/tui/project/worker.py', import.meta.url))
const destination = fileURLToPath(new URL('../dist/src/tui/project/worker.py', import.meta.url))
cpSync(worker, destination)
+15 -39
View File
@@ -19,7 +19,6 @@ import { portableConfig, validateNoConfigSecrets } from './configuration.js'
import { containsPath, type PackagedSource } from './packaging.js'
const MAX_PROJECT_BYTES = 50 * 1024 * 1024
const VENDOR_PATH = 'vendor/strands-harness'
interface SkillPackage {
id: string
@@ -73,11 +72,6 @@ export async function writeAgentProject(
addBytes(buffer.length)
zip.addBuffer(buffer, path, { compress: false, mode: 0o100644 })
}
if (language === 'typescript') {
addTypeScriptPackage(zip, addBytes)
} else {
addDirectory(zip, pythonPackageRoot(), VENDOR_PATH, addBytes)
}
for (const skill of skills) {
addPath(zip, skill.path, `agent/skills/${skill.id}`, addBytes, privatePaths)
}
@@ -259,7 +253,7 @@ function typescriptProject(config: HarnessAgentConfig): Record<string, string> {
check: 'tsc --noEmit',
},
dependencies: {
'@strands-agents/harness': `file:${VENDOR_PATH}`,
'@strands-agents/harness': `^${harnessVersion()}`,
...typescriptProviderDependencies(config),
...config.dependencies.typescript,
},
@@ -315,7 +309,7 @@ function pythonProject(config: HarnessAgentConfig): Record<string, string> {
}
: {}),
'requirements.txt': [
`./${VENDOR_PATH}${pythonProviderExtras(config)}`,
`strands-harness${pythonProviderExtras(config)}${harnessPythonSpecifier()}`,
...(hasCedarPolicy(config) ? ['strands-agents[cedar]'] : []),
...config.dependencies.python,
'',
@@ -416,7 +410,7 @@ function projectReadme(config: HarnessAgentConfig, language: AgentProjectLanguag
`Edit \`agent/${python ? 'agent.py' : 'agent.ts'}\` to change the agent. The CLI imports the agent defined there.`,
'Each new launch imports the code again; there is no separate configuration snapshot.',
'Packaged tools, skills, plugins, subagents, MCP servers, and policies live under `agent/`.',
'`vendor/` contains the matching harness library; `.agent/` holds generated sessions and memory.',
'The harness library is a normal dependency installed from the registry; `.agent/` holds generated sessions and memory.',
'',
'For a local MCP server, list its helper files and data in `files` so export can include them.',
'`files` paths are relative to the saved configuration directory and are removed from exported SDK options.',
@@ -543,21 +537,6 @@ function addDirectory(
visit(root)
}
function addTypeScriptPackage(zip: ZipFile, addBytes: (size: number) => void): void {
const root = typescriptPackageRoot()
for (const name of ['package.json', 'README.md', 'LICENSE', 'NOTICE']) {
const path = join(root, name)
if (regularFile(path)) {
addFile(zip, path, `${VENDOR_PATH}/${name}`, addBytes)
}
}
const dist = join(root, 'dist')
if (!regularDirectory(dist)) {
throw new Error('The installed TypeScript harness package is missing its dist directory.')
}
addDirectory(zip, dist, `${VENDOR_PATH}/dist`, addBytes)
}
function addFile(zip: ZipFile, source: string, destination: string, addBytes: (size: number) => void): void {
const details = lstatSync(source)
if (!details.isFile()) {
@@ -630,14 +609,19 @@ function packageRoot(entrypoint: string): string {
}
}
function pythonPackageRoot(): string {
const packaged = join(packageRoot(fileURLToPath(import.meta.url)), 'dist', 'python')
if (regularFile(join(packaged, 'pyproject.toml'))) {
return packaged
// The exported project depends on the harness the CLI itself resolved: `@strands-agents/harness` on
// npm, `strands-harness` on PyPI. They release in parity from this repo, so the installed npm version
// drives both specifiers.
function harnessVersion(): string {
const manifest = JSON.parse(readFileSync(join(typescriptPackageRoot(), 'package.json'), 'utf8')) as {
version: string
}
throw new Error(
'The Strands CLI is missing its bundled Python package. Rebuild or reinstall Strands before exporting.'
)
return manifest.version
}
function harnessPythonSpecifier(): string {
const [major, minor] = harnessVersion().split('.')
return `~=${major}.${minor}.0`
}
function writeZip(zip: ZipFile, destination: string, overwrite: boolean): Promise<void> {
@@ -671,14 +655,6 @@ function writeZip(zip: ZipFile, destination: string, overwrite: boolean): Promis
})
}
function regularDirectory(path: string): boolean {
try {
return lstatSync(path).isDirectory()
} catch {
return false
}
}
function safeComponent(value: string): string {
if (!value || value.includes('/') || value.includes('\\') || value === '.' || value === '..') {
throw new Error('Exported projects contain an unsafe path component.')
+6 -4
View File
@@ -334,11 +334,13 @@ export const agent = await createHarness({
const imported = importAgentProject(extracted)
expect(imported.language).toBe(language)
if (language === 'python') {
expect(entries.has('vendor/strands-harness/src/strands_harness/config.py')).toBe(true)
expect(entries.get('requirements.txt')?.toString()).toContain('./vendor/strands-harness')
expect([...entries.keys()].some((path) => path.startsWith('vendor/'))).toBe(false)
expect(entries.get('requirements.txt')?.toString()).toMatch(/^strands-harness~=/m)
expect(entries.get('requirements.txt')?.toString()).toContain('strands-agents[cedar]')
} else {
expect(JSON.parse(entries.get('package.json')!.toString()).dependencies['@cedar-policy/cedar-wasm']).toBeTruthy()
const dependencies = JSON.parse(entries.get('package.json')!.toString()).dependencies
expect(dependencies['@cedar-policy/cedar-wasm']).toBeTruthy()
expect(dependencies['@strands-agents/harness']).toMatch(/^\^\d/)
}
})
@@ -360,7 +362,7 @@ export const agent = await createHarness({
const tsEntries = await readZipEntries(typescript)
expect(JSON.parse(tsEntries.get('package.json')!.toString()).dependencies[dependency!]).toBeTruthy()
const pyEntries = await readZipEntries(python)
expect(pyEntries.get('requirements.txt')?.toString()).toContain(`./vendor/strands-harness[${extra}]`)
expect(pyEntries.get('requirements.txt')?.toString()).toContain(`strands-harness[${extra}]~=`)
})
it('includes a production build and points chat users to the Strands CLI', async () => {