mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* fix(harness): print error lines again; register qm stops before QM's pack cap instead of claiming QM works Bun gives every thrown Error an own numeric 'line' property, which shadowed HarnessError.prototype.line(), so every refused gstack-harness command crashed with 'e.line is not a function'. The printer is now report(). QM imports a skill pack by cloning the whole repository and refuses past 32 MiB / 5,000 files (Harness Evals preflight, HARN-5); gstack's repository is 73 MiB in 4,280 files. register qm now measures first and stops with HARNESS_QM_PACK_TOO_LARGE before any request; QM's own cap refusal maps to the same code. docs/hosts.md lists QM as not available yet. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * s Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(harness): read QM's recorded import error for the cap; refuse to size a non-git checkout; skip symlinks like QM Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: drop a stray a/SKILL.md committed by a scratch experiment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: no doc implies QM registration works (document-release audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(install): stable's 72-hour rule also excepts a regression fix (#3533) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
139 lines
8.4 KiB
TypeScript
139 lines
8.4 KiB
TypeScript
/**
|
|
* QM (yc-software/qm) target for gstack-harness: gstack's skills become an org
|
|
* skill pack, the way QM itself imports skills (docs/skill-registry.md in the
|
|
* QM repository): an org admin registers a git repository at a pinned commit
|
|
* (POST /v1/admin/skill-packs) and imports it (POST /v1/admin/skill-packs/:id/import).
|
|
*
|
|
* Never automatic: it publishes skills to a whole organization, so it runs only
|
|
* as gstack-harness register qm --yes with an admin credential in the
|
|
* environment, and refuses with the exact request otherwise. Before creating a
|
|
* pack it lists the packs and reuses one with the same url and ref, so a retry
|
|
* after a partial failure never makes a second pack. The ledger records the
|
|
* pack id; unregister detaches the local record, and only --remote archives
|
|
* the org pack. Tokens are never printed or written.
|
|
*/
|
|
import { createHmac } from 'node:crypto';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { HarnessError, ledgerPath, readLedger, registrationId, writeLedger } from './harness-register';
|
|
|
|
type Env = Record<string, string | undefined>;
|
|
export const GSTACK_REPO_URL = 'https://github.com/garrytan/gstack';
|
|
/** Only the top-level skills: the committed renders at the repository root, not fixtures or the openclaw/ folder. */
|
|
export const QM_PACK_CONFIG = { skillGlobs: ['*/SKILL.md'] };
|
|
/** QM's git fetcher reads the whole tree before it applies skillGlobs, and refuses past these defaults (QM src/skills/pack-fetcher.ts). */
|
|
export const QM_PACK_LIMITS = { files: 5000, bytes: 32 * 1024 * 1024 };
|
|
|
|
const mib = (n: number) => (n / 1024 / 1024).toFixed(1) + ' MiB';
|
|
const tooLarge = (what: string) => new HarnessError('HARNESS_QM_PACK_TOO_LARGE', what + '. QM imports the whole repository and caps a pack at ' + mib(QM_PACK_LIMITS.bytes) + ' and ' + QM_PACK_LIMITS.files + ' files by default, so it cannot import gstack yet');
|
|
|
|
/** Files and bytes QM would read from the repository at ref. */
|
|
export function packSize(checkout: string, ref: string): { files: number; bytes: number } {
|
|
const r = spawnSync('git', ['-C', checkout, 'ls-tree', '-r', '-l', ref], { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 });
|
|
if (r.status !== 0 || !ref) throw new HarnessError('HARNESS_QM_API', 'could not read the gstack checkout at ' + (ref || 'HEAD') + ' to size the pack (QM imports a git commit, so register qm needs a git checkout of gstack)');
|
|
let files = 0; let bytes = 0;
|
|
for (const line of (r.stdout ?? '').split('\n')) {
|
|
const [mode, type, , size] = line.split(/\s+/);
|
|
if (type !== 'blob' || mode === '120000' || !Number.isFinite(Number(size))) continue;
|
|
files++; bytes += Number(size);
|
|
}
|
|
return { files, bytes };
|
|
}
|
|
|
|
export interface QmAuth { url: string; headers: (method: string, pathname: string, body: string) => Record<string, string> }
|
|
|
|
export function qmAuth(env: Env): QmAuth | null {
|
|
const url = (env.GSTACK_QM_URL ?? '').replace(/\/+$/, '');
|
|
if (!url) return null;
|
|
if (env.GSTACK_QM_ADMIN_TOKEN) {
|
|
const token = env.GSTACK_QM_ADMIN_TOKEN;
|
|
return { url, headers: () => ({ authorization: 'Bearer ' + token }) };
|
|
}
|
|
if (env.GSTACK_QM_SIGNING_SECRET && env.GSTACK_QM_ADMIN_ACTOR) {
|
|
const secret = env.GSTACK_QM_SIGNING_SECRET; const actor = env.GSTACK_QM_ADMIN_ACTOR;
|
|
return {
|
|
url, headers: (method, pathname, body) => {
|
|
const ts = Math.floor(Date.now() / 1000);
|
|
const sig = 'v0=' + createHmac('sha256', secret).update('v0:' + ts + ':' + method + '\n' + pathname + '\n' + body).digest('hex');
|
|
return { 'x-timestamp': String(ts), 'x-signature': sig, 'x-admin-actor': actor };
|
|
},
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function call(auth: QmAuth, method: string, pathname: string, payload?: unknown): Promise<any> {
|
|
const body = payload === undefined ? '' : JSON.stringify(payload);
|
|
const ctl = new AbortController();
|
|
const timer = setTimeout(() => ctl.abort(), 60_000);
|
|
try {
|
|
const res = await fetch(auth.url + pathname, {
|
|
method, signal: ctl.signal, body: method === 'GET' ? undefined : body,
|
|
headers: { 'content-type': 'application/json', ...auth.headers(method, pathname, body) },
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) throw new HarnessError('HARNESS_QM_API', method + ' ' + pathname + ' returned HTTP ' + res.status + ': ' + text.slice(0, 200));
|
|
return text ? JSON.parse(text) : {};
|
|
} catch (e) {
|
|
if (e instanceof HarnessError) throw e;
|
|
throw new HarnessError('HARNESS_QM_API', method + ' ' + pathname + ' failed: ' + (ctl.signal.aborted ? 'timed out after 60 s' : String((e as Error).message ?? e)));
|
|
} finally { clearTimeout(timer); }
|
|
}
|
|
|
|
export function checkoutCommit(checkout: string): string {
|
|
const r = spawnSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 10_000 });
|
|
return (r.stdout ?? '').trim();
|
|
}
|
|
|
|
/** The request an admin would send, printed when gstack cannot send it. */
|
|
export function qmRequest(ref: string): string {
|
|
return 'POST <QM core>/v1/admin/skill-packs ' + JSON.stringify({ url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })
|
|
+ '\nthen POST <QM core>/v1/admin/skill-packs/<pack id>/import {"selected":"all"}';
|
|
}
|
|
|
|
export interface QmResult { id: string; packId: string; ref: string; status: 'installed' | 'unchanged'; imported: unknown }
|
|
|
|
export async function registerQm(opts: { checkout: string; stateRoot: string; env: Env; version: string; yes: boolean }): Promise<QmResult> {
|
|
const ref = checkoutCommit(opts.checkout);
|
|
const auth = qmAuth(opts.env);
|
|
if (!auth || !opts.yes) {
|
|
throw new HarnessError('HARNESS_QM_CREDENTIALS', (auth ? 'registering a QM pack publishes skills to the whole organization, so it needs --yes' : 'no QM admin credential in the environment') + '. The request is:\n' + qmRequest(ref));
|
|
}
|
|
const size = packSize(opts.checkout, ref);
|
|
if (size.files > QM_PACK_LIMITS.files || size.bytes > QM_PACK_LIMITS.bytes) throw tooLarge('gstack at ' + ref.slice(0, 12) + ' is ' + mib(size.bytes) + ' in ' + size.files + ' files; nothing was sent to QM');
|
|
const id = registrationId('qm', auth.url);
|
|
const lfile = ledgerPath(opts.stateRoot, id);
|
|
const prev = readLedger(lfile);
|
|
const attempt = async () => {
|
|
const listed = await call(auth, 'GET', '/v1/admin/skill-packs');
|
|
const existing = (listed.packs ?? []).find((p: any) => p.url === GSTACK_REPO_URL && p.ref === ref);
|
|
const pack = existing ?? (await call(auth, 'POST', '/v1/admin/skill-packs', { url: GSTACK_REPO_URL, ref, config: QM_PACK_CONFIG, trustTier: 'third-party' })).pack;
|
|
if (!pack?.id) throw new HarnessError('HARNESS_QM_API', 'QM did not return a pack id');
|
|
writeLedger(lfile, { meta: { id, target: 'qm', dest: auth.url, mode: 'all', checkout: opts.checkout, version: opts.version, qm_pack: pack.id, qm_ref: ref }, files: [], dirs: [], blocks: [] });
|
|
const imported = await call(auth, 'POST', '/v1/admin/skill-packs/' + encodeURIComponent(pack.id) + '/import', { selected: 'all' }).catch(async (e) => {
|
|
const recorded = ((await call(auth, 'GET', '/v1/admin/skill-packs').catch(() => ({}))).packs ?? []).find((p: any) => p.id === pack.id)?.lastImport;
|
|
const cap = /pack exceeds max (bytes|files) \(\d+\)/.exec(String(recorded?.error ?? ''));
|
|
throw cap ? tooLarge('QM refused the pack: ' + cap[0]) : e;
|
|
});
|
|
return { pack, existing: !!existing, imported };
|
|
};
|
|
let out;
|
|
try { out = await attempt(); } catch (e) {
|
|
if (!(e instanceof HarnessError) || e.code !== 'HARNESS_QM_API') throw e;
|
|
out = await attempt();
|
|
}
|
|
return { id, packId: out.pack.id, ref, status: out.existing && prev?.meta.qm_pack === out.pack.id ? 'unchanged' : 'installed', imported: out.imported };
|
|
}
|
|
|
|
/** Archive the org pack (DELETE), then drop the local record. Without --remote, callers drop only the record. */
|
|
export async function removeQmPack(stateRoot: string, env: Env, id: string): Promise<string> {
|
|
const l = readLedger(ledgerPath(stateRoot, id));
|
|
if (!l?.meta.qm_pack) throw new HarnessError('HARNESS_NOT_REGISTERED', 'no QM pack recorded under ' + id);
|
|
const auth = qmAuth(env);
|
|
if (!auth) throw new HarnessError('HARNESS_QM_CREDENTIALS', 'archiving the org pack needs the admin credential');
|
|
await call(auth, 'DELETE', '/v1/admin/skill-packs/' + encodeURIComponent(l.meta.qm_pack));
|
|
return l.meta.qm_pack;
|
|
}
|
|
|
|
export const qmLedgerFile = (stateRoot: string, env: Env) => env.GSTACK_QM_URL ? ledgerPath(stateRoot, registrationId('qm', env.GSTACK_QM_URL.replace(/\/+$/, ''))) : path.join(stateRoot, 'harness', 'qm-none.ledger');
|