44b915554b
壓測 §7.2:seed 通了但 creds push/push/runtime 全卡「缺少 api_key」——
self-hosted init 從不發 api_key,但三條路徑都建在多租戶 {api_key}:cred 模型上。
richblack 拍板:self-hosted 不需祕密 api_key,只需 namespace(分區標籤):
- config:ENV_MAP 加 NAMESPACE/ENCRYPTION_KEY + .env 自動載入(無 dotenv 依賴)
- namespace 明碼用戶自填(.env NAMESPACE=leo),沿用 api_key 路徑 → 零分叉
- encryption_key 用戶 .env 自填(工具不生成不 hash),須與 worker secret 一致
- creds/push/init:缺值改引導設 .env,不再叫去 register
- runtime:cypher 加 POST /webhooks/named/:ns/:name/trigger(namespace 走 path,
公開表單免 header);與 header 路徑共用 triggerNamed,不分叉
- push:self-hosted 顯示 path-based 公開 webhook URL
誠實限制:namespace 明碼非密碼;防外部呼叫靠 webhook 保護(mindset §6)。
CLI 1.3.0 → 1.3.1。SDD: self-hosted-init.md §7.7。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
5.1 KiB
TypeScript
124 lines
5.1 KiB
TypeScript
/**
|
||
* acr creds push [credentials.yaml]
|
||
*
|
||
* 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。
|
||
* Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。
|
||
*
|
||
* 不再需要用戶提供 CF API Token 或 KV Namespace ID。
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import yaml from 'js-yaml';
|
||
import chalk from 'chalk';
|
||
import ora from 'ora';
|
||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
|
||
|
||
async function encryptValue(value: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
|
||
const keyBytes = hexToUint8Array(encryptionKey);
|
||
const cryptoKey = await crypto.subtle.importKey(
|
||
'raw',
|
||
keyBytes.buffer as ArrayBuffer,
|
||
{ name: 'AES-GCM' },
|
||
false,
|
||
['encrypt'],
|
||
);
|
||
|
||
const ivBytes = crypto.getRandomValues(new Uint8Array(12));
|
||
const encoded = new TextEncoder().encode(value);
|
||
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, encoded);
|
||
|
||
return {
|
||
encrypted: Buffer.from(new Uint8Array(cipherBuffer)).toString('base64'),
|
||
iv: Buffer.from(ivBytes).toString('base64'),
|
||
};
|
||
}
|
||
|
||
function hexToUint8Array(hex: string): Uint8Array {
|
||
const bytes = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||
return bytes;
|
||
}
|
||
|
||
export async function cmdCredsPush(filePath: string): Promise<void> {
|
||
const config = loadConfig();
|
||
|
||
if (config.mode === 'local') {
|
||
console.error(chalk.red('Local 模式不支援 acr creds push。'));
|
||
console.log(chalk.gray('請先執行 acr init 設定 Standard 模式,取得 API Key。'));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!config.api_key) {
|
||
// self-hosted 用「資料分區標籤」(明碼,用戶在 .env 設 NAMESPACE)當 KV 前綴,非平台發的 api_key。
|
||
if (config.mode === 'self-hosted') {
|
||
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
|
||
console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):'));
|
||
console.log(chalk.cyan(' NAMESPACE=leo'));
|
||
console.log(chalk.gray('(要防外部呼叫請對 webhook 加保護;見 README「讓 AI 連到對的 arcrun」段)'));
|
||
} else {
|
||
console.error(chalk.red('缺少 api_key,請重新執行 acr init。'));
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
// 讀取 credentials.yaml
|
||
let creds: Record<string, string>;
|
||
try {
|
||
const raw = readFileSync(filePath, 'utf8');
|
||
creds = yaml.load(raw) as Record<string, string>;
|
||
} catch (e) {
|
||
console.error(chalk.red(`無法讀取 ${filePath}:${e instanceof Error ? e.message : e}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0);
|
||
if (entries.length === 0) {
|
||
console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)'));
|
||
return;
|
||
}
|
||
|
||
// 加密金鑰:優先從 config 讀(含 .env 的 ENCRYPTION_KEY / ARCRUN_ENCRYPTION_KEY,見 config.ts loadDotEnvOnce),
|
||
// 其次環境變數。self-hosted:你自己保管這把(工具不生成、不外傳),須與 worker 的 ENCRYPTION_KEY secret 一致。
|
||
const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? '';
|
||
if (!encryptionKey || encryptionKey.length < 64) {
|
||
if (config.mode === 'self-hosted') {
|
||
console.error(chalk.red('缺少 encryption_key(或長度不足,需 ≥64 hex chars = 256-bit)。'));
|
||
console.log(chalk.gray('在專案 .env 設(你自己保管,忘了就解不開已上傳的 credential):'));
|
||
console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # 產生:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'));
|
||
console.log(chalk.gray('同一把也要設進 worker:wrangler secret put ENCRYPTION_KEY(見 acr init 提示)'));
|
||
} else {
|
||
console.error(chalk.red('缺少 encryption_key。請重新執行 acr init 取得設定。'));
|
||
}
|
||
process.exit(1);
|
||
}
|
||
|
||
const baseUrl = getCypherExecutorUrl(config);
|
||
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`));
|
||
|
||
for (const [name, value] of entries) {
|
||
const spinner = ora(` ${name}`).start();
|
||
try {
|
||
const { encrypted, iv } = await encryptValue(String(value), encryptionKey);
|
||
|
||
const res = await fetch(`${baseUrl}/credentials`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Arcrun-API-Key': config.api_key!,
|
||
},
|
||
body: JSON.stringify({ name, encrypted, iv }),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errBody = await res.text().catch(() => '');
|
||
throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`);
|
||
}
|
||
|
||
spinner.succeed(chalk.green(` ✓ ${name}`));
|
||
} catch (e) {
|
||
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
|
||
}
|
||
}
|
||
|
||
console.log(chalk.gray('\n Credential 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n'));
|
||
}
|