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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rewritable",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "CLI for re-writeable: emit and import single-file rwa documents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6426,22 +6426,59 @@ function runtimeSetView(name) {
6426
6426
  sessionStorage.setItem(rwaViewKey(), '');
6427
6427
  } else {
6428
6428
  const spec = providers.view;
6429
- if (!spec || spec.name !== name) throw new Error('no registered view named ' + 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 });
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
- vault: { get: runtimeVaultGet, set: runtimeVaultSet, has: runtimeVaultHas, namespaces: runtimeVaultNamespaces, unlock: runtimeVaultUnlock, lock: runtimeVaultLock, isLocked: runtimeVaultIsLocked }, // v0.8 §6
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,
@@ -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
+ }