From c8177759232d08106dee8c8e2eb8c5c925925183 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 17 Jul 2026 13:05:18 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20`harper=20deploy=20setup=3Dtru?= =?UTF-8?q?e`=20=E2=80=94=20seal=20a=20durable=20deploy=20credential=20cli?= =?UTF-8?q?ent-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive command that provisions the credential the cluster uses to fetch a private deploy source (private GitHub repo, or private npm registry): (1) fetch the cluster's public secrets key (get_secrets_public_key); (2) source a token (gh auth token, or a pasted fine-grained PAT / npm token create); (3) envelope-encrypt it LOCALLY with encryptEnvelope — only ciphertext leaves the machine; (4) store via set_secret {envelope}, granted to the component; (5) print the credentials reference the deploy uses. Because the token is sealed client-side, the plaintext never touches the request body, the operations log, replication, or the server heap — the cluster decrypts it only in-memory at deploy/rollback time. And because it's a durable token (unlike a CI GITHUB_TOKEN that dies at job end), a re-resolve rollback works for the token's lifetime. Wired as `harper deploy setup=true`, intercepted in bin/harper.ts before the deploy dispatch. Prototype for the create-harper deploy-by-reference work; concretizes #1778. Relates to #1777, #1799, #641. Co-Authored-By: Claude Opus 4.8 --- bin/deploySetup.ts | 186 +++++++++++++++++++++++++++++++++++++++++++++ bin/harper.ts | 7 ++ 2 files changed, 193 insertions(+) create mode 100644 bin/deploySetup.ts diff --git a/bin/deploySetup.ts b/bin/deploySetup.ts new file mode 100644 index 0000000000..e4c1a3ac57 --- /dev/null +++ b/bin/deploySetup.ts @@ -0,0 +1,186 @@ +'use strict'; + +// `harper deploy setup=true` — provision a durable, encrypted credential the cluster uses to fetch +// private deploy sources (a private GitHub repo, or a private npm registry). +// +// The whole point is that the token is sealed *on this machine* with the cluster's public secrets +// key and only the ciphertext is ever transmitted or stored — the cluster (and its logs, its +// operations journal, its replication) never sees the plaintext, and decrypts it only in-memory at +// deploy/rollback time. Because the token is durable (unlike a CI `GITHUB_TOKEN`, which dies at job +// end), a re-resolve rollback works for as long as the token is valid. +// +// Flow: +// 1. get_secrets_public_key → the cluster's RSA public key + fingerprint +// 2. source a token → `gh auth token`, or paste a fine-grained PAT / npm token +// 3. encryptEnvelope(...) → seal it locally into an `enc:v1:` envelope (pure client-side crypto) +// 4. set_secret {envelope} → store ciphertext, granted to the component +// 5. print the `credentials` reference the deploy should use + +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cliOperations } from './cliOperations.ts'; +import { encryptEnvelope } from '../utility/secretEnvelope.ts'; +import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts'; + +// Mirror the server's derived secret name (secretOperations `deriveRegistrySecretName` / the #1799 +// `deploy..` convention) so re-running overwrites the same row rather than piling up. +function deriveSecretName(component: string, key: string): string { + const keyPart = key + .trim() + .replace(/^https?:\/\//i, '') + .replace(/^\/\//, '') + .replace(/\/+$/, '') + .toLowerCase() + .replace(/[^\w.-]+/g, '_'); + const componentPart = String(component).replace(/[^\w.-]+/g, '_'); + return `deploy.${componentPart}.${keyPart}`; +} + +function tryCommand(command: string, args: string[]): string { + try { + return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} + +function defaultComponentName(): string { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')); + if (typeof pkg.name === 'string' && pkg.name.length > 0) return pkg.name.replace(/^@[^/]+\//, ''); + } catch { + // no package.json — fall back to the directory name + } + return path.basename(process.cwd()); +} + +export async function deploySetup(req: any): Promise { + // 1. Fetch the cluster's public secrets key. Target + auth are resolved by cliOperations exactly + // as they are for a deploy (harper login, or CLI_TARGET + HARPER_CLI_USERNAME/PASSWORD). + let keyResponse: any; + try { + keyResponse = await cliOperations({ operation: 'get_secrets_public_key', target: req.target }, true); + } catch (error) { + throw new Error( + `Could not fetch the cluster's secrets public key: ${error instanceof Error ? error.message : String(error)}` + ); + } + const publicKey: string | undefined = keyResponse?.public_key; + const fingerprint: string | undefined = keyResponse?.fingerprint; + if (!publicKey || !fingerprint) { + throw new Error( + "This cluster didn't return a secrets public key — secrets custody isn't initialized on it " + + '(available on Harper Pro / Fabric). Without it, a credential cannot be sealed client-side.' + ); + } + + // 2. Which private source? + const provider: string = + req.provider ?? + ( + await inquirer.prompt({ + type: 'list', + name: 'provider', + message: 'What private source needs a credential?', + choices: [ + { name: 'GitHub repository (private git clone)', value: 'github' }, + { name: 'npm registry (private packages / dependencies)', value: 'npm' }, + ], + }) + ).provider; + + // 3. Which component is the credential for? (the grant is scoped to it) + const component: string = + req.project ?? + req.component ?? + ( + await inquirer.prompt({ + type: 'input', + name: 'component', + message: 'Component (project) name this credential is for:', + default: defaultComponentName(), + }) + ).component; + + let credentialKey: string; // host (github) or registry (npm) — the credentials-entry discriminator + let credentialEntry: Record; + let token: string | undefined = req.token; + + if (provider === 'github') { + const host = req.host ?? 'github.com'; + credentialKey = host; + if (!token) { + const ghToken = tryCommand('gh', ['auth', 'token']); + const choices: Array<{ name: string; value: string }> = []; + if (ghToken) choices.push({ name: 'Use your gh CLI session token (gh auth token)', value: 'gh' }); + choices.push({ name: 'Paste a fine-grained PAT (Contents: Read-only on this repo)', value: 'paste' }); + const how = + choices.length === 1 + ? 'paste' + : ( + await inquirer.prompt({ + type: 'list', + name: 'how', + message: 'How should I get the GitHub token?', + choices, + }) + ).how; + if (how === 'gh') { + token = ghToken; + } else { + console.log( + chalk.gray( + 'Create one at https://github.com/settings/personal-access-tokens/new\n' + + ' Repository access → only your repo; Permissions → Contents: Read-only.' + ) + ); + token = (await inquirer.prompt({ type: 'password', name: 'token', message: 'Paste the token:', mask: '*' })) + .token; + } + } + credentialEntry = { host }; + } else { + const registry = req.registry ?? 'registry.npmjs.org'; + credentialKey = registry; + if (!token) { + console.log(chalk.gray('Tip: `npm token create --read-only` mints a granular npm token from the CLI.')); + token = (await inquirer.prompt({ type: 'password', name: 'token', message: 'Paste the npm token:', mask: '*' })) + .token; + } + credentialEntry = req.scope ? { registry, scope: req.scope } : { registry }; + } + + token = token?.trim(); + if (!token) throw new Error('No token was provided; nothing to store.'); + + // 4. Seal the token locally. Only ciphertext leaves this machine. + const envelope = ENV_ENCRYPTED_PREFIX + encryptEnvelope(token, publicKey, fingerprint); + + // 5. Store the sealed token, granted to the component. The server never sees the plaintext. + const secretName = deriveSecretName(component, credentialKey); + await cliOperations( + { operation: 'set_secret', name: secretName, envelope, grants: [component], target: req.target }, + true + ); + + // 6. Print the credentials reference the deploy should use. + credentialEntry.secret = secretName; + console.log(chalk.green(`\n✓ Sealed "${credentialKey}" credential and stored it as secret "${secretName}".`)); + console.log(chalk.gray(" It was encrypted here with the cluster's public key — only ciphertext was sent.")); + console.log( + chalk.gray(` Granted to component "${component}"; the cluster decrypts it only at deploy/rollback time.`) + ); + console.log('\nUse it in your deploy:'); + console.log(chalk.cyan(` credentials='${JSON.stringify([credentialEntry])}'`)); + if (provider === 'github') { + console.log( + chalk.gray( + ` e.g. harper deploy_component project=${component} package=github:/# \\\n` + + ` credentials='${JSON.stringify([credentialEntry])}' restart=true replicated=true` + ) + ); + } +} diff --git a/bin/harper.ts b/bin/harper.ts index 7f8bc8bafe..a3af126fa0 100644 --- a/bin/harper.ts +++ b/bin/harper.ts @@ -153,6 +153,13 @@ async function harper() { return require('./run').main(); default: const cliApiOp = cliOperations.buildRequest(); + // `harper deploy setup=true` provisions an encrypted deploy credential (client-side sealed + // token) rather than deploying — an interactive flow, not a single operation call. + if (cliApiOp.operation === 'deploy_component' && cliApiOp.setup) { + const { deploySetup } = require('./deploySetup'); + await deploySetup(cliApiOp); + return; + } logger.trace('calling cli operations with:', cliApiOp); await cliOperations.cliOperations(cliApiOp); return; From 7e1890785ccea98530c6265169043eadb71ec74f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Fri, 17 Jul 2026 15:57:10 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20match?= =?UTF-8?q?=20server=20git-secret-name=20convention=20+=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveSecretName now mirrors the server's deriveGitSecretName/deriveRegistrySecretName exactly: git hosts seal under deploy..git. (normalizeGitHost handling), registries under deploy.. — so a `deploy setup` seal and a literal-token deploy hit the same hdb_secret row. Also: validate provider is github|npm (reject others loudly), and guard token.trim() against a non-string token= arg. Co-Authored-By: Claude Opus 4.8 --- bin/deploySetup.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/bin/deploySetup.ts b/bin/deploySetup.ts index e4c1a3ac57..b40346b3e3 100644 --- a/bin/deploySetup.ts +++ b/bin/deploySetup.ts @@ -25,18 +25,32 @@ import { cliOperations } from './cliOperations.ts'; import { encryptEnvelope } from '../utility/secretEnvelope.ts'; import { ENV_ENCRYPTED_PREFIX } from '../utility/envFile.ts'; -// Mirror the server's derived secret name (secretOperations `deriveRegistrySecretName` / the #1799 -// `deploy..` convention) so re-running overwrites the same row rather than piling up. -function deriveSecretName(component: string, key: string): string { - const keyPart = key +// Mirror the server's derived secret names (secretOperations.ts `deriveRegistrySecretName` and +// `deriveGitSecretName`) EXACTLY, so a `deploy setup` seal and a later literal-token deploy overwrite +// the SAME hdb_secret row. git hosts get a `.git.` segment (and normalizeGitHost handling); registries +// don't — without the segment, a git and a registry credential for the same host would collide. +function deriveSecretName(component: string, key: string, provider: string): string { + const componentPart = String(component).replace(/[^\w.-]+/g, '_'); + if (provider === 'github') { + // mirrors gitCredentialServer.normalizeGitHost() + deriveGitSecretName's `.git.` segment + const hostPart = key + .trim() + .replace(/^[a-z0-9+.-]+:\/\//i, '') + .replace(/^\/\//, '') + .replace(/\/.*$/, '') + .replace(/^[^@]*@/, '') + .toLowerCase() + .replace(/[^\w.-]+/g, '_'); + return `deploy.${componentPart}.git.${hostPart}`; + } + const registryPart = key .trim() .replace(/^https?:\/\//i, '') .replace(/^\/\//, '') .replace(/\/+$/, '') .toLowerCase() .replace(/[^\w.-]+/g, '_'); - const componentPart = String(component).replace(/[^\w.-]+/g, '_'); - return `deploy.${componentPart}.${keyPart}`; + return `deploy.${componentPart}.${registryPart}`; } function tryCommand(command: string, args: string[]): string { @@ -92,6 +106,10 @@ export async function deploySetup(req: any): Promise { }) ).provider; + if (provider !== 'github' && provider !== 'npm') { + throw new Error(`Unsupported provider "${provider}" — supported providers are "github" and "npm".`); + } + // 3. Which component is the credential for? (the grant is scoped to it) const component: string = req.project ?? @@ -153,14 +171,14 @@ export async function deploySetup(req: any): Promise { credentialEntry = req.scope ? { registry, scope: req.scope } : { registry }; } - token = token?.trim(); + token = typeof token === 'string' ? token.trim() : undefined; if (!token) throw new Error('No token was provided; nothing to store.'); // 4. Seal the token locally. Only ciphertext leaves this machine. const envelope = ENV_ENCRYPTED_PREFIX + encryptEnvelope(token, publicKey, fingerprint); // 5. Store the sealed token, granted to the component. The server never sees the plaintext. - const secretName = deriveSecretName(component, credentialKey); + const secretName = deriveSecretName(component, credentialKey, provider); await cliOperations( { operation: 'set_secret', name: secretName, envelope, grants: [component], target: req.target }, true