rewritable 0.9.0 → 0.10.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/bin/rwa.mjs +58 -0
- package/package.json +1 -1
- package/seeds/rewritable.html +269 -15
- package/src/skill-manifest.mjs +7 -0
- package/src/skill-publish.mjs +59 -0
package/bin/rwa.mjs
CHANGED
|
@@ -77,6 +77,10 @@ Usage:
|
|
|
77
77
|
skill into the frozen #rwa-skills zone. Requires --yes
|
|
78
78
|
(no dialog to consent in); gate failures are final.
|
|
79
79
|
--json emits {skillId,name,kind,verified,status}.
|
|
80
|
+
rwa skill publish <file> publish a SIGNED .rwa-skill.json to the marketplace
|
|
81
|
+
index (POST /skills/publish). The envelope is already
|
|
82
|
+
signed — no key needed. Online; --url overrides the
|
|
83
|
+
service, --json emits {skillId,registryUrl,verified}.
|
|
80
84
|
rwa skin <path> <name> apply a named style preset to a rewritable in
|
|
81
85
|
place (deterministic, offline, model-free). Names:
|
|
82
86
|
notion-clean, linear-dark, editorial-serif,
|
|
@@ -721,6 +725,60 @@ function detectProductKind(fileText) {
|
|
|
721
725
|
return;
|
|
722
726
|
}
|
|
723
727
|
|
|
728
|
+
// `rwa skill publish <file.rwa-skill.json> [--url base] [--json]` — publish a SIGNED skill
|
|
729
|
+
// envelope to the marketplace index (POST /skills/publish, I6 §11). The envelope is already
|
|
730
|
+
// signed (no key needed). Online by design; exit 4 labeled `publish_error` (like `publish`).
|
|
731
|
+
if (verb === 'skill') {
|
|
732
|
+
const sub = rest[0];
|
|
733
|
+
const subRest = rest.slice(1);
|
|
734
|
+
if (sub !== 'publish') {
|
|
735
|
+
process.stderr.write("rwa skill: unknown subcommand '" + (sub || '') + "' (try: rwa skill publish <file>)\n");
|
|
736
|
+
process.exitCode = 1;
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const jsonMode = subRest.includes('--json');
|
|
740
|
+
const urlFlag = getFlag('--url', subRest);
|
|
741
|
+
const urlIdx = subRest.indexOf('--url');
|
|
742
|
+
const skip = urlIdx >= 0 ? urlIdx + 1 : -1;
|
|
743
|
+
const filePath = subRest.find((a, i) => !a.startsWith('-') && i !== skip);
|
|
744
|
+
const emitSP = (payload) => {
|
|
745
|
+
if (jsonMode) { process.stderr.write(JSON.stringify(payload) + '\n'); return; }
|
|
746
|
+
const parts = [payload.code, payload.subcode].filter(Boolean);
|
|
747
|
+
let line = 'rwa skill publish: ' + parts.join('/');
|
|
748
|
+
if (payload.details && Object.keys(payload.details).length) line += ' ' + JSON.stringify(payload.details);
|
|
749
|
+
process.stderr.write(line + '\n');
|
|
750
|
+
};
|
|
751
|
+
if (!filePath) { emitSP({ code: 'usage_error', subcode: 'missing_file_arg' }); process.exitCode = 1; return; }
|
|
752
|
+
if (urlFlag.present && (urlFlag.value === undefined || urlFlag.value.startsWith('-'))) {
|
|
753
|
+
emitSP({ code: 'usage_error', subcode: 'missing_flag_value', details: { flag: '--url' } }); process.exitCode = 1; return;
|
|
754
|
+
}
|
|
755
|
+
const baseUrl = urlFlag.value || process.env.RWA_PUBLISH_URL || undefined;
|
|
756
|
+
const { skillPublishCmd } = await import('../src/skill-publish.mjs');
|
|
757
|
+
let result;
|
|
758
|
+
try {
|
|
759
|
+
result = await skillPublishCmd(filePath, { baseUrl });
|
|
760
|
+
} catch (e) {
|
|
761
|
+
if (e && typeof e.exitCode === 'number') {
|
|
762
|
+
const code = e.exitCode === 4 ? 'publish_error' : codeName(e.exitCode);
|
|
763
|
+
emitSP({ code, subcode: e.subcode, details: e.details });
|
|
764
|
+
process.exitCode = e.exitCode;
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
throw e;
|
|
768
|
+
}
|
|
769
|
+
if (jsonMode) {
|
|
770
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
771
|
+
} else {
|
|
772
|
+
process.stdout.write(
|
|
773
|
+
'✓ Published skill to the index!\n' +
|
|
774
|
+
` skillId: ${result.skillId}\n` +
|
|
775
|
+
` URL: ${result.registryUrl}\n` +
|
|
776
|
+
` verified: ${result.verified}\n`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
724
782
|
// `rwa publish-site <file> [--host h] [--path p] [--url base] [--json]` —
|
|
725
783
|
// copy a rewritable VERBATIM onto a static site over scp; print the live URL.
|
|
726
784
|
// Durable counterpart to `rwa publish` (ephemeral share). Online by design.
|
package/package.json
CHANGED
package/seeds/rewritable.html
CHANGED
|
@@ -6426,22 +6426,59 @@ function runtimeSetView(name) {
|
|
|
6426
6426
|
sessionStorage.setItem(rwaViewKey(), '');
|
|
6427
6427
|
} else {
|
|
6428
6428
|
const spec = providers.view;
|
|
6429
|
-
if (
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6429
|
+
if (spec && spec.name === name) {
|
|
6430
|
+
validateViewOutput(spec.render(currentDocCache, viewCtx()), spec); // throws → never activates
|
|
6431
|
+
releaseAnchor();
|
|
6432
|
+
if (rwaMode !== 'document') {
|
|
6433
|
+
hideEditTransients();
|
|
6434
|
+
closeRuntimePanels();
|
|
6435
|
+
rwaMode = 'document';
|
|
6436
|
+
emitRuntimeEvent('mode', { mode: rwaMode });
|
|
6437
|
+
}
|
|
6438
|
+
activeView = spec;
|
|
6439
|
+
sessionStorage.setItem(rwaViewKey(), name);
|
|
6440
|
+
} else {
|
|
6441
|
+
// I7 (v0.9 §8) — an INSTALLED view skill (by skillId or name). Resolve SYNCHRONOUSLY so an
|
|
6442
|
+
// unknown name still throws (unchanged behavior); only a genuine installed view goes async.
|
|
6443
|
+
// Its render() runs in a Worker, so we invoke once, validate the returned HTML main-side (same
|
|
6444
|
+
// contract as a first-party view — no <script>, no reserved ids), and activate a SNAPSHOT
|
|
6445
|
+
// overlay whose sync render() returns the cached HTML. A view never commits (read-only);
|
|
6446
|
+
// re-activate to refresh (auto-refresh-on-change is the deferred `observe` opt-in).
|
|
6447
|
+
const rec = installedSkills.get(name) || Array.from(installedSkills.values()).find(s => s.name === name && s.kind === 'view');
|
|
6448
|
+
if (!rec || rec.kind !== 'view' || !(rec.manifest && rec.manifest.output && rec.manifest.output.kind === 'html-render')) throw new Error('no registered view named ' + name);
|
|
6449
|
+
runtimeActivateInstalledView(rec);
|
|
6450
|
+
return;
|
|
6437
6451
|
}
|
|
6438
|
-
activeView = spec;
|
|
6439
|
-
sessionStorage.setItem(rwaViewKey(), name);
|
|
6440
6452
|
}
|
|
6441
6453
|
if (typeof syncModeChrome === 'function') syncModeChrome();
|
|
6442
6454
|
if (typeof syncViewChrome === 'function') syncViewChrome();
|
|
6443
6455
|
getDoc().then(d => renderDoc(canonLF(d)));
|
|
6444
6456
|
}
|
|
6457
|
+
async function runtimeActivateInstalledView(rec) {
|
|
6458
|
+
try {
|
|
6459
|
+
const d = canonLF(await getDoc());
|
|
6460
|
+
const html = String(await runtimeInvokeSkill(rec.skillId, { doc: d, ctx: viewCtx() }));
|
|
6461
|
+
validateViewOutput(html, { name: rec.name }); // throws → never activates (fail-loud, same as first-party)
|
|
6462
|
+
releaseAnchor();
|
|
6463
|
+
if (rwaMode !== 'document') { hideEditTransients(); closeRuntimePanels(); rwaMode = 'document'; emitRuntimeEvent('mode', { mode: rwaMode }); }
|
|
6464
|
+
activeView = { name: rec.name, label: rec.name, skillId: rec.skillId, __provenance: 'installed', __html: html, render() { return this.__html; } };
|
|
6465
|
+
if (typeof syncModeChrome === 'function') syncModeChrome();
|
|
6466
|
+
if (typeof syncViewChrome === 'function') syncViewChrome();
|
|
6467
|
+
renderDoc(d);
|
|
6468
|
+
} catch (e) {
|
|
6469
|
+
setStatus('err', '✗ ' + ((e && e.message) || 'view failed'));
|
|
6470
|
+
}
|
|
6471
|
+
}
|
|
6472
|
+
// I7 (v0.9 §8) — invoke an INSTALLED edit-surface skill: its run() returns an rwa-edit/1 envelope
|
|
6473
|
+
// (a deterministic, model-free transform), which is applied through the SAME validated commit path
|
|
6474
|
+
// the agent/lens use (frozen-zone + structural-shape guards, one ⌘Z), attributed to the skill.
|
|
6475
|
+
async function runtimeInvokeEditSurface(skillId, input) {
|
|
6476
|
+
const rec = installedSkills.get(skillId);
|
|
6477
|
+
if (!rec || rec.kind !== 'edit-surface') throw new Error('not an edit-surface skill');
|
|
6478
|
+
const envelope = await runtimeInvokeSkill(skillId, input || {});
|
|
6479
|
+
if (!envelope || typeof envelope !== 'object' || envelope.version !== 'rwa-edit/1') throw new Error('invalid_transform_output');
|
|
6480
|
+
return runtimeApplyEnvelope(envelope, { surface: 'skill:edit-surface', actor: 'skill:transform:' + String(skillId).slice(0, 8) });
|
|
6481
|
+
}
|
|
6445
6482
|
function rwaViewKey() { return 'rwa_view_active_' + DOC_UUID; }
|
|
6446
6483
|
function rwaSlideKey() { return 'rwa_view_slide_' + DOC_UUID; }
|
|
6447
6484
|
|
|
@@ -6655,6 +6692,43 @@ async function _skSourceGet(pubkey) {
|
|
|
6655
6692
|
if (!pubkey) return null;
|
|
6656
6693
|
try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
|
|
6657
6694
|
}
|
|
6695
|
+
// I6 (v0.9 §11) — TOFU author identity. Fingerprint = sha256(pubkey).hex[:16] (mirrors the
|
|
6696
|
+
// service's skillFingerprint). The per-author install count + first_seen come from rwa_sources (I5),
|
|
6697
|
+
// so the dialog can say "first time seeing this author" vs "trusted, N installs".
|
|
6698
|
+
async function _skFingerprint(pubkey) {
|
|
6699
|
+
const h = await _skSha256(_skUtf8(String(pubkey)));
|
|
6700
|
+
let s = ''; for (const b of h) s += b.toString(16).padStart(2, '0');
|
|
6701
|
+
return s.slice(0, 16);
|
|
6702
|
+
}
|
|
6703
|
+
async function _skTofu(pubkey) {
|
|
6704
|
+
const rec = await _skSourceGet(pubkey);
|
|
6705
|
+
const installs = (rec && rec.count) || 0;
|
|
6706
|
+
return { fingerprint: await _skFingerprint(pubkey), firstTime: installs === 0, installs };
|
|
6707
|
+
}
|
|
6708
|
+
// I6 (v0.9 §11) — marketplace discovery (opt-in network, like the ↗ share panel). discover →
|
|
6709
|
+
// GET /skills/index (paginated/filterable). fetch → GET /skills/index/:id → the full envelope,
|
|
6710
|
+
// VERIFIED client-side (WebCrypto Ed25519 via _skVerify) before any install; a revoked skill returns
|
|
6711
|
+
// {revoked:true}. The index only informs — install still runs the dialog + gates (the trust anchor).
|
|
6712
|
+
const SKILLS_INDEX_DEFAULT = 'https://rewritable.ikangai.com';
|
|
6713
|
+
async function runtimeDiscoverSkills(opts) {
|
|
6714
|
+
opts = opts || {};
|
|
6715
|
+
const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
|
|
6716
|
+
const qs = new URLSearchParams();
|
|
6717
|
+
for (const k of ['kind', 'author', 'search', 'verified_only', 'page', 'limit']) if (opts[k] != null) qs.set(k, String(opts[k]));
|
|
6718
|
+
const res = await fetch(base + '/skills/index' + (qs.toString() ? '?' + qs.toString() : ''));
|
|
6719
|
+
if (!res.ok) throw new Error('discover_failed:' + res.status);
|
|
6720
|
+
return res.json(); // { entries, total, page, limit }
|
|
6721
|
+
}
|
|
6722
|
+
async function runtimeFetchSkillFromIndex(skillId, opts) {
|
|
6723
|
+
opts = opts || {};
|
|
6724
|
+
const base = (opts.baseUrl || SKILLS_INDEX_DEFAULT).replace(/\/+$/, '');
|
|
6725
|
+
const res = await fetch(base + '/skills/index/' + encodeURIComponent(skillId));
|
|
6726
|
+
if (res.status === 410) return { revoked: true, envelope: null, verified: false };
|
|
6727
|
+
if (!res.ok) throw new Error('fetch_failed:' + res.status);
|
|
6728
|
+
const data = await res.json();
|
|
6729
|
+
const verified = await _skVerify(data.envelope); // client-side: never trust the index's `verified`
|
|
6730
|
+
return { envelope: data.envelope, metadata: data.metadata, verified, revoked: false };
|
|
6731
|
+
}
|
|
6658
6732
|
// Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
|
|
6659
6733
|
async function _skSourceRecord(pubkey, name, at) {
|
|
6660
6734
|
if (!pubkey || !name) return;
|
|
@@ -6766,6 +6840,64 @@ async function runtimeVaultNamespaces() {
|
|
|
6766
6840
|
for (const k of Object.keys(rec.entries)) { const i = k.indexOf('\0'); if (i > 0) set.add(k.slice(0, i)); }
|
|
6767
6841
|
return Array.from(set);
|
|
6768
6842
|
}
|
|
6843
|
+
// ── I13 (v0.9 §14) — portable vault EXPORT/IMPORT (offline; escrow + account service deferred to
|
|
6844
|
+
// v1). A version-tagged, self-contained `rwa-vault-export/1` file: selected namespaces re-encrypted
|
|
6845
|
+
// under a SEPARATE transport passphrase (PBKDF2-200k + AES-256-GCM, per-namespace salt + check), so
|
|
6846
|
+
// it decrypts on another machine with only the passphrase — no server. The machine-local vault stays
|
|
6847
|
+
// the default; this travels ONLY on an explicit user action. Requires the vault unlocked (to read
|
|
6848
|
+
// plaintext to re-wrap). Never logs the passphrase. CLI/Worker have no access (UI/runtime action only).
|
|
6849
|
+
async function runtimeVaultExport(passphrase, namespaces) {
|
|
6850
|
+
if (!_vaultKey) throw new Error('vault_locked');
|
|
6851
|
+
if (!passphrase) throw new Error('vault_bad_passphrase');
|
|
6852
|
+
const rec = await _vaultLoadRec();
|
|
6853
|
+
const allNs = await runtimeVaultNamespaces();
|
|
6854
|
+
const sel = (Array.isArray(namespaces) && namespaces.length) ? namespaces.filter(n => allNs.includes(n)) : allNs;
|
|
6855
|
+
const out = { rwa: 'rwa-vault-export/1', containerUuid: DOC_UUID, exportedAt: Date.now(), namespaces: sel, entries: {} };
|
|
6856
|
+
for (const ns of sel) {
|
|
6857
|
+
const salt = _skB64(crypto.getRandomValues(new Uint8Array(16)));
|
|
6858
|
+
const ekey = await _vaultDeriveKey(passphrase, salt);
|
|
6859
|
+
const enc = async (text) => { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ekey, _skUtf8(text))); return { iv: _skB64(iv), ct: _skB64(ct) }; };
|
|
6860
|
+
const items = [];
|
|
6861
|
+
for (const k of Object.keys(rec.entries)) {
|
|
6862
|
+
const i = k.indexOf('\0'); if (i <= 0 || k.slice(0, i) !== ns) continue;
|
|
6863
|
+
const plain = await _vaultDec(rec.entries[k]); // decrypt under the LOCAL vault key…
|
|
6864
|
+
items.push(Object.assign({ key: k.slice(i + 1) }, await enc(plain))); // …re-encrypt under the export key
|
|
6865
|
+
}
|
|
6866
|
+
out.entries[ns] = { salt, check: await enc('rwa-vault-export-ok'), items };
|
|
6867
|
+
}
|
|
6868
|
+
return out;
|
|
6869
|
+
}
|
|
6870
|
+
async function runtimeVaultImport(exportObj, passphrase, opts) {
|
|
6871
|
+
opts = opts || {};
|
|
6872
|
+
if (!_vaultKey) throw new Error('vault_locked'); // need the local key to re-wrap imported items
|
|
6873
|
+
if (!exportObj || exportObj.rwa !== 'rwa-vault-export/1' || !exportObj.entries || typeof exportObj.entries !== 'object') throw new Error('account_export_malformed');
|
|
6874
|
+
const rec = await _vaultLoadRec();
|
|
6875
|
+
const result = { imported: 0, skipped: 0, namespaces: [], containerMismatch: exportObj.containerUuid !== DOC_UUID };
|
|
6876
|
+
for (const ns of Object.keys(exportObj.entries)) {
|
|
6877
|
+
const e = exportObj.entries[ns];
|
|
6878
|
+
if (!e || typeof e.salt !== 'string' || !Array.isArray(e.items)) throw new Error('account_export_malformed');
|
|
6879
|
+
const ekey = await _vaultDeriveKey(passphrase, e.salt);
|
|
6880
|
+
const dec = async (entry) => new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: _skFromB64(entry.iv) }, ekey, _skFromB64(entry.ct)));
|
|
6881
|
+
if (e.check) { try { await dec(e.check); } catch (_) { throw new Error('vault_decrypt_failed'); } } // wrong passphrase fails here, before any write
|
|
6882
|
+
for (const it of e.items) {
|
|
6883
|
+
let plain; try { plain = await dec(it); } catch (_) { throw new Error('vault_decrypt_failed'); }
|
|
6884
|
+
const dk = ns + '\0' + it.key;
|
|
6885
|
+
if (rec.entries[dk] && !opts.overwrite) { result.skipped++; continue; } // don't clobber without explicit overwrite
|
|
6886
|
+
rec.entries[dk] = await _vaultEnc(plain); // re-encrypt under the LOCAL vault key (usable immediately)
|
|
6887
|
+
result.imported++;
|
|
6888
|
+
}
|
|
6889
|
+
result.namespaces.push(ns);
|
|
6890
|
+
}
|
|
6891
|
+
try { await idbPut(RWA.VAULT, rec); } catch (_) { throw new Error('vault_storage_error'); }
|
|
6892
|
+
return result;
|
|
6893
|
+
}
|
|
6894
|
+
// I13 — live-only account identity (opt-in, sessionStorage rwa_account; default null). Never stamped
|
|
6895
|
+
// into the file; never exposed to skill code (UI/describe only). Escrow/account-service deferred to v1.
|
|
6896
|
+
function runtimeAccountIdentity() {
|
|
6897
|
+
let raw = null; try { raw = sessionStorage.getItem('rwa_account'); } catch (_) { return null; }
|
|
6898
|
+
if (!raw) return null;
|
|
6899
|
+
try { const a = JSON.parse(raw); return a && a.mode ? { mode: a.mode, accountId: a.accountId || null, lastSync: a.lastSync || null } : null; } catch (_) { return null; }
|
|
6900
|
+
}
|
|
6769
6901
|
// §6 — the bridge's per-call vault gate (mirror of cli/src/skill-manifest.mjs vaultNamespaceAllowed).
|
|
6770
6902
|
function _skVaultAllowed(skill, ns) {
|
|
6771
6903
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
@@ -6776,6 +6908,9 @@ function _skBusAllowed(skill, topic) {
|
|
|
6776
6908
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6777
6909
|
return perms.indexOf('bus:' + topic) !== -1;
|
|
6778
6910
|
}
|
|
6911
|
+
// §5 (I1b) — per-message subscribe filter. true for all today; I12 wires a peer-allowlist here
|
|
6912
|
+
// (defense-in-depth — a declared bus: perm can't be further runtime-restricted; Shape B holds).
|
|
6913
|
+
function _skBusMessageAllowed(_skill, _envelope) { return true; }
|
|
6779
6914
|
// §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
|
|
6780
6915
|
// a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
|
|
6781
6916
|
function _skFsAllowed(skill, path) {
|
|
@@ -6955,6 +7090,13 @@ function _skValidateInstall(skill, vr) {
|
|
|
6955
7090
|
if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
|
|
6956
7091
|
// §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
|
|
6957
7092
|
if (skill.kind === 'hook' && perms.some(p => { try { return _skParsePermission(p).tier !== 'hook'; } catch (_) { return false; } })) errors.push('compute_with_permissions');
|
|
7093
|
+
// §8 (I7): view/edit-surface are zero-capability DOM authors — reject any permission + require a
|
|
7094
|
+
// matching typed output contract (view → html-render, edit-surface → dom-transform).
|
|
7095
|
+
if (skill.kind === 'view' || skill.kind === 'edit-surface') {
|
|
7096
|
+
if (perms.length > 0) errors.push('output_skill_with_permissions');
|
|
7097
|
+
const want = skill.kind === 'view' ? 'html-render' : 'dom-transform';
|
|
7098
|
+
if (!skill.output || skill.output.kind !== want) errors.push('invalid_output_kind');
|
|
7099
|
+
}
|
|
6958
7100
|
if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
|
|
6959
7101
|
// Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
|
|
6960
7102
|
if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
|
|
@@ -7004,6 +7146,8 @@ async function runtimeReviewSkill(envelope) {
|
|
|
7004
7146
|
} catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
|
|
7005
7147
|
// I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
|
|
7006
7148
|
const nameInfo = await _skNameChange(skill.author_pubkey, skill.name);
|
|
7149
|
+
// I6 — TOFU author identity (fingerprint + per-author install count) for the install dialog.
|
|
7150
|
+
const tofu = await _skTofu(skill.author_pubkey);
|
|
7007
7151
|
return {
|
|
7008
7152
|
name: skill.name, version: skill.version, kind: skill.kind,
|
|
7009
7153
|
purpose: skill.description || '(no description provided)',
|
|
@@ -7011,7 +7155,7 @@ async function runtimeReviewSkill(envelope) {
|
|
|
7011
7155
|
permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
|
|
7012
7156
|
compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
|
|
7013
7157
|
lookalike, lookalikeKind, lookalikeBlock,
|
|
7014
|
-
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange,
|
|
7158
|
+
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange, tofu,
|
|
7015
7159
|
gates: _skValidateInstall(skill, vr), update,
|
|
7016
7160
|
};
|
|
7017
7161
|
}
|
|
@@ -7070,6 +7214,7 @@ async function runtimeInstallSkill(envelope) {
|
|
|
7070
7214
|
// I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
|
|
7071
7215
|
// install). On a same-key rename the new name is appended; identity stays anchored on the key.
|
|
7072
7216
|
await _skSourceRecord(skill.author_pubkey, skill.name);
|
|
7217
|
+
_skEvictPool(id); // I2 — an install/update may change the code → drop any stale pooled Workers
|
|
7073
7218
|
return { ok: true, skillId: id };
|
|
7074
7219
|
}
|
|
7075
7220
|
// §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
|
|
@@ -7077,6 +7222,7 @@ async function runtimeUninstallSkill(skillId) {
|
|
|
7077
7222
|
const prev = installedSkills.get(skillId);
|
|
7078
7223
|
if (!prev) return { ok: false, errors: ['not_installed'] };
|
|
7079
7224
|
installedSkills.delete(skillId);
|
|
7225
|
+
_skEvictPool(skillId); // I2 — drop any pooled Workers for the removed skill
|
|
7080
7226
|
try {
|
|
7081
7227
|
await runtimeRegionCommit({ regions: [_skSkillsRegion()], actor: 'skill:uninstall', reachability: 'frozen' });
|
|
7082
7228
|
} catch (e) {
|
|
@@ -7253,6 +7399,8 @@ function showSkillInstallDialog(envelope) {
|
|
|
7253
7399
|
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
|
|
7254
7400
|
'<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
|
|
7255
7401
|
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
|
|
7402
|
+
// I6 — TOFU author identity: the key fingerprint + whether you've installed from this author before.
|
|
7403
|
+
(rv.tofu ? '<p style="margin:.2em 0;color:#555">🔑 Author fingerprint: <code>' + _skEsc(rv.tofu.fingerprint) + '</code>. ' + (rv.tofu.firstTime ? 'First time seeing this author.' : 'Trusted — ' + rv.tofu.installs + ' previous install' + (rv.tofu.installs === 1 ? '' : 's') + '.') + '</p>' : '') +
|
|
7256
7404
|
lookalikeHtml +
|
|
7257
7405
|
nameChangeHtml +
|
|
7258
7406
|
updHtml +
|
|
@@ -7346,13 +7494,13 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
7346
7494
|
'use strict';
|
|
7347
7495
|
var REMOVE = ['importScripts','Worker','SharedWorker','ServiceWorkerContainer','XMLHttpRequest','WebSocket','EventSource','indexedDB','eval','Function','fetch','WebAssembly'];
|
|
7348
7496
|
for (var i=0;i<REMOVE.length;i++){ try { Object.defineProperty(self, REMOVE[i], { value: undefined, writable: false, configurable: false }); } catch(_e){} }
|
|
7349
|
-
var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={};
|
|
7497
|
+
var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={}, _subs={};
|
|
7350
7498
|
function _bridge(type, payload){ return new Promise(function(res,rej){ var id=++_seq; _pending.set(id,{res:res,rej:rej}); self.postMessage({ type:type, id:id, identity_tag:IDENTITY, payload:payload }); }); }
|
|
7351
7499
|
function _serializeOpts(o){ if(!o||typeof o!=='object') return undefined; var out={}; if(o.method) out.method=String(o.method); if(typeof o.body==='string') out.body=o.body; if(o.headers&&typeof o.headers==='object') out.headers=o.headers; return out; }
|
|
7352
7500
|
function _installBridge(){
|
|
7353
7501
|
RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
|
|
7354
7502
|
RUNTIME.vault={ get:function(ns,k){ return _bridge('bridge:vault',{op:'get',ns:ns,key:k}); }, set:function(ns,k,v){ return _bridge('bridge:vault',{op:'set',ns:ns,key:k,val:v}); }, has:function(ns,k){ return _bridge('bridge:vault',{op:'has',ns:ns,key:k}); } };
|
|
7355
|
-
RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); } };
|
|
7503
|
+
RUNTIME.bus={ publish:function(topic,message){ return _bridge('bridge:bus:publish',{ topic:String(topic), message:message }); }, subscribe:function(topic,cb){ var t=String(topic); return _bridge('bridge:bus:subscribe',{ topic:t }).then(function(){ (_subs[t]=_subs[t]||[]).push(cb); return function(){ var a=_subs[t]||[], i=a.indexOf(cb); if(i>=0) a.splice(i,1); try{ _bridge('bridge:bus:unsubscribe',{ topic:t }); }catch(_e){} }; }); } };
|
|
7356
7504
|
RUNTIME.fs={ read:function(p){ return _bridge('bridge:fs',{op:'read',path:String(p)}); }, write:function(p,d){ return _bridge('bridge:fs',{op:'write',path:String(p),data:d}); }, del:function(p){ return _bridge('bridge:fs',{op:'del',path:String(p)}); }, list:function(p){ return _bridge('bridge:fs',{op:'list',path:String(p)}); } };
|
|
7357
7505
|
RUNTIME.db={ get:function(s,k){ return _bridge('bridge:idb',{op:'get',store:String(s),key:k}); }, put:function(s,k,v){ return _bridge('bridge:idb',{op:'put',store:String(s),key:k,value:v}); }, del:function(s,k){ return _bridge('bridge:idb',{op:'del',store:String(s),key:k}); }, all:function(s){ return _bridge('bridge:idb',{op:'all',store:String(s)}); } };
|
|
7358
7506
|
}
|
|
@@ -7366,10 +7514,85 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
7366
7514
|
.catch(function(err){ self.postMessage({ type:'result', id:msg.id, identity_tag:IDENTITY, ok:false, error:String(err&&err.message||err) }); });
|
|
7367
7515
|
return;
|
|
7368
7516
|
}
|
|
7517
|
+
if(msg.type==='bus:message'){ var env=msg.envelope||{}, subs=_subs[env.topic]||[]; for(var i=0;i<subs.length;i++){ try{ subs[i](env); }catch(_e){} } return; } // I1b — deliver to skill-side subscribers
|
|
7518
|
+
if(msg.type==='shutdown'){ try{ self.postMessage({ type:'shutdown_ack', identity_tag:null }); }catch(_e){} return; } // I2 — pool drain handshake
|
|
7369
7519
|
};
|
|
7370
7520
|
})();
|
|
7371
7521
|
`;
|
|
7372
7522
|
|
|
7523
|
+
// ── I2 (v0.9 §10) — optional compute-Worker POOL. DISABLED BY DEFAULT: only an explicit
|
|
7524
|
+
// poolingHint {pooling:'enabled'} on a COMPUTE skill (no role) takes this path; every other invoke
|
|
7525
|
+
// (tools, agent-role, no-hint) rides the byte-unchanged spawn→invoke→terminate path below. Pooled
|
|
7526
|
+
// Workers are compute-only (bridgeless, same worker-scoped CSP), keyed by skillId+code-hash (a code
|
|
7527
|
+
// change evicts the pool), bounded by an idle timeout + a hard cap, and drained on shutdown. Per-
|
|
7528
|
+
// invocation isolation is unchanged: each invoke re-inits a fresh identity_tag and races the 5s
|
|
7529
|
+
// timeout; a timeout/error terminates the Worker (never returns it to the pool). Statelessness is
|
|
7530
|
+
// the author's responsibility (Inv 25; no global reset between invokes — documented "pool only if pure").
|
|
7531
|
+
const SKILL_POOLS = new Map(); // skillId → { codeHash, idle:[Worker], lastUsed:Map<Worker,ts> }
|
|
7532
|
+
const SKILL_POOL_CAP = Math.min(4, (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 1);
|
|
7533
|
+
let SKILL_POOL_IDLE_MS = 60000; // mutable so the browser proof can shorten it
|
|
7534
|
+
async function _skCodeHash(skillId, code) {
|
|
7535
|
+
return _skB64url(await _skSha256(_skConcat(_skUtf8(String(skillId)), _skNUL, _skUtf8(String(code || '')))));
|
|
7536
|
+
}
|
|
7537
|
+
function _skSpawnComputeWorker(skill) {
|
|
7538
|
+
const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
|
|
7539
|
+
const url = URL.createObjectURL(blob);
|
|
7540
|
+
const w = new Worker(url); w.__rwaUrl = url; return w;
|
|
7541
|
+
}
|
|
7542
|
+
function _skKillWorker(w) { try { w.terminate(); } catch (_) {} try { URL.revokeObjectURL(w.__rwaUrl); } catch (_) {} }
|
|
7543
|
+
function _skEnforcePoolCap(pool) {
|
|
7544
|
+
while (pool.idle.length > SKILL_POOL_CAP) {
|
|
7545
|
+
let oi = 0; for (let i = 1; i < pool.idle.length; i++) if ((pool.lastUsed.get(pool.idle[i]) || 0) < (pool.lastUsed.get(pool.idle[oi]) || 0)) oi = i;
|
|
7546
|
+
const [w] = pool.idle.splice(oi, 1); pool.lastUsed.delete(w); _skKillWorker(w); // evict oldest-idle
|
|
7547
|
+
}
|
|
7548
|
+
}
|
|
7549
|
+
function _skEvictPool(skillId) { // code-hash may change on install/update/uninstall → drop stale Workers
|
|
7550
|
+
const pool = SKILL_POOLS.get(skillId);
|
|
7551
|
+
if (pool) { pool.idle.forEach(_skKillWorker); SKILL_POOLS.delete(skillId); }
|
|
7552
|
+
}
|
|
7553
|
+
function _skPoolEvictIdle() { // background sweep: terminate Workers idle ≥ SKILL_POOL_IDLE_MS
|
|
7554
|
+
const now = Date.now();
|
|
7555
|
+
for (const pool of SKILL_POOLS.values())
|
|
7556
|
+
pool.idle = pool.idle.filter(w => { if (now - (pool.lastUsed.get(w) || 0) >= SKILL_POOL_IDLE_MS) { pool.lastUsed.delete(w); _skKillWorker(w); return false; } return true; });
|
|
7557
|
+
}
|
|
7558
|
+
async function _skPoolShutdown() { // send shutdown, 500ms grace, terminate (idempotent)
|
|
7559
|
+
const all = [];
|
|
7560
|
+
for (const pool of SKILL_POOLS.values()) for (const w of pool.idle) all.push(w);
|
|
7561
|
+
for (const w of all) { try { w.postMessage({ type: 'shutdown', identity_tag: null }); } catch (_) {} }
|
|
7562
|
+
await new Promise(r => setTimeout(r, 500));
|
|
7563
|
+
for (const w of all) _skKillWorker(w);
|
|
7564
|
+
SKILL_POOLS.clear();
|
|
7565
|
+
}
|
|
7566
|
+
function runtimePoolStats() {
|
|
7567
|
+
const pools = {}; let live = 0;
|
|
7568
|
+
for (const [id, pool] of SKILL_POOLS) { pools[id] = pool.idle.length; live += pool.idle.length; }
|
|
7569
|
+
return { live, cap: SKILL_POOL_CAP, idleMs: SKILL_POOL_IDLE_MS, pools };
|
|
7570
|
+
}
|
|
7571
|
+
// Run ONE invocation on a pooled compute Worker (reuse-or-spawn). Success → return to pool; timeout
|
|
7572
|
+
// or error → terminate (never pool). Compute skills have no bridge, so a pooled Worker only ever
|
|
7573
|
+
// emits `result` — the onmessage here is the full message contract for the pooled path.
|
|
7574
|
+
async function _skPooledInvoke(skillId, skill, input) {
|
|
7575
|
+
const codeHash = await _skCodeHash(skillId, skill.code);
|
|
7576
|
+
let pool = SKILL_POOLS.get(skillId);
|
|
7577
|
+
if (pool && pool.codeHash !== codeHash) { _skEvictPool(skillId); pool = null; }
|
|
7578
|
+
if (!pool) { pool = { codeHash, idle: [], lastUsed: new Map() }; SKILL_POOLS.set(skillId, pool); }
|
|
7579
|
+
const w = pool.idle.pop() || _skSpawnComputeWorker(skill);
|
|
7580
|
+
const tag = crypto.randomUUID();
|
|
7581
|
+
return new Promise((resolve, reject) => {
|
|
7582
|
+
let settled = false;
|
|
7583
|
+
const done = (fn, arg, keep) => {
|
|
7584
|
+
if (settled) return; settled = true; clearTimeout(timer); w.onmessage = null; w.onerror = null;
|
|
7585
|
+
if (keep) { pool.lastUsed.set(w, Date.now()); pool.idle.push(w); _skEnforcePoolCap(pool); }
|
|
7586
|
+
else { pool.lastUsed.delete(w); _skKillWorker(w); }
|
|
7587
|
+
fn(arg);
|
|
7588
|
+
};
|
|
7589
|
+
const timer = setTimeout(() => done(reject, new Error('timeout'), false), 5000); // per-invocation, not per-tenure
|
|
7590
|
+
w.onmessage = (e) => { const m = e.data; if (!m || m.identity_tag !== tag) return; if (m.type === 'result') m.ok ? done(resolve, m.result, true) : done(reject, new Error(m.error || 'runtime_error'), false); };
|
|
7591
|
+
w.onerror = () => done(reject, new Error('runtime_error'), false);
|
|
7592
|
+
w.postMessage({ type: 'init', identity_tag: tag, bridged: false }); // re-init each invoke: fresh tag, never bridged
|
|
7593
|
+
w.postMessage({ type: 'invoke', id: 1, input });
|
|
7594
|
+
});
|
|
7595
|
+
}
|
|
7373
7596
|
// §5a — invoke an installed skill in an isolated Worker. compute = bridgeless; tool =
|
|
7374
7597
|
// bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
|
|
7375
7598
|
// Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
|
|
@@ -7397,6 +7620,9 @@ function runtimeInvokeSkill(skillId, input, opts) {
|
|
|
7397
7620
|
// skills (which never passed through runtimeInstallSkill's gate), every kind.
|
|
7398
7621
|
const forbidden = _skCodeForbidden(skill.code);
|
|
7399
7622
|
if (forbidden) return Promise.reject(new Error(forbidden));
|
|
7623
|
+
// I2 — opt-in compute-Worker pool. Only a compute skill with an explicit pooling hint and no
|
|
7624
|
+
// agent-role takes the warm path; everything else falls through to the byte-unchanged fresh spawn.
|
|
7625
|
+
if (opts && opts.pooling === 'enabled' && skill.kind === 'compute' && !agentRole) return _skPooledInvoke(skillId, skill, input);
|
|
7400
7626
|
const identity_tag = crypto.randomUUID();
|
|
7401
7627
|
const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
|
|
7402
7628
|
const url = URL.createObjectURL(blob);
|
|
@@ -7405,8 +7631,9 @@ function runtimeInvokeSkill(skillId, input, opts) {
|
|
|
7405
7631
|
// F6: cancel an in-flight bridge fetch when the 5s timeout (or any settle)
|
|
7406
7632
|
// fires, so a slow request doesn't keep running after the skill is finished.
|
|
7407
7633
|
const _skAc = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
7634
|
+
const busSubs = []; // I1b — active bus subscriptions for THIS invoke; torn down on settle
|
|
7408
7635
|
return new Promise((resolve, reject) => {
|
|
7409
|
-
const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
|
|
7636
|
+
const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); if (_skAc) { try { _skAc.abort(); } catch (_) {} } for (const u of busSubs) { try { u(); } catch (_) {} } try { w.terminate(); } catch (_) {} URL.revokeObjectURL(url); fn(arg); };
|
|
7410
7637
|
const timer = setTimeout(() => finish(reject, new Error('timeout')), 5000);
|
|
7411
7638
|
const reply = (id, ok, extra) => w.postMessage(Object.assign({ type: 'bridge:response', id, identity_tag, ok }, extra));
|
|
7412
7639
|
w.onmessage = async (e) => {
|
|
@@ -7451,6 +7678,22 @@ function runtimeInvokeSkill(skillId, input, opts) {
|
|
|
7451
7678
|
catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
|
|
7452
7679
|
return;
|
|
7453
7680
|
}
|
|
7681
|
+
if (msg.type === 'bridge:bus:subscribe') {
|
|
7682
|
+
const pl = msg.payload || {};
|
|
7683
|
+
if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
|
|
7684
|
+
// I1b — forward each allowed envelope to the Worker until it settles (finish() tears these
|
|
7685
|
+
// down). The 5s timeout bounds the subscribe CALL (the ok reply), not the subscription.
|
|
7686
|
+
try {
|
|
7687
|
+
const unsub = runtimeBusSubscribe(pl.topic, (env) => {
|
|
7688
|
+
if (!_skBusMessageAllowed(skill, env)) return;
|
|
7689
|
+
try { w.postMessage({ type: 'bus:message', id: msg.id, identity_tag, envelope: { topic: env.topic, from: env.from, at: env.at, message: env.message } }); } catch (_) {}
|
|
7690
|
+
});
|
|
7691
|
+
busSubs.push(unsub);
|
|
7692
|
+
reply(msg.id, true, { result: true });
|
|
7693
|
+
} catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
|
|
7694
|
+
return;
|
|
7695
|
+
}
|
|
7696
|
+
if (msg.type === 'bridge:bus:unsubscribe') { reply(msg.id, true, { result: true }); return; } // MVP: precise teardown deferred; finish() unsubscribes all on settle
|
|
7454
7697
|
if (msg.type === 'bridge:fs') {
|
|
7455
7698
|
const pl = msg.payload || {}, op = pl.op, p = pl.path;
|
|
7456
7699
|
// Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
|
|
@@ -7558,6 +7801,9 @@ function runtimeDescribe() {
|
|
|
7558
7801
|
// undo-only — there is no redo (re-write-able-spec Invariant 7).
|
|
7559
7802
|
baseline: { edit: ['lens'], tools: ['apply_dsl_plan', 'apply_edits', 'replace_document'], export: ['html', 'print'], history: ['undo'] },
|
|
7560
7803
|
activeView: activeView ? activeView.name : null,
|
|
7804
|
+
// I13 (v0.9 §14) — opt-in account identity, LIVE-only (never stamped into the file). null unless
|
|
7805
|
+
// the user linked an account this session (sessionStorage rwa_account); machine-local is the default.
|
|
7806
|
+
accountIdentity: runtimeAccountIdentity(),
|
|
7561
7807
|
};
|
|
7562
7808
|
}
|
|
7563
7809
|
|
|
@@ -8741,12 +8987,16 @@ document.addEventListener('keydown', e => {
|
|
|
8741
8987
|
describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
|
|
8742
8988
|
listSkills: runtimeListSkills, // v0.8 §8 — installed skills (provenance:'installed')
|
|
8743
8989
|
invokeSkill: runtimeInvokeSkill, // v0.8 §5a — run a skill in an isolated Worker
|
|
8990
|
+
invokeEditSurface: runtimeInvokeEditSurface, // v0.9 §8 (I7) — run an edit-surface skill → apply its rwa-edit/1 transform
|
|
8991
|
+
poolStats: runtimePoolStats, // v0.9 §10 (I2) — compute-Worker pool observability {live,cap,idleMs,pools}
|
|
8744
8992
|
reviewSkill: runtimeReviewSkill, // v0.8 §1 — structured trust info for the install dialog
|
|
8745
8993
|
installSkill: runtimeInstallSkill, // v0.8 §1/§7 — gates + verify + register + persist to the frozen zone (survives reload)
|
|
8746
8994
|
uninstallSkill: runtimeUninstallSkill, // v0.8 §7 — remove + persist
|
|
8747
8995
|
showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
|
|
8748
8996
|
promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
|
|
8749
|
-
|
|
8997
|
+
discoverSkills: runtimeDiscoverSkills, // v0.9 §11 (I6) — GET the marketplace index (opt-in network)
|
|
8998
|
+
fetchSkillFromIndex: runtimeFetchSkillFromIndex, // v0.9 §11 (I6) — fetch + client-side-verify an indexed skill
|
|
8999
|
+
vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked, export: runtimeVaultExport, import: runtimeVaultImport }, // v0.8 §6 + v0.9 §14 (I13) portable export/import
|
|
8750
9000
|
agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
|
|
8751
9001
|
hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
|
|
8752
9002
|
};
|
|
@@ -8764,6 +9014,10 @@ document.addEventListener('keydown', e => {
|
|
|
8764
9014
|
configurable: false,
|
|
8765
9015
|
});
|
|
8766
9016
|
startWorkspacePresence();
|
|
9017
|
+
// I2 (v0.9 §10) — background idle eviction (every 30s, terminate compute Workers idle ≥ idleMs)
|
|
9018
|
+
// + drain the pool on unload (shutdown handshake + 500ms grace). No-op until a pooled invoke runs.
|
|
9019
|
+
setInterval(_skPoolEvictIdle, 30000);
|
|
9020
|
+
window.addEventListener('pagehide', () => { _skPoolShutdown(); });
|
|
8767
9021
|
// §5.10: the presentation render mode ships ONLY for presentation
|
|
8768
9022
|
// containers. For every other kind this block is skipped entirely —
|
|
8769
9023
|
// activeView stays null, no provider is registered, no chrome is built,
|
package/src/skill-manifest.mjs
CHANGED
|
@@ -348,6 +348,13 @@ export function validateInstall(envelope, { signed, verified } = {}) {
|
|
|
348
348
|
// §9 (I8): a hook is compute-only — only hook:<event> perms are allowed; any other tier (a real
|
|
349
349
|
// capability) is rejected as compute_with_permissions (no network/vault/escalation in a hook).
|
|
350
350
|
if (skill.kind === 'hook' && perms.some((p) => { try { return parsePermission(p).tier !== 'hook'; } catch { return false; } })) errors.push('compute_with_permissions');
|
|
351
|
+
// §8 (I7): view/edit-surface are zero-capability DOM authors — any permission is rejected (no
|
|
352
|
+
// render→fetch encoding loop), and they MUST carry a matching typed output contract.
|
|
353
|
+
if (skill.kind === 'view' || skill.kind === 'edit-surface') {
|
|
354
|
+
if (perms.length > 0) errors.push('output_skill_with_permissions');
|
|
355
|
+
const want = skill.kind === 'view' ? 'html-render' : 'dom-transform';
|
|
356
|
+
if (!skill.output || skill.output.kind !== want) errors.push('invalid_output_kind');
|
|
357
|
+
}
|
|
351
358
|
if (!signed && perms.length > 0) errors.push('unsigned_with_permissions');
|
|
352
359
|
// Tools AND hooks carry capability (a hook runs autonomously on events) → must be signed+verified.
|
|
353
360
|
if ((skill.kind === 'tool' || skill.kind === 'hook') && !verified) errors.push('unsigned_capability');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// `rwa skill publish <file.rwa-skill.json>` — publish a signed skill envelope to the marketplace
|
|
2
|
+
// index (`POST /skills/publish`, service/server.js, I6 §11) and return its registry URL.
|
|
3
|
+
//
|
|
4
|
+
// A THIN client: the envelope is ALREADY signed (no private key needed to publish — the signature
|
|
5
|
+
// travels in the envelope). The local verify here is fail-fast only; the server re-validates
|
|
6
|
+
// authoritatively (verifyEnvelope + validateInstall). Intentionally ONLINE (offline-first excludes
|
|
7
|
+
// it, like `rwa publish`/`clone`). Failure surface mirrors `rwa publish`: exit 2 file_error, exit 3
|
|
8
|
+
// for a gate failure (unsigned/compute_with_permissions), exit 4 for every remote/network failure.
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
import { CliError } from './edit.mjs';
|
|
11
|
+
import { verifyEnvelope, validateInstall } from './skill-manifest.mjs';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_SKILLS_URL = 'https://rewritable.ikangai.com';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} filePath a .rwa-skill.json envelope
|
|
17
|
+
* @param {{ baseUrl?: string, fetchImpl?: Function }} [opts] fetchImpl is injected in tests
|
|
18
|
+
* @returns {Promise<{skillId:string, registryUrl:string, verified:boolean}>}
|
|
19
|
+
* @throws {CliError} 2 file_error · 3 gate failure · 4 publish_error
|
|
20
|
+
*/
|
|
21
|
+
export async function skillPublishCmd(filePath, { baseUrl, fetchImpl } = {}) {
|
|
22
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
23
|
+
let bytes;
|
|
24
|
+
try { bytes = await readFile(filePath, 'utf8'); }
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
|
|
27
|
+
throw new CliError(2, 'read_error', { path: filePath, errno: e && e.code, message: e && e.message });
|
|
28
|
+
}
|
|
29
|
+
let env;
|
|
30
|
+
try { env = JSON.parse(bytes); } catch { throw new CliError(2, 'not_a_skill', { path: filePath, reason: 'invalid_json' }); }
|
|
31
|
+
if (!env || env.format !== 'rwa-skill/1' || !env.skill || typeof env.skill.name !== 'string') {
|
|
32
|
+
throw new CliError(2, 'not_a_skill', { path: filePath });
|
|
33
|
+
}
|
|
34
|
+
// Local fail-fast gate — the same codes the server returns (avoids a wasted round trip + works offline).
|
|
35
|
+
const { signed, verified } = verifyEnvelope(env);
|
|
36
|
+
const gate = validateInstall(env, { signed, verified });
|
|
37
|
+
if (!gate.ok) throw new CliError(3, gate.errors[0], { errors: gate.errors });
|
|
38
|
+
|
|
39
|
+
const base = (baseUrl || DEFAULT_SKILLS_URL).replace(/\/+$/, '');
|
|
40
|
+
const endpoint = `${base}/skills/publish`;
|
|
41
|
+
let res;
|
|
42
|
+
try { res = await doFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(env) }); }
|
|
43
|
+
catch (e) { throw new CliError(4, 'network_error', { url: endpoint, message: (e && e.message) || String(e) }); }
|
|
44
|
+
|
|
45
|
+
const text = await res.text();
|
|
46
|
+
let payload = null;
|
|
47
|
+
if (text) { try { payload = JSON.parse(text); } catch { payload = null; } }
|
|
48
|
+
|
|
49
|
+
if (res.status === 201) {
|
|
50
|
+
if (!payload || typeof payload.skillId !== 'string') throw new CliError(4, 'server_error', { status: 201, error: 'malformed_success_response' });
|
|
51
|
+
return { skillId: payload.skillId, verified: !!payload.verified, registryUrl: base + (payload.registryUrl || '/skills/index/' + payload.skillId) };
|
|
52
|
+
}
|
|
53
|
+
const errName = payload && typeof payload.error === 'string' ? payload.error : null;
|
|
54
|
+
if (res.status === 422) throw new CliError(3, errName || 'validation_failed', { errors: payload && payload.errors });
|
|
55
|
+
if (res.status === 429 || errName === 'rate_limited') throw new CliError(4, 'rate_limited', { retryAfterSec: payload && payload.retryAfterSec });
|
|
56
|
+
if (res.status === 410) throw new CliError(4, 'revoked', {});
|
|
57
|
+
if (res.status >= 500) throw new CliError(4, 'server_error', { status: res.status, error: errName });
|
|
58
|
+
throw new CliError(4, 'unexpected_status', { status: res.status, error: errName });
|
|
59
|
+
}
|