@encorearia/install 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # Encore Aria installer trust anchor
2
+
3
+ This package installs only shell packages signed by an Ed25519 key pinned in
4
+ `trusted-keys.json`. The shell package API may report trusted and revoked keys,
5
+ but network data never adds a new trusted key: a package update or an explicit
6
+ `ENCOREARIA_TRUSTED_KEYS` deployment override is required.
7
+
8
+ ## Bootstrap a production key
9
+
10
+ Run this only in a secure release terminal after the backend migrations are
11
+ applied. It writes the public key and fingerprint to
12
+ `callable_shell_signing_keys`, then prints the private key once. It never
13
+ writes the private key to a file or database.
14
+
15
+ ```sh
16
+ DATABASE_URL='postgres://…' make agent-shell-keygen KEY_ID=agent-shell-YYYYMMDD
17
+ ```
18
+
19
+ Immediately place these values in the backend deployment secret manager:
20
+
21
+ ```text
22
+ AGENT_SHELL_SIGNING_KEY_ID=<printed key id>
23
+ AGENT_SHELL_SIGNING_PRIVATE_KEY=<printed private key>
24
+ ```
25
+
26
+ Copy only the printed public `trusted-keys.json` fragment into this package's
27
+ `trusted-keys.json`, commit it, and ship the installer update before enabling
28
+ the backend signer. Never paste the private value into Git, a database,
29
+ configuration file, log, ticket, or chat.
30
+
31
+ An empty `keys` array is deliberately fail-closed: installation reports that
32
+ the trust anchor is empty instead of accepting an API-supplied key. It is the
33
+ expected repository state until the first production release key has been
34
+ created securely.
35
+
36
+ ## Rotate a key
37
+
38
+ 1. Generate a new key with a new `KEY_ID`.
39
+ 2. Add its public fragment alongside the old key in `trusted-keys.json` and
40
+ release the installer.
41
+ 3. Set the two backend signing environment variables to the new key and deploy
42
+ the backend. Existing shell releases remain verifiable through the old
43
+ pinned public key.
44
+ 4. Mark the old database key `retired` only after the rollout is complete; do
45
+ not reuse a key id or replace its public key.
46
+
47
+ ## Revoke a compromised key
48
+
49
+ Use the administrator signing-key revoke endpoint. The API's revoked list
50
+ blocks online installers immediately. Follow with an installer release that
51
+ removes the revoked key from `trusted-keys.json`, because an offline installer
52
+ cannot learn a revocation. Issue a fresh key and follow the rotation procedure
53
+ before publishing new shells.
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import process from 'node:process';
3
+ import { request, ensureDeviceLogin } from '../lib/api.mjs';
4
+ import { atomicInstall } from '../lib/atomic-install.mjs';
5
+ import { loadCredentials } from '../lib/credentials.mjs';
6
+ import { detectTargets, targetRoot } from '../lib/targets.mjs';
7
+ import { loadTrustedKeys, verifyPackage } from '../lib/verify.mjs';
8
+
9
+ const args = process.argv.slice(2);
10
+ const baseURL = String(valueAfter('--api') || process.env.ENCOREARIA_API_BASE || 'https://encorearia.com').replace(/\/$/, '');
11
+ const targetArg = valueAfter('--target');
12
+ const installerVersion = '1.0.0';
13
+
14
+ if (args.includes('--list')) {
15
+ const target = (await detectTargets(targetArg ? targetArg.split(',') : []))[0];
16
+ let credentials = await loadCredentials();
17
+ if (!credentials?.access_token) {
18
+ const loginService = valueAfter('--service');
19
+ if (!loginService) fail('--list 首次使用需要同时提供 --service <id>');
20
+ credentials = await ensureDeviceLogin(baseURL, loginService, 'list', target);
21
+ }
22
+ let services;
23
+ try { services = await request(baseURL, '/api/agent/services', {}, credentials); }
24
+ catch (error) {
25
+ const loginService = valueAfter('--service');
26
+ if (!loginService) throw new Error(`设备授权已失效;请使用 --service <id> 重新授权。${error.message}`);
27
+ credentials = await ensureDeviceLogin(baseURL, loginService, 'list', target);
28
+ services = await request(baseURL, '/api/agent/services', {}, credentials);
29
+ }
30
+ for (const item of services) {
31
+ process.stdout.write(`${item.service_public_id}\t${item.title}\t${item.currency} ${(item.price_cents / 100).toFixed(2)}\t${item.active_installs} installs\n`);
32
+ }
33
+ process.exit(0);
34
+ }
35
+
36
+ const serviceID = positionalServiceID();
37
+ if (!serviceID) fail('Usage: npx @encorearia/install <service_public_id> [--target codex,claude-code] [--api URL]');
38
+ const pkg = await request(baseURL, '/api/callable-services/' + encodeURIComponent(serviceID) + '/shell');
39
+ const trust = await loadTrustedKeys(baseURL);
40
+ verifyPackage(pkg, trust, installerVersion);
41
+ const targets = await detectTargets(targetArg ? targetArg.split(',') : []);
42
+ const primaryTarget = targets[0];
43
+ const credentials = await ensureDeviceLogin(baseURL, serviceID, pkg.manifest.shell_version, primaryTarget);
44
+ for (const target of targets) {
45
+ const destination = await atomicInstall(targetRoot(target), serviceID, pkg.files, pkg.manifest);
46
+ await request(baseURL, '/api/agent/installs', { method: 'POST', body: JSON.stringify({
47
+ service_public_id: serviceID, shell_version: pkg.manifest.shell_version, client_target: target,
48
+ }) }, credentials);
49
+ process.stdout.write(`已安装到 ${target}: ${destination}\n`);
50
+ }
51
+
52
+ function valueAfter(flag) {
53
+ const index = args.indexOf(flag);
54
+ return index >= 0 ? args[index + 1] : '';
55
+ }
56
+
57
+ function positionalServiceID() {
58
+ const valuedFlags = new Set(['--api', '--target', '--service']);
59
+ for (let index = 0; index < args.length; index += 1) {
60
+ const value = args[index];
61
+ if (valuedFlags.has(value)) { index += 1; continue; }
62
+ if (!value.startsWith('--')) return value;
63
+ }
64
+ return '';
65
+ }
66
+
67
+ function fail(message) {
68
+ process.stderr.write(message + '\n');
69
+ process.exit(1);
70
+ }
package/lib/api.mjs ADDED
@@ -0,0 +1,97 @@
1
+ import os from 'node:os';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { credentialsDirectory, loadCredentials, saveCredentials } from './credentials.mjs';
6
+
7
+ export async function request(baseURL, route, options = {}, credentials = null) {
8
+ return requestWithRefresh(baseURL, route, options, credentials, true);
9
+ }
10
+
11
+ async function requestWithRefresh(baseURL, route, options, credentials, canRefresh) {
12
+ const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
13
+ if (credentials?.access_token) headers.Authorization = `Bearer ${credentials.access_token}`;
14
+ const response = await fetch(baseURL + route, { ...options, headers });
15
+ const body = await response.json().catch(() => ({}));
16
+ const data = body.data === undefined ? body : body.data;
17
+ if (response.status === 401 && canRefresh && credentials?.refresh_token && route !== '/api/agent/token/refresh') {
18
+ await refreshCredentials(baseURL, credentials);
19
+ return requestWithRefresh(baseURL, route, options, credentials, false);
20
+ }
21
+ if (!response.ok) {
22
+ const error = new Error(body.message || body.code || `HTTP ${response.status}`);
23
+ error.code = body.code;
24
+ error.payload = body;
25
+ throw error;
26
+ }
27
+ return data;
28
+ }
29
+
30
+ async function refreshCredentials(baseURL, credentials) {
31
+ const directory = credentialsDirectory();
32
+ const lock = path.join(directory, 'refresh.lock');
33
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
34
+ for (let attempt = 0; attempt < 80; attempt += 1) {
35
+ try {
36
+ await fs.mkdir(lock);
37
+ try {
38
+ const latest = await loadCredentials();
39
+ if (latest?.refresh_token && latest.refresh_token !== credentials.refresh_token) {
40
+ Object.assign(credentials, latest);
41
+ return;
42
+ }
43
+ const refreshed = await requestWithRefresh(baseURL, '/api/agent/token/refresh', {
44
+ method: 'POST', body: JSON.stringify({ refresh_token: credentials.refresh_token }),
45
+ }, null, false);
46
+ Object.assign(credentials, refreshed);
47
+ await saveCredentials(credentials);
48
+ return;
49
+ } finally {
50
+ await fs.rm(lock, { recursive: true, force: true });
51
+ }
52
+ } catch (error) {
53
+ if (error.code !== 'EEXIST') throw error;
54
+ const stat = await fs.stat(lock).catch(() => null);
55
+ if (stat && Date.now() - stat.mtimeMs > 30_000) await fs.rm(lock, { recursive: true, force: true });
56
+ await new Promise(resolve => setTimeout(resolve, 250));
57
+ const latest = await loadCredentials();
58
+ if (latest?.refresh_token && latest.refresh_token !== credentials.refresh_token) {
59
+ Object.assign(credentials, latest);
60
+ return;
61
+ }
62
+ }
63
+ }
64
+ throw new Error('Timed out waiting for the credential refresh lock.');
65
+ }
66
+
67
+ export async function ensureDeviceLogin(baseURL, serviceID, shellVersion, clientTarget) {
68
+ let credentials = await loadCredentials();
69
+ if (credentials?.access_token) {
70
+ try { await request(baseURL, '/api/agent/me', {}, credentials); return credentials; } catch {}
71
+ }
72
+ const start = await request(baseURL, '/api/agent/device/start', { method: 'POST', body: JSON.stringify({
73
+ service_public_id: serviceID, shell_version: shellVersion, client_name: os.hostname(), client_target: clientTarget,
74
+ }) });
75
+ process.stdout.write(`请在浏览器确认设备:${start.verification_uri_complete}\n验证码:${start.user_code}\n`);
76
+ openBrowser(start.verification_uri_complete);
77
+ let interval = start.interval_seconds || 5;
78
+ while (Date.now() < new Date(start.expires_at).getTime()) {
79
+ await new Promise(resolve => setTimeout(resolve, interval * 1000));
80
+ const token = await request(baseURL, '/api/agent/device/token', { method: 'POST', body: JSON.stringify({ device_code: start.device_code }) });
81
+ if (token.status === 'slow_down') { interval = token.interval_seconds || interval + 2; continue; }
82
+ if (token.status === 'authorization_pending') continue;
83
+ if (token.status !== 'authorized') throw new Error(`Device authorization ${token.status}.`);
84
+ credentials = token;
85
+ await saveCredentials(token);
86
+ return token;
87
+ }
88
+ throw new Error('Device authorization timed out.');
89
+ }
90
+
91
+ function openBrowser(url) {
92
+ const command = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
93
+ : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
94
+ const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore', windowsHide: true });
95
+ child.on('error', () => {});
96
+ child.unref();
97
+ }
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function atomicInstall(root, serviceID, files, manifest = null) {
5
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(serviceID)) throw new Error('Unsafe service identifier.');
6
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
7
+ const destination = path.join(root, serviceID);
8
+ const temporary = path.join(root, `.${serviceID}.tmp-${process.pid}-${Date.now()}`);
9
+ const backup = path.join(root, `.${serviceID}.backup-${process.pid}-${Date.now()}`);
10
+ if (manifest) await assertNoRollback(destination, manifest);
11
+ await fs.mkdir(temporary, { recursive: false, mode: 0o700 });
12
+ try {
13
+ for (const [relative, content] of Object.entries(files)) {
14
+ const target = path.resolve(temporary, relative);
15
+ if (!target.startsWith(path.resolve(temporary) + path.sep)) throw new Error(`Unsafe path: ${relative}`);
16
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
17
+ await fs.writeFile(target, content, { mode: relative.endsWith('.mjs') ? 0o700 : 0o600 });
18
+ }
19
+ if (manifest) {
20
+ await fs.writeFile(path.join(temporary, '.encorearia-release.json'), JSON.stringify({
21
+ service_public_id: manifest.service_public_id,
22
+ shell_version: manifest.shell_version,
23
+ signing_key_id: manifest.signing_key_id,
24
+ issued_at: manifest.issued_at,
25
+ }, null, 2), { mode: 0o600 });
26
+ }
27
+ let hadPrevious = false;
28
+ try { await fs.rename(destination, backup); hadPrevious = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
29
+ try {
30
+ await fs.rename(temporary, destination);
31
+ } catch (error) {
32
+ if (hadPrevious) await fs.rename(backup, destination).catch(() => {});
33
+ throw error;
34
+ }
35
+ if (hadPrevious) await fs.rm(backup, { recursive: true, force: true });
36
+ return destination;
37
+ } catch (error) {
38
+ await fs.rm(temporary, { recursive: true, force: true });
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ async function assertNoRollback(destination, manifest) {
44
+ let current;
45
+ try { current = JSON.parse(await fs.readFile(path.join(destination, '.encorearia-release.json'), 'utf8')); }
46
+ catch { return; }
47
+ if (current.service_public_id !== manifest.service_public_id) throw new Error('Installed service identity does not match the signed package.');
48
+ const oldSequence = releaseSequence(current.shell_version);
49
+ const newSequence = releaseSequence(manifest.shell_version);
50
+ const olderSequence = newSequence[0] < oldSequence[0] || (newSequence[0] === oldSequence[0] && newSequence[1] < oldSequence[1]);
51
+ const olderIssue = newSequence[0] === oldSequence[0] && newSequence[1] === oldSequence[1]
52
+ && Date.parse(manifest.issued_at) < Date.parse(current.issued_at);
53
+ if (olderSequence || olderIssue) throw new Error(`Shell rollback rejected: ${manifest.shell_version} is older than ${current.shell_version}.`);
54
+ }
55
+
56
+ function releaseSequence(value) {
57
+ const parts = String(value || '').split('.');
58
+ return [Number.parseInt(parts[0], 10) || 0, Number.parseInt(parts[1], 10) || 0];
59
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const service = 'encorearia-agent';
6
+ const account = 'default';
7
+ export const credentialsDirectory = () => process.env.ENCOREARIA_CONFIG_DIR || path.join(os.homedir(), '.encorearia');
8
+ const file = () => path.join(credentialsDirectory(), 'credentials.json');
9
+
10
+ async function keytar() {
11
+ try { return (await import('keytar')).default; } catch { return null; }
12
+ }
13
+
14
+ export async function loadCredentials() {
15
+ const keychain = await keytar();
16
+ if (keychain) {
17
+ const raw = await keychain.getPassword(service, account);
18
+ if (raw) return JSON.parse(raw);
19
+ }
20
+ try { return JSON.parse(await fs.readFile(file(), 'utf8')); } catch { return null; }
21
+ }
22
+
23
+ export async function saveCredentials(value) {
24
+ const keychain = await keytar();
25
+ if (keychain) {
26
+ await keychain.setPassword(service, account, JSON.stringify(value));
27
+ return;
28
+ }
29
+ const dir = path.dirname(file());
30
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
31
+ await fs.writeFile(file(), JSON.stringify(value, null, 2), { mode: 0o600 });
32
+ if (process.platform !== 'win32') await fs.chmod(file(), 0o600);
33
+ }
@@ -0,0 +1,28 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export const TARGETS = {
6
+ codex: () => process.env.CODEX_SKILLS_DIR || path.join(os.homedir(), '.codex', 'skills'),
7
+ 'claude-code': () => process.env.CLAUDE_SKILLS_DIR || path.join(os.homedir(), '.claude', 'skills'),
8
+ };
9
+
10
+ export async function detectTargets(requested) {
11
+ if (requested && requested.length) return [...new Set(requested.map(normalizeTarget))];
12
+ const found = [];
13
+ for (const [name, root] of Object.entries(TARGETS)) {
14
+ try { await fs.access(path.dirname(root())); found.push(name); } catch {}
15
+ }
16
+ return found.length ? found : ['codex'];
17
+ }
18
+
19
+ export function targetRoot(target) {
20
+ const normalized = normalizeTarget(target);
21
+ return TARGETS[normalized]();
22
+ }
23
+
24
+ function normalizeTarget(value) {
25
+ const target = String(value || '').toLowerCase();
26
+ if (!TARGETS[target]) throw new Error(`Unsupported Agent target: ${value}`);
27
+ return target;
28
+ }
package/lib/verify.mjs ADDED
@@ -0,0 +1,78 @@
1
+ import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+
4
+ const safePath = /^(?![./])(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/;
5
+
6
+ export function sha256(content) {
7
+ return createHash('sha256').update(content).digest('hex');
8
+ }
9
+
10
+ export function compareVersions(left, right) {
11
+ const a = String(left).split('.').map(value => Number.parseInt(value, 10) || 0);
12
+ const b = String(right).split('.').map(value => Number.parseInt(value, 10) || 0);
13
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
14
+ if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) - (b[i] || 0);
15
+ }
16
+ return 0;
17
+ }
18
+
19
+ export async function loadTrustedKeys(baseURL) {
20
+ const bundled = JSON.parse(await fs.readFile(new URL('../trusted-keys.json', import.meta.url), 'utf8'));
21
+ const injected = process.env.ENCOREARIA_TRUSTED_KEYS ? JSON.parse(process.env.ENCOREARIA_TRUSTED_KEYS) : { keys: [] };
22
+ const trusted = new Map([...(bundled.keys || []), ...(injected.keys || [])]
23
+ .filter(item => item && typeof item.key_id === 'string' && typeof item.public_key_base64 === 'string')
24
+ .map(item => [item.key_id, item.public_key_base64]));
25
+ let revoked = new Set();
26
+ try {
27
+ const response = await fetch(baseURL + '/api/agent/signing-keys');
28
+ const body = await response.json();
29
+ const data = body.data || body;
30
+ revoked = new Set((data.revoked || []).map(item => item.key_id));
31
+ } catch {
32
+ // Network failure must not expand trust. Bundled/injected trust remains,
33
+ // while a package explicitly marked revoked is still rejected below.
34
+ }
35
+ return { trusted, revoked };
36
+ }
37
+
38
+ export function verifyPackage(pkg, trust, installerVersion) {
39
+ if (!pkg || !pkg.manifest || !pkg.files) throw new Error('Invalid shell package response.');
40
+ const manifest = pkg.manifest;
41
+ if (!trust || !trust.trusted || trust.trusted.size === 0) {
42
+ throw new Error('Installer trust anchor is empty. Install an official installer release that includes this signing key, or ask the platform operator to publish the trusted-key update.');
43
+ }
44
+ if (!['active', 'retired'].includes(pkg.key_status)) throw new Error(`Signing key is ${pkg.key_status || 'untrusted'}.`);
45
+ if (trust.revoked.has(manifest.signing_key_id)) throw new Error('Signing key has been revoked.');
46
+ const trustedPublicKey = trust.trusted.get(manifest.signing_key_id);
47
+ if (!trustedPublicKey) {
48
+ throw new Error(`Signing key ${manifest.signing_key_id || '(missing)'} is not pinned by this installer. Update to an official installer release that trusts this key.`);
49
+ }
50
+ if (trustedPublicKey !== pkg.public_key_base64) {
51
+ throw new Error('Pinned signing key does not match the package public key. Stop installation and obtain an official installer update.');
52
+ }
53
+ if (compareVersions(installerVersion, manifest.min_installer_version) < 0) {
54
+ throw new Error(`Installer ${manifest.min_installer_version}+ is required.`);
55
+ }
56
+ const canonical = Buffer.from(pkg.manifest_canonical_base64 || '', 'base64');
57
+ if (!canonical.length) throw new Error('Missing canonical signed manifest.');
58
+ const parsedCanonical = JSON.parse(canonical.toString('utf8'));
59
+ if (JSON.stringify(parsedCanonical) !== JSON.stringify(manifest)) throw new Error('Manifest canonical payload does not match response.');
60
+ const rawPublic = Buffer.from(trustedPublicKey, 'base64');
61
+ if (rawPublic.length !== 32) throw new Error('Pinned Ed25519 public key has invalid length.');
62
+ const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
63
+ const publicKey = createPublicKey({ key: Buffer.concat([spkiPrefix, rawPublic]), format: 'der', type: 'spki' });
64
+ if (!verifySignature(null, canonical, publicKey, Buffer.from(pkg.signature_base64, 'base64'))) throw new Error('Shell signature verification failed.');
65
+ const listed = new Map();
66
+ for (const file of manifest.files || []) {
67
+ if (!safePath.test(file.path) || file.path.includes('..')) throw new Error(`Unsafe shell path: ${file.path}`);
68
+ if (listed.has(file.path)) throw new Error(`Duplicate shell path: ${file.path}`);
69
+ listed.set(file.path, file.sha256);
70
+ }
71
+ const actual = Object.keys(pkg.files).sort();
72
+ const expected = [...listed.keys()].sort();
73
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error('Shell file list does not exactly match the signed manifest.');
74
+ for (const [file, expectedHash] of listed) {
75
+ if (sha256(pkg.files[file]) !== expectedHash) throw new Error(`Shell file hash mismatch: ${file}`);
76
+ }
77
+ return pkg;
78
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@encorearia/install",
3
+ "version": "1.0.0",
4
+ "description": "Verify and install signed Encore Aria call-shell skills for Codex and Claude Code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "encorearia-install": "./bin/install.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "trusted-keys.json"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "license": "UNLICENSED"
21
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema_version": 1,
3
+ "keys": [
4
+ {
5
+ "key_id": "agent-shell-20260718",
6
+ "public_key_base64": "81nWHPJZePLAvdN5NbKhYWVHc1IxpsGqTrfZ1lYCfCE=",
7
+ "fingerprint_sha256": "7a8acc34839a47f49689e708c5dcc19832678cfb2b003eb041385cbe48802ca0"
8
+ }
9
+ ]
10
+ }