rewritable 0.8.1 → 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 +124 -0
- package/package.json +1 -1
- package/seeds/rewritable.html +873 -30
- package/src/identity.mjs +3 -2
- package/src/install.mjs +206 -0
- package/src/skill-manifest.mjs +239 -4
- package/src/skill-publish.mjs +59 -0
package/seeds/rewritable.html
CHANGED
|
@@ -407,7 +407,7 @@ window.ANCHORABLE_TAGS = ANCHORABLE_TAGS; // expose for tests
|
|
|
407
407
|
// ─── Storage ────────────────────────────────────────────────────────
|
|
408
408
|
const RWA = {
|
|
409
409
|
DB:'rwa_'+DOC_UUID, KEY:'self',
|
|
410
|
-
DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state', VAULT:'rwa_vault',
|
|
410
|
+
DOC:'rwa_doc', UNDO:'rwa_undo', HIST:'rwa_hist', FSA:'rwa_fsa', STATE:'rwa_state', VAULT:'rwa_vault', SOURCES:'rwa_sources', HOOK_LOG:'rwa_hook_log',
|
|
411
411
|
UNDO_CAP:10, HIST_CAP:1000, NUDGE_THRESHOLD:5,
|
|
412
412
|
K_API:'rwa_apikey', K_MODEL:'rwa_model', K_BACKEND:'rwa_backend',
|
|
413
413
|
// Per-backend base-URL overrides for local OpenAI-compatible servers.
|
|
@@ -486,7 +486,7 @@ try {
|
|
|
486
486
|
const canonLF = s => s == null ? '' : String(s).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
487
487
|
|
|
488
488
|
let _db;
|
|
489
|
-
const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE, RWA.VAULT];
|
|
489
|
+
const REQUIRED_STORES = [RWA.DOC, RWA.UNDO, RWA.HIST, RWA.FSA, RWA.STATE, RWA.VAULT, RWA.SOURCES, RWA.HOOK_LOG];
|
|
490
490
|
// User-declared store registry. Persisted to RWA.STATE so declarations survive
|
|
491
491
|
// reload. The openDB() upgrade handler creates declared stores on every bump.
|
|
492
492
|
// Populated at bootstrap via loadUserStoreDecls(); mutated by runtime.db.open.
|
|
@@ -924,6 +924,15 @@ function emitRuntimeEvent(event, payload) {
|
|
|
924
924
|
for (const cb of [...runtimeEvents[event]]) {
|
|
925
925
|
try { cb(payload); } catch (_) { /* swallow user-cb errors */ }
|
|
926
926
|
}
|
|
927
|
+
// I8 (v0.9 §9) — fire installed hooks for the mapped lifecycle event. 'modify' (an edit commit,
|
|
928
|
+
// carrying instruction/lensMeta) → on-commit; 'mode' → on-mode-change (with the previous mode).
|
|
929
|
+
// Fire-and-forget: wrapped so a firing failure can never propagate into the commit/mode path.
|
|
930
|
+
try {
|
|
931
|
+
if (typeof fireHooks === 'function') {
|
|
932
|
+
if (event === 'modify') fireHooks('on-commit', { event: 'on-commit', instruction: payload && payload.instruction, lensMeta: payload && payload.lensMeta });
|
|
933
|
+
else if (event === 'mode') { const previous = _lastHookMode; _lastHookMode = payload && payload.mode; fireHooks('on-mode-change', { event: 'on-mode-change', mode: payload && payload.mode, previous }); }
|
|
934
|
+
}
|
|
935
|
+
} catch (_) { /* hook firing never breaks the emitter */ }
|
|
927
936
|
}
|
|
928
937
|
|
|
929
938
|
// === Cross-container bus (workspace-presence subset) ========================
|
|
@@ -5110,11 +5119,30 @@ window.submitLens = submitLens;
|
|
|
5110
5119
|
// workspaces (audit R7) can carry richer identifiers (e.g. signed source
|
|
5111
5120
|
// keys per the action-spec) without changing the rwa_hist schema.
|
|
5112
5121
|
function getActiveActor() {
|
|
5122
|
+
// I12 — when a multi-agent role is active, commits attribute to it (agents:${role}); the role is
|
|
5123
|
+
// the actor of record (rendered as a history badge). Else fall back to the backend/model string.
|
|
5124
|
+
if (activeAgentRole) return 'agents:' + activeAgentRole;
|
|
5113
5125
|
const backend = sessionStorage.getItem(RWA.K_BACKEND) || 'openrouter';
|
|
5114
5126
|
if (backend === 'bridge') return 'bridge:claude-p';
|
|
5115
5127
|
if (backend === 'bridge-session') return 'bridge:claude-session';
|
|
5116
5128
|
return sessionStorage.getItem(RWA.K_MODEL) || RWA.MODEL;
|
|
5117
5129
|
}
|
|
5130
|
+
// I12 — the modify() system prompt, keyed by the active agent's role. An active (verified) agent's
|
|
5131
|
+
// system_prompt swaps the per-kind FRAMING; the shared SYSTEM_PROMPT_RULES (tool protocol, frozen-
|
|
5132
|
+
// zone rules, data-rwa-id) are always appended so editing still works. No agent → the singleton.
|
|
5133
|
+
function resolveSystemPrompt() {
|
|
5134
|
+
if (activeAgentRole) {
|
|
5135
|
+
const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole && a.verified);
|
|
5136
|
+
if (rec && typeof rec.manifest.system_prompt === 'string') return rec.manifest.system_prompt + '\n' + SYSTEM_PROMPT_RULES;
|
|
5137
|
+
}
|
|
5138
|
+
return SYSTEM_PROMPT;
|
|
5139
|
+
}
|
|
5140
|
+
// I12 — the role-scoped vault gate: exact vault:<ns> membership in the agent's vault_namespace_set
|
|
5141
|
+
// (mirrors _skVaultAllowed against the agent record instead of the skill's permissions).
|
|
5142
|
+
function _agVaultAllowed(agent, ns) {
|
|
5143
|
+
const set = (agent && Array.isArray(agent.vault_namespace_set)) ? agent.vault_namespace_set : [];
|
|
5144
|
+
return set.indexOf('vault:' + ns) !== -1;
|
|
5145
|
+
}
|
|
5118
5146
|
|
|
5119
5147
|
// Resolve the user-selected backend into a transport config.
|
|
5120
5148
|
// kind:'openai_compat' covers openrouter/ollama/lmstudio — same wire protocol,
|
|
@@ -6316,6 +6344,17 @@ let activeView = null;
|
|
|
6316
6344
|
// runtimeDescribe()/runtimeListSkills(). The agent can never write the zone (it is
|
|
6317
6345
|
// data-rwa-frozen); only the runtime rewrites it (increment 7, registry-aware commit).
|
|
6318
6346
|
const installedSkills = new Map();
|
|
6347
|
+
// I12 (v0.9 §12) — installed AGENT registry. Map<agentId, {agentId, role, verified, manifest,
|
|
6348
|
+
// envelope}>. Populated at boot from the frozen #rwa-agents zone (readTrustworthyAgents). Identity
|
|
6349
|
+
// is the author key; activeAgentRole is the in-tab active role (null = none; not persisted — boot
|
|
6350
|
+
// starts with no agent active per §12).
|
|
6351
|
+
const installedAgents = new Map();
|
|
6352
|
+
let activeAgentRole = null;
|
|
6353
|
+
// I8 (v0.9 §9) — hook firing state. activeHooks holds skillIds currently executing (re-entrancy
|
|
6354
|
+
// guard, Inv 23); _lastHookMode tracks the prior mode for on-mode-change's `previous`; _hookSeq
|
|
6355
|
+
// makes rwa_hook_log keys unique within a millisecond.
|
|
6356
|
+
const activeHooks = new Set();
|
|
6357
|
+
let _lastHookMode = null, _hookSeq = 0;
|
|
6319
6358
|
|
|
6320
6359
|
const RWA_VIEW_RESERVED_IDS = ['rwa-doc-mount', 'rwa-lens', 'rwa-runtime'];
|
|
6321
6360
|
const RWA_VIEW_RESERVED_MARKERS = ['rwa:frozen:begin', 'rwa:frozen:end', 'data-rwa-frozen'];
|
|
@@ -6387,22 +6426,59 @@ function runtimeSetView(name) {
|
|
|
6387
6426
|
sessionStorage.setItem(rwaViewKey(), '');
|
|
6388
6427
|
} else {
|
|
6389
6428
|
const spec = providers.view;
|
|
6390
|
-
if (
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
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;
|
|
6398
6451
|
}
|
|
6399
|
-
activeView = spec;
|
|
6400
|
-
sessionStorage.setItem(rwaViewKey(), name);
|
|
6401
6452
|
}
|
|
6402
6453
|
if (typeof syncModeChrome === 'function') syncModeChrome();
|
|
6403
6454
|
if (typeof syncViewChrome === 'function') syncViewChrome();
|
|
6404
6455
|
getDoc().then(d => renderDoc(canonLF(d)));
|
|
6405
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
|
+
}
|
|
6406
6482
|
function rwaViewKey() { return 'rwa_view_active_' + DOC_UUID; }
|
|
6407
6483
|
function rwaSlideKey() { return 'rwa_view_slide_' + DOC_UUID; }
|
|
6408
6484
|
|
|
@@ -6487,6 +6563,62 @@ async function _skVerify(env) {
|
|
|
6487
6563
|
return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _skSigningMessage(skill, skill.code)));
|
|
6488
6564
|
} catch (_) { return false; } // Ed25519 unsupported / bad bytes → verified:false (honest)
|
|
6489
6565
|
}
|
|
6566
|
+
// ── I12 (v0.9 §12) — rwa-agent/1 canon (LIVE mirror of cli/src/skill-manifest.mjs canonicalAgent/
|
|
6567
|
+
// agentSigningMessage/agentId/verifyAgentEnvelope/validateAgentInstall). An agent is a role-scoped,
|
|
6568
|
+
// signed identity (role + system_prompt + vault_namespace_set; NO code). Same canon as the CLI so a
|
|
6569
|
+
// seed-live verify == the CLI-static verify for the same bytes.
|
|
6570
|
+
const _AG_ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
6571
|
+
function _agCanonicalAgent(a) { a = a || {}; return JSON.stringify({ author_pubkey: a.author_pubkey ?? null, description: a.description ?? null, role: a.role ?? null, system_prompt: a.system_prompt ?? null, vault_namespace_set: Array.isArray(a.vault_namespace_set) ? a.vault_namespace_set : [], version: a.version ?? null }); }
|
|
6572
|
+
async function _agSigningMessage(a) { return _skSha256(_skUtf8(_agCanonicalAgent(a))); }
|
|
6573
|
+
async function _agAgentId(role, pubkey) { return _skB64url(await _skSha256(_skConcat(_skUtf8(String(role)), _skNUL, _skUtf8(String(pubkey))))); }
|
|
6574
|
+
async function _agVerify(env) {
|
|
6575
|
+
const sig = env && env.signature;
|
|
6576
|
+
if (!sig) return false;
|
|
6577
|
+
const agent = env.agent || {};
|
|
6578
|
+
try {
|
|
6579
|
+
const key = await crypto.subtle.importKey('raw', _skFromB64(agent.author_pubkey), { name: 'Ed25519' }, false, ['verify']);
|
|
6580
|
+
return !!(await crypto.subtle.verify({ name: 'Ed25519' }, key, _skFromB64(sig), await _agSigningMessage(agent)));
|
|
6581
|
+
} catch (_) { return false; }
|
|
6582
|
+
}
|
|
6583
|
+
function _agPromptInjectionRisk(s) { const p = String(s ?? ''); return p.includes('`') || p.includes('${') || /<\/?DOC>/i.test(p); }
|
|
6584
|
+
function _agValidateInstall(agent, signed) {
|
|
6585
|
+
agent = agent || {}; const errors = [];
|
|
6586
|
+
if (/\0/.test(String(agent.role == null ? '' : agent.role))) errors.push('invalid_agent_id');
|
|
6587
|
+
if (typeof agent.role !== 'string' || !_AG_ROLE_RE.test(agent.role)) errors.push('invalid_role');
|
|
6588
|
+
if (typeof agent.system_prompt !== 'string' || _agPromptInjectionRisk(agent.system_prompt)) errors.push('agent_prompt_injection_risk');
|
|
6589
|
+
const set = agent.vault_namespace_set;
|
|
6590
|
+
if (set != null && !Array.isArray(set)) errors.push('invalid_permission');
|
|
6591
|
+
for (const p of (Array.isArray(set) ? set : [])) {
|
|
6592
|
+
try { if (_skParsePermission(p).tier !== 'vault') errors.push('invalid_permission'); }
|
|
6593
|
+
catch (e) { errors.push(/unknown_permission_tier/.test(e.message) ? 'unknown_permission_tier' : 'invalid_permission'); }
|
|
6594
|
+
}
|
|
6595
|
+
if (!signed) errors.push('unsigned_agent');
|
|
6596
|
+
return { ok: errors.length === 0, errors };
|
|
6597
|
+
}
|
|
6598
|
+
// §12 inter-agent bus message shape (mirror of cli validateAgentMessage/agentMessage). Data-model
|
|
6599
|
+
// only; the request→response choreography is the conductor's job, correlated by id.
|
|
6600
|
+
function _agValidateMessage(m) {
|
|
6601
|
+
m = m || {}; const errors = [];
|
|
6602
|
+
if (m.type !== 'request' && m.type !== 'response') errors.push('invalid_type');
|
|
6603
|
+
if (typeof m.id !== 'string' || !m.id) errors.push('invalid_id');
|
|
6604
|
+
if (typeof m.from_role !== 'string' || !_AG_ROLE_RE.test(m.from_role)) errors.push('invalid_from_role');
|
|
6605
|
+
if (typeof m.to_role !== 'string' || !_AG_ROLE_RE.test(m.to_role)) errors.push('invalid_to_role');
|
|
6606
|
+
if (!('payload' in m)) errors.push('missing_payload');
|
|
6607
|
+
return { ok: errors.length === 0, errors };
|
|
6608
|
+
}
|
|
6609
|
+
function _agBuildMessage(type, fromRole, toRole, payload, id) {
|
|
6610
|
+
const m = { type, id, from_role: fromRole, to_role: toRole, payload };
|
|
6611
|
+
const v = _agValidateMessage(m);
|
|
6612
|
+
if (!v.ok) throw new RwaEditError('invalid_agent_message', null, { errors: v.errors });
|
|
6613
|
+
return m;
|
|
6614
|
+
}
|
|
6615
|
+
// runtime.agents.message — convenience builder from the ACTIVE role. A request mints a fresh
|
|
6616
|
+
// correlation id; a response echoes the request's id (pass it as `id`). The caller publishes it
|
|
6617
|
+
// over the bus (a conductor skill with bus:agents:* permission), per §12.
|
|
6618
|
+
function runtimeAgentMessage(type, toRole, payload, id) {
|
|
6619
|
+
if (!activeAgentRole) throw new RwaEditError('no_active_agent', null, {});
|
|
6620
|
+
return _agBuildMessage(type, activeAgentRole, toRole, payload, id || (type === 'request' ? crypto.randomUUID() : undefined));
|
|
6621
|
+
}
|
|
6490
6622
|
// Locate the inner HTML of the agent-unreachable <div data-rwa-frozen id="rwa-skills">
|
|
6491
6623
|
// in the DOC TEXT (boot has the doc string before render). Only the frozen zone is
|
|
6492
6624
|
// trusted; base64 envelopes contain no </div> so a flat scan is safe.
|
|
@@ -6522,6 +6654,124 @@ async function readTrustworthySkills(doc) {
|
|
|
6522
6654
|
}
|
|
6523
6655
|
return out;
|
|
6524
6656
|
}
|
|
6657
|
+
// I12 (v0.9 §12) — the frozen #rwa-agents zone (coexists with #rwa-skills). Same strict
|
|
6658
|
+
// frozen-attribute read as _skExtractZone so an agent-committed lookalike div can't forge an agent.
|
|
6659
|
+
function _agExtractZone(doc) {
|
|
6660
|
+
const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(String(doc || ''));
|
|
6661
|
+
if (!open || !tagHasFrozenAttr(open[0])) return null;
|
|
6662
|
+
const start = open.index + open[0].length;
|
|
6663
|
+
const end = doc.indexOf('</div>', start);
|
|
6664
|
+
return end < 0 ? null : doc.slice(start, end);
|
|
6665
|
+
}
|
|
6666
|
+
// Parse installed agents from the frozen zone, re-verifying each signature. Soft-fail. Returns a
|
|
6667
|
+
// Map<agentId, {agentId, role, verified, manifest, envelope}>. Mirrors readTrustworthySkills.
|
|
6668
|
+
async function readTrustworthyAgents(doc) {
|
|
6669
|
+
const out = new Map();
|
|
6670
|
+
const zone = _agExtractZone(doc);
|
|
6671
|
+
if (!zone) return out;
|
|
6672
|
+
const blocks = zone.matchAll(/<script\s+type="application\/rwa-agent\+json">([\s\S]*?)<\/script>/g);
|
|
6673
|
+
for (const m of blocks) {
|
|
6674
|
+
let env;
|
|
6675
|
+
try { env = JSON.parse(new TextDecoder().decode(_skFromB64(m[1].trim()))); } catch (_) { continue; }
|
|
6676
|
+
const agent = env && env.agent;
|
|
6677
|
+
if (!agent || typeof agent.role !== 'string') continue;
|
|
6678
|
+
const verified = await _agVerify(env);
|
|
6679
|
+
const id = await _agAgentId(agent.role, agent.author_pubkey);
|
|
6680
|
+
out.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope: env });
|
|
6681
|
+
}
|
|
6682
|
+
return out;
|
|
6683
|
+
}
|
|
6684
|
+
// I5 (v0.9 §4) — per-author source index (rwa_sources, keyed by public key):
|
|
6685
|
+
// { pubkey, count, first_seen, name_history:[{ name, first_seen_at_name }] }
|
|
6686
|
+
// name_history is APPEND-ONLY and holds one entry per DISTINCT name a key has published, in first-
|
|
6687
|
+
// seen order (Invariant 22). It anchors author identity across renames: the install dialog shows a
|
|
6688
|
+
// same-key name change ("previously published [old]"). DATES are best-effort — the frozen-zone bytes
|
|
6689
|
+
// carry no timestamps (determinism), so a cleared-IDB reload reconciles names from the in-file
|
|
6690
|
+
// manifests with the reconcile time as first_seen_at_name. Identity itself is always the key.
|
|
6691
|
+
async function _skSourceGet(pubkey) {
|
|
6692
|
+
if (!pubkey) return null;
|
|
6693
|
+
try { return (await idbGet(RWA.SOURCES, pubkey)) || null; } catch (_) { return null; }
|
|
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
|
+
}
|
|
6732
|
+
// Append (name, now) for a key, creating the record if absent. Idempotent per distinct name.
|
|
6733
|
+
async function _skSourceRecord(pubkey, name, at) {
|
|
6734
|
+
if (!pubkey || !name) return;
|
|
6735
|
+
const now = at || Date.now();
|
|
6736
|
+
let rec = await _skSourceGet(pubkey);
|
|
6737
|
+
if (!rec) rec = { pubkey, count: 0, first_seen: now, name_history: [] };
|
|
6738
|
+
if (!Array.isArray(rec.name_history)) rec.name_history = [];
|
|
6739
|
+
if (!rec.name_history.some(e => e.name === name)) rec.name_history.push({ name, first_seen_at_name: now });
|
|
6740
|
+
rec.count = (rec.count | 0) + 1;
|
|
6741
|
+
try { await idbPut(RWA.SOURCES, rec, pubkey); } catch (_) { /* best-effort; never blocks an install */ }
|
|
6742
|
+
}
|
|
6743
|
+
// Boot reconcile: ensure every (pubkey, name) present in the live registry has a name_history entry.
|
|
6744
|
+
// Rebuilds from the in-file manifests so an IDB-cleared reload still knows the recorded renames.
|
|
6745
|
+
async function runtimeBuildSourceIndex() {
|
|
6746
|
+
const now = Date.now(), byKey = new Map();
|
|
6747
|
+
for (const s of installedSkills.values()) {
|
|
6748
|
+
const pk = s.manifest && s.manifest.author_pubkey;
|
|
6749
|
+
if (!pk || !s.name) continue;
|
|
6750
|
+
if (!byKey.has(pk)) byKey.set(pk, new Set());
|
|
6751
|
+
byKey.get(pk).add(s.name);
|
|
6752
|
+
}
|
|
6753
|
+
for (const [pk, names] of byKey) {
|
|
6754
|
+
let rec = await _skSourceGet(pk);
|
|
6755
|
+
if (!rec) rec = { pubkey: pk, count: 0, first_seen: now, name_history: [] };
|
|
6756
|
+
if (!Array.isArray(rec.name_history)) rec.name_history = [];
|
|
6757
|
+
const known = new Set(rec.name_history.map(e => e.name));
|
|
6758
|
+
let changed = false;
|
|
6759
|
+
for (const nm of names) if (!known.has(nm)) { rec.name_history.push({ name: nm, first_seen_at_name: now }); changed = true; }
|
|
6760
|
+
if ((rec.count | 0) < names.size) { rec.count = names.size; changed = true; }
|
|
6761
|
+
if (changed) { try { await idbPut(RWA.SOURCES, rec, pk); } catch (_) {} }
|
|
6762
|
+
}
|
|
6763
|
+
}
|
|
6764
|
+
// What prior names has this author (key) published, other than the incoming one? Returns the prior
|
|
6765
|
+
// names (newest-first) + the most-recent prior, for the dialog's rename note. A rename surfaces only
|
|
6766
|
+
// when the incoming name is genuinely NEW for the key (so a same-name re-install / I10 update is not
|
|
6767
|
+
// a "rename").
|
|
6768
|
+
async function _skNameChange(pubkey, name) {
|
|
6769
|
+
const rec = await _skSourceGet(pubkey);
|
|
6770
|
+
const hist = (rec && Array.isArray(rec.name_history)) ? rec.name_history : [];
|
|
6771
|
+
const priorNames = hist.filter(e => e.name !== name).map(e => ({ name: e.name, date: e.first_seen_at_name })).reverse();
|
|
6772
|
+
const isRename = priorNames.length > 0 && !hist.some(e => e.name === name);
|
|
6773
|
+
return { priorNames, nameChange: isRename ? { prev: priorNames[0] } : null };
|
|
6774
|
+
}
|
|
6525
6775
|
function runtimeListSkills() {
|
|
6526
6776
|
return Array.from(installedSkills.values()).map(s => ({ skillId: s.skillId, kind: s.kind, name: s.name, verified: s.verified }));
|
|
6527
6777
|
}
|
|
@@ -6590,11 +6840,91 @@ async function runtimeVaultNamespaces() {
|
|
|
6590
6840
|
for (const k of Object.keys(rec.entries)) { const i = k.indexOf('\0'); if (i > 0) set.add(k.slice(0, i)); }
|
|
6591
6841
|
return Array.from(set);
|
|
6592
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
|
+
}
|
|
6593
6901
|
// §6 — the bridge's per-call vault gate (mirror of cli/src/skill-manifest.mjs vaultNamespaceAllowed).
|
|
6594
6902
|
function _skVaultAllowed(skill, ns) {
|
|
6595
6903
|
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6596
6904
|
return perms.indexOf('vault:' + ns) !== -1;
|
|
6597
6905
|
}
|
|
6906
|
+
// §5 (I1) — the bridge's per-call bus gate: exact-string bus:<topic> match, no wildcards.
|
|
6907
|
+
function _skBusAllowed(skill, topic) {
|
|
6908
|
+
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6909
|
+
return perms.indexOf('bus:' + topic) !== -1;
|
|
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; }
|
|
6914
|
+
// §6 (I3) — the bridge's per-call fs gate: the (already traversal-checked) path must fall under
|
|
6915
|
+
// a declared fsa:<scope> subtree (left-anchored prefix, no wildcards).
|
|
6916
|
+
function _skFsAllowed(skill, path) {
|
|
6917
|
+
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6918
|
+
for (const p of perms) {
|
|
6919
|
+
if (String(p).startsWith('fsa:')) { const scope = String(p).slice(4); if (path === scope || path.startsWith(scope + '/')) return true; }
|
|
6920
|
+
}
|
|
6921
|
+
return false;
|
|
6922
|
+
}
|
|
6923
|
+
// §7 (I4) — the bridge's per-call idb gate: exact-string idb:<store> match, no wildcards.
|
|
6924
|
+
function _skIdbAllowed(skill, store) {
|
|
6925
|
+
const perms = (skill.manifest && Array.isArray(skill.manifest.permissions)) ? skill.manifest.permissions : [];
|
|
6926
|
+
return perms.indexOf('idb:' + store) !== -1;
|
|
6927
|
+
}
|
|
6598
6928
|
|
|
6599
6929
|
// ─── Install dialog (shannon, v0.8 §1) — the trust anchor. Mirrors of the cli
|
|
6600
6930
|
// consent helpers (permissionToProse/compoundRisk/capabilityScan/levenshtein). ──────
|
|
@@ -6613,12 +6943,32 @@ function _skPermProse(perm) {
|
|
|
6613
6943
|
if (v === '*') return 'Read and write credentials stored under ANY vault namespace — every credential you have stored. Use only for vault administration.';
|
|
6614
6944
|
return 'Read and write credentials stored under ' + v + '.';
|
|
6615
6945
|
}
|
|
6946
|
+
if (s.startsWith('bus:')) {
|
|
6947
|
+
return 'Send and receive messages on the "' + s.slice(4) + '" channel shared with other rewritables on this machine.';
|
|
6948
|
+
}
|
|
6949
|
+
if (s.startsWith('fsa:')) {
|
|
6950
|
+
return 'Read and write files under "' + s.slice(4) + '" in this document\'s private storage.';
|
|
6951
|
+
}
|
|
6952
|
+
if (s.startsWith('idb:')) {
|
|
6953
|
+
return 'Read and write the "' + s.slice(4) + '" data store in this document\'s database.';
|
|
6954
|
+
}
|
|
6955
|
+
if (s.startsWith('hook:')) {
|
|
6956
|
+
const ev = s.slice(5);
|
|
6957
|
+
const when = ev === 'on-commit' ? 'every time the document is saved' : ev === 'on-open' ? 'every time the document opens' : ev === 'on-mode-change' ? 'every time you switch modes' : ev;
|
|
6958
|
+
return 'Run automatically ' + when + ' (no network or credential access).';
|
|
6959
|
+
}
|
|
6616
6960
|
return s;
|
|
6617
6961
|
}
|
|
6618
6962
|
function _skCompoundRisk(perms) {
|
|
6619
6963
|
perms = Array.isArray(perms) ? perms : [];
|
|
6620
|
-
|
|
6964
|
+
const has = (t) => perms.some(p => String(p).startsWith(t + ':'));
|
|
6965
|
+
const hasVault = has('vault'), hasNetwork = has('network'), hasBus = has('bus'), hasFsa = has('fsa'), hasIdb = has('idb');
|
|
6966
|
+
if (hasVault && hasNetwork)
|
|
6621
6967
|
return 'This skill can both read your stored credentials AND make network requests. A skill with this combination can send credentials to its allowed destination — intentionally or by mistake. Install only if you fully trust this author.';
|
|
6968
|
+
if (hasBus && (hasVault || hasNetwork))
|
|
6969
|
+
return 'This skill can message other rewritables on this machine AND ' + (hasVault ? 'read your stored credentials' : 'make network requests') + '. Together these let it coordinate a multi-step action across your workspace — intentionally or by mistake. Install only if you fully trust this author.';
|
|
6970
|
+
if ((hasFsa || hasIdb) && (hasNetwork || hasVault || hasBus))
|
|
6971
|
+
return 'This skill can ' + (hasFsa ? 'read and write files in this document' : 'read and write this document\'s stored data') + ' AND ' + (hasNetwork ? 'make network requests' : hasVault ? 'read your stored credentials' : 'message other rewritables on this machine') + '. Together these let it move your local data off this document — intentionally or by mistake. Install only if you fully trust this author.';
|
|
6622
6972
|
return null;
|
|
6623
6973
|
}
|
|
6624
6974
|
function _skCapabilityScan(code) {
|
|
@@ -6647,6 +6997,26 @@ function _skLevenshtein(a, b) {
|
|
|
6647
6997
|
for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur; }
|
|
6648
6998
|
return prev[n];
|
|
6649
6999
|
}
|
|
7000
|
+
// I5 (v0.9 §4) — Unicode-confusable skeleton. NFKC + toLowerCase fold case, fullwidth forms,
|
|
7001
|
+
// ligatures, and mathematical-alphanumeric letters to ASCII; this baked table folds the
|
|
7002
|
+
// CROSS-SCRIPT homoglyphs NFKC leaves alone (Cyrillic, Greek, Armenian, a few Latin-extended).
|
|
7003
|
+
// CURATED, not the full UTS #39 confusables.txt: every entry maps a non-ASCII glyph that renders
|
|
7004
|
+
// ~identically to an ASCII letter. ASCII→ASCII is NEVER folded (legit "tool"/"toml" stay distinct).
|
|
7005
|
+
// Byte-mirror of cli/src/skill-manifest.mjs CONFUSABLES. Keys are post-NFKC-lowercase codepoints.
|
|
7006
|
+
const _SK_CONFUSABLES = {
|
|
7007
|
+
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c',
|
|
7008
|
+
'у': 'y', 'х': 'x', 'к': 'k', 'ѕ': 's', 'і': 'i',
|
|
7009
|
+
'ј': 'j', 'ԁ': 'd', 'һ': 'h', 'ԛ': 'q', 'ԝ': 'w',
|
|
7010
|
+
'ѵ': 'v', 'ӏ': 'l', 'ɠ': 'g',
|
|
7011
|
+
'α': 'a', 'ο': 'o', 'ρ': 'p', 'ε': 'e', 'ι': 'i',
|
|
7012
|
+
'κ': 'k', 'ν': 'v', 'υ': 'u', 'χ': 'x', 'τ': 't',
|
|
7013
|
+
'ϲ': 'c', 'ϳ': 'j',
|
|
7014
|
+
'օ': 'o', 'ո': 'n',
|
|
7015
|
+
'ı': 'i', 'ɑ': 'a', 'ɡ': 'g',
|
|
7016
|
+
};
|
|
7017
|
+
function _skNormalize(s) { return String(s == null ? '' : s).normalize('NFKC').toLowerCase(); }
|
|
7018
|
+
function _skSkeleton(s) { let out = ''; for (const ch of _skNormalize(s)) out += (_SK_CONFUSABLES[ch] || ch); return out; }
|
|
7019
|
+
function _skSkeletonDistance(a, b) { return _skLevenshtein(_skSkeleton(a), _skSkeleton(b)); }
|
|
6650
7020
|
// §3.4 install gates (mirror cli validateInstall).
|
|
6651
7021
|
const _SK_VAULT_NS = /^[a-z0-9_](?:[a-z0-9_-]{0,62}[a-z0-9_])?$/;
|
|
6652
7022
|
// §4 permission grammar (mirror cli parsePermission) — validates the VALUE, not
|
|
@@ -6670,6 +7040,32 @@ function _skParsePermission(p) {
|
|
|
6670
7040
|
if (value.length > 64 || !_SK_VAULT_NS.test(value)) throw new Error(`invalid vault namespace: ${value}`);
|
|
6671
7041
|
return { tier, value };
|
|
6672
7042
|
}
|
|
7043
|
+
if (tier === 'bus') {
|
|
7044
|
+
// §5 (I1): topic 1–96 chars, start alphanumeric, charset [A-Za-z0-9:_./%-], NOT a
|
|
7045
|
+
// runtime-reserved prefix (rwa_/rwa:/skills:/workspace:). Mirror of cli parsePermission.
|
|
7046
|
+
if (!value || value.length > 96 || !/^[A-Za-z0-9][A-Za-z0-9:_./%-]*$/.test(value) || /^(?:rwa[:_]|skills:|workspace:)/.test(value))
|
|
7047
|
+
throw new Error(`invalid bus topic: ${value}`);
|
|
7048
|
+
return { tier, value };
|
|
7049
|
+
}
|
|
7050
|
+
if (tier === 'fsa') {
|
|
7051
|
+
// §6 (I3): relative OPFS scope — lowercase [a-z0-9_/-], start+end alphanumeric/underscore,
|
|
7052
|
+
// ≤128 chars, no '.'/'..' (excluded by charset), not _rwa/-prefixed. Mirror of cli.
|
|
7053
|
+
if (!value || value.length > 128 || /^_rwa(?:\/|$)/.test(value) || !/^[a-z0-9_](?:[a-z0-9_/-]*[a-z0-9_])?$/.test(value))
|
|
7054
|
+
throw new Error(`invalid fsa scope: ${value}`);
|
|
7055
|
+
return { tier, value };
|
|
7056
|
+
}
|
|
7057
|
+
if (tier === 'idb') {
|
|
7058
|
+
// §7 (I4): store ^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$ (≤64 octets, no wildcards); never rwa_*
|
|
7059
|
+
// (reserved) or rwa_vault — distinct subcodes so the dialog can explain. Mirror of cli.
|
|
7060
|
+
if (/^rwa_/.test(value)) throw new Error(value === 'rwa_vault' ? 'idb_vault_store_forbidden' : 'idb_reserved_store');
|
|
7061
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_-]{0,62}$/.test(value)) throw new Error(`invalid idb store: ${value}`);
|
|
7062
|
+
return { tier, value };
|
|
7063
|
+
}
|
|
7064
|
+
if (tier === 'hook') {
|
|
7065
|
+
// §9 (I8): lifecycle event, exact enum, no wildcards. Unknown event → unknown_permission_tier.
|
|
7066
|
+
if (value === 'on-commit' || value === 'on-open' || value === 'on-mode-change') return { tier, value };
|
|
7067
|
+
throw new Error(`unknown_permission_tier: hook event ${value}`);
|
|
7068
|
+
}
|
|
6673
7069
|
throw new Error(`unknown_permission_tier: ${tier}`);
|
|
6674
7070
|
}
|
|
6675
7071
|
function _skValidateInstall(skill, vr) {
|
|
@@ -6684,11 +7080,26 @@ function _skValidateInstall(skill, vr) {
|
|
|
6684
7080
|
// F7: validate each permission's VALUE via the grammar, not just the tier.
|
|
6685
7081
|
for (const p of perms) {
|
|
6686
7082
|
try { _skParsePermission(p); }
|
|
6687
|
-
catch (e) {
|
|
7083
|
+
catch (e) {
|
|
7084
|
+
const m = e.message;
|
|
7085
|
+
if (/unknown_permission_tier/.test(m)) errors.push('unknown_permission_tier');
|
|
7086
|
+
else if (m === 'idb_reserved_store' || m === 'idb_vault_store_forbidden') errors.push(m); // §7 distinct subcodes
|
|
7087
|
+
else errors.push('invalid_permission');
|
|
7088
|
+
}
|
|
6688
7089
|
}
|
|
6689
7090
|
if (skill.kind === 'compute' && perms.length > 0) errors.push('compute_with_permissions');
|
|
7091
|
+
// §9 (I8): a hook is compute-only — only hook:<event> perms; any other tier → compute_with_permissions.
|
|
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
|
+
}
|
|
6690
7100
|
if (!vr.signed && perms.length > 0) errors.push('unsigned_with_permissions');
|
|
6691
|
-
|
|
7101
|
+
// Tools AND hooks carry capability (a hook runs autonomously) → must be signed+verified.
|
|
7102
|
+
if ((skill.kind === 'tool' || skill.kind === 'hook') && !vr.verified) errors.push('unsigned_capability');
|
|
6692
7103
|
return { ok: errors.length === 0, errors };
|
|
6693
7104
|
}
|
|
6694
7105
|
// §1 — the structured trust info the dialog renders.
|
|
@@ -6696,22 +7107,56 @@ async function runtimeReviewSkill(envelope) {
|
|
|
6696
7107
|
const skill = (envelope && envelope.skill) || {};
|
|
6697
7108
|
const perms = Array.isArray(skill.permissions) ? skill.permissions : [];
|
|
6698
7109
|
const vr = { signed: !!(envelope && envelope.signature), verified: await _skVerify(envelope) };
|
|
6699
|
-
let
|
|
7110
|
+
let levMatch = null, skelMatch = null;
|
|
6700
7111
|
for (const s of installedSkills.values()) {
|
|
7112
|
+
if (!s.manifest || s.manifest.author_pubkey === skill.author_pubkey) continue; // same author ≠ impersonation
|
|
6701
7113
|
const d = _skLevenshtein(s.name, skill.name);
|
|
6702
7114
|
// A DIFFERENT key with the same/near name is impersonation. Exact-name (d===0) ALWAYS fires —
|
|
6703
7115
|
// it's the strongest spoof and the §3.3 case lookalike is responsible for; a near miss (1–2 edits)
|
|
6704
7116
|
// fires only when both names are long enough that 1–2 edits is "similar", not "unrelated".
|
|
6705
7117
|
const exact = d === 0, near = d >= 1 && d <= 2 && String(skill.name).length >= 4 && String(s.name).length >= 4;
|
|
6706
|
-
if (
|
|
7118
|
+
if (!levMatch && (exact || near)) levMatch = s.name;
|
|
7119
|
+
// I5 — Unicode-confusable (skeleton) match. Fires only when confusable folding COLLAPSED a real
|
|
7120
|
+
// byte difference (sd < normalized-Levenshtein) — i.e. a cross-script homoglyph that renders
|
|
7121
|
+
// identically — NOT on an honest ASCII near-miss (where sd === ld). That distinction is what lets
|
|
7122
|
+
// an exact ASCII name from a different key WARN (Invariant 10) while a homoglyph BLOCKS.
|
|
7123
|
+
if (!skelMatch) {
|
|
7124
|
+
const sd = _skSkeletonDistance(s.name, skill.name);
|
|
7125
|
+
const ld = _skLevenshtein(_skNormalize(s.name), _skNormalize(skill.name));
|
|
7126
|
+
if (sd <= 1 && sd < ld) skelMatch = s.name;
|
|
7127
|
+
}
|
|
6707
7128
|
}
|
|
7129
|
+
// skeleton (homoglyph) takes precedence over a plain Levenshtein warn; a signed skeleton match is
|
|
7130
|
+
// a hard BLOCK (the skill carries capability to escalate), an unsigned one only warns.
|
|
7131
|
+
const lookalike = skelMatch || levMatch;
|
|
7132
|
+
const lookalikeKind = skelMatch ? 'skeleton' : (levMatch ? 'levenshtein' : null);
|
|
7133
|
+
const lookalikeBlock = !!(skelMatch && vr.signed);
|
|
7134
|
+
// I10 (Shape C) — the update delta. If this skillId is already installed, surface the
|
|
7135
|
+
// permission change (added/removed, order-invariant case-sensitive set ops) so the dialog
|
|
7136
|
+
// can show it and require explicit re-affirmation — no silent permission escalation on update.
|
|
7137
|
+
let update = { isUpdate: false, added: [], removed: [], changed: false };
|
|
7138
|
+
try {
|
|
7139
|
+
const prevS = installedSkills.get(await _skSkillId(skill.name, skill.author_pubkey));
|
|
7140
|
+
if (prevS) {
|
|
7141
|
+
const oldPerms = (prevS.manifest && Array.isArray(prevS.manifest.permissions)) ? prevS.manifest.permissions : [];
|
|
7142
|
+
const oldSet = new Set(oldPerms), newSet = new Set(perms);
|
|
7143
|
+
const added = perms.filter(p => !oldSet.has(p)), removed = oldPerms.filter(p => !newSet.has(p));
|
|
7144
|
+
update = { isUpdate: true, added: added.map(p => ({ perm: p, prose: _skPermProse(p) })), removed: removed.map(p => ({ perm: p, prose: _skPermProse(p) })), changed: added.length > 0 || removed.length > 0 };
|
|
7145
|
+
}
|
|
7146
|
+
} catch (_) { /* a malformed name/id can't match an install → treat as fresh; gates reject it downstream */ }
|
|
7147
|
+
// I5 — per-author name_history: surface a same-key rename so identity reads across name changes.
|
|
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);
|
|
6708
7151
|
return {
|
|
6709
7152
|
name: skill.name, version: skill.version, kind: skill.kind,
|
|
6710
7153
|
purpose: skill.description || '(no description provided)',
|
|
6711
7154
|
author_pubkey: skill.author_pubkey, signed: vr.signed, verified: vr.verified,
|
|
6712
7155
|
permissions: perms.map(p => ({ perm: p, prose: _skPermProse(p) })),
|
|
6713
7156
|
compoundRisk: _skCompoundRisk(perms), scanNotes: _skCapabilityScan(skill.code),
|
|
6714
|
-
lookalike,
|
|
7157
|
+
lookalike, lookalikeKind, lookalikeBlock,
|
|
7158
|
+
priorNames: nameInfo.priorNames, nameChange: nameInfo.nameChange, tofu,
|
|
7159
|
+
gates: _skValidateInstall(skill, vr), update,
|
|
6715
7160
|
};
|
|
6716
7161
|
}
|
|
6717
7162
|
// §7 — serialize the registry into the frozen #rwa-skills zone. CANONICAL: sorted by
|
|
@@ -6751,6 +7196,10 @@ function _skSkillsRegion() {
|
|
|
6751
7196
|
async function runtimeInstallSkill(envelope) {
|
|
6752
7197
|
const review = await runtimeReviewSkill(envelope);
|
|
6753
7198
|
if (!review.gates.ok) return { ok: false, errors: review.gates.errors };
|
|
7199
|
+
// I5 — a signed skill whose name skeleton-folds to a DIFFERENT author's installed skill is a
|
|
7200
|
+
// homoglyph impersonation: refuse before any code is registered (Invariant 21). Unsigned skills
|
|
7201
|
+
// can't escalate, so review.lookalikeBlock is false for them — they only warn in the dialog.
|
|
7202
|
+
if (review.lookalikeBlock) return { ok: false, errors: ['lookalike_skeleton_blocked'] };
|
|
6754
7203
|
const forbidden = _skCodeForbidden((envelope.skill || {}).code);
|
|
6755
7204
|
if (forbidden) return { ok: false, errors: [forbidden] };
|
|
6756
7205
|
const skill = envelope.skill, id = await _skSkillId(skill.name, skill.author_pubkey);
|
|
@@ -6762,6 +7211,10 @@ async function runtimeInstallSkill(envelope) {
|
|
|
6762
7211
|
if (prev) installedSkills.set(id, prev); else installedSkills.delete(id);
|
|
6763
7212
|
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
6764
7213
|
}
|
|
7214
|
+
// I5 — record this (key, name) in the per-author name_history (best-effort; never fails the
|
|
7215
|
+
// install). On a same-key rename the new name is appended; identity stays anchored on the key.
|
|
7216
|
+
await _skSourceRecord(skill.author_pubkey, skill.name);
|
|
7217
|
+
_skEvictPool(id); // I2 — an install/update may change the code → drop any stale pooled Workers
|
|
6765
7218
|
return { ok: true, skillId: id };
|
|
6766
7219
|
}
|
|
6767
7220
|
// §7 — remove a skill + persist the emptied/updated zone (same rollback discipline).
|
|
@@ -6769,6 +7222,7 @@ async function runtimeUninstallSkill(skillId) {
|
|
|
6769
7222
|
const prev = installedSkills.get(skillId);
|
|
6770
7223
|
if (!prev) return { ok: false, errors: ['not_installed'] };
|
|
6771
7224
|
installedSkills.delete(skillId);
|
|
7225
|
+
_skEvictPool(skillId); // I2 — drop any pooled Workers for the removed skill
|
|
6772
7226
|
try {
|
|
6773
7227
|
await runtimeRegionCommit({ regions: [_skSkillsRegion()], actor: 'skill:uninstall', reachability: 'frozen' });
|
|
6774
7228
|
} catch (e) {
|
|
@@ -6777,6 +7231,126 @@ async function runtimeUninstallSkill(skillId) {
|
|
|
6777
7231
|
}
|
|
6778
7232
|
return { ok: true };
|
|
6779
7233
|
}
|
|
7234
|
+
// ── I12 (v0.9 §12) — agent registry ops. The #rwa-agents zone is built/committed exactly like
|
|
7235
|
+
// #rwa-skills (skillId-sorted base64 envelopes, runtimeRegionCommit reachability:'frozen'); the two
|
|
7236
|
+
// zones coexist and the region primitive commits each atomically. insertAt is the no-zone fallback
|
|
7237
|
+
// (a fresh skill-host has no agents zone yet → the first install appends one).
|
|
7238
|
+
function buildAgentZone(agents) {
|
|
7239
|
+
const blocks = Array.from(agents.values())
|
|
7240
|
+
.filter(a => a.envelope)
|
|
7241
|
+
.sort((a, b) => (a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0))
|
|
7242
|
+
.map(a => '<script type="application/rwa-agent+json">' + _skB64(_skUtf8(JSON.stringify(a.envelope))) + '<\/script>')
|
|
7243
|
+
.join('');
|
|
7244
|
+
return '<div data-rwa-frozen id="rwa-agents">' + blocks + '</div>';
|
|
7245
|
+
}
|
|
7246
|
+
function _agAgentsRegion() {
|
|
7247
|
+
return {
|
|
7248
|
+
frozenId: 'rwa-agents',
|
|
7249
|
+
select(doc) {
|
|
7250
|
+
const open = /<div\b[^>]*\bid="rwa-agents"[^>]*>/i.exec(doc);
|
|
7251
|
+
if (!open) return null;
|
|
7252
|
+
const close = doc.indexOf('</div>', open.index + open[0].length);
|
|
7253
|
+
return close < 0 ? null : [open.index, close + 6];
|
|
7254
|
+
},
|
|
7255
|
+
insertAt(doc) { return doc.length; },
|
|
7256
|
+
build() { return buildAgentZone(installedAgents); },
|
|
7257
|
+
};
|
|
7258
|
+
}
|
|
7259
|
+
function runtimeListAgents() {
|
|
7260
|
+
return Array.from(installedAgents.values()).map(a => ({ role: a.role, author_pubkey: a.manifest && a.manifest.author_pubkey, verified: a.verified }));
|
|
7261
|
+
}
|
|
7262
|
+
function runtimeAgentActive() {
|
|
7263
|
+
if (!activeAgentRole) return null;
|
|
7264
|
+
const rec = Array.from(installedAgents.values()).find(a => a.role === activeAgentRole);
|
|
7265
|
+
return rec ? { role: rec.role, author_pubkey: rec.manifest && rec.manifest.author_pubkey } : null;
|
|
7266
|
+
}
|
|
7267
|
+
// Switch the active role. The trust anchor is the signature: an unverified (tampered) agent can be
|
|
7268
|
+
// installed but NEVER activated (unverified_agent), so a tampered prompt can't drive modify().
|
|
7269
|
+
function runtimeSetActiveAgent(role) {
|
|
7270
|
+
if (role == null) { activeAgentRole = null; return; }
|
|
7271
|
+
const rec = Array.from(installedAgents.values()).find(a => a.role === role);
|
|
7272
|
+
if (!rec) throw new RwaEditError('agent_not_found', null, { role });
|
|
7273
|
+
if (!rec.verified) throw new RwaEditError('unverified_agent', null, { role });
|
|
7274
|
+
activeAgentRole = role;
|
|
7275
|
+
}
|
|
7276
|
+
// §12 — validate + verify + register an agent, then persist the #rwa-agents zone (same discipline as
|
|
7277
|
+
// runtimeInstallSkill: rollback the in-memory map on a persist failure). A signed-but-unverified
|
|
7278
|
+
// agent registers (verified:false) but can't activate.
|
|
7279
|
+
async function runtimeInstallAgent(envelope) {
|
|
7280
|
+
const agent = (envelope && envelope.agent) || {};
|
|
7281
|
+
const signed = !!(envelope && envelope.signature);
|
|
7282
|
+
const gate = _agValidateInstall(agent, signed);
|
|
7283
|
+
if (!gate.ok) return { ok: false, errors: gate.errors };
|
|
7284
|
+
const verified = await _agVerify(envelope);
|
|
7285
|
+
const id = await _agAgentId(agent.role, agent.author_pubkey);
|
|
7286
|
+
const prev = installedAgents.get(id);
|
|
7287
|
+
installedAgents.set(id, { agentId: id, role: agent.role, verified, manifest: agent, envelope });
|
|
7288
|
+
try {
|
|
7289
|
+
await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: prev ? 'agent:update' : 'agent:install', reachability: 'frozen' });
|
|
7290
|
+
} catch (e) {
|
|
7291
|
+
if (prev) installedAgents.set(id, prev); else installedAgents.delete(id);
|
|
7292
|
+
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
7293
|
+
}
|
|
7294
|
+
return { ok: true, agentId: id, verified };
|
|
7295
|
+
}
|
|
7296
|
+
async function runtimeUninstallAgent(agentId) {
|
|
7297
|
+
const prev = installedAgents.get(agentId);
|
|
7298
|
+
if (!prev) return { ok: false, errors: ['not_installed'] };
|
|
7299
|
+
installedAgents.delete(agentId);
|
|
7300
|
+
if (activeAgentRole && prev.role === activeAgentRole && !Array.from(installedAgents.values()).some(a => a.role === activeAgentRole)) activeAgentRole = null;
|
|
7301
|
+
try {
|
|
7302
|
+
await runtimeRegionCommit({ regions: [_agAgentsRegion()], actor: 'agent:uninstall', reachability: 'frozen' });
|
|
7303
|
+
} catch (e) {
|
|
7304
|
+
installedAgents.set(agentId, prev);
|
|
7305
|
+
return { ok: false, errors: [(e && e.code) || (e && e.message) || 'persist_failed'] };
|
|
7306
|
+
}
|
|
7307
|
+
return { ok: true };
|
|
7308
|
+
}
|
|
7309
|
+
// ── I8 (v0.9 §9) — hook firing. Installed `hook` skills that declare hook:<event> run (compute-only,
|
|
7310
|
+
// no bridge) when the mapped lifecycle event fires. Fire-and-forget: never blocks/throws into the
|
|
7311
|
+
// emitter (Inv 24); deterministic skillId order; re-entrancy-guarded (Inv 23); every run logged to
|
|
7312
|
+
// rwa_hook_log (Inv 25). A hook must be verified (boot re-check) to fire.
|
|
7313
|
+
function _hooksForEvent(hookEvent) {
|
|
7314
|
+
return Array.from(installedSkills.values())
|
|
7315
|
+
.filter(s => s && s.kind === 'hook' && s.verified && Array.isArray(s.manifest && s.manifest.permissions) && s.manifest.permissions.indexOf('hook:' + hookEvent) !== -1)
|
|
7316
|
+
.sort((a, b) => (a.skillId < b.skillId ? -1 : a.skillId > b.skillId ? 1 : 0));
|
|
7317
|
+
}
|
|
7318
|
+
function _hookLog(entry) {
|
|
7319
|
+
return idbPut(RWA.HOOK_LOG, entry, entry.ts + '-' + (_hookSeq++)).catch(() => {});
|
|
7320
|
+
}
|
|
7321
|
+
function fireHooks(hookEvent, input) {
|
|
7322
|
+
for (const h of _hooksForEvent(hookEvent)) {
|
|
7323
|
+
if (activeHooks.has(h.skillId)) continue; // a hook can't re-fire its own event
|
|
7324
|
+
activeHooks.add(h.skillId);
|
|
7325
|
+
const t0 = Date.now(), sid = h.skillId;
|
|
7326
|
+
let p; try { p = Promise.resolve(runtimeInvokeSkill(sid, input)); } catch (e) { p = Promise.reject(e); }
|
|
7327
|
+
p.then(
|
|
7328
|
+
result => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, result, duration: Date.now() - t0 }),
|
|
7329
|
+
err => _hookLog({ skillId: sid, event: hookEvent, ts: t0, input, error: String((err && err.message) || err), duration: Date.now() - t0 })
|
|
7330
|
+
).then(() => activeHooks.delete(sid), () => activeHooks.delete(sid));
|
|
7331
|
+
}
|
|
7332
|
+
}
|
|
7333
|
+
// The audit trail. getAll in key (≈ chronological) order; never throws.
|
|
7334
|
+
async function runtimeHookLog() {
|
|
7335
|
+
try {
|
|
7336
|
+
const db = await openDB();
|
|
7337
|
+
return await new Promise((res) => { const tx = db.transaction(RWA.HOOK_LOG); const r = tx.objectStore(RWA.HOOK_LOG).getAll(); r.onsuccess = () => res(r.result || []); tx.onerror = () => res([]); });
|
|
7338
|
+
} catch (_) { return []; }
|
|
7339
|
+
}
|
|
7340
|
+
// Boot prune: drop entries older than 30d, then cap to the 1000 most recent.
|
|
7341
|
+
async function _hookLogPrune() {
|
|
7342
|
+
try {
|
|
7343
|
+
const db = await openDB();
|
|
7344
|
+
const read = (fn) => new Promise((res) => { const tx = db.transaction(RWA.HOOK_LOG); const r = tx.objectStore(RWA.HOOK_LOG)[fn](); r.onsuccess = () => res(r.result || []); tx.onerror = () => res([]); });
|
|
7345
|
+
const keys = await read('getAllKeys'), vals = await read('getAll');
|
|
7346
|
+
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
|
|
7347
|
+
const pairs = keys.map((k, i) => ({ k, ts: (vals[i] && vals[i].ts) || 0 }));
|
|
7348
|
+
const fresh = pairs.filter(p => p.ts >= cutoff);
|
|
7349
|
+
const drop = pairs.filter(p => p.ts < cutoff).map(p => p.k)
|
|
7350
|
+
.concat(fresh.length > 1000 ? fresh.sort((a, b) => a.ts - b.ts).slice(0, fresh.length - 1000).map(p => p.k) : []);
|
|
7351
|
+
for (const k of drop) await idbDel(RWA.HOOK_LOG, k);
|
|
7352
|
+
} catch (_) {}
|
|
7353
|
+
}
|
|
6780
7354
|
// §1 — the install dialog (trust anchor). Renders the review and resolves with the user's choice.
|
|
6781
7355
|
function showSkillInstallDialog(envelope) {
|
|
6782
7356
|
return runtimeReviewSkill(envelope).then(rv => new Promise(resolve => {
|
|
@@ -6789,11 +7363,47 @@ function showSkillInstallDialog(envelope) {
|
|
|
6789
7363
|
const authorHtml = rv.signed
|
|
6790
7364
|
? (rv.verified ? 'Signed by key <code>' + _skEsc(String(rv.author_pubkey).slice(0, 12)) + '…</code>' : '⚠ Signature does NOT verify — the code may have been modified.')
|
|
6791
7365
|
: '⚠ Unsigned — the runtime can\'t verify the author or that the code is unmodified.';
|
|
7366
|
+
// I10 (Shape C) — on an update whose permissions changed, surface the delta and make the
|
|
7367
|
+
// affirm button explicitly about the NEW permissions. No silent escalation: the install
|
|
7368
|
+
// call still fires only on the click below.
|
|
7369
|
+
const upd = rv.update || { isUpdate: false, changed: false, added: [], removed: [] };
|
|
7370
|
+
const updHtml = (upd.isUpdate && upd.changed)
|
|
7371
|
+
? '<div style="background:#fef3c7;border-radius:8px;padding:10px 12px;margin:.8em 0"><strong>You already installed this skill. This version changes what it can do:</strong>'
|
|
7372
|
+
+ (upd.added.length ? '<p style="margin:.6em 0 .2em;color:#b45309"><strong>+ Newly requested permissions</strong></p><ul style="margin:.2em 0;padding-left:1.2em">' + upd.added.map(p => '<li>' + _skEsc(p.prose) + '</li>').join('') + '</ul>' : '')
|
|
7373
|
+
+ (upd.removed.length ? '<p style="margin:.6em 0 .2em;color:#6b7280"><strong>− No longer requested</strong></p><ul style="margin:.2em 0;padding-left:1.2em;color:#6b7280">' + upd.removed.map(p => '<li>' + _skEsc(p.prose) + '</li>').join('') + '</ul>' : '')
|
|
7374
|
+
+ '</div>'
|
|
7375
|
+
: '';
|
|
7376
|
+
const affirmText = (upd.isUpdate && upd.changed) ? 'I have reviewed the new permissions and want to update this skill'
|
|
7377
|
+
: upd.isUpdate ? 'Update this skill'
|
|
7378
|
+
: 'I have reviewed this skill and want to install it';
|
|
7379
|
+
// I5 — tiered lookalike notice. A signed homoglyph (lookalikeBlock) is a hard impersonation
|
|
7380
|
+
// block (red, install suppressed); an unsigned homoglyph (kind:'skeleton') is a severe warning;
|
|
7381
|
+
// an honest ASCII near-miss (kind:'levenshtein') is the existing amber name-collision warning.
|
|
7382
|
+
const lookalikeHtml = rv.lookalikeBlock
|
|
7383
|
+
? '<p style="background:#fee2e2;border:1px solid #ef4444;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠️ This name uses characters that look identical to <strong>' + _skEsc(rv.lookalike) + '</strong> from a DIFFERENT author. This is a known impersonation pattern, so installation is blocked. Identity is the key, not the name.</p>'
|
|
7384
|
+
: rv.lookalikeKind === 'skeleton'
|
|
7385
|
+
? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠️ This name uses characters that look identical to <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. Review the key carefully — identity is the key, not the name.</p>'
|
|
7386
|
+
: rv.lookalike
|
|
7387
|
+
? '<p style="background:#fef3c7;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ This name closely matches <strong>' + _skEsc(rv.lookalike) + '</strong>, which you installed from a DIFFERENT key. The author is identified by the key, not the name.</p>'
|
|
7388
|
+
: '';
|
|
7389
|
+
// I5 — same-key rename note (informational, non-permission). Suppressed under a homoglyph block
|
|
7390
|
+
// (impersonation supersedes). Anchors identity on the key: "the author is identified by the key".
|
|
7391
|
+
const nc = rv.nameChange;
|
|
7392
|
+
const nameChangeHtml = (nc && nc.prev && !rv.lookalikeBlock)
|
|
7393
|
+
? '<p style="background:#eff6ff;border-radius:8px;padding:8px 10px;margin:.6em 0">ℹ️ This author previously published a skill named <strong>' + _skEsc(nc.prev.name) + '</strong>'
|
|
7394
|
+
+ (typeof nc.prev.date === 'number' ? ' on ' + _skEsc(new Date(nc.prev.date).toLocaleDateString()) : '')
|
|
7395
|
+
+ '. The current name is <strong>' + _skEsc(rv.name) + '</strong>. The author is identified by the key, not the name.</p>'
|
|
7396
|
+
: '';
|
|
7397
|
+
const canInstall = rv.gates.ok && !rv.lookalikeBlock;
|
|
6792
7398
|
card.innerHTML =
|
|
6793
7399
|
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install ' + _skEsc(rv.name) + '?</h2>' +
|
|
6794
7400
|
'<p style="margin:.2em 0 1em;color:#444"><strong>What it claims to do:</strong> ' + _skEsc(rv.purpose) + '</p>' +
|
|
6795
7401
|
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + '</p>' +
|
|
6796
|
-
|
|
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>' : '') +
|
|
7404
|
+
lookalikeHtml +
|
|
7405
|
+
nameChangeHtml +
|
|
7406
|
+
updHtml +
|
|
6797
7407
|
'<p style="margin:1em 0 .2em"><strong>What it can do on your machine</strong></p>' + permsHtml +
|
|
6798
7408
|
(rv.compoundRisk ? '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">⚠ ' + _skEsc(rv.compoundRisk) + '</p>' : '') +
|
|
6799
7409
|
(rv.scanNotes.length ? '<p style="margin:1em 0 .2em"><strong>Notes from the runtime\'s code review</strong></p><ul style="margin:.2em 0;padding-left:1.2em;color:#555">' + rv.scanNotes.map(n => '<li>' + _skEsc(n) + '</li>').join('') + '</ul>' : '') +
|
|
@@ -6801,7 +7411,7 @@ function showSkillInstallDialog(envelope) {
|
|
|
6801
7411
|
(rv.gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(rv.gates.errors.join(', ')) + '</p>') +
|
|
6802
7412
|
'<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
|
|
6803
7413
|
'<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
|
|
6804
|
-
(
|
|
7414
|
+
(canInstall ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">' + _skEsc(affirmText) + '</button>' : '') +
|
|
6805
7415
|
'</div>';
|
|
6806
7416
|
overlay.appendChild(card); document.body.appendChild(overlay);
|
|
6807
7417
|
const close = (r) => { overlay.remove(); resolve(r); };
|
|
@@ -6809,10 +7419,49 @@ function showSkillInstallDialog(envelope) {
|
|
|
6809
7419
|
const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallSkill(envelope));
|
|
6810
7420
|
}));
|
|
6811
7421
|
}
|
|
6812
|
-
//
|
|
7422
|
+
// I12 (v0.9 §12) — the AGENT install dialog (SHOULD): role + author key + vault namespaces +
|
|
7423
|
+
// a system-prompt preview (≤200 chars). The install button is offered only for a VERIFIED agent
|
|
7424
|
+
// whose record passes the gates; a tampered (unverified) or gate-failing record shows the reason
|
|
7425
|
+
// and suppresses install. The trust anchor is the key, not the role.
|
|
7426
|
+
function showAgentInstallDialog(envelope) {
|
|
7427
|
+
const agent = (envelope && envelope.agent) || {};
|
|
7428
|
+
const signed = !!(envelope && envelope.signature);
|
|
7429
|
+
return Promise.resolve(_agVerify(envelope)).then(verified => new Promise(resolve => {
|
|
7430
|
+
const gates = _agValidateInstall(agent, signed);
|
|
7431
|
+
const prev = document.getElementById('rwa-agent-install'); if (prev) prev.remove();
|
|
7432
|
+
const overlay = document.createElement('div'); overlay.id = 'rwa-agent-install';
|
|
7433
|
+
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;';
|
|
7434
|
+
const card = document.createElement('div');
|
|
7435
|
+
card.style.cssText = 'background:#fff;max-width:560px;width:92%;max-height:86vh;overflow:auto;border-radius:18px;padding:26px 28px;font:14px/1.5 var(--font-ui,system-ui);box-shadow:0 12px 48px rgba(0,0,0,.28);';
|
|
7436
|
+
const nsSet = Array.isArray(agent.vault_namespace_set) ? agent.vault_namespace_set : [];
|
|
7437
|
+
const nsHtml = nsSet.length ? '<ul style="margin:.4em 0;padding-left:1.2em">' + nsSet.map(n => '<li>' + _skEsc(n) + '</li>').join('') + '</ul>' : '<p style="color:#666">No credential namespaces — this role reads/writes no vault entries.</p>';
|
|
7438
|
+
const promptPreview = String(agent.system_prompt == null ? '' : agent.system_prompt).slice(0, 200);
|
|
7439
|
+
const authorHtml = signed
|
|
7440
|
+
? (verified ? 'Signed by key <code>' + _skEsc(String(agent.author_pubkey).slice(0, 12)) + '…</code>' : '⚠ Signature does NOT verify — this record was modified. It cannot be activated.')
|
|
7441
|
+
: '⚠ Unsigned — agents must be signed; this cannot be installed.';
|
|
7442
|
+
const canInstall = gates.ok && verified;
|
|
7443
|
+
card.innerHTML =
|
|
7444
|
+
'<h2 style="margin:0 0 .2em;font-size:1.25rem">Install agent role “' + _skEsc(agent.role) + '”?</h2>' +
|
|
7445
|
+
'<p style="margin:.2em 0 1em;color:#444"><strong>Purpose.</strong> ' + _skEsc(agent.description || '(no description provided)') + '</p>' +
|
|
7446
|
+
'<p style="margin:.2em 0"><strong>Author.</strong> ' + authorHtml + ' <em>The author is identified by the key, not the role name.</em></p>' +
|
|
7447
|
+
'<p style="margin:1em 0 .2em"><strong>Credentials this role can reach</strong></p>' + nsHtml +
|
|
7448
|
+
'<p style="margin:1em 0 .2em"><strong>Role instructions (preview)</strong></p><pre style="white-space:pre-wrap;background:#f6f6f6;border-radius:8px;padding:8px 10px;margin:.2em 0;color:#333;font:12px/1.5 var(--font-mono,monospace)">' + _skEsc(promptPreview) + (String(agent.system_prompt || '').length > 200 ? '…' : '') + '</pre>' +
|
|
7449
|
+
'<p style="margin:1.2em 0 .4em;color:#444"><strong>An active role drives edits with its own instructions and reaches only its declared credentials.</strong> The runtime shows you what the role <em>can</em> do, not whether it <em>should</em>.</p>' +
|
|
7450
|
+
(gates.ok ? '' : '<p style="background:#fee2e2;border-radius:8px;padding:8px 10px;margin:.6em 0">Cannot install: ' + _skEsc(gates.errors.join(', ')) + '</p>') +
|
|
7451
|
+
'<div style="display:flex;gap:10px;margin-top:1.2em;justify-content:flex-end">' +
|
|
7452
|
+
'<button data-act="cancel" style="padding:9px 16px;border:1px solid #ccc;border-radius:10px;background:#fff;cursor:pointer">Cancel</button>' +
|
|
7453
|
+
(canInstall ? '<button data-act="install" style="padding:9px 16px;border:none;border-radius:10px;background:var(--gray-900,#111);color:#fff;cursor:pointer">I have reviewed this role and want to install it</button>' : '') +
|
|
7454
|
+
'</div>';
|
|
7455
|
+
overlay.appendChild(card); document.body.appendChild(overlay);
|
|
7456
|
+
const close = (r) => { overlay.remove(); resolve(r); };
|
|
7457
|
+
card.querySelector('[data-act=cancel]').onclick = () => close({ ok: false, errors: ['cancelled'] });
|
|
7458
|
+
const ib = card.querySelector('[data-act=install]'); if (ib) ib.onclick = async () => close(await runtimeInstallAgent(envelope));
|
|
7459
|
+
}));
|
|
7460
|
+
}
|
|
7461
|
+
// §1.3 / §12 — install trigger: pick a .rwa-skill.json OR .rwa-agent.json, parse, dispatch by format.
|
|
6813
7462
|
function runtimePromptInstall() {
|
|
6814
|
-
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,application/json,.json';
|
|
6815
|
-
inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid
|
|
7463
|
+
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.rwa-skill.json,.rwa-agent.json,application/json,.json';
|
|
7464
|
+
inp.onchange = () => { const f = inp.files && inp.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { let env; try { env = JSON.parse(rd.result); } catch (_) { setStatus && setStatus('err', 'invalid skill/agent JSON'); return; } if (env && env.format === 'rwa-agent/1') showAgentInstallDialog(env); else showSkillInstallDialog(env); }; rd.readAsText(f); };
|
|
6816
7465
|
inp.click();
|
|
6817
7466
|
}
|
|
6818
7467
|
|
|
@@ -6845,12 +7494,15 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
6845
7494
|
'use strict';
|
|
6846
7495
|
var REMOVE = ['importScripts','Worker','SharedWorker','ServiceWorkerContainer','XMLHttpRequest','WebSocket','EventSource','indexedDB','eval','Function','fetch','WebAssembly'];
|
|
6847
7496
|
for (var i=0;i<REMOVE.length;i++){ try { Object.defineProperty(self, REMOVE[i], { value: undefined, writable: false, configurable: false }); } catch(_e){} }
|
|
6848
|
-
var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={};
|
|
7497
|
+
var IDENTITY=null, BRIDGED=false, _seq=0, _pending=new Map(), RUNTIME={}, _subs={};
|
|
6849
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 }); }); }
|
|
6850
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; }
|
|
6851
7500
|
function _installBridge(){
|
|
6852
7501
|
RUNTIME.fetch=function(url,opts){ return _bridge('bridge:fetch',{ url:String(url), opts:_serializeOpts(opts) }); };
|
|
6853
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}); } };
|
|
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){} }; }); } };
|
|
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)}); } };
|
|
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)}); } };
|
|
6854
7506
|
}
|
|
6855
7507
|
self.onmessage=function(e){
|
|
6856
7508
|
var msg=e.data; if(!msg) return;
|
|
@@ -6862,17 +7514,101 @@ const SKILL_WORKER_PROLOGUE = `(function(){
|
|
|
6862
7514
|
.catch(function(err){ self.postMessage({ type:'result', id:msg.id, identity_tag:IDENTITY, ok:false, error:String(err&&err.message||err) }); });
|
|
6863
7515
|
return;
|
|
6864
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
|
|
6865
7519
|
};
|
|
6866
7520
|
})();
|
|
6867
7521
|
`;
|
|
6868
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
|
+
}
|
|
6869
7596
|
// §5a — invoke an installed skill in an isolated Worker. compute = bridgeless; tool =
|
|
6870
7597
|
// bridged fetch/vault with per-call origin/namespace enforcement on THIS (main) thread.
|
|
6871
7598
|
// Per-invocation identity_tag binds responses; 5s timeout; spawn -> invoke -> terminate.
|
|
6872
7599
|
// (Vault: increment 8. The worker-scoped CSP that walls remote import() is the frozen-<head> 7b meta.)
|
|
6873
|
-
function runtimeInvokeSkill(skillId, input) {
|
|
7600
|
+
function runtimeInvokeSkill(skillId, input, opts) {
|
|
6874
7601
|
const skill = installedSkills.get(skillId);
|
|
6875
7602
|
if (!skill) return Promise.reject(new Error('skill_not_found'));
|
|
7603
|
+
// I12 — run under a role: vault ops gate on the AGENT's vault_namespace_set (the role is the
|
|
7604
|
+
// capability boundary for this invoke). Resolve BEFORE any Worker spawns so a bad role fails fast.
|
|
7605
|
+
let agentRec = null;
|
|
7606
|
+
const agentRole = opts && opts.agentRole;
|
|
7607
|
+
if (agentRole != null) {
|
|
7608
|
+
agentRec = Array.from(installedAgents.values()).find(a => a.role === agentRole) || null;
|
|
7609
|
+
if (!agentRec) return Promise.reject(new Error('agent_not_found'));
|
|
7610
|
+
if (!agentRec.verified) return Promise.reject(new Error('unverified_agent'));
|
|
7611
|
+
}
|
|
6876
7612
|
const isTool = skill.kind === 'tool';
|
|
6877
7613
|
// §3.4/Inv 20 gate at the capability boundary: a `tool` that did not re-verify from the bytes
|
|
6878
7614
|
// (unsigned, or signed-then-tampered → boot re-verify flips verified:false) must NOT reach the
|
|
@@ -6884,6 +7620,9 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6884
7620
|
// skills (which never passed through runtimeInstallSkill's gate), every kind.
|
|
6885
7621
|
const forbidden = _skCodeForbidden(skill.code);
|
|
6886
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);
|
|
6887
7626
|
const identity_tag = crypto.randomUUID();
|
|
6888
7627
|
const blob = new Blob([SKILL_WORKER_PROLOGUE + '\n' + String(skill.code || '')], { type: 'text/javascript' });
|
|
6889
7628
|
const url = URL.createObjectURL(blob);
|
|
@@ -6892,8 +7631,9 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6892
7631
|
// F6: cancel an in-flight bridge fetch when the 5s timeout (or any settle)
|
|
6893
7632
|
// fires, so a slow request doesn't keep running after the skill is finished.
|
|
6894
7633
|
const _skAc = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
|
7634
|
+
const busSubs = []; // I1b — active bus subscriptions for THIS invoke; torn down on settle
|
|
6895
7635
|
return new Promise((resolve, reject) => {
|
|
6896
|
-
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); };
|
|
6897
7637
|
const timer = setTimeout(() => finish(reject, new Error('timeout')), 5000);
|
|
6898
7638
|
const reply = (id, ok, extra) => w.postMessage(Object.assign({ type: 'bridge:response', id, identity_tag, ok }, extra));
|
|
6899
7639
|
w.onmessage = async (e) => {
|
|
@@ -6915,7 +7655,10 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6915
7655
|
}
|
|
6916
7656
|
if (msg.type === 'bridge:vault') {
|
|
6917
7657
|
const pl = msg.payload || {};
|
|
6918
|
-
|
|
7658
|
+
// I12 — under an active role the AGENT's vault_namespace_set is the gate (role-scoped);
|
|
7659
|
+
// otherwise the skill's own vault: permissions. Either way an out-of-scope ns is denied.
|
|
7660
|
+
const vaultOk = agentRec ? _agVaultAllowed(agentRec.manifest, pl.ns) : _skVaultAllowed(skill, pl.ns);
|
|
7661
|
+
if (!vaultOk) { reply(msg.id, false, { error: 'vault_namespace_denied' }); return; } // §6 per-call gate
|
|
6919
7662
|
try {
|
|
6920
7663
|
if (pl.op === 'get') reply(msg.id, true, { result: await runtimeVaultGet(pl.ns, pl.key) });
|
|
6921
7664
|
else if (pl.op === 'has') reply(msg.id, true, { result: await runtimeVaultHas(pl.ns, pl.key) });
|
|
@@ -6924,6 +7667,71 @@ function runtimeInvokeSkill(skillId, input) {
|
|
|
6924
7667
|
} catch (e) { reply(msg.id, false, { error: String(e && e.message || 'vault_error') }); }
|
|
6925
7668
|
return;
|
|
6926
7669
|
}
|
|
7670
|
+
if (msg.type === 'bridge:bus:publish') {
|
|
7671
|
+
const pl = msg.payload || {};
|
|
7672
|
+
if (!_skBusAllowed(skill, pl.topic)) { reply(msg.id, false, { error: 'bus_topic_denied' }); return; } // §5 per-call gate
|
|
7673
|
+
// Inv 23: bound the payload (structured-clone-safe + ≤65536 bytes via the JSON proxy)
|
|
7674
|
+
// so a skill can't flood subscribers. A non-serializable payload fails closed.
|
|
7675
|
+
let enc; try { enc = JSON.stringify(pl.message); } catch (_) { reply(msg.id, false, { error: 'bus_error' }); return; }
|
|
7676
|
+
if (enc != null && enc.length > 65536) { reply(msg.id, false, { error: 'bus_error' }); return; }
|
|
7677
|
+
try { runtimeBusPublish(pl.topic, pl.message); reply(msg.id, true, { result: true }); }
|
|
7678
|
+
catch (_) { reply(msg.id, false, { error: 'bus_error' }); }
|
|
7679
|
+
return;
|
|
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
|
|
7697
|
+
if (msg.type === 'bridge:fs') {
|
|
7698
|
+
const pl = msg.payload || {}, op = pl.op, p = pl.path;
|
|
7699
|
+
// Reject traversal/invalid paths BEFORE the scope check (mirror of assertUserFsPath); a
|
|
7700
|
+
// declared scope is a left-anchored prefix, so a '..'-free path is safe to literal-match.
|
|
7701
|
+
if (typeof p !== 'string' || !p || p.startsWith('/') || p.startsWith('_rwa/') || p.split('/').some(s => s === '.' || s === '..')) { reply(msg.id, false, { error: 'fs_path_denied' }); return; }
|
|
7702
|
+
if (!_skFsAllowed(skill, p)) { reply(msg.id, false, { error: 'fs_permission_denied' }); return; } // §6 per-call gate
|
|
7703
|
+
try {
|
|
7704
|
+
let result;
|
|
7705
|
+
if (op === 'read') result = await runtimeFsRead(p);
|
|
7706
|
+
else if (op === 'write') { await runtimeFsWrite(p, pl.data); result = true; }
|
|
7707
|
+
else if (op === 'del') { await runtimeFsDel(p); result = true; }
|
|
7708
|
+
else if (op === 'list') result = await runtimeFsList(p);
|
|
7709
|
+
else { reply(msg.id, false, { error: 'fs_path_invalid' }); return; }
|
|
7710
|
+
reply(msg.id, true, { result });
|
|
7711
|
+
} catch (e) {
|
|
7712
|
+
const m = String(e && e.message || ''), nm = e && e.name;
|
|
7713
|
+
const code = (nm === 'QuotaExceededError' || /quota/i.test(m)) ? 'fs_quota_exceeded'
|
|
7714
|
+
: (nm === 'SecurityError' || /OPFS is not available|not supported/i.test(m)) ? 'fs_unsupported'
|
|
7715
|
+
: (nm === 'NotFoundError' || /no file at/i.test(m)) ? 'fs_path_not_found'
|
|
7716
|
+
: 'fs_write_failed';
|
|
7717
|
+
reply(msg.id, false, { error: code });
|
|
7718
|
+
}
|
|
7719
|
+
return;
|
|
7720
|
+
}
|
|
7721
|
+
if (msg.type === 'bridge:idb') {
|
|
7722
|
+
const pl = msg.payload || {}, op = pl.op, store = pl.store;
|
|
7723
|
+
if (!_skIdbAllowed(skill, store)) { reply(msg.id, false, { error: 'idb_store_denied' }); return; } // §7 per-call gate
|
|
7724
|
+
try {
|
|
7725
|
+
let result;
|
|
7726
|
+
if (op === 'get') result = await runtimeDbGet(store, pl.key);
|
|
7727
|
+
else if (op === 'put') { await runtimeDbPut(store, pl.key, pl.value); result = true; }
|
|
7728
|
+
else if (op === 'del') { await runtimeDbDel(store, pl.key); result = true; }
|
|
7729
|
+
else if (op === 'all') result = await runtimeDbAll(store);
|
|
7730
|
+
else { reply(msg.id, false, { error: 'invalid_argument' }); return; }
|
|
7731
|
+
reply(msg.id, true, { result });
|
|
7732
|
+
} catch (e) { reply(msg.id, false, { error: String(e && e.message || 'idb_error') }); }
|
|
7733
|
+
return;
|
|
7734
|
+
}
|
|
6927
7735
|
};
|
|
6928
7736
|
w.onerror = (e) => finish(reject, new Error('runtime_error: ' + (e.message || '')));
|
|
6929
7737
|
w.postMessage({ type: 'init', identity_tag, bridged: isTool });
|
|
@@ -6970,6 +7778,15 @@ function runtimeDescribe() {
|
|
|
6970
7778
|
affordances.push({ kind: s.kind, name: s.name, skillId: s.skillId, provenance: 'installed', verified: s.verified || false });
|
|
6971
7779
|
seen.add(key);
|
|
6972
7780
|
}
|
|
7781
|
+
// I12/SD-04 — union INSTALLED agents (kind:'agent', name:role). Same shape as the static
|
|
7782
|
+
// parseAgentZone projection so live == static (cross-surface affordance convergence).
|
|
7783
|
+
for (const a of installedAgents.values()) {
|
|
7784
|
+
if (!a || typeof a.role !== 'string') continue;
|
|
7785
|
+
const key = 'agent\0' + a.role;
|
|
7786
|
+
if (seen.has(key)) continue;
|
|
7787
|
+
affordances.push({ kind: 'agent', name: a.role, agentId: a.agentId, provenance: 'installed', verified: a.verified || false });
|
|
7788
|
+
seen.add(key);
|
|
7789
|
+
}
|
|
6973
7790
|
return {
|
|
6974
7791
|
rwa: 'self-description/1',
|
|
6975
7792
|
source: 'live',
|
|
@@ -6984,6 +7801,9 @@ function runtimeDescribe() {
|
|
|
6984
7801
|
// undo-only — there is no redo (re-write-able-spec Invariant 7).
|
|
6985
7802
|
baseline: { edit: ['lens'], tools: ['apply_dsl_plan', 'apply_edits', 'replace_document'], export: ['html', 'print'], history: ['undo'] },
|
|
6986
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(),
|
|
6987
7807
|
};
|
|
6988
7808
|
}
|
|
6989
7809
|
|
|
@@ -7394,7 +8214,7 @@ async function modify(instr, lensMeta = null, opts = null) {
|
|
|
7394
8214
|
const frozenZones = extractFrozenZones(cur);
|
|
7395
8215
|
|
|
7396
8216
|
const messages = [
|
|
7397
|
-
{ role: 'system', content:
|
|
8217
|
+
{ role: 'system', content: resolveSystemPrompt() },
|
|
7398
8218
|
{ role: 'user', content: buildUserPrompt(instr, cur, frozenZones) }
|
|
7399
8219
|
];
|
|
7400
8220
|
|
|
@@ -7581,7 +8401,7 @@ async function modifyViaBridge(instr, lensMeta = null) {
|
|
|
7581
8401
|
const vOpts = { assets: vimg.assets, orphans: vimg.orphans };
|
|
7582
8402
|
const frozenZones = extractFrozenZones(cur);
|
|
7583
8403
|
|
|
7584
|
-
const prompt =
|
|
8404
|
+
const prompt = resolveSystemPrompt() + '\n\n' + buildUserPrompt(instr, cur, frozenZones) +
|
|
7585
8405
|
'\n\nThis backend is single-shot (no tool-calling protocol). Output ONLY a single JSON envelope as your last response, no markdown fences, no commentary, no preamble. The envelope MUST be one of these three exact shapes:\n\n' +
|
|
7586
8406
|
'{"tool":"apply_dsl_plan","envelope":{"version":"rwa-edit-dsl/1","ops":[/* per the rules above */]}}\n\n' +
|
|
7587
8407
|
'{"tool":"apply_edits","envelope":{"version":"rwa-edit/1","edits":[{"find":"...","replace":"..."}]}}\n\n' +
|
|
@@ -8117,6 +8937,19 @@ document.addEventListener('keydown', e => {
|
|
|
8117
8937
|
const skMap = await readTrustworthySkills(doc);
|
|
8118
8938
|
installedSkills.clear();
|
|
8119
8939
|
skMap.forEach((v, k) => installedSkills.set(k, v));
|
|
8940
|
+
// I12 — rebuild the agent registry from the frozen #rwa-agents zone (re-verify each). No agent
|
|
8941
|
+
// is active by default; an unverified (tampered) agent is registered but can't activate.
|
|
8942
|
+
const agMap = await readTrustworthyAgents(doc);
|
|
8943
|
+
installedAgents.clear();
|
|
8944
|
+
agMap.forEach((v, k) => installedAgents.set(k, v));
|
|
8945
|
+
// I5 — reconcile the per-author name_history (rwa_sources) from the in-file manifests, so a
|
|
8946
|
+
// fresh load (or an IDB-cleared one) still knows the renames recorded in the frozen zone.
|
|
8947
|
+
await runtimeBuildSourceIndex();
|
|
8948
|
+
// I8 — seed the mode tracker for on-mode-change's `previous`, prune the hook log, then fire
|
|
8949
|
+
// on-open hooks (fire-and-forget so a hook can never slow first paint; results logged).
|
|
8950
|
+
_lastHookMode = rwaMode;
|
|
8951
|
+
await _hookLogPrune();
|
|
8952
|
+
fireHooks('on-open', { event: 'on-open', docUuid: DOC_UUID });
|
|
8120
8953
|
} catch (_) { /* no installed skills */ }
|
|
8121
8954
|
await _vaultReimportSession().catch(() => {}); // v0.8 §6 — restore an unlocked vault within the tab session
|
|
8122
8955
|
// Spec §7: expose the public runtime API. Only constructed on the success
|
|
@@ -8154,12 +8987,18 @@ document.addEventListener('keydown', e => {
|
|
|
8154
8987
|
describe: runtimeDescribe, // rwa-identity/1 — what this container is + can do
|
|
8155
8988
|
listSkills: runtimeListSkills, // v0.8 §8 — installed skills (provenance:'installed')
|
|
8156
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}
|
|
8157
8992
|
reviewSkill: runtimeReviewSkill, // v0.8 §1 — structured trust info for the install dialog
|
|
8158
8993
|
installSkill: runtimeInstallSkill, // v0.8 §1/§7 — gates + verify + register + persist to the frozen zone (survives reload)
|
|
8159
8994
|
uninstallSkill: runtimeUninstallSkill, // v0.8 §7 — remove + persist
|
|
8160
8995
|
showInstallDialog: showSkillInstallDialog, // v0.8 §1 — render the consent dialog for an envelope → resolves with the choice
|
|
8161
8996
|
promptInstall: runtimePromptInstall, // v0.8 §1.3 — pick a .rwa-skill.json → show the dialog
|
|
8162
|
-
|
|
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
|
|
9000
|
+
agents: { list: runtimeListAgents, active: runtimeAgentActive, setActive: runtimeSetActiveAgent, install: runtimeInstallAgent, uninstall: runtimeUninstallAgent, message: runtimeAgentMessage, showInstallDialog: showAgentInstallDialog }, // v0.9 §12 — multi-agent roles
|
|
9001
|
+
hookLog: runtimeHookLog, // v0.9 §9 — the hook audit trail (rwa_hook_log)
|
|
8163
9002
|
};
|
|
8164
9003
|
// `status` is a getter so each read returns a fresh snapshot of
|
|
8165
9004
|
// dirty/fsa/storage; an enumerable data prop would let stale references
|
|
@@ -8175,6 +9014,10 @@ document.addEventListener('keydown', e => {
|
|
|
8175
9014
|
configurable: false,
|
|
8176
9015
|
});
|
|
8177
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(); });
|
|
8178
9021
|
// §5.10: the presentation render mode ships ONLY for presentation
|
|
8179
9022
|
// containers. For every other kind this block is skipped entirely —
|
|
8180
9023
|
// activeView stays null, no provider is registered, no chrome is built,
|