From 7fa906b1715585b76f2b4ab6844dd550b7c8f1a0 Mon Sep 17 00:00:00 2001 From: Lia Date: Thu, 5 Nov 2026 14:42:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=A6=20feat:=20Add=20Verified=20npm?= =?UTF-8?q?=20Worker=20Releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/code-package.yml | 160 ++++++++ .gitignore | 4 + packages/code/package-lock.json | 4 +- packages/code/package.json | 19 +- packages/code/scripts/prepare-package.mjs | 26 ++ scripts/code-package-release.mjs | 465 ++++++++++++++++++++++ scripts/code-package-release.test.mjs | 438 ++++++++++++++++++++ scripts/smoke-code-package.mjs | 272 +++++++++++++ 8 files changed, 1384 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/code-package.yml create mode 100644 packages/code/scripts/prepare-package.mjs create mode 100644 scripts/code-package-release.mjs create mode 100644 scripts/code-package-release.test.mjs create mode 100644 scripts/smoke-code-package.mjs diff --git a/.github/workflows/code-package.yml b/.github/workflows/code-package.yml new file mode 100644 index 00000000..f4add5dd --- /dev/null +++ b/.github/workflows/code-package.yml @@ -0,0 +1,160 @@ +name: Code Package + +on: + pull_request: + paths: + - '.github/workflows/code-package.yml' + - 'packages/code/**' + - 'scripts/code-package-release*' + - 'scripts/smoke-code-package.mjs' + - 'LICENSE' + - 'NOTICE' + push: + tags: ['code-v*'] + workflow_dispatch: + inputs: + version: + description: 'Committed worker version, e.g. 0.1.0-rc.1. Dispatch from main.' + required: true + type: string + publish: + description: 'Publish the verified artifact to npm (requires the npm environment).' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: code-package-${{ github.event_name == 'pull_request' && github.ref || 'release' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + pack: + name: Pack Worker + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + outputs: + sha: ${{ steps.plan.outputs.sha }} + filename: ${{ steps.pack.outputs.filename }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.22.1 + cache: npm + cache-dependency-path: packages/code/package-lock.json + - name: Validate release source and channel + id: plan + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: node scripts/code-package-release.mjs plan + - name: Require successful main CI for releases + if: github.event_name != 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ steps.plan.outputs.sha }} + run: | + set -euo pipefail + count="$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$SOURCE_SHA&event=push&status=success" \ + --jq '[.workflow_runs[] | select(.head_branch == "main")] | length')" + test "$count" -gt 0 || { echo 'Successful main CI is required for this exact commit'; exit 1; } + - name: Install worker build dependencies + working-directory: packages/code + run: npm ci + - name: Test release guards and typecheck + run: | + node --test scripts/code-package-release.test.mjs + npm exec --prefix packages/code -- tsc --noEmit -p packages/code/tsconfig.json + - name: Build the candidate once + id: pack + run: node scripts/code-package-release.mjs pack "$RUNNER_TEMP/code-package" + - name: Focused protocol and enrollment regressions + working-directory: packages/code + run: >- + node --test dist/identity.test.js dist/pairing.test.js + dist/protocol.test.js dist/credential-recovery.test.js + dist/browser-setup.test.js dist/browser-protection.test.js + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: code-package + path: ${{ runner.temp }}/code-package/ + if-no-files-found: error + retention-days: 7 + + verify: + name: Packed Worker (${{ matrix.runner }}, Node ${{ matrix.node }}) + needs: pack + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + node: 22.23.4 + - runner: ubuntu-latest + node: 24.22.1 + - runner: ubuntu-24.04-arm + node: 24.22.1 + - runner: macos-14 + node: 24.22.1 + - runner: macos-15-intel + node: 24.22.1 + runs-on: ${{ matrix.runner }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: ${{ matrix.node }} + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: code-package + path: ${{ runner.temp }}/code-package + - name: Verify the exact candidate bytes + run: node scripts/code-package-release.mjs verify "$RUNNER_TEMP/code-package" + - name: Install and smoke-test outside the source checkout + env: + PACKAGE_FILENAME: ${{ needs.pack.outputs.filename }} + run: node scripts/smoke-code-package.mjs "$RUNNER_TEMP/code-package/$PACKAGE_FILENAME" + + publish: + name: Publish Verified Worker + needs: [pack, verify] + if: >- + github.repository == 'LibreChat-AI/code-interpreter' && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && inputs.publish == true)) + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.pack.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.22.1 + - name: Install the trusted-publishing npm CLI + run: npm install --global npm@11.13.0 --ignore-scripts + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: code-package + path: ${{ runner.temp }}/code-package + - name: Publish without rebuilding, or verify an identical prior publication + env: + EXPECTED_VERSION: ${{ inputs.version }} + CODE_PACKAGE_PUBLISH: 'true' + run: node scripts/code-package-release.mjs publish "$RUNNER_TEMP/code-package" diff --git a/.gitignore b/.gitignore index 958b3332..87e99ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ data/ # Editor config .vscode/ + +# Staged by the worker package prepack hook from the repository legal files. +/packages/code/LICENSE +/packages/code/NOTICE diff --git a/packages/code/package-lock.json b/packages/code/package-lock.json index a27188a6..6d78d9bf 100644 --- a/packages/code/package-lock.json +++ b/packages/code/package-lock.json @@ -1,12 +1,12 @@ { "name": "@librechat/code", - "version": "0.1.0", + "version": "0.1.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@librechat/code", - "version": "0.1.0", + "version": "0.1.0-rc.1", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.75", diff --git a/packages/code/package.json b/packages/code/package.json index d3e6395b..5c4aebf8 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/code", - "version": "0.1.0", + "version": "0.1.0-rc.1", "description": "LibreChat stateful code environment protocol and worker CLI", "license": "Apache-2.0", "type": "module", @@ -57,12 +57,14 @@ }, "files": [ "dist", + "LICENSE", + "NOTICE", "!dist/*.test.*" ], "scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && node --test dist/*.test.js", - "prepack": "npm run build" + "prepack": "node scripts/prepare-package.mjs" }, "devDependencies": { "@types/node": "^22.5.5", @@ -75,5 +77,18 @@ "@anthropic-ai/sandbox-runtime": "0.0.75", "koffi": "3.2.1", "yaml": "2.9.1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/LibreChat-AI/code-interpreter.git", + "directory": "packages/code" + }, + "homepage": "https://github.com/LibreChat-AI/code-interpreter/tree/main/packages/code", + "bugs": { + "url": "https://github.com/LibreChat-AI/code-interpreter/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" } } diff --git a/packages/code/scripts/prepare-package.mjs b/packages/code/scripts/prepare-package.mjs new file mode 100644 index 00000000..0bca743f --- /dev/null +++ b/packages/code/scripts/prepare-package.mjs @@ -0,0 +1,26 @@ +import { execFileSync } from 'node:child_process'; +import { copyFile, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(import.meta.url); +// Never carry stale compiler output or scratch files into the published artifact. +await rm(new URL('../dist/', import.meta.url), { + recursive: true, + force: true, +}); +execFileSync( + process.execPath, + [require.resolve('typescript/bin/tsc'), '-p', 'tsconfig.json'], + { + cwd: packageRoot, + stdio: 'inherit', + } +); +for (const name of ['LICENSE', 'NOTICE']) { + await copyFile( + new URL(`../../../${name}`, import.meta.url), + new URL(`../${name}`, import.meta.url) + ); +} diff --git a/scripts/code-package-release.mjs b/scripts/code-package-release.mjs new file mode 100644 index 00000000..4624c4c6 --- /dev/null +++ b/scripts/code-package-release.mjs @@ -0,0 +1,465 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + appendFile, + lstat, + mkdir, + readFile, + writeFile, +} from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const PACKAGE_NAME = '@librechat/code'; +export const REGISTRY = 'https://registry.npmjs.org/'; +const ROOT = fileURLToPath(new URL('../', import.meta.url)); +const PACKAGE_ROOT = join(ROOT, 'packages/code'); +const MAX_PACKED_BYTES = 1024 * 1024; +const MAX_UNPACKED_BYTES = 3 * 1024 * 1024; +const MAX_FILES = 300; + +export function versionParts(version) { + assert.ok( + typeof version === 'string' && version.length <= 128, + 'Package version is required and must be at most 128 characters' + ); + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-rc\.(0|[1-9]\d*))?$/.exec( + version + ); + assert.ok(match, 'Use MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-rc.N'); + return match + .slice(1) + .map(part => (part === undefined ? undefined : BigInt(part))); +} + +export function releasePlan(version, event, ref, expectedVersion = '') { + const parts = versionParts(version); + assert.ok( + ['pull_request', 'push', 'workflow_dispatch'].includes(event), + 'Unsupported release event' + ); + if (event === 'push') + assert.equal( + ref, + `refs/tags/code-v${version}`, + 'Tag must match the package version' + ); + if (event === 'workflow_dispatch') { + assert.equal( + ref, + 'refs/heads/main', + 'Dispatch package releases from main' + ); + assert.equal( + expectedVersion, + version, + 'Requested version must match the committed package version' + ); + } + return { version, tag: parts[3] === undefined ? 'latest' : 'next' }; +} + +function compareVersions(left, right) { + const a = versionParts(left); + const b = versionParts(right); + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1; + } + if (a[3] === b[3]) return 0; + if (a[3] === undefined) return 1; + if (b[3] === undefined) return -1; + return a[3] > b[3] ? 1 : -1; +} + +export function publicationDecision(candidate, existingIntegrity, distTags) { + assert.ok( + distTags && typeof distTags === 'object' && !Array.isArray(distTags), + 'Invalid registry dist-tags' + ); + if (existingIntegrity !== undefined) { + assert.equal( + existingIntegrity, + candidate.integrity, + 'Published version has different bytes; use a new version' + ); + return 'skip'; + } + const current = distTags[candidate.tag]; + if (current !== undefined) { + assert.ok( + compareVersions(candidate.version, current) >= 0, + `Refusing to move ${candidate.tag} backwards` + ); + } + return 'publish'; +} + +export function validateManifest(manifest) { + assert.equal(manifest.name, PACKAGE_NAME); + versionParts(manifest.version); + assert.equal(manifest.private, undefined, 'Worker package must be public'); + assert.equal(manifest.license, 'Apache-2.0'); + assert.equal( + manifest.repository?.url, + 'git+https://github.com/LibreChat-AI/code-interpreter.git' + ); + assert.equal(manifest.repository?.directory, 'packages/code'); + assert.deepEqual(manifest.publishConfig, { + access: 'public', + registry: REGISTRY, + }); + assert.equal(manifest.bin?.['librechat-code'], './dist/cli.js'); + assert.ok(manifest.exports?.['./browser-enrollment']); +} + +export function validatePackFiles(paths, manifest) { + assert.ok( + paths.length > 0 && paths.length <= MAX_FILES, + 'Package file-count budget exceeded' + ); + assert.equal( + new Set(paths).size, + paths.length, + 'Package contains duplicate entries' + ); + for (const path of paths) { + assert.ok( + /^(package\.json|README\.md|LICENSE|NOTICE|dist\/[a-z0-9-]+\.(js|js\.map|d\.ts))$/.test( + path + ), + `Unexpected package file: ${path}` + ); + assert.ok(!/\.(test|spec)\./.test(path), 'Tests must not be published'); + } + const required = [ + 'package.json', + 'LICENSE', + 'NOTICE', + 'dist/cli.js', + 'dist/native-process-child.js', + ]; + for (const entry of Object.values(manifest.exports)) { + assert.ok( + entry && + typeof entry.import === 'string' && + typeof entry.types === 'string' + ); + required.push( + entry.import.replace(/^\.\//, ''), + entry.types.replace(/^\.\//, '') + ); + } + for (const path of required) + assert.ok(paths.includes(path), `Missing package file: ${path}`); +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: ROOT, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 4 * 1024 * 1024, + ...options, + }); +} + +async function output(values) { + for (const [key, value] of Object.entries(values)) { + assert.ok(!/[\r\n]/.test(String(value))); + console.log(`${key}=${value}`); + if (process.env.GITHUB_OUTPUT) + await appendFile(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); + } +} + +async function sourceManifest() { + const manifest = JSON.parse( + await readFile(join(PACKAGE_ROOT, 'package.json'), 'utf8') + ); + const lock = JSON.parse( + await readFile(join(PACKAGE_ROOT, 'package-lock.json'), 'utf8') + ); + validateManifest(manifest); + assert.equal(lock.version, manifest.version); + assert.equal(lock.packages[''].version, manifest.version); + return manifest; +} + +async function plan() { + const manifest = await sourceManifest(); + const selected = releasePlan( + manifest.version, + process.env.GITHUB_EVENT_NAME, + process.env.GITHUB_REF, + process.env.EXPECTED_VERSION + ); + if (process.env.GITHUB_EVENT_NAME !== 'pull_request') { + run('git', [ + 'merge-base', + '--is-ancestor', + 'HEAD', + 'refs/remotes/origin/main', + ]); + } + const sha = run('git', ['rev-parse', 'HEAD']).trim(); + assert.match(sha, /^[a-f0-9]{40}$/); + await output({ ...selected, sha }); +} + +async function pack(directory) { + const manifest = await sourceManifest(); + directory = resolve(directory); + await mkdir(directory, { recursive: true }); + const result = JSON.parse( + run( + 'npm', + ['pack', PACKAGE_ROOT, '--pack-destination', directory, '--json'], + { timeout: 180_000 } + ) + ); + assert.equal(result.length, 1); + const info = result[0]; + validatePackFiles( + info.files.map(file => file.path), + manifest + ); + assert.ok( + info.unpackedSize <= MAX_UNPACKED_BYTES, + 'Package unpacked-size budget exceeded' + ); + const filename = `librechat-code-${manifest.version}.tgz`; + assert.equal(info.filename, filename); + const bytes = await readFile(join(directory, filename)); + assert.ok( + bytes.length <= MAX_PACKED_BYTES, + 'Package compressed-size budget exceeded' + ); + const integrity = `sha512-${createHash('sha512') + .update(bytes) + .digest('base64')}`; + assert.equal(integrity, info.integrity); + const candidate = { + name: PACKAGE_NAME, + version: manifest.version, + tag: + versionParts(manifest.version)[3] === undefined ? 'latest' : 'next', + filename, + integrity, + sha: run('git', ['rev-parse', 'HEAD']).trim(), + }; + await writeFile( + join(directory, 'candidate.json'), + `${JSON.stringify(candidate, null, 2)}\n` + ); + await verifyCandidate(directory); + await output({ + filename, + integrity, + compressedBytes: bytes.length, + unpackedBytes: info.unpackedSize, + }); +} + +export async function verifyCandidate(directory) { + const candidate = JSON.parse( + await readFile(join(directory, 'candidate.json'), 'utf8') + ); + assert.equal(candidate.name, PACKAGE_NAME); + const parts = versionParts(candidate.version); + assert.equal(candidate.tag, parts[3] === undefined ? 'latest' : 'next'); + assert.equal(candidate.filename, `librechat-code-${candidate.version}.tgz`); + assert.match(candidate.sha, /^[a-f0-9]{40}$/); + const path = join(directory, candidate.filename); + const stats = await lstat(path); + assert.ok( + stats.isFile() && stats.size > 0 && stats.size <= MAX_PACKED_BYTES, + 'Invalid package artifact size or type' + ); + assert.equal( + candidate.integrity, + `sha512-${createHash('sha512') + .update(await readFile(path)) + .digest('base64')}`, + 'Package bytes do not match the candidate integrity' + ); + const archivePaths = run('tar', ['-tzf', path]).trim().split('\n'); + const entries = run('tar', ['-tvzf', path]).trim().split('\n'); + assert.ok( + entries.length === archivePaths.length && + entries.every(entry => entry.startsWith('-')), + 'Package archive must contain only regular files' + ); + const unpacked = run('tar', ['-xOzf', path], { + encoding: 'buffer', + maxBuffer: MAX_UNPACKED_BYTES + 1, + }); + assert.ok( + unpacked.length <= MAX_UNPACKED_BYTES, + 'Package unpacked-size budget exceeded' + ); + assert.ok( + archivePaths.every(entry => entry.startsWith('package/')), + 'Invalid package archive root' + ); + const manifest = JSON.parse( + run('tar', ['-xOzf', path, 'package/package.json']) + ); + validateManifest(manifest); + assert.equal(manifest.version, candidate.version); + validatePackFiles( + archivePaths.map(entry => entry.slice('package/'.length)), + manifest + ); + for (const name of ['LICENSE', 'NOTICE']) { + assert.equal( + run('tar', ['-xOzf', path, `package/${name}`]), + await readFile(join(ROOT, name), 'utf8'), + `Package ${name} differs from the repository` + ); + } + return candidate; +} + +export function registryValue(result) { + if (result.error || result.signal || !Number.isInteger(result.status)) { + throw new Error('npm registry lookup did not complete'); + } + if (result.status === 0) { + assert.ok(result.stdout.trim(), 'npm registry returned empty metadata'); + try { + return JSON.parse(result.stdout); + } catch { + throw new Error('npm registry returned invalid metadata'); + } + } + let error; + try { + error = JSON.parse(result.stdout).error; + } catch { + /* Fail closed without echoing registry output. */ + } + if (error?.code === 'E404') return undefined; + throw new Error( + 'npm registry lookup failed; authentication, transport, or registry errors are not an absent version' + ); +} + +function npmView(spec, field) { + const result = spawnSync( + 'npm', + [ + 'view', + spec, + field, + '--json', + '--registry', + REGISTRY, + '--fetch-retries=0', + '--fetch-timeout=15000', + ], + { + cwd: ROOT, + encoding: 'utf8', + timeout: 20_000, + maxBuffer: 1024 * 1024, + } + ); + return registryValue(result); +} + +async function publish(directory) { + assert.equal( + process.env.CODE_PACKAGE_PUBLISH, + 'true', + 'Publishing requires explicit workflow authorization' + ); + assert.equal( + process.env.GITHUB_ACTIONS, + 'true', + 'Publish only from the trusted GitHub workflow' + ); + assert.ok( + process.env.ACTIONS_ID_TOKEN_REQUEST_URL, + 'Trusted publishing OIDC is unavailable' + ); + const candidate = await verifyCandidate(resolve(directory)); + releasePlan( + candidate.version, + process.env.GITHUB_EVENT_NAME, + process.env.GITHUB_REF, + process.env.EXPECTED_VERSION + ); + assert.equal( + run('git', ['rev-parse', 'HEAD']).trim(), + candidate.sha, + 'Candidate must match the checked-out source commit' + ); + run('git', [ + 'merge-base', + '--is-ancestor', + candidate.sha, + 'refs/remotes/origin/main', + ]); + const decision = publicationDecision( + candidate, + npmView(`${PACKAGE_NAME}@${candidate.version}`, 'dist.integrity'), + npmView(PACKAGE_NAME, 'dist-tags') ?? {} + ); + if (decision === 'skip') { + console.log( + 'Identical package version is already published; no package or dist-tag changes made.' + ); + return; + } + run( + 'npm', + [ + 'publish', + resolve(directory, candidate.filename), + '--ignore-scripts', + '--access', + 'public', + '--tag', + candidate.tag, + '--registry', + REGISTRY, + ], + { stdio: 'inherit', timeout: 180_000 } + ); + assert.equal( + npmView(`${PACKAGE_NAME}@${candidate.version}`, 'dist.integrity'), + candidate.integrity, + 'Published artifact integrity does not match the tested candidate' + ); + console.log( + `Verified published ${PACKAGE_NAME}@${candidate.version} (${candidate.tag}).` + ); +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + const [command, directory] = process.argv.slice(2); + if (command === 'plan') await plan(); + else if (command === 'pack' && directory) await pack(directory); + else if (command === 'verify' && directory) { + const candidate = await verifyCandidate(resolve(directory)); + await output({ + filename: candidate.filename, + version: candidate.version, + tag: candidate.tag, + }); + } else if (command === 'publish' && directory) await publish(directory); + else + throw new Error( + 'Usage: code-package-release.mjs plan | pack|verify|publish ' + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/code-package-release.test.mjs b/scripts/code-package-release.test.mjs new file mode 100644 index 00000000..deb16beb --- /dev/null +++ b/scripts/code-package-release.test.mjs @@ -0,0 +1,438 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +import { + PACKAGE_NAME, + REGISTRY, + publicationDecision, + registryValue, + releasePlan, + validateManifest, + validatePackFiles, + verifyCandidate, + versionParts, +} from './code-package-release.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const manifest = JSON.parse( + await readFile(join(root, 'packages/code/package.json'), 'utf8') +); +const requiredFiles = [ + 'package.json', + 'README.md', + 'LICENSE', + 'NOTICE', + 'dist/cli.js', + 'dist/native-process-child.js', + ...Object.values(manifest.exports).flatMap(entry => + [entry.import, entry.types].map(path => path.slice(2)) + ), +].filter((path, index, all) => all.indexOf(path) === index); + +for (const [version, tag] of [ + ['0.1.0-rc.1', 'next'], + ['1.2.3', 'latest'], +]) { + test(`routes ${version} to ${tag} only from the matching tag or main dispatch`, () => { + assert.deepEqual( + releasePlan(version, 'push', `refs/tags/code-v${version}`), + { version, tag } + ); + assert.deepEqual( + releasePlan( + version, + 'workflow_dispatch', + 'refs/heads/main', + version + ), + { version, tag } + ); + assert.deepEqual( + releasePlan(version, 'pull_request', 'refs/pull/1/merge'), + { version, tag } + ); + }); +} + +for (const version of [ + 'v1.2.3', + '1.2', + '01.2.3', + '1.2.3-rc1', + '1.2.3-beta.1', + '1.2.3+build', + '1.2.3\n', + '1.2.3-rc.01', + '../package', + 'latest', + '', + null, +]) { + test(`rejects ambiguous version ${JSON.stringify(version)}`, () => + assert.throws(() => versionParts(version))); +} + +test('rejects wrong tag, branch, dispatch version, and unsupported release triggers', () => { + assert.throws( + () => releasePlan('1.2.3', 'push', 'refs/tags/v1.2.3'), + /Tag must match/ + ); + assert.throws( + () => releasePlan('1.2.3', 'push', 'refs/tags/code-v1.2.4'), + /Tag must match/ + ); + assert.throws( + () => + releasePlan( + '1.2.3', + 'workflow_dispatch', + 'refs/heads/topic', + '1.2.3' + ), + /from main/ + ); + assert.throws( + () => + releasePlan( + '1.2.3', + 'workflow_dispatch', + 'refs/heads/main', + '1.2.4' + ), + /must match/ + ); + assert.throws( + () => releasePlan('1.2.3', 'workflow_run', 'refs/heads/main'), + /Unsupported/ + ); +}); + +test('published versions are immutable and retries do not move dist-tags', () => { + const candidate = { + version: '1.2.3', + tag: 'latest', + integrity: 'sha512-tested', + }; + assert.equal( + publicationDecision(candidate, candidate.integrity, { + latest: '2.0.0', + }), + 'skip' + ); + assert.throws( + () => publicationDecision(candidate, 'sha512-other', {}), + /different bytes/ + ); + assert.equal( + publicationDecision(candidate, undefined, { latest: '1.2.2' }), + 'publish' + ); + assert.throws( + () => publicationDecision(candidate, undefined, { latest: '1.2.4' }), + /backwards/ + ); +}); + +test('release candidates cannot roll next backwards and stable wins over its prerelease', () => { + const candidate = { + version: '0.1.0-rc.2', + tag: 'next', + integrity: 'sha512-tested', + }; + assert.equal( + publicationDecision(candidate, undefined, { next: '0.1.0-rc.1' }), + 'publish' + ); + assert.throws( + () => publicationDecision(candidate, undefined, { next: '0.1.0-rc.3' }), + /backwards/ + ); + assert.throws( + () => publicationDecision(candidate, undefined, { next: '0.1.0' }), + /backwards/ + ); + assert.equal( + publicationDecision( + { ...candidate, version: '0.1.0', tag: 'latest' }, + undefined, + { latest: '0.1.0-rc.2' } + ), + 'publish' + ); + assert.equal(publicationDecision(candidate, undefined, {}), 'publish'); +}); + +test('registry absence is distinct from transport, authentication, and malformed metadata', () => { + assert.equal( + registryValue({ + status: 1, + stdout: JSON.stringify({ error: { code: 'E404' } }), + }), + undefined + ); + assert.equal( + registryValue({ status: 0, stdout: '"sha512-tested"' }), + 'sha512-tested' + ); + for (const result of [ + { + status: 1, + stdout: JSON.stringify({ + error: { code: 'E403', detail: 'private diagnostic' }, + }), + }, + { status: 1, stdout: JSON.stringify({ error: { code: 'E503' } }) }, + { status: null, stdout: '', error: new Error('private diagnostic') }, + { status: 1, stdout: 'private diagnostic' }, + { status: 0, stdout: '' }, + { status: 0, stdout: 'private diagnostic' }, + ]) + assert.throws( + () => registryValue(result), + error => { + assert.ok(!error.message.includes('private diagnostic')); + return true; + } + ); + assert.throws( + () => + publicationDecision( + { version: '1.0.0', tag: 'latest' }, + undefined, + [] + ), + /Invalid registry/ + ); +}); + +test('manifest declares public provenance metadata and remains lockfile-aligned', async () => { + validateManifest(manifest); + const lock = JSON.parse( + await readFile(join(root, 'packages/code/package-lock.json'), 'utf8') + ); + assert.equal(lock.version, manifest.version); + assert.equal(lock.packages[''].version, manifest.version); + assert.ok(versionParts(manifest.version)); + for (const altered of [ + { ...manifest, name: 'another-package' }, + { ...manifest, private: true }, + { + ...manifest, + repository: { + ...manifest.repository, + url: 'git+https://github.com/another/repo.git', + }, + }, + { + ...manifest, + publishConfig: { access: 'restricted', registry: REGISTRY }, + }, + { ...manifest, bin: { 'librechat-code': './src/cli.ts' } }, + ]) + assert.throws(() => validateManifest(altered)); +}); + +test('archive allowlist rejects tests, secrets, directories, links and untracked payloads', () => { + validatePackFiles(requiredFiles, manifest); + for (const extra of [ + '.env', + 'src/cli.ts', + 'node_modules/package/index.js', + 'dist/worker.test.js', + 'dist/scratch.json', + 'dist/../token.js', + '../LICENSE', + 'dist/', + ]) { + assert.throws(() => + validatePackFiles([...requiredFiles, extra], manifest) + ); + } + assert.throws( + () => validatePackFiles([...requiredFiles, 'dist/cli.js'], manifest), + /duplicate/ + ); + assert.throws( + () => + validatePackFiles( + requiredFiles.filter(path => path !== 'LICENSE'), + manifest + ), + /Missing/ + ); + assert.throws( + () => + validatePackFiles( + requiredFiles.filter( + path => path !== 'dist/native-process-child.js' + ), + manifest + ), + /Missing/ + ); + assert.throws( + () => + validatePackFiles( + Array.from({ length: 301 }, (_, i) => `dist/file-${i}.js`), + manifest + ), + /budget/ + ); +}); + +async function artifact(t) { + const directory = await mkdtemp(join(tmpdir(), 'code-package-release-')); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const path of requiredFiles) { + await mkdir(dirname(join(directory, 'package', path)), { + recursive: true, + }); + const content = path === 'package.json' ? JSON.stringify(manifest) : ''; + await writeFile(join(directory, 'package', path), content); + } + for (const name of ['LICENSE', 'NOTICE']) + await cp(join(root, name), join(directory, 'package', name)); + const candidate = { + name: PACKAGE_NAME, + version: manifest.version, + tag: releasePlan(manifest.version, 'pull_request', 'refs/pull/1/merge') + .tag, + filename: `librechat-code-${manifest.version}.tgz`, + integrity: '', + sha: 'a'.repeat(40), + }; + const save = async () => { + execFileSync('tar', [ + '-czf', + join(directory, candidate.filename), + '-C', + directory, + ...requiredFiles.map(path => `package/${path}`), + ]); + candidate.integrity = `sha512-${createHash('sha512') + .update(await readFile(join(directory, candidate.filename))) + .digest('base64')}`; + await writeFile( + join(directory, 'candidate.json'), + JSON.stringify(candidate) + ); + }; + await save(); + return { directory, candidate, save }; +} + +test('verifies the real archive bytes, version, exports, and repository legal contents', async t => { + const { directory, candidate } = await artifact(t); + assert.deepEqual(await verifyCandidate(directory), candidate); + await writeFile(join(directory, candidate.filename), 'different bytes'); + await assert.rejects(verifyCandidate(directory), /candidate integrity/); +}); + +test('rejects legal-file drift even with a recomputed archive integrity', async t => { + const { directory, save } = await artifact(t); + await writeFile( + join(directory, 'package/NOTICE'), + 'unreviewed legal contents' + ); + await save(); + await assert.rejects(verifyCandidate(directory), /NOTICE differs/); +}); + +test('rejects candidate version, channel, filename and source mismatches', async t => { + const { directory, candidate } = await artifact(t); + for (const override of [ + { name: 'another-package' }, + { tag: 'latest' }, + { filename: '../escape.tgz' }, + { sha: 'not a commit' }, + { version: '0.1.0-rc.2', filename: 'librechat-code-0.1.0-rc.2.tgz' }, + ]) { + await writeFile( + join(directory, 'candidate.json'), + JSON.stringify({ ...candidate, ...override }) + ); + await assert.rejects(verifyCandidate(directory)); + } +}); + +test('publish refuses local or unapproved execution before touching a candidate or registry', () => { + for (const environment of [ + { CODE_PACKAGE_PUBLISH: 'false', GITHUB_ACTIONS: 'true' }, + { CODE_PACKAGE_PUBLISH: 'true', GITHUB_ACTIONS: 'false' }, + { + CODE_PACKAGE_PUBLISH: 'true', + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: '', + }, + ]) { + const result = spawnSync( + process.execPath, + [ + join(root, 'scripts/code-package-release.mjs'), + 'publish', + '/does-not-exist', + ], + { + encoding: 'utf8', + env: { ...process.env, ...environment }, + timeout: 5000, + } + ); + assert.equal(result.status, 1); + assert.match( + result.stderr, + /explicit workflow authorization|trusted GitHub workflow|OIDC is unavailable/ + ); + assert.ok(!result.stderr.includes('ENOENT')); + } +}); + +test('workflow keeps publication opt-in, serialized, main-gated and separate from artifact verification', async () => { + const require = createRequire( + new URL('../packages/code/package.json', import.meta.url) + ); + const { parse } = require('yaml'); + const workflow = parse( + await readFile(join(root, '.github/workflows/code-package.yml'), 'utf8') + ); + assert.equal(workflow.on.workflow_dispatch.inputs.publish.default, false); + assert.deepEqual(workflow.on.push.tags, ['code-v*']); + assert.equal(workflow.permissions['id-token'], undefined); + assert.equal(workflow.jobs.pack.permissions['id-token'], undefined); + assert.equal(workflow.jobs.verify.permissions, undefined); + assert.deepEqual(workflow.jobs.publish.needs, ['pack', 'verify']); + assert.equal(workflow.jobs.publish.permissions['id-token'], 'write'); + assert.equal(workflow.jobs.publish.environment, 'npm'); + assert.match(workflow.jobs.publish.if, /inputs.publish == true/); + assert.match( + workflow.jobs.publish.if, + /github.repository == 'LibreChat-AI\/code-interpreter'/ + ); + assert.match(workflow.concurrency.group, /'release'/); + const releaseChecks = workflow.jobs.pack.steps.find( + step => step.name === 'Require successful main CI for releases' + ); + assert.match(releaseChecks.run, /head_sha=\$SOURCE_SHA/); + assert.match(releaseChecks.run, /status=success/); + for (const job of Object.values(workflow.jobs)) { + for (const step of job.steps) { + if (step.uses) assert.match(step.uses, /@[a-f0-9]{40}$/); + if (step.uses?.startsWith('actions/checkout@')) + assert.equal(step.with['persist-credentials'], false); + } + } + assert.equal( + workflow.jobs.publish.steps.some(step => + /npm (ci|run build|pack)/.test(step.run ?? '') + ), + false, + 'Publishing must not rebuild the tested artifact' + ); +}); diff --git a/scripts/smoke-code-package.mjs b/scripts/smoke-code-package.mjs new file mode 100644 index 00000000..92698a69 --- /dev/null +++ b/scripts/smoke-code-package.mjs @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function within(root, path) { + const child = relative(root, path); + return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`)); +} + +function diagnostic(value) { + return String(value) + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/(https?:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1[redacted]@') + .replace(/((?:_authToken|_auth|password|token)\s*[=:]\s*)[^\s]+/gi, '$1[redacted]') + .slice(-8192); +} + +function run(command, args, { cwd, timeoutMs = 30_000, env = {}, label = command } = {}) { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '', ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let timedOut = false; + const tail = (previous, chunk) => Buffer.concat([previous, chunk]).subarray(-65_536); + child.stdout.on('data', chunk => { stdout = tail(stdout, chunk); }); + child.stderr.on('data', chunk => { stderr = tail(stderr, chunk); }); + const timer = setTimeout(() => { + timedOut = true; + try { + if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); + else child.kill('SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') child.kill('SIGKILL'); + } + }, timeoutMs); + child.once('error', error => { + clearTimeout(timer); + reject(new Error(`${label}: ${error.message}`)); + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + if (code === 0 && !timedOut) { + resolveRun(stdout.toString('utf8')); + } else { + const status = timedOut ? `timed out after ${timeoutMs} ms` : `exit ${code}, signal ${signal ?? 'none'}`; + reject(new Error(`${label}: ${status}\n${diagnostic(`${stdout}\n${stderr}`)}`)); + } + }); + }); +} + +async function checkConsumer(expected) { + const assert = (await import('node:assert/strict')).default; + const { fork } = await import('node:child_process'); + const { randomBytes } = await import('node:crypto'); + const { constants } = await import('node:fs'); + const { access, lstat, open, readFile, readdir, realpath, stat } = await import('node:fs/promises'); + const { createRequire } = await import('node:module'); + const { isAbsolute, join, relative, resolve, sep } = await import('node:path'); + const { fileURLToPath, pathToFileURL } = await import('node:url'); + const consumer = await realpath(process.cwd()); + const modules = join(consumer, 'node_modules'); + const packageRoot = join(modules, '@librechat', 'code'); + assert.ok((await lstat(packageRoot)).isDirectory(), 'The package must be installed, not symlinked'); + assert.equal(await realpath(packageRoot), packageRoot); + const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + assert.equal(manifest.name, '@librechat/code'); + assert.equal(manifest.name, expected.name); + assert.equal(manifest.version, expected.version); + assert.match(manifest.version, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/); + console.log(`ok: installed ${manifest.name}@${manifest.version}`); + + assert.ok(manifest.exports && typeof manifest.exports === 'object' && !Array.isArray(manifest.exports)); + const specifiers = new Set(['@librechat/code']); + for (const subpath of Object.keys(manifest.exports)) { + assert.ok(subpath === '.' || subpath.startsWith('./'), `Unexpected export: ${subpath}`); + assert.ok(!subpath.includes('*'), `Export patterns need concrete smoke cases: ${subpath}`); + specifiers.add(subpath === '.' ? manifest.name : `${manifest.name}${subpath.slice(1)}`); + } + const entries = new Map(); + for (const specifier of specifiers) { + const target = await realpath(fileURLToPath(import.meta.resolve(specifier))); + assert.ok(within(packageRoot, target), `Export escaped the installed package: ${specifier}`); + entries.set(specifier, await import(specifier)); + } + console.log(`ok: imports (${entries.size}): ${[...entries.keys()].join(', ')}`); + + const require = createRequire(import.meta.url); + for (const dependency of ['koffi', '@anthropic-ai/sandbox-runtime']) { + const path = dependency === 'koffi' ? require.resolve(dependency) : fileURLToPath(import.meta.resolve(dependency)); + assert.ok(within(modules, await realpath(path)), `${dependency} resolved outside the consumer`); + } + const koffi = require('koffi'); + const getpid = koffi.load(null).func('int getpid()'); + assert.equal(getpid(), process.pid); + console.log('ok: Koffi host native getpid()'); + + const identity = entries.get('@librechat/code/identity'); + const enrollment = entries.get('@librechat/code/browser-enrollment'); + assert.ok(identity && enrollment, 'Identity and enrollment subpaths must be public'); + assert.equal(entries.get('@librechat/code').createBridgeIdentity, identity.createBridgeIdentity); + const key = identity.createBridgeIdentity(); + const timestamp = new Date().toISOString(); + const token = () => randomBytes(32).toString('base64url'); + const request = { + credential: 'package-smoke-placeholder', method: 'POST', path: '/v1/package-smoke', + timestamp, nonce: token(), body: '{}', + }; + const requestSignature = identity.signBridgeRequest(key.privateKey, request); + assert.equal(identity.verifyBridgeRequest(key.publicKey, request, requestSignature), true); + assert.equal(identity.verifyBridgeRequest(key.publicKey, { ...request, body: '{"changed":true}' }, requestSignature), false); + const setup = { + version: 1, instance: 'https://smoke.example.invalid', attemptId: token(), createdAt: timestamp, + publicKey: key.publicKey, machineName: 'package-smoke', workspace: { type: 'private' }, + allowWorkspaceWrites: false, allowWorkspaceCommands: false, + }; + const proof = { + operation: 'setup.start', instance: setup.instance, attemptId: setup.attemptId, + requestHash: enrollment.hashBrowserEnrollmentRequest(setup), timestamp, nonce: token(), + }; + const proofSignature = enrollment.signBrowserSetupProof(key.privateKey, proof); + assert.equal(enrollment.verifyBrowserSetupProof(key.publicKey, proof, proofSignature), true); + assert.equal(enrollment.verifyBrowserSetupProof(key.publicKey, { ...proof, operation: 'setup.cancel' }, proofSignature), false); + const redemption = { + operation: 'enrollment.redeem', serverId: setup.instance, instance: setup.instance, + attemptId: setup.attemptId, requestHash: proof.requestHash, workerId: 'package-smoke', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; + const redemptionSignature = enrollment.signBrowserEnrollmentRedemption(key.privateKey, redemption); + assert.equal(enrollment.verifyBrowserEnrollmentRedemption(key.publicKey, redemption, redemptionSignature), true); + assert.equal(enrollment.verifyBrowserEnrollmentRedemption(key.publicKey, { ...redemption, workerId: 'changed' }, redemptionSignature), false); + console.log('ok: identity request and browser setup/redemption signatures; tampering rejected'); + + const binTarget = resolve(packageRoot, manifest.bin?.['librechat-code'] ?? ''); + assert.ok(binTarget !== packageRoot && within(packageRoot, binTarget), 'Missing or invalid librechat-code bin'); + assert.match(await readFile(binTarget, 'utf8'), /^#!\/usr\/bin\/env node\r?\n/); + const executable = join(modules, '.bin', 'librechat-code'); + assert.equal(await realpath(executable), await realpath(binTarget)); + if (process.platform !== 'win32') { + assert.ok((await stat(binTarget)).mode & 0o111, 'Installed bin must be executable'); + await access(executable, constants.X_OK); + } + const help = await run(executable, ['--help'], { cwd: consumer, timeoutMs: 15_000, label: 'librechat-code --help' }); + assert.match(help, /Usage: librechat-code /); + const setupHelp = await run(executable, ['setup', '--help'], { cwd: consumer, timeoutMs: 15_000, label: 'librechat-code setup --help' }); + assert.match(setupHelp, /Usage: librechat-code setup/); + assert.match(setupHelp, /--instance/); + console.log('ok: installed executable shebang, permissions, --help and setup --help'); + + const childPath = join(packageRoot, 'dist', 'native-process-child.js'); + assert.ok((await lstat(childPath)).isFile(), 'Missing installed dist/native-process-child.js'); + const child = fork(childPath, [], { + cwd: consumer, execArgv: [], stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' }, + timeout: 20_000, killSignal: 'SIGKILL', + }); + let childOutput = ''; + const capture = chunk => { childOutput = (childOutput + chunk.toString()).slice(-4096); }; + child.stdout.on('data', capture); + child.stderr.on('data', capture); + const exited = new Promise(resolveExited => child.once('exit', (code, signal) => resolveExited({ code, signal }))); + try { + const reply = await new Promise((resolveReply, reject) => { + const fail = error => { clearTimeout(timer); reject(error); }; + const timer = setTimeout(() => fail(new Error('Native executor IPC timed out')), 15_000); + child.once('error', fail); + child.once('exit', (code, signal) => fail(new Error(`Native executor exited before IPC reply (${code}, ${signal})`))); + child.once('message', message => { clearTimeout(timer); resolveReply(message); }); + child.send({ id: 'package-smoke', type: 'close' }, error => { if (error) fail(error); }); + }); + assert.equal(reply.id, 'package-smoke'); + assert.equal(reply.ok, false); + assert.equal(reply.code, 'COMMAND_UNAVAILABLE'); + assert.deepEqual(reply.diagnostic, { stage: 'cleanup', reason: 'cleanup_failed' }); + assert.equal(reply.mutation, false); + assert.equal(reply.requiresQuarantine, false); + assert.equal(reply.fatal, false); + } catch (error) { + throw new Error(`${error.message}\n${diagnostic(childOutput)}`); + } finally { + const killTimer = setTimeout(() => child.kill('SIGKILL'), 1000); + if (child.connected) child.disconnect(); + const status = await exited; + clearTimeout(killTimer); + child.stdout.destroy(); + child.stderr.destroy(); + assert.equal(status.code, 0, `Native executor did not shut down cleanly (${status.signal ?? status.code})`); + } + console.log('ok: installed native executor fork IPC; unprepared close safely rejected'); + + if (process.platform === 'darwin') { + const { removeMacOsAcl, verifyMacOsAcl } = await import(pathToFileURL(join(packageRoot, 'dist', 'macos-storage.js')).href); + const path = join(consumer, 'private-acl-smoke'); + const handle = await open(path, 'wx', 0o600); + try { + const metadata = await handle.stat(); + assert.equal(metadata.mode & 0o777, 0o600); + assert.equal(metadata.uid, process.getuid()); + removeMacOsAcl(handle.fd, path); + verifyMacOsAcl(handle.fd, path, false, true); + } finally { + await handle.close(); + } + console.log('ok: macOS descriptor ACL native calls on an owner-only temporary file'); + } + + let packageBytes = 0; + let dependencyBytes = 0; + async function measure(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + assert.ok(within(modules, await realpath(path)), 'Installed dependency symlink escaped the consumer'); + } else if (entry.isDirectory()) { + await measure(path); + } else if (entry.isFile()) { + const { size } = await stat(path); + dependencyBytes += size; + if (within(packageRoot, path)) packageBytes += size; + } + } + } + await measure(modules); + console.log(`size: installed package ${packageBytes} bytes; production node_modules ${dependencyBytes} bytes`); +} + +async function main() { + assert.equal(process.argv.length, 3, 'Usage: node scripts/smoke-code-package.mjs '); + const tarball = await realpath(resolve(process.argv[2])); + const archive = await stat(tarball); + assert.ok(archive.isFile(), 'Tarball must be a regular file'); + const sourceRoot = await realpath(fileURLToPath(new URL('..', import.meta.url))); + const tempRoot = await realpath(tmpdir()); + assert.ok(!within(sourceRoot, tempRoot), 'TMPDIR must be outside the source repository'); + const expected = JSON.parse(await readFile(new URL('../packages/code/package.json', import.meta.url), 'utf8')); + assert.equal(expected.name, '@librechat/code'); + const consumer = await mkdtemp(join(tempRoot, 'librechat-code-smoke-')); + try { + await writeFile(join(consumer, 'package.json'), JSON.stringify({ name: 'code-package-smoke', private: true, type: 'module' })); + console.log(`size: tarball ${archive.size} bytes`); + await run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund', tarball], { + cwd: consumer, timeoutMs: 180_000, label: 'npm install tarball', + env: { npm_config_cache: join(consumer, '.npm-cache'), npm_config_update_notifier: 'false' }, + }); + const script = join(consumer, 'smoke.mjs'); + await writeFile(script, [ + "import { spawn } from 'node:child_process';", + "import { isAbsolute, relative, sep } from 'node:path';", + within.toString(), diagnostic.toString(), run.toString(), checkConsumer.toString(), + `await checkConsumer(${JSON.stringify({ name: expected.name, version: expected.version })});`, + ].join('\n')); + // Keep all package resolution anchored to the consumer, never this checkout. + const output = await run(process.execPath, [script], { cwd: consumer, timeoutMs: 90_000, label: 'installed package checks' }); + process.stdout.write(output); + console.log('ok: packed consumer smoke passed'); + } finally { + await rm(consumer, { recursive: true, force: true }); + } +} + +main().catch(error => { + console.error(`smoke-code-package: ${diagnostic(error.message)}`); + process.exitCode = 1; +}); From 19ea392b7e3573a02b588743050fa738ee079704 Mon Sep 17 00:00:00 2001 From: Lia Date: Thu, 5 Nov 2026 18:29:23 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E2=8C=A8=EF=B8=8F=20feat:=20Use=20lc-code?= =?UTF-8?q?=20As=20The=20Primary=20CLI=20Command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/code/package-lock.json | 1 + packages/code/package.json | 1 + packages/code/src/browser-setup-cli.ts | 2 +- packages/code/src/cli.test.ts | 12 +++++++ packages/code/src/cli.ts | 13 ++++---- packages/code/src/projects.test.ts | 2 +- packages/code/src/setup-cli.test.ts | 5 ++- packages/code/src/setup-cli.ts | 9 +++--- packages/code/src/setup.ts | 2 +- scripts/code-package-release.mjs | 5 ++- scripts/code-package-release.test.mjs | 12 ++++++- scripts/smoke-code-package.mjs | 45 ++++++++++++++++++-------- 12 files changed, 79 insertions(+), 30 deletions(-) diff --git a/packages/code/package-lock.json b/packages/code/package-lock.json index 6d78d9bf..8598d27e 100644 --- a/packages/code/package-lock.json +++ b/packages/code/package-lock.json @@ -14,6 +14,7 @@ "yaml": "2.9.1" }, "bin": { + "lc-code": "dist/cli.js", "librechat-code": "dist/cli.js" }, "devDependencies": { diff --git a/packages/code/package.json b/packages/code/package.json index 5c4aebf8..f55205ee 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -53,6 +53,7 @@ } }, "bin": { + "lc-code": "./dist/cli.js", "librechat-code": "./dist/cli.js" }, "files": [ diff --git a/packages/code/src/browser-setup-cli.ts b/packages/code/src/browser-setup-cli.ts index 250fcdb7..1e102b77 100644 --- a/packages/code/src/browser-setup-cli.ts +++ b/packages/code/src/browser-setup-cli.ts @@ -98,6 +98,6 @@ export async function runBrowserSetupCommand( }); } else { const quoted = `'${profilePath.replace(/'/g, "'\\''")}'`; - process.stdout.write(`Start it with:\n librechat-code run${profilePath === resolve(defaultWorkerProfilePath()) ? '' : ` --profile ${quoted}`}\n`); + process.stdout.write(`Start it with:\n lc-code run${profilePath === resolve(defaultWorkerProfilePath()) ? '' : ` --profile ${quoted}`}\n`); } } diff --git a/packages/code/src/cli.test.ts b/packages/code/src/cli.test.ts index f6737d5f..c16a3f03 100644 --- a/packages/code/src/cli.test.ts +++ b/packages/code/src/cli.test.ts @@ -7,6 +7,18 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; +test('CLI help presents lc-code as primary and retains the compatibility alias', () => { + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('./cli.js', import.meta.url)), '--help'], + { encoding: 'utf8', timeout: 5000 }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^Usage: lc-code /); + assert.match(result.stdout, /Run lc-code setup --help/); + assert.match(result.stdout, /librechat-code remains available as a compatibility alias/); +}); + test('CLI rejects an invalid worker ID before entering the run loop', () => { const result = spawnSync( process.execPath, diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index a0352d34..a5eff92f 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -1639,7 +1639,7 @@ async function run( ); if (record != null) { throw new BridgeProtocolError( - `Workspace mutations are quarantined since ${record.quarantinedAt}: ${record.reason}. Inspect or restore the workspace, then run librechat-code clear-workspace-quarantine`, + `Workspace mutations are quarantined since ${record.quarantinedAt}: ${record.reason}. Inspect or restore the workspace, then run lc-code clear-workspace-quarantine`, undefined, 'WORKER_QUARANTINED', ); @@ -1822,7 +1822,7 @@ async function clearMutationQuarantine(args: string[]): Promise { async function main(): Promise { const args = process.argv.slice(2); if (args.length === 1 && (args[0] === '--help' || args[0] === 'help')) { - process.stdout.write(`Usage: librechat-code + process.stdout.write(`Usage: lc-code setup --instance Browser-approved enrollment and private profile setup run [--profile ] Start a saved profile or existing worker configuration @@ -1831,7 +1831,8 @@ async function main(): Promise { reset-workspace Existing runtime maintenance clear-workspace-quarantine Existing workspace maintenance -Run librechat-code setup --help for setup options. +Run lc-code setup --help for setup options. +librechat-code remains available as a compatibility alias. Legacy LIBRECHAT_CODE_* configuration takes precedence over the default profile. Explicit profiles cannot be mixed with legacy settings or run options. `); @@ -1846,7 +1847,7 @@ Explicit profiles cannot be mixed with legacy settings or run options. for (let i = 1; i < args.length; i++) { if (args[i] === '--apply') continue; if (args[i] !== '--environment' || !args[i + 1] || args[i + 1].startsWith('--')) - throw new Error('Usage: librechat-code prune-environment-storage --environment [--environment ...] [--apply]'); + throw new Error('Usage: lc-code prune-environment-storage --environment [--environment ...] [--apply]'); paths.push(args[++i]); } if (!paths.length || paths.length > 32) throw new Error('Supply between one and 32 environment definitions'); @@ -1867,7 +1868,7 @@ Explicit profiles cannot be mixed with legacy settings or run options. const root = option(args, '--root'); if (!root || args.slice(1).some((arg, index, rest) => arg !== '--root' && rest[index - 1] !== '--root' && !arg.startsWith('--root='))) { - throw new Error('Usage: librechat-code projects --root '); + throw new Error('Usage: lc-code projects --root '); } const inventory = await discoverProjects({ root }); process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`); @@ -1885,7 +1886,7 @@ Explicit profiles cannot be mixed with legacy settings or run options. const runtimeSessionId = args[1]?.trim(); if (!runtimeSessionId) { throw new Error( - 'Usage: librechat-code reset-workspace ', + 'Usage: lc-code reset-workspace ', ); } await run(runtimeSessionId); diff --git a/packages/code/src/projects.test.ts b/packages/code/src/projects.test.ts index 98ce0949..b678dc00 100644 --- a/packages/code/src/projects.test.ts +++ b/packages/code/src/projects.test.ts @@ -363,6 +363,6 @@ test('CLI inventories a real checkout without pairing or starting a worker', asy fileURLToPath(new URL('./cli.js', import.meta.url)), 'projects', ]), - /Usage: librechat-code projects/ + /Usage: lc-code projects/ ); }); diff --git a/packages/code/src/setup-cli.test.ts b/packages/code/src/setup-cli.test.ts index 1bef6e14..8f71bac1 100644 --- a/packages/code/src/setup-cli.test.ts +++ b/packages/code/src/setup-cli.test.ts @@ -58,6 +58,9 @@ test('CLI help explains the pairing prerequisite and setup does not silently rea encoding: 'utf8', }); assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /^Usage: lc-code setup/); + assert.match(help.stdout, /lc-code setup --instance/); + assert.match(help.stdout, /librechat-code remains available as a compatibility alias/); assert.match(help.stdout, /Settings > Code Environments/); assert.match(help.stdout, /does not\nsign in to LibreChat/); const guided = await execute(['setup'], env, code); @@ -138,7 +141,7 @@ test( `${code}\n`, ); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /librechat-code run\n/); + assert.match(result.stdout, /lc-code run\n/); assert.match(result.stdout, /still needs to start/); assert.ok(!result.stdout.includes(code)); assert.ok(!result.stderr.includes(code)); diff --git a/packages/code/src/setup-cli.ts b/packages/code/src/setup-cli.ts index 08262153..3be600c6 100644 --- a/packages/code/src/setup-cli.ts +++ b/packages/code/src/setup-cli.ts @@ -11,10 +11,10 @@ import { defaultWorkerProfilePath } from './worker-profile.js'; import { isValidBridgeWorkerId } from './protocol.js'; import { runBrowserSetupCommand } from './browser-setup-cli.js'; -export const SETUP_HELP = `Usage: librechat-code setup [options] +export const SETUP_HELP = `Usage: lc-code setup [options] Enroll with browser approval: - librechat-code setup --instance https://chat.example.com + lc-code setup --instance https://chat.example.com The browser uses LibreChat's normal login, including configured OAuth/OIDC. SSH/headless machines display a URL and matching verification code. No inbound @@ -44,6 +44,7 @@ sign in to LibreChat or bypass its enrollment policy. --code-stdin Read the pairing code from stdin, never an argument --help Show this help +librechat-code remains available as a compatibility alias. Existing profiles and identities are never replaced. Choose another profile and identity for a second machine. Setup does not install OS dependencies, start a background service, or change the restricted command policy. @@ -80,7 +81,7 @@ export function parseSetupArguments( options.has(flag) ) { throw new Error( - 'Unknown or repeated setup option; use librechat-code setup --help', + 'Unknown or repeated setup option; use lc-code setup --help', ); } if (BOOLEAN_FLAGS.has(flag)) { @@ -275,7 +276,7 @@ export async function runSetupCommand(args: string[]): Promise { const defaultProfile = profilePath === resolve(defaultWorkerProfilePath()); const quotedProfile = `'${profilePath.replace(/'/g, "'\\''")}'`; process.stdout.write( - `\nSetup saved. Start the worker with:\n librechat-code run${defaultProfile ? '' : ` --profile ${quotedProfile}`}\n\nPairing is complete. The worker still needs to start, prepare its sandbox,\nand register before LibreChat can report it ready.\n`, + `\nSetup saved. Start the worker with:\n lc-code run${defaultProfile ? '' : ` --profile ${quotedProfile}`}\n\nPairing is complete. The worker still needs to start, prepare its sandbox,\nand register before LibreChat can report it ready.\n`, ); } finally { process.off('SIGINT', abort); diff --git a/packages/code/src/setup.ts b/packages/code/src/setup.ts index 46879207..486923b5 100644 --- a/packages/code/src/setup.ts +++ b/packages/code/src/setup.ts @@ -242,7 +242,7 @@ export async function profileRunConfiguration( if (!profile) { if (profilePath) throw new Error( - 'Saved profile not found; run librechat-code setup first', + 'Saved profile not found; run lc-code setup first', ); return { args }; } diff --git a/scripts/code-package-release.mjs b/scripts/code-package-release.mjs index 4624c4c6..f3120624 100644 --- a/scripts/code-package-release.mjs +++ b/scripts/code-package-release.mjs @@ -110,7 +110,10 @@ export function validateManifest(manifest) { access: 'public', registry: REGISTRY, }); - assert.equal(manifest.bin?.['librechat-code'], './dist/cli.js'); + assert.deepEqual(manifest.bin, { + 'lc-code': './dist/cli.js', + 'librechat-code': './dist/cli.js', + }); assert.ok(manifest.exports?.['./browser-enrollment']); } diff --git a/scripts/code-package-release.test.mjs b/scripts/code-package-release.test.mjs index deb16beb..a3310340 100644 --- a/scripts/code-package-release.test.mjs +++ b/scripts/code-package-release.test.mjs @@ -219,6 +219,10 @@ test('manifest declares public provenance metadata and remains lockfile-aligned' ); assert.equal(lock.version, manifest.version); assert.equal(lock.packages[''].version, manifest.version); + assert.deepEqual(lock.packages[''].bin, { + 'lc-code': 'dist/cli.js', + 'librechat-code': 'dist/cli.js', + }); assert.ok(versionParts(manifest.version)); for (const altered of [ { ...manifest, name: 'another-package' }, @@ -234,7 +238,13 @@ test('manifest declares public provenance metadata and remains lockfile-aligned' ...manifest, publishConfig: { access: 'restricted', registry: REGISTRY }, }, - { ...manifest, bin: { 'librechat-code': './src/cli.ts' } }, + { ...manifest, bin: { 'librechat-code': './dist/cli.js' } }, + { ...manifest, bin: { 'lc-code': './dist/cli.js' } }, + { ...manifest, bin: { ...manifest.bin, 'lc-code': './src/cli.ts' } }, + { + ...manifest, + bin: { ...manifest.bin, 'librechat-code': './src/cli.ts' }, + }, ]) assert.throws(() => validateManifest(altered)); }); diff --git a/scripts/smoke-code-package.mjs b/scripts/smoke-code-package.mjs index 92698a69..1c4ced15 100644 --- a/scripts/smoke-code-package.mjs +++ b/scripts/smoke-code-package.mjs @@ -139,21 +139,38 @@ async function checkConsumer(expected) { assert.equal(enrollment.verifyBrowserEnrollmentRedemption(key.publicKey, { ...redemption, workerId: 'changed' }, redemptionSignature), false); console.log('ok: identity request and browser setup/redemption signatures; tampering rejected'); - const binTarget = resolve(packageRoot, manifest.bin?.['librechat-code'] ?? ''); - assert.ok(binTarget !== packageRoot && within(packageRoot, binTarget), 'Missing or invalid librechat-code bin'); - assert.match(await readFile(binTarget, 'utf8'), /^#!\/usr\/bin\/env node\r?\n/); - const executable = join(modules, '.bin', 'librechat-code'); - assert.equal(await realpath(executable), await realpath(binTarget)); - if (process.platform !== 'win32') { - assert.ok((await stat(binTarget)).mode & 0o111, 'Installed bin must be executable'); - await access(executable, constants.X_OK); + assert.deepEqual(manifest.bin, { + 'lc-code': './dist/cli.js', + 'librechat-code': './dist/cli.js', + }); + let primaryHelp; + let primarySetupHelp; + for (const name of ['lc-code', 'librechat-code']) { + const binTarget = resolve(packageRoot, manifest.bin[name]); + assert.ok(binTarget !== packageRoot && within(packageRoot, binTarget), `Missing or invalid ${name} bin`); + assert.match(await readFile(binTarget, 'utf8'), /^#!\/usr\/bin\/env node\r?\n/); + const executable = join(modules, '.bin', name); + assert.equal(await realpath(executable), await realpath(binTarget)); + if (process.platform !== 'win32') { + assert.ok((await stat(binTarget)).mode & 0o111, 'Installed bin must be executable'); + await access(executable, constants.X_OK); + } + const help = await run(executable, ['--help'], { cwd: consumer, timeoutMs: 15_000, label: `${name} --help` }); + assert.match(help, /Usage: lc-code /); + assert.match(help, /librechat-code remains available as a compatibility alias/); + const setupHelp = await run(executable, ['setup', '--help'], { cwd: consumer, timeoutMs: 15_000, label: `${name} setup --help` }); + assert.match(setupHelp, /Usage: lc-code setup/); + assert.match(setupHelp, /--instance/); + if (primaryHelp === undefined) { + primaryHelp = help; + primarySetupHelp = setupHelp; + } else { + assert.equal(help, primaryHelp, 'Compatibility alias changed CLI help'); + assert.equal(setupHelp, primarySetupHelp, 'Compatibility alias changed setup help'); + } + await assert.rejects(run(executable, ['unknown-command'], { cwd: consumer, timeoutMs: 15_000, label: `${name} unknown-command` }), /exit 1.*[\s\S]*Unknown command: unknown-command/); } - const help = await run(executable, ['--help'], { cwd: consumer, timeoutMs: 15_000, label: 'librechat-code --help' }); - assert.match(help, /Usage: librechat-code /); - const setupHelp = await run(executable, ['setup', '--help'], { cwd: consumer, timeoutMs: 15_000, label: 'librechat-code setup --help' }); - assert.match(setupHelp, /Usage: librechat-code setup/); - assert.match(setupHelp, /--instance/); - console.log('ok: installed executable shebang, permissions, --help and setup --help'); + console.log('ok: lc-code and librechat-code alias share executable, help, setup and error behavior'); const childPath = join(packageRoot, 'dist', 'native-process-child.js'); assert.ok((await lstat(childPath)).isFile(), 'Missing installed dist/native-process-child.js');