@yottameta/yotta-memory 0.13.0 → 0.13.2

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.
@@ -23,7 +23,10 @@ const crypto = require('crypto');
23
23
  const http = require('http');
24
24
  const child_process = require('child_process');
25
25
 
26
- const VERSION = '0.13.0';
26
+ const VERSION = '0.13.2';
27
+ // @generated view-html:start
28
+ const VIEW_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>元忆 · 用户查看平台</title><style>\nbody{font-family:system-ui,-apple-system,\"Microsoft YaHei\",sans-serif;max-width:1000px;margin:24px auto;padding:0 16px;color:#1f2328;background:#fafafa}\nh1{font-size:22px} .card{background:#fff;border:1px solid #e2e2e2;border-radius:10px;padding:16px 18px;margin:14px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)}\nbutton{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:7px 14px;cursor:pointer;margin:2px;font-size:14px}\nbutton.danger{background:#dc2626} button.ghost{background:#e5e7eb;color:#1f2328}\ninput,select{padding:8px;border:1px solid #c9c9c9;border-radius:6px;margin:2px;font-size:14px;box-sizing:border-box}\ntable{border-collapse:collapse;width:100%;font-size:13px} td,th{border:1px solid #ececec;padding:6px 8px;text-align:left;vertical-align:top}\n.owner{display:inline-flex;align-items:center;gap:6px;border:1px solid #ddd;border-radius:8px;padding:5px 10px;margin:4px 6px 4px 0;background:#f6f8fa}\n.entry{border-bottom:1px solid #eee;padding:8px 0} .meta{color:#8a8a8a;font-size:12px}\n.err{color:#dc2626;margin-top:8px} .ok{color:#16a34a;margin-top:8px}\n#app{display:none} code{background:#f0f0f0;padding:1px 5px;border-radius:4px;font-size:12px}\n</style></head><body>\n<h1>元忆 · 用户查看平台 <span id=\"ver\" style=\"font-size:14px;color:#888\"></span></h1>\n<div id=\"lock\" class=\"card\">\n <p><b>输入主口令解锁</b>(口令只在本地内存派生,不落盘、不发送远端)。忘口令可在 CLI 用恢复钥匙重设:<code>yotta-memory reset-password --recovery-key &lt;钥匙&gt;</code></p>\n <input type=\"password\" id=\"pw\" placeholder=\"主口令\" style=\"width:260px\">\n <button onclick=\"unlock()\">解锁</button>\n <div class=\"err\" id=\"lockerr\"></div>\n</div>\n<div id=\"app\">\n <div class=\"card\">\n <b>AI 列表</b>(✅=已授权可读自己私密,🔒=未授权)\n <div class=\"meta\" style=\"margin-top:6px\">「授权」由你(用户)操作:确认后生成只显示一次的 agent_key,请立即单独保存;服务端同时写临时待领取文件 <code>keys/pending/&lt;agent_id&gt;.key</code>,供该 AI 新会话领取,领取成功后自动删除。</div>\n <div id=\"owners\" style=\"margin-top:8px\"></div>\n </div>\n <div class=\"card\">\n <b>记忆</b>\n <input id=\"q\" placeholder=\"搜索关键词\" style=\"width:220px\" onkeydown=\"if(event.key==='Enter'){off=0;load()}\">\n <button onclick=\"off=0;load()\">搜索</button>\n <button class=\"ghost\" onclick=\"doExport()\">导出 JSON</button>\n <button class=\"ghost\" onclick=\"showRk()\">显示恢复钥匙</button>\n <span id=\"rkout\" style=\"font-size:12px;color:#888;margin-left:8px\"></span>\n <div id=\"meta\" style=\"margin-top:10px;font-size:12px;color:#666\"></div>\n <div id=\"entries\" style=\"margin-top:6px\"></div>\n <div id=\"pager\" style=\"margin-top:10px\">\n <button class=\"ghost\" id=\"prevb\" onclick=\"prevPage()\">上一页</button>\n <span id=\"pageinfo\" style=\"font-size:12px;color:#888;margin:0 8px\"></span>\n <button class=\"ghost\" id=\"nextb\" onclick=\"nextPage()\">下一页</button>\n </div>\n </div>\n <div class=\"card\">\n <b>重设口令</b><br>\n <input type=\"password\" id=\"cur\" placeholder=\"当前口令\">\n <input type=\"password\" id=\"np1\" placeholder=\"新口令\">\n <input type=\"password\" id=\"np2\" placeholder=\"确认新口令\">\n <button onclick=\"resetPw()\">重设</button>\n <span id=\"pwout\"></span>\n </div>\n</div>\n<script>\nfunction esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}\nasync function api(p,b){try{const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})});return await r.json();}catch(e){return{error:String(e)};}}\nasync function boot(){const s=await api('/api/status');document.getElementById('ver').textContent='v'+(s.version||'');if(s.unlocked){showApp();}}\nfunction showApp(){document.getElementById('lock').style.display='none';document.getElementById('app').style.display='block';loadOwners();load();}\nasync function unlock(){const d=await api('/api/unlock',{password:document.getElementById('pw').value});if(d.error){document.getElementById('lockerr').textContent=d.error;return;}showApp();}\nasync function loadOwners(){const d=await api('/api/owners');const box=document.getElementById('owners');box.innerHTML='';if(!d.owners||!d.owners.length){box.innerHTML='(无 owner)';return;}\n for(const o of d.owners){const c=document.createElement('span');c.className='owner';c.innerHTML=esc(o.owner)+(o.authorized?' ✅':' 🔒')+' <button class=\"ghost\" data-a=\"'+esc(o.owner)+'\">授权</button><button class=\"danger\" data-r=\"'+esc(o.owner)+'\">吊销</button>';box.appendChild(c);}\n box.querySelectorAll('[data-a]').forEach(function(b){b.onclick=function(){var owner=b.getAttribute('data-a');if(!confirm('确认由你为用户授权 '+owner+' 读取其私密记忆?授权后将生成只显示一次的 agent_key,请立即保存;同时写入待领取文件供该 AI 新会话领取。AI 不应代为执行该授权操作。'))return;b.disabled=true;api('/api/authorize',{owner:owner}).then(function(d){b.disabled=false;if(!d||d.error){alert((d&&d.error)||'授权失败');loadOwners();return;}if(d.agentKey){showKey(d.agentKey);}loadOwners();});};});\n box.querySelectorAll('[data-r]').forEach(function(b){b.onclick=function(){if(!confirm('确认吊销 '+b.getAttribute('data-r')+' 的 agent_key?吊销后该智能体立即失去私密读写能力。'))return;api('/api/revoke',{owner:b.getAttribute('data-r')}).then(function(){loadOwners();});};});\nfunction showKey(k){var ov=document.createElement('div');ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:99';var box=document.createElement('div');box.className='card';box.style.cssText='max-width:640px;word-break:break-all';var t=document.createElement('div');t.innerHTML='<b>agent_key(只显示一次)</b>';var hint=document.createElement('div');hint.className='meta';hint.textContent='请用户立即单独保存。AI 新会话执行 yotta-memory key claim <agent_id> --to <AI_HOME> 领取,写入 <AI_HOME>/.yotta-memory-agent-key 后删除待领取文件。若 key 丢失,可吊销后重新授权;旧 key 会立即校验失败。';var ta=document.createElement('textarea');ta.readOnly=true;ta.value=k;ta.style.cssText='width:100%;height:72px;margin-top:8px;font-family:monospace;font-size:12px';var close=document.createElement('button');close.textContent='我已保存,关闭';close.onclick=function(){ov.remove();};box.appendChild(t);box.appendChild(hint);box.appendChild(ta);box.appendChild(close);ov.appendChild(box);document.body.appendChild(ov);ta.focus();ta.select();}\n}\nlet off=0,PS=50;\nasync function load(){const d=await api('/api/entries',{query:document.getElementById('q').value,offset:off,limit:PS});const meta=document.getElementById('meta');const pg=document.getElementById('pageinfo');if(meta)meta.textContent='共 '+d.count+' 条';const lim=d.limit||PS;const totalPg=Math.max(1,Math.ceil(d.count/lim));const curPg=Math.floor((d.offset||0)/lim)+1;if(pg)pg.textContent='第 '+curPg+' / '+totalPg+' 页';const box=document.getElementById('entries');box.innerHTML='';if(d.entries)for(const e of d.entries){const div=document.createElement('div');div.className='entry';div.innerHTML='<b>['+esc(e.type)+'] '+esc(e.subject)+'</b><div>'+esc(e.statement)+'</div><div class=\"meta\">'+esc(e.file)+' · owner='+esc(e.owner||'-')+' · '+esc(e.updated||e.created||'')+'</div>';box.appendChild(div);}const pb=document.getElementById('prevb'),nb=document.getElementById('nextb');if(pb)pb.disabled=(d.offset||0)<=0;if(nb)nb.disabled=!d.hasMore;}\nfunction prevPage(){if(off>=PS){off-=PS;load();}}\nfunction nextPage(){off+=PS;load();}\nasync function doExport(){const d=await api('/api/export');if(d.error){alert(d.error);return;}const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='yottamemory-view-export.json';a.click();}\nasync function showRk(){const d=await api('/api/recovery-key');document.getElementById('rkout').textContent=d.recoveryKey?('恢复钥匙: '+d.recoveryKey):(d.error||'');}\nasync function resetPw(){const np1=document.getElementById('np1').value,np2=document.getElementById('np2').value;if(np1!==np2){document.getElementById('pwout').innerHTML='<span class=\"err\">两次新口令不一致</span>';return;}\n const d=await api('/api/reset-password',{currentPassword:document.getElementById('cur').value,newPassword:np1});document.getElementById('pwout').innerHTML=d.error?('<span class=\"err\">'+esc(d.error)+'</span>'):('<span class=\"ok\">'+esc(d.text||'ok')+'</span>');}\nboot();\n</script></body></html>\n";
29
+ // @generated view-html:end
27
30
  // MCP 协议(2026-07-28 无状态 + 2025-11-25 legacy 握手,dual-era)
28
31
  const MCP_PROTOCOL_MODERN = '2026-07-28';
29
32
  const MCP_PROTOCOL_LEGACY = '2025-11-25';
@@ -91,9 +94,77 @@ function today() {
91
94
  const p = (n) => String(n).padStart(2, '0');
92
95
  return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
93
96
  }
94
- function currentAgent() {
97
+ function envAgentTrusted() {
98
+ const v = String(process.env.YOTTA_MEMORY_TRUST_ENV_AGENT || '').trim().toLowerCase();
99
+ return v === '1' || v === 'true' || v === 'yes';
100
+ }
101
+ function rawEnvAgent() {
95
102
  return process.env.YOTTA_AGENT_ID || process.env.AGENT_ID || '';
96
103
  }
104
+ let RUNTIME_AGENT = { id: '', agentKey: '' };
105
+ const RUNTIME_OWNER_KEYS = new Map();
106
+ function setRuntimeAgent(id, agentKey) {
107
+ RUNTIME_AGENT = { id: String(id || '').trim(), agentKey: String(agentKey || '').trim() };
108
+ RUNTIME_OWNER_KEYS.clear();
109
+ }
110
+ function trustedEnvAgentKey() {
111
+ return envAgentTrusted() ? String(process.env.YOTTA_MEMORY_AGENT_KEY || '').trim() : '';
112
+ }
113
+ function currentAgent() {
114
+ // Ambient environment identity is only trusted for host-managed MCP processes.
115
+ if (RUNTIME_AGENT.id) return RUNTIME_AGENT.id;
116
+ return envAgentTrusted() ? rawEnvAgent() : '';
117
+ }
118
+ function resolveIdentity(opts) {
119
+ opts = opts || {};
120
+ const explicit = String(opts.agent || opts.selfAgent || '').trim();
121
+ let explicitKey = String(opts.agentKey || '').trim();
122
+ const keyFile = String(opts.agentKeyFile || '').trim();
123
+ const env = String(rawEnvAgent() || '').trim();
124
+ const trusted = envAgentTrusted();
125
+ if (keyFile) {
126
+ let fileKey = '';
127
+ try { fileKey = fs.readFileSync(path.resolve(keyFile), 'utf8').trim(); } catch (e) {
128
+ return { id: '', agentKey: '', source: 'invalid-key-file', error: '无法读取 agent-key 文件: ' + keyFile };
129
+ }
130
+ if (!fileKey) return { id: '', agentKey: '', source: 'invalid-key-file', error: 'agent-key 文件为空: ' + keyFile };
131
+ if (explicitKey && explicitKey !== fileKey) return { id: '', agentKey: '', source: 'key-conflict', error: '--agent-key 与 --agent-key-file 内容不一致。' };
132
+ explicitKey = fileKey;
133
+ }
134
+ if (explicit && !isSafeAgentId(explicit)) {
135
+ return { id: '', agentKey: '', source: 'invalid', error: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
136
+ }
137
+ if (env && !isSafeAgentId(env)) {
138
+ return { id: '', agentKey: '', source: 'invalid-env', error: '环境中的 agent ID 非法:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
139
+ }
140
+ if (explicit && env && explicit !== env) {
141
+ return {
142
+ id: '',
143
+ agentKey: '',
144
+ source: 'conflict',
145
+ error: '身份冲突:显式身份 ' + explicit + ' 与环境身份 ' + env + ' 不一致。请只保留一个明确身份。',
146
+ };
147
+ }
148
+ if (explicit) return { id: explicit, agentKey: explicitKey, source: 'explicit', error: '' };
149
+ if (env && trusted) return { id: env, agentKey: trustedEnvAgentKey(), source: 'trusted-env', error: '' };
150
+ if (env && !trusted) {
151
+ return {
152
+ id: '',
153
+ agentKey: '',
154
+ source: 'untrusted-env',
155
+ error: '检测到未受信任的环境身份 ' + env + '。请显式传 --agent <id>,或在 MCP 配置中设置 YOTTA_MEMORY_TRUST_ENV_AGENT=1 后重试。',
156
+ };
157
+ }
158
+ return { id: '', agentKey: '', source: 'none', error: '' };
159
+ }
160
+ function isSafeAgentId(id) {
161
+ const value = String(id || '');
162
+ if (!value || value.length > 128) return false;
163
+ if (value === '.' || value === '..') return false;
164
+ if (/[\\/]/.test(value)) return false;
165
+ if (/[\u0000-\u001f]/.test(value)) return false;
166
+ return true;
167
+ }
97
168
  function typeDir(type) {
98
169
  const t = String(type).toUpperCase();
99
170
  if (!TYPE_DIRS[t]) {
@@ -528,8 +599,12 @@ function nextSeq(dir) {
528
599
  const ENC_MAGIC = 'YTMENC1';
529
600
  const IDX_MAGIC = 'YTMIDX1';
530
601
  const KEY_MAGIC = 'YTMKEY1';
602
+ const AGENT_MAGIC = 'YTMAGENT1';
531
603
  const KEYS_DIR = 'keys';
532
604
  const KEY_CACHE_DIR = 'cache';
605
+ const AGENT_BINDINGS_DIR = 'bindings';
606
+ const AGENT_PENDING_DIR = 'pending';
607
+ const AGENT_KEY_FILENAME = '.yotta-memory-agent-key';
533
608
  const ENC_INDEX_FILE = 'index.enc';
534
609
  const ENC_SUFFIX = '.enc';
535
610
  const PBKDF2_ITER = 600000;
@@ -544,6 +619,11 @@ function encOwnerRecPath(root, id) { return path.join(encKeysDir(root), id + '.k
544
619
  function encRecoveryEncPath(root) { return path.join(encKeysDir(root), 'recovery.key.enc'); }
545
620
  function encCacheDir(root) { return path.join(encKeysDir(root), KEY_CACHE_DIR); }
546
621
  function encCachePath(root, id) { return path.join(encCacheDir(root), id + '.key'); }
622
+ function encBindingsDir(root) { return path.join(encKeysDir(root), AGENT_BINDINGS_DIR); }
623
+ function encAgentBindingPath(root, id) { return path.join(encBindingsDir(root), id + '.key.agent'); }
624
+ function encPendingDir(root) { return path.join(encKeysDir(root), AGENT_PENDING_DIR); }
625
+ function encPendingPath(root, id) { return path.join(encPendingDir(root), id + '.key'); }
626
+ function agentKeyFilePath(agentHome) { return path.join(path.resolve(agentHome), AGENT_KEY_FILENAME); }
547
627
  function isEncrypted(root) { return fs.existsSync(encKeysDir(root)); }
548
628
  function isEncFile(fp) { return String(fp).slice(-ENC_SUFFIX.length) === ENC_SUFFIX; }
549
629
  function ownerFromPrivatePath(root, fp) {
@@ -637,26 +717,181 @@ function unwrapOwnerKeyRecovery(root, owner, rk) {
637
717
  if (buf.slice(0, KEY_MAGIC.length).toString('utf8') !== KEY_MAGIC) throw new Error('恢复密钥文件格式错误: ' + p);
638
718
  return aesDecryptBytes(rk, buf.slice(KEY_MAGIC.length), Buffer.from('owner:' + owner, 'utf8'));
639
719
  }
640
- function writeOwnerKeyCache(root, owner, ownerKey) {
641
- ensureKeysDir(root);
642
- const p = encCachePath(root, owner);
643
- fs.writeFileSync(p, ownerKey);
644
- try { fs.chmodSync(p, 0o600); } catch (e) {}
720
+ function recoverOwnerKeyFromRecovery(root, owner, umk, rk) {
721
+ if (!isSafeAgentId(owner) || !Buffer.isBuffer(rk)) return null;
722
+ try {
723
+ const ownerKey = unwrapOwnerKeyRecovery(root, owner, rk);
724
+ if (!ownerKey) return null;
725
+ fs.writeFileSync(encOwnerKeyPath(root, owner), Buffer.concat([
726
+ Buffer.from(KEY_MAGIC, 'utf8'),
727
+ aesEncryptBytes(umk, ownerKey, Buffer.from('owner:' + owner, 'utf8')),
728
+ ]));
729
+ return ownerKey;
730
+ } catch (e) {
731
+ return null;
732
+ }
645
733
  }
646
- function loadOwnerKeyCache(root, owner) {
647
- const p = encCachePath(root, owner);
648
- if (!fs.existsSync(p)) return null;
649
- return fs.readFileSync(p);
734
+ function privateOwnerHasData(root, owner) {
735
+ if (!isSafeAgentId(owner)) return false;
736
+ const dir = path.join(root, PRIVATE_DIR, owner);
737
+ if (!fs.existsSync(dir)) return false;
738
+ const stack = [dir];
739
+ while (stack.length) {
740
+ const current = stack.pop();
741
+ let entries = [];
742
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch (e) { continue; }
743
+ for (const entry of entries) {
744
+ if (entry.isDirectory()) {
745
+ stack.push(path.join(current, entry.name));
746
+ } else if (entry.isFile() && entry.name !== ENC_INDEX_FILE) {
747
+ return true;
748
+ }
749
+ }
750
+ }
751
+ return false;
650
752
  }
651
753
  function revokeOwnerKeyCache(root, owner) {
652
754
  const p = encCachePath(root, owner);
653
755
  if (fs.existsSync(p)) { fs.unlinkSync(p); return true; }
654
756
  return false;
655
757
  }
758
+ function writeAgentBinding(root, owner, ownerKey, agentKey) {
759
+ if (!isSafeAgentId(owner)) throw new Error('非法 agent ID');
760
+ if (!Buffer.isBuffer(agentKey) || agentKey.length !== 32) throw new Error('agent_key 必须是 32 字节');
761
+ fs.mkdirSync(encBindingsDir(root), { recursive: true });
762
+ const aad = Buffer.from('agent:' + owner, 'utf8');
763
+ const env = aesEncryptBytes(agentKey, ownerKey, aad);
764
+ fs.writeFileSync(encAgentBindingPath(root, owner), Buffer.concat([Buffer.from(AGENT_MAGIC, 'utf8'), env]));
765
+ try { fs.chmodSync(encAgentBindingPath(root, owner), 0o600); } catch (e) {}
766
+ }
767
+ function writePendingAgentKey(root, owner, agentKey) {
768
+ if (!isSafeAgentId(owner)) throw new Error('非法 agent ID');
769
+ if (!Buffer.isBuffer(agentKey) || agentKey.length !== 32) throw new Error('agent_key 必须是 32 字节');
770
+ fs.mkdirSync(encPendingDir(root), { recursive: true });
771
+ const fp = encPendingPath(root, owner);
772
+ fs.writeFileSync(fp, agentKey.toString('base64') + '\n', { encoding: 'utf8', mode: 0o600 });
773
+ try { fs.chmodSync(fp, 0o600); } catch (e) {}
774
+ return fp;
775
+ }
776
+ function writeAgentBindingWithPending(root, owner, ownerKey, agentKey) {
777
+ writeAgentBinding(root, owner, ownerKey, agentKey);
778
+ try {
779
+ return writePendingAgentKey(root, owner, agentKey);
780
+ } catch (e) {
781
+ try { fs.unlinkSync(encAgentBindingPath(root, owner)); } catch (ignore) {}
782
+ try { fs.unlinkSync(encPendingPath(root, owner)); } catch (ignore) {}
783
+ throw e;
784
+ }
785
+ }
786
+ function removePendingAgentKey(root, owner) {
787
+ if (!isSafeAgentId(owner)) return false;
788
+ const fp = encPendingPath(root, owner);
789
+ if (!fs.existsSync(fp)) return false;
790
+ fs.unlinkSync(fp);
791
+ return true;
792
+ }
793
+ function readPendingAgentKey(root, owner) {
794
+ if (!isSafeAgentId(owner)) return null;
795
+ const fp = encPendingPath(root, owner);
796
+ if (!fs.existsSync(fp)) return null;
797
+ const raw = fs.readFileSync(fp, 'utf8').trim();
798
+ if (!raw) throw new Error('pending agent_key 文件为空');
799
+ const key = Buffer.from(raw, 'base64');
800
+ if (key.length !== 32) throw new Error('pending agent_key 格式错误');
801
+ return key;
802
+ }
803
+ function readAgentKeyFile(fp) {
804
+ const raw = fs.readFileSync(path.resolve(String(fp)), 'utf8').trim();
805
+ if (!raw) throw new Error('agent-key 文件为空');
806
+ const key = Buffer.from(raw, 'base64');
807
+ if (key.length !== 32) throw new Error('agent-key 文件格式错误');
808
+ return key;
809
+ }
810
+ function unwrapAgentBinding(root, owner, agentKey) {
811
+ if (!isSafeAgentId(owner)) return null;
812
+ if (!Buffer.isBuffer(agentKey) || agentKey.length !== 32) return null;
813
+ const p = encAgentBindingPath(root, owner);
814
+ if (!fs.existsSync(p)) return null;
815
+ const buf = fs.readFileSync(p);
816
+ if (buf.slice(0, AGENT_MAGIC.length).toString('utf8') !== AGENT_MAGIC) throw new Error('agent binding 文件格式错误: ' + p);
817
+ return aesDecryptBytes(agentKey, buf.slice(AGENT_MAGIC.length), Buffer.from('agent:' + owner, 'utf8'));
818
+ }
656
819
  function getOwnerKeyFor(root, owner) {
657
820
  if (!owner) return null;
658
821
  if (!isEncrypted(root)) return null;
659
- return loadOwnerKeyCache(root, owner);
822
+ if (!RUNTIME_AGENT.id || RUNTIME_AGENT.id !== owner || !RUNTIME_AGENT.agentKey) return null;
823
+ const agentKey = Buffer.from(RUNTIME_AGENT.agentKey, 'base64');
824
+ return unwrapAgentBinding(root, owner, agentKey);
825
+ }
826
+ function validatePrivateIdentity(root, ident, owner) {
827
+ if (!ident || !ident.id) return '私密操作必须先声明显式身份。';
828
+ if (!isSafeAgentId(owner || ident.id)) return '非法 owner ID:只允许单段名称,禁止 /、\\、.. 和控制字符。';
829
+ if (!isEncrypted(root)) return '';
830
+ const agentKey = ident.agentKey || (RUNTIME_AGENT.id === ident.id ? RUNTIME_AGENT.agentKey : '');
831
+ if (!agentKey) {
832
+ return '记忆库已加密,但缺少 agent_key。请使用 --agent-key <key>,或在 MCP 配置中设置 YOTTA_MEMORY_AGENT_KEY + YOTTA_MEMORY_TRUST_ENV_AGENT=1。若该 agent 尚未绑定,请执行 yotta-memory key bind ' + (owner || ident.id) + '。';
833
+ }
834
+ setRuntimeAgent(ident.id, agentKey);
835
+ try {
836
+ if (!getOwnerKeyFor(root, owner || ident.id)) {
837
+ return 'agent_key 校验失败:未找到 ' + (owner || ident.id) + ' 的 agent binding(可能已吊销)。请由用户重新授权,或由用户执行 yotta-memory key bind ' + (owner || ident.id) + '。';
838
+ }
839
+ } catch (e) {
840
+ return 'agent_key 无效或 binding 损坏:' + e.message;
841
+ }
842
+ return '';
843
+ }
844
+ function legacyCacheOwners(root) {
845
+ if (!isEncrypted(root)) return [];
846
+ const dir = encCacheDir(root);
847
+ if (!fs.existsSync(dir)) return [];
848
+ const found = [];
849
+ let names = [];
850
+ try { names = fs.readdirSync(dir); } catch (e) { return found; }
851
+ for (const f of names) {
852
+ if (!/\.key$/.test(f)) continue;
853
+ const owner = f.slice(0, -4);
854
+ if (!isSafeAgentId(owner)) continue;
855
+ if (found.indexOf(owner) === -1) found.push(owner);
856
+ }
857
+ return found;
858
+ }
859
+ function migrationRequiredInfo(root) {
860
+ if (!isEncrypted(root)) return null;
861
+ const owners = new Set();
862
+ for (const owner of collectOwners(root)) owners.add(owner);
863
+ for (const owner of legacyCacheOwners(root)) owners.add(owner);
864
+ const actionable = [];
865
+ const legacyOnly = [];
866
+ for (const owner of owners) {
867
+ const hasPrivate = fs.existsSync(path.join(root, PRIVATE_DIR, owner)) || fs.existsSync(path.join(root, PRIVATE_DIR, owner, PROFILE_FILE)) || fs.existsSync(path.join(root, PRIVATE_DIR, owner, PROFILE_FILE + ENC_SUFFIX));
868
+ const hasLegacy = fs.existsSync(encCachePath(root, owner));
869
+ const bound = fs.existsSync(encAgentBindingPath(root, owner));
870
+ if (!hasPrivate) {
871
+ if (hasLegacy) legacyOnly.push(owner);
872
+ continue;
873
+ }
874
+ if (!bound) actionable.push(owner);
875
+ }
876
+ if (!actionable.length) return null;
877
+ const reasons = [];
878
+ if (actionable.some(function (owner) { return fs.existsSync(encCachePath(root, owner)); })) reasons.push('legacy_cache');
879
+ reasons.push('no_agent_binding');
880
+ return {
881
+ marker: '[YTM_MIGRATION_REQUIRED] agent=' + actionable.join(',') + ' reason=' + reasons.join(','),
882
+ owners: actionable,
883
+ reasons: reasons,
884
+ legacyOnly: legacyOnly,
885
+ };
886
+ }
887
+ function migrationReminderText(root) {
888
+ const info = migrationRequiredInfo(root);
889
+ if (!info) return '';
890
+ let text = info.marker + '\nAI 提醒(升级后首次调用元忆 / 重启会话时请主动转达):旧版授权缓存不再加载,需要重新授权。请让用户自己执行 `yotta-memory view` → 浏览器打开平台 → 输入主口令 → 点「授权」并保存只展示一次的 `agent_key`;这是用户侧操作,AI 不得代替用户执行 `migrate` / `key bind` 迁移,只负责提醒和讲解步骤。';
891
+ if (info.legacyOnly && info.legacyOnly.length) {
892
+ text += '\n另有 legacy 明文 cache 缺少可迁移私密数据(不属于迁移清单): ' + info.legacyOnly.join(', ') + ';如需清理请单独确认。';
893
+ }
894
+ return text;
660
895
  }
661
896
  function encryptMemoryText(text, ownerKey) {
662
897
  const fileKey = crypto.randomBytes(32);
@@ -1849,6 +2084,14 @@ function doctorCore(opts) {
1849
2084
  }
1850
2085
  }
1851
2086
 
2087
+ const migration = migrationRequiredInfo(root);
2088
+ if (migration) {
2089
+ checks.agentBindings = { ok: false, pending: migration.owners };
2090
+ warnings.push(migrationReminderText(root));
2091
+ } else if (encrypted) {
2092
+ checks.agentBindings = { ok: true, pending: [] };
2093
+ }
2094
+
1852
2095
  const configuredDir = opts.dir || opts.backupDir || cfg.backup_dir;
1853
2096
  if (!configuredDir) {
1854
2097
  if (cfg.backup_setup_choice === 'manual') {
@@ -2009,7 +2252,10 @@ function isAllowedBackupPath(rel) {
2009
2252
  const top = normalized.split('/')[0];
2010
2253
  if (top === 'facts' || top === 'private' || top === '.archive') return true;
2011
2254
  if (top === 'agents.json' || top === 'index.json' || /^index-\d{4}\.json$/.test(top)) return true;
2012
- if (top === 'keys') return normalized !== 'keys/cache' && normalized.indexOf('keys/cache/') !== 0;
2255
+ if (top === 'keys') {
2256
+ return normalized !== 'keys/cache' && normalized.indexOf('keys/cache/') !== 0 &&
2257
+ normalized !== 'keys/pending' && normalized.indexOf('keys/pending/') !== 0;
2258
+ }
2013
2259
  return false;
2014
2260
  }
2015
2261
  function backupCreateCore(opts) {
@@ -2196,18 +2442,12 @@ function backupDrillCore(opts) {
2196
2442
  if (!salt) throw new Error('恢复副本缺少 keys/salt');
2197
2443
  ownerKey = unwrapOwnerKey(target, privateFile.owner, deriveUmk(opts.password, salt));
2198
2444
  source = 'password';
2199
- } else {
2200
- const ownerKeyFile = opts.ownerKeyFile || path.join(opts.sourceRoot || userRoot(), 'keys', 'cache', privateFile.owner + '.key');
2201
- if (fs.existsSync(ownerKeyFile)) {
2202
- ownerKey = fs.readFileSync(ownerKeyFile);
2203
- source = 'owner-cache';
2204
- }
2205
2445
  }
2206
2446
  } catch (error) {
2207
2447
  ownerKey = null;
2208
2448
  }
2209
2449
  if (!ownerKey) {
2210
- return fail('恢复演练失败: 已恢复文件结构,但无法解密测试私密记忆。请提供 --recovery-key 或 --password,或确认本机 keys/cache 中有 ' + privateFile.owner + ' 的授权缓存。', {
2450
+ return fail('恢复演练失败: 已恢复文件结构,但无法解密测试私密记忆。请提供 --recovery-key 或 --password;legacy keys/cache 明文缓存不再作为恢复凭证。', {
2211
2451
  manifest: true,
2212
2452
  index: true,
2213
2453
  private: { checked: true, decrypted: false, source: '', file: privateFile.rel },
@@ -2253,14 +2493,22 @@ function rememberCore(type, subject, statement, opts) {
2253
2493
  const subj = String(subject || '').trim();
2254
2494
  if (!stmt) return { error: true, text: 'statement 不能为空' };
2255
2495
  if (!subj) return { error: true, text: 'subject 不能为空' };
2256
- const selfAgent = opts.selfAgent || currentAgent();
2496
+ const ident = resolveIdentity(opts);
2497
+ const selfAgent = ident.id;
2257
2498
  const owner = opts.owner || selfAgent;
2258
2499
  const scope = opts.scope || defaultScope(t);
2500
+ if (scope === 'private' && ident.error) {
2501
+ return { error: true, text: ident.error };
2502
+ }
2503
+ if (scope === 'private') {
2504
+ const keyError = validatePrivateIdentity(root, ident, owner);
2505
+ if (keyError) return { error: true, text: keyError };
2506
+ }
2259
2507
  if (scope === 'private' && !owner) {
2260
- return { error: true, text: '私密记忆必须声明归属智能体:请设环境变量 YOTTA_AGENT_ID(或 AGENT_ID)、传 --owner <id>,或先 yotta-memory whoami / iam 登记唯一身份。公共记忆(FACT)不受影响。' };
2508
+ return { error: true, text: '私密记忆必须显式声明归属智能体:请传 --agent <id>;MCP 场景需由宿主在配置中设置 YOTTA_AGENT_ID=<id> + YOTTA_MEMORY_TRUST_ENV_AGENT=1。公共记忆(FACT)不受影响。' };
2261
2509
  }
2262
2510
  if (scope === 'private' && owner && selfAgent && owner !== selfAgent && !opts.unsafe) {
2263
- return { error: true, text: '拒绝: 当前声明身份 ' + selfAgent + ' 不能写入其它智能体 ' + owner + ' 的私密区。请用 YOTTA_AGENT_ID 声明自己的身份,或加 --unsafe(用户显式授权)。' };
2511
+ return { error: true, text: '拒绝: 当前显式身份 ' + selfAgent + ' 不能写入其它智能体 ' + owner + ' 的私密区。请传正确的 --agent <id>,或加 --unsafe(用户显式授权)。' };
2264
2512
  }
2265
2513
  const encrypted = isEncrypted(root);
2266
2514
  if (scope === 'private' && encrypted && !getOwnerKeyFor(root, owner)) {
@@ -2366,12 +2614,31 @@ function recallCore(query, opts) {
2366
2614
  const limit = opts.limit || 50;
2367
2615
  const onlyType = opts.type ? String(opts.type).toUpperCase() : null;
2368
2616
  const q = query ? String(query).toLowerCase() : '';
2369
- const selfAgent = currentAgent();
2370
- const agent = opts.agent || selfAgent;
2617
+ const ident = resolveIdentity(opts);
2618
+ if (ident.error) return { error: true, exitCode: 3, text: ident.error };
2619
+ const selfAgent = ident.id;
2620
+ const agent = selfAgent;
2371
2621
  const ownerFilter = opts.owner || '';
2372
2622
  const allSafe = !!opts.unsafe;
2373
- const impersonation = !!(opts.agent && selfAgent && opts.agent !== selfAgent);
2374
- const explicitCross = !!opts.all || impersonation || (!!ownerFilter && ownerFilter.toLowerCase() !== 'user' && ownerFilter !== agent);
2623
+ if (ownerFilter && !isSafeAgentId(ownerFilter)) {
2624
+ return { error: true, exitCode: 3, text: '非法 owner ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
2625
+ }
2626
+ if (!selfAgent && (opts.all || ownerFilter)) {
2627
+ return {
2628
+ error: true,
2629
+ exitCode: 3,
2630
+ text: '显式跨智能体读取必须先声明身份:请传 --agent <id>;MCP 场景需设置 YOTTA_AGENT_ID=<id> + YOTTA_MEMORY_TRUST_ENV_AGENT=1。',
2631
+ };
2632
+ }
2633
+ if (selfAgent && !allSafe) {
2634
+ for (const root of roots) {
2635
+ const keyError = validatePrivateIdentity(root, ident, ownerFilter || selfAgent);
2636
+ if (keyError && isEncrypted(root)) {
2637
+ return { error: true, exitCode: 3, text: keyError };
2638
+ }
2639
+ }
2640
+ }
2641
+ const explicitCross = !!opts.all || (!!ownerFilter && ownerFilter !== agent);
2375
2642
  const wantExplain = !!opts.explain;
2376
2643
  const useSemantic = opts.semantic !== false;
2377
2644
  const prefilter = (q && useSemantic) ? recallPrefilter(q) : null;
@@ -2483,6 +2750,7 @@ function recallCore(query, opts) {
2483
2750
  }
2484
2751
  }
2485
2752
  const lines = ['共 ' + shown.length + ' 条记忆(' + (hits.length > limit ? '前 ' + limit + ' 条' : '全部') + '):'];
2753
+ if (!selfAgent) lines.push('[注意] 未声明身份,本次仅读取公共 FACT;私密 PREF / BOUND / COMMIT 已跳过。');
2486
2754
  for (const h of shown) {
2487
2755
  lines.push('[' + h.entry.type + '] ' + h.entry.subject + ': ' + h.entry.statement);
2488
2756
  lines.push(' ' + path.join(h.root, h.entry.file));
@@ -2532,7 +2800,7 @@ function appendTrashAudit(root, record) {
2532
2800
  }
2533
2801
  function forgetCore(fileRef, opts) {
2534
2802
  opts = opts || {};
2535
- const selfAgent = opts.selfAgent || currentAgent();
2803
+ const selfAgent = resolveIdentity(opts).id;
2536
2804
  const roots = memoryRoots();
2537
2805
  const ref = String(fileRef || '').replace(/\\/g, '/');
2538
2806
  let target = null, targetRoot = null, targetRel = null;
@@ -2650,7 +2918,7 @@ function checkOwnerWritable(targetRoot, targetRel, selfAgent, unsafe) {
2650
2918
  // 显式使用反馈:useful/useless → weight/confidence/feedback_net 演化(自我学习闭环)
2651
2919
  function feedbackCore(ref, opts) {
2652
2920
  opts = opts || {};
2653
- const selfAgent = opts.selfAgent || currentAgent();
2921
+ const selfAgent = resolveIdentity(opts).id;
2654
2922
  const target = resolveMemoryTarget(ref);
2655
2923
  if (!target) return { error: true, text: '未找到记忆文件: ' + ref };
2656
2924
  const deny = checkOwnerWritable(target.root, target.rel, selfAgent, opts.unsafe);
@@ -2709,7 +2977,7 @@ function feedbackUndoCore(target, owner, meta, selfAgent, opts) {
2709
2977
  // 解释单条记忆效用分项(为什么靠前/归档/遗忘)
2710
2978
  function explainCore(ref, opts) {
2711
2979
  opts = opts || {};
2712
- const selfAgent = opts.selfAgent || currentAgent();
2980
+ const selfAgent = resolveIdentity(opts).id;
2713
2981
  const target = resolveMemoryTarget(ref);
2714
2982
  if (!target) return { error: true, text: '未找到记忆文件: ' + ref };
2715
2983
  const owner = ownerFromPrivatePath(target.root, target.fp);
@@ -2864,7 +3132,7 @@ function maintainCore(opts) {
2864
3132
  // 手动合并两条相似记忆:保留高 confidence,低 confidence 移入 .archive
2865
3133
  function mergeCore(refA, refB, opts) {
2866
3134
  opts = opts || {};
2867
- const selfAgent = opts.selfAgent || currentAgent();
3135
+ const selfAgent = resolveIdentity(opts).id;
2868
3136
  const ta = resolveMemoryTarget(refA);
2869
3137
  const tb = resolveMemoryTarget(refB);
2870
3138
  if (!ta || !tb) return { error: true, text: '未找到记忆文件: ' + refA + ' / ' + refB };
@@ -2908,7 +3176,7 @@ function distillCore(opts) {
2908
3176
  opts = opts || {};
2909
3177
  const root = userRoot();
2910
3178
  if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
2911
- const selfAgent = opts.selfAgent || currentAgent();
3179
+ const selfAgent = resolveIdentity(opts).id;
2912
3180
  const owner = opts.owner || selfAgent;
2913
3181
  const entries = [];
2914
3182
  for (const r of memoryRoots()) {
@@ -3059,6 +3327,8 @@ function promptPassword(promptText) {
3059
3327
  }
3060
3328
 
3061
3329
  function migrateCore(root, password, recoveryKeyIn) {
3330
+ // 第三个参数历史上是 recovery key;兼容旧调用,同时支持 { agent, recoveryKey } 选项对象。
3331
+ const opts = recoveryKeyIn && typeof recoveryKeyIn === 'object' ? recoveryKeyIn : {};
3062
3332
  if (!fs.existsSync(root)) return { error: true, text: '记忆库不存在,请先 yotta-memory init。' };
3063
3333
  if (isEncrypted(root)) return { error: true, text: '记忆库已启用加密(keys/ 存在),无需迁移。' };
3064
3334
  if (!hasPlaintextPrivate(root)) return { error: false, text: '无私密明文可迁移(private/ 下无 .md 文件);如需新建加密库请 init --encrypt。' };
@@ -3070,10 +3340,9 @@ function migrateCore(root, password, recoveryKeyIn) {
3070
3340
  fs.writeFileSync(encRecoveryEncPath(root), Buffer.concat([Buffer.from(KEY_MAGIC, 'utf8'), aesEncryptBytes(umk, rk, Buffer.from('recovery', 'utf8'))]));
3071
3341
  let moved = 0;
3072
3342
  const owners = collectOwners(root);
3073
- const self = currentAgent();
3343
+ const self = resolveIdentity(opts).id;
3074
3344
  for (const owner of owners) {
3075
3345
  const ok = wrapOwnerKey(root, umk, rk, owner);
3076
- if (self && owner === self) writeOwnerKeyCache(root, owner, ok);
3077
3346
  for (const t of PRIVATE_LEAF) {
3078
3347
  const d = path.join(root, PRIVATE_DIR, owner, t);
3079
3348
  if (!fs.existsSync(d)) continue;
@@ -3097,8 +3366,8 @@ function migrateCore(root, password, recoveryKeyIn) {
3097
3366
  buildIndex(root);
3098
3367
  let tail = '已迁移 ' + moved + ' 个私密文件(' + owners.length + ' 个 owner)到密文。';
3099
3368
  tail += '\n[恢复钥匙](务必离线保存,仅此一次,泄露=可解全部私密): ' + rk.toString('base64');
3100
- if (self && owners.indexOf(self) !== -1) tail += '\n已为本智能体 ' + self + ' 写入授权缓存,可继续读写自己的私密。';
3101
- else tail += '\n各智能体需在用户平台授权(yotta-memory view → 授权本智能体)后才能读写自己的私密。';
3369
+ tail += '\n迁移不会写明文 owner key cache;请为每个 agent 执行一次 yotta-memory key bind <id>,再注入 agent_key。';
3370
+ if (self && owners.indexOf(self) !== -1) tail += '\n你的身份:' + self + ',请先执行 yotta-memory key bind ' + self + '。';
3102
3371
  return { error: false, text: tail };
3103
3372
  }
3104
3373
 
@@ -3111,7 +3380,7 @@ async function cmdMigrate(opts) {
3111
3380
  password = await promptPassword('主口令(迁移后私密区加密,勿忘;忘口令可用恢复钥匙重设): ') || '';
3112
3381
  if (!password) { console.error('已取消。'); process.exit(2); }
3113
3382
  }
3114
- const r = migrateCore(root, password, null);
3383
+ const r = migrateCore(root, password, { agent: opts.agent, recoveryKey: null });
3115
3384
  console.log(r.text);
3116
3385
  if (r.error) process.exit(2);
3117
3386
  }
@@ -3177,51 +3446,170 @@ async function cmdResetPassword(opts) {
3177
3446
  if (r.error) process.exit(2);
3178
3447
  }
3179
3448
 
3180
- function keyAuthorizeCore(root, owner, password) {
3449
+ function keyBindCore(root, owner, opts) {
3450
+ opts = opts || {};
3451
+ if (!isSafeAgentId(owner)) return { error: true, text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
3181
3452
  if (!isEncrypted(root)) return { error: true, text: '记忆库未启用加密。' };
3182
3453
  const salt = loadSalt(root);
3183
3454
  if (!salt) return { error: true, text: '密钥库损坏(缺 keys/salt)。' };
3184
- const umk = deriveUmk(password, salt);
3185
- let ok = null;
3186
- try { ok = unwrapOwnerKey(root, owner, umk); } catch (e) { ok = null; }
3187
- if (!ok) {
3188
- let rk = null;
3189
- try { rk = unwrapRecoveryEnc(root, umk); } catch (e) { rk = null; }
3190
- if (!rk) return { error: true, text: '口令错误。' };
3191
- ok = wrapOwnerKey(root, umk, rk, owner);
3455
+ let ownerKey = null;
3456
+ if (opts.recoveryKey) {
3457
+ const rk = Buffer.from(String(opts.recoveryKey), 'base64');
3458
+ if (rk.length !== 32) return { error: true, text: '恢复钥匙须为 32 字节(base64)。' };
3459
+ try { ownerKey = unwrapOwnerKeyRecovery(root, owner, rk); } catch (e) { ownerKey = null; }
3460
+ if (!ownerKey) return { error: true, text: '恢复钥匙无法解开 ' + owner + ' 的 owner key。' };
3461
+ } else {
3462
+ const password = String(opts.password || '');
3463
+ if (!password) return { error: true, text: '请提供主口令(--password 或交互输入),或用 --recovery-key。' };
3464
+ const umk = deriveUmk(password, salt);
3465
+ try { ownerKey = unwrapOwnerKey(root, owner, umk); } catch (e) { ownerKey = null; }
3466
+ if (!ownerKey) {
3467
+ let rk = null;
3468
+ try { rk = unwrapRecoveryEnc(root, umk); } catch (e) { rk = null; }
3469
+ if (!rk) return { error: true, text: '口令错误,或该 owner 的密钥不存在。' };
3470
+ ownerKey = recoverOwnerKeyFromRecovery(root, owner, umk, rk);
3471
+ if (!ownerKey) {
3472
+ if (privateOwnerHasData(root, owner)) {
3473
+ return { error: true, text: '检测到 private/' + owner + ' 仍有密文,但 keys/' + owner + '.key.enc 与恢复文件均不可用;拒绝新建 owner key(会导致旧数据无法解密)。请从备份恢复原 owner key,或使用恢复钥匙修复后重试。' };
3474
+ }
3475
+ ownerKey = wrapOwnerKey(root, umk, rk, owner);
3476
+ }
3477
+ }
3192
3478
  }
3193
- writeOwnerKeyCache(root, owner, ok);
3194
- return { error: false, text: '已授权 ' + owner + ' 读取其私密(写入授权缓存 keys/cache/' + owner + '.key)。' };
3479
+ const agentKey = crypto.randomBytes(32);
3480
+ const pendingFile = writeAgentBindingWithPending(root, owner, ownerKey, agentKey);
3481
+ revokeOwnerKeyCache(root, owner);
3482
+ return {
3483
+ error: false,
3484
+ agentKey: agentKey.toString('base64'),
3485
+ pendingFile: pendingFile,
3486
+ text: '已绑定 ' + owner + ' 的 agent_key,并删除旧明文 cache。\nagent_key: ' + agentKey.toString('base64') + '\n待领取文件: ' + pendingFile + '\nAI 在新会话执行 yotta-memory key claim ' + owner + ' --to <AI_HOME> 领取;用户请另外保存此 key;此 key 只显示一次。',
3487
+ };
3195
3488
  }
3196
3489
 
3197
3490
  function keyRevokeCore(root, owner) {
3198
- if (!revokeOwnerKeyCache(root, owner)) return { error: false, text: owner + ' 无授权缓存(已是未授权状态)。' };
3199
- return { error: false, text: '已吊销 ' + owner + ' 的授权缓存(该智能体将无法解密其私密)。' };
3491
+ if (!isSafeAgentId(owner)) return { error: true, text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
3492
+ let removed = false;
3493
+ const binding = encAgentBindingPath(root, owner);
3494
+ if (fs.existsSync(binding)) { fs.unlinkSync(binding); removed = true; }
3495
+ if (removePendingAgentKey(root, owner)) removed = true;
3496
+ if (revokeOwnerKeyCache(root, owner)) removed = true;
3497
+ if (!removed) return { error: false, text: owner + ' 无 agent binding(已是未绑定状态)。' };
3498
+ return { error: false, text: '已吊销 ' + owner + ' 的 agent_key binding;该 agent 将无法解密其私密。' };
3499
+ }
3500
+
3501
+ function keyClaimCore(root, owner, targetHome) {
3502
+ if (!isSafeAgentId(owner)) return { error: true, text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
3503
+ if (!targetHome) return { error: true, text: '请用 --to <AI_HOME> 指定 AI 宿主目录。' };
3504
+ const pending = encPendingPath(root, owner);
3505
+ if (!fs.existsSync(pending)) {
3506
+ return { error: true, text: '未找到待领取 agent_key:' + pending + '。请先在 yotta-memory view 中授权,或执行 yotta-memory key bind ' + owner + '。' };
3507
+ }
3508
+ let agentKey;
3509
+ try {
3510
+ agentKey = readPendingAgentKey(root, owner);
3511
+ if (!agentKey) throw new Error('pending agent_key 不存在');
3512
+ if (!unwrapAgentBinding(root, owner, agentKey)) {
3513
+ throw new Error('agent binding 不存在或已失效');
3514
+ }
3515
+ } catch (e) {
3516
+ return { error: true, text: 'agent_key 校验失败:' + e.message + '。未写入宿主目录,pending 文件保留。' };
3517
+ }
3518
+ let targetDir;
3519
+ let targetFile;
3520
+ let tempFile;
3521
+ try {
3522
+ targetDir = path.resolve(String(targetHome));
3523
+ fs.mkdirSync(targetDir, { recursive: true });
3524
+ targetFile = agentKeyFilePath(targetDir);
3525
+ tempFile = path.join(targetDir, AGENT_KEY_FILENAME + '.tmp-' + process.pid + '-' + crypto.randomBytes(4).toString('hex'));
3526
+ fs.writeFileSync(tempFile, agentKey.toString('base64') + '\n', { encoding: 'utf8', mode: 0o600 });
3527
+ try { fs.chmodSync(tempFile, 0o600); } catch (e) {}
3528
+ try {
3529
+ fs.renameSync(tempFile, targetFile);
3530
+ } catch (e) {
3531
+ fs.rmSync(targetFile, { force: true });
3532
+ fs.renameSync(tempFile, targetFile);
3533
+ }
3534
+ const readBack = fs.readFileSync(targetFile, 'utf8').trim();
3535
+ if (readBack !== agentKey.toString('base64')) throw new Error('宿主 key 文件回读不一致');
3536
+ } catch (e) {
3537
+ try { if (tempFile) fs.rmSync(tempFile, { force: true }); } catch (ignore) {}
3538
+ return { error: true, text: '写入宿主 key 文件失败:' + e.message + '。pending 文件保留,可重试。' };
3539
+ }
3540
+ removePendingAgentKey(root, owner);
3541
+ return { error: false, targetFile: targetFile, text: '已领取 ' + owner + ' 的 agent_key。\n目标文件: ' + targetFile + '\npending 文件已删除。' };
3542
+ }
3543
+
3544
+ function keyStatusCore(root, owner, targetHome) {
3545
+ if (!isSafeAgentId(owner)) return { error: true, text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' };
3546
+ const binding = fs.existsSync(encAgentBindingPath(root, owner));
3547
+ const pending = fs.existsSync(encPendingPath(root, owner));
3548
+ let hostKey = 'missing';
3549
+ if (targetHome) {
3550
+ const fp = agentKeyFilePath(targetHome);
3551
+ if (fs.existsSync(fp)) {
3552
+ hostKey = 'invalid';
3553
+ try {
3554
+ const key = readAgentKeyFile(fp);
3555
+ if (binding && unwrapAgentBinding(root, owner, key)) hostKey = 'valid';
3556
+ } catch (e) {}
3557
+ }
3558
+ }
3559
+ return {
3560
+ error: false,
3561
+ binding: binding,
3562
+ pending: pending,
3563
+ hostKey: hostKey,
3564
+ targetFile: targetHome ? agentKeyFilePath(targetHome) : '',
3565
+ text: 'agent: ' + owner + '\nbinding: ' + (binding ? 'yes' : 'no') + '\npending: ' + (pending ? 'yes' : 'no') + '\nhost_key: ' + hostKey + (targetHome ? '\ntarget: ' + agentKeyFilePath(targetHome) : ''),
3566
+ };
3567
+ }
3568
+
3569
+ function keyListCore(root) {
3570
+ root = root || userRoot();
3571
+ if (!isEncrypted(root)) return { text: '记忆库未启用加密。' };
3572
+ const owners = collectOwners(root);
3573
+ const bound = owners.filter(function (o) { return fs.existsSync(encAgentBindingPath(root, o)); });
3574
+ const pending = owners.filter(function (o) { return fs.existsSync(encPendingPath(root, o)); });
3575
+ const legacy = owners.filter(function (o) { return fs.existsSync(encCachePath(root, o)); });
3576
+ const lines = [
3577
+ 'owner: ' + (owners.length ? owners.join(', ') : '(无)'),
3578
+ '已绑定 agent_key: ' + (bound.length ? bound.join(', ') : '(无)'),
3579
+ '待领取 agent_key: ' + (pending.length ? pending.join(', ') : '(无)'),
3580
+ 'legacy 明文 cache: ' + (legacy.length ? legacy.join(', ') + '(升级后不再加载)' : '(无)'),
3581
+ ];
3582
+ return { text: lines.join('\n'), owners: owners, bound: bound, pending: pending, legacy: legacy };
3200
3583
  }
3201
3584
 
3202
3585
  function cmdKeyList() {
3203
3586
  const root = userRoot();
3204
- if (!isEncrypted(root)) { console.log('记忆库未启用加密。'); return; }
3205
- const owners = collectOwners(root);
3206
- const authorized = owners.filter(function (o) { return fs.existsSync(encCachePath(root, o)); });
3207
- console.log('owner: ' + (owners.length ? owners.join(', ') : '(无)'));
3208
- console.log('已授权缓存: ' + (authorized.length ? authorized.join(', ') : '(无)'));
3587
+ const r = keyListCore(root);
3588
+ console.log(r.text);
3589
+ if (!r.owners) return;
3590
+ const reminder = migrationReminderText(root);
3591
+ if (reminder) console.log('\n' + reminder);
3209
3592
  }
3210
3593
 
3211
- async function cmdKeyAuthorize(id, opts) {
3594
+ async function cmdKeyBind(id, opts) {
3212
3595
  const root = userRoot();
3213
3596
  if (!isEncrypted(root)) { console.error('记忆库未启用加密。'); process.exit(2); }
3214
- if (!id) { console.error('请指定 <id>:yotta-memory key authorize <id>'); process.exit(2); }
3597
+ if (!id) { console.error('请指定 <id>:yotta-memory key bind <id>'); process.exit(2); }
3215
3598
  let password = opts.password || process.env.YOTTA_MEMORY_PASS || '';
3216
- if (!password) {
3599
+ if (!password && !opts.recoveryKey) {
3217
3600
  password = await promptPassword('主口令: ') || '';
3218
3601
  if (!password) { console.error('已取消。'); process.exit(2); }
3219
3602
  }
3220
- const r = keyAuthorizeCore(root, id, password);
3603
+ const r = keyBindCore(root, id, { password: password, recoveryKey: opts.recoveryKey });
3221
3604
  console.log(r.text);
3222
3605
  if (r.error) process.exit(2);
3223
3606
  }
3224
3607
 
3608
+ async function cmdKeyAuthorize(id, opts) {
3609
+ console.error('提示: key authorize 已由 key bind 替代,本次按 bind 执行。');
3610
+ return cmdKeyBind(id, opts);
3611
+ }
3612
+
3225
3613
  function cmdKeyRevoke(id) {
3226
3614
  const root = userRoot();
3227
3615
  if (!id) { console.error('请指定 <id>:yotta-memory key revoke <id>'); process.exit(2); }
@@ -3230,6 +3618,22 @@ function cmdKeyRevoke(id) {
3230
3618
  if (r.error) process.exit(2);
3231
3619
  }
3232
3620
 
3621
+ function cmdKeyClaim(id, opts) {
3622
+ const root = userRoot();
3623
+ if (!id) { console.error('请指定 <id>:yotta-memory key claim <id> --to <AI_HOME>'); process.exit(2); }
3624
+ const r = keyClaimCore(root, id, opts.to);
3625
+ console.log(r.text);
3626
+ if (r.error) process.exit(2);
3627
+ }
3628
+
3629
+ function cmdKeyStatus(id, opts) {
3630
+ const root = userRoot();
3631
+ if (!id) { console.error('请指定 <id>:yotta-memory key status <id> [--to <AI_HOME>]'); process.exit(2); }
3632
+ const r = keyStatusCore(root, id, opts.to);
3633
+ console.log(r.text);
3634
+ if (r.error) process.exit(2);
3635
+ }
3636
+
3233
3637
  // v0.8.1: 查看平台分页——服务端只返回当前页(offset/limit),避免记忆多了一次全量加载/渲染/传输
3234
3638
  function viewEntriesCore(root, session, query, offset, limit) {
3235
3639
  limit = Math.max(1, parseInt(limit, 10) || 50);
@@ -3250,13 +3654,50 @@ function viewEntriesCore(root, session, query, offset, limit) {
3250
3654
  const page = all.slice(offset, offset + limit);
3251
3655
  return { count: count, offset: offset, limit: limit, hasMore: offset + limit < count, entries: page };
3252
3656
  }
3657
+ function viewHostName(req) {
3658
+ const raw = String(req.headers.host || '').trim();
3659
+ if (!raw) return '';
3660
+ try {
3661
+ return new URL('http://' + raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
3662
+ } catch (e) {
3663
+ return '';
3664
+ }
3665
+ }
3666
+ function viewHostAllowed(req, bindHost) {
3667
+ const name = viewHostName(req);
3668
+ if (!name) return false;
3669
+ const bind = String(bindHost || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
3670
+ if (bind !== '127.0.0.1' && bind !== 'localhost' && bind !== '::1') return true;
3671
+ return name === '127.0.0.1' || name === 'localhost' || name === '::1';
3672
+ }
3673
+ function viewOriginAllowed(req) {
3674
+ const origin = req.headers.origin;
3675
+ if (!origin) return true;
3676
+ let originHost = '';
3677
+ try {
3678
+ originHost = new URL(String(origin)).host.toLowerCase();
3679
+ } catch (e) {
3680
+ return false;
3681
+ }
3682
+ const requestHost = String(req.headers.host || '').trim().toLowerCase();
3683
+ return !!requestHost && originHost === requestHost;
3684
+ }
3253
3685
  function viewServerCore(root, port, host) {
3254
3686
  let session = { umk: null, ownerKeys: {} };
3255
3687
  const server = http.createServer(function (req, res) {
3688
+ if (!viewHostAllowed(req, host) || !viewOriginAllowed(req)) {
3689
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
3690
+ res.end('forbidden');
3691
+ return;
3692
+ }
3256
3693
  let pathname = '/';
3257
3694
  try { pathname = new URL(req.url, 'http://' + (req.headers.host || 'localhost')).pathname; } catch (e) {}
3258
3695
  function json(code, obj) {
3259
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
3696
+ res.writeHead(code, {
3697
+ 'Content-Type': 'application/json; charset=utf-8',
3698
+ 'Cache-Control': 'no-store',
3699
+ 'X-Content-Type-Options': 'nosniff',
3700
+ });
3260
3701
  res.end(JSON.stringify(obj));
3261
3702
  }
3262
3703
  function readBody(cb) {
@@ -3269,8 +3710,15 @@ function viewServerCore(root, port, host) {
3269
3710
  });
3270
3711
  }
3271
3712
  if (req.method === 'GET' && pathname === '/') {
3272
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
3273
- res.end(VIEW_HTML);
3713
+ res.writeHead(200, {
3714
+ 'Content-Type': 'text/html; charset=utf-8',
3715
+ 'Cache-Control': 'no-store',
3716
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'",
3717
+ 'X-Content-Type-Options': 'nosniff',
3718
+ 'X-Frame-Options': 'DENY',
3719
+ 'Referrer-Policy': 'no-referrer',
3720
+ });
3721
+ res.end(viewHtml());
3274
3722
  return;
3275
3723
  }
3276
3724
  if (req.method === 'POST' && pathname === '/api/status') {
@@ -3298,7 +3746,7 @@ function viewServerCore(root, port, host) {
3298
3746
  const agents = loadAgents(root).agents || {};
3299
3747
  const owners = keyOwners(root);
3300
3748
  const all = Array.from(new Set(owners.concat(Object.keys(agents))));
3301
- return json(200, { owners: all.map(function (o) { return { owner: o, registered: !!agents[o], authorized: !!session.ownerKeys[o] && fs.existsSync(encCachePath(root, o)) }; }) });
3749
+ return json(200, { owners: all.map(function (o) { return { owner: o, registered: !!agents[o], authorized: fs.existsSync(encAgentBindingPath(root, o)) }; }) });
3302
3750
  }
3303
3751
  if (req.method === 'POST' && pathname === '/api/entries') {
3304
3752
  if (!session.umk) return json(401, { error: '请先解锁。' });
@@ -3320,22 +3768,39 @@ function viewServerCore(root, port, host) {
3320
3768
  return readBody(function (d) {
3321
3769
  const owner = String(d.owner || '');
3322
3770
  if (!owner) return json(400, { error: '缺少 owner。' });
3771
+ if (!isSafeAgentId(owner)) return json(400, { error: '非法 owner ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' });
3772
+ if (fs.existsSync(encAgentBindingPath(root, owner))) {
3773
+ return json(409, { error: '该智能体已有 agent binding。为避免打断在用的 agent_key,请先“吊销”,再重新授权生成新 key。' });
3774
+ }
3323
3775
  let ok = session.ownerKeys[owner];
3324
3776
  let rk = null;
3325
3777
  try { rk = unwrapRecoveryEnc(root, session.umk); } catch (e) { rk = null; }
3326
3778
  if (!ok) {
3327
3779
  if (!rk) return json(400, { error: '密钥库缺少恢复钥匙(recovery.key.enc),无法为新 owner 建钥。' });
3328
- ok = wrapOwnerKey(root, session.umk, rk, owner);
3780
+ ok = recoverOwnerKeyFromRecovery(root, owner, session.umk, rk);
3781
+ if (!ok) {
3782
+ if (privateOwnerHasData(root, owner)) {
3783
+ return json(400, { error: '检测到 private/' + owner + ' 仍有密文,但 keys/' + owner + '.key.enc 与恢复文件均不可用;拒绝新建 owner key(会导致旧数据无法解密)。请从备份恢复原 owner key,或使用 CLI 恢复钥匙修复后重试。' });
3784
+ }
3785
+ ok = wrapOwnerKey(root, session.umk, rk, owner);
3786
+ }
3329
3787
  session.ownerKeys[owner] = ok;
3330
3788
  }
3331
- writeOwnerKeyCache(root, owner, ok);
3332
- json(200, { ok: true });
3789
+ const agentKey = crypto.randomBytes(32);
3790
+ const pendingFile = writeAgentBindingWithPending(root, owner, ok, agentKey);
3791
+ revokeOwnerKeyCache(root, owner);
3792
+ json(200, { ok: true, agentKey: agentKey.toString('base64'), pendingFile: pendingFile, text: 'agent_key 只显示一次,请用户自行保存;AI 可由 pending 文件领取并写入宿主目录。' });
3333
3793
  });
3334
3794
  }
3335
3795
  if (req.method === 'POST' && pathname === '/api/revoke') {
3336
3796
  if (!session.umk) return json(401, { error: '请先解锁。' });
3337
3797
  return readBody(function (d) {
3338
- revokeOwnerKeyCache(root, String(d.owner || ''));
3798
+ const owner = String(d.owner || '');
3799
+ if (!isSafeAgentId(owner)) return json(400, { error: '非法 owner ID:只允许单段名称,禁止 /、\\、.. 和控制字符。' });
3800
+ const binding = encAgentBindingPath(root, owner);
3801
+ if (fs.existsSync(binding)) fs.unlinkSync(binding);
3802
+ removePendingAgentKey(root, owner);
3803
+ revokeOwnerKeyCache(root, owner);
3339
3804
  json(200, { ok: true });
3340
3805
  });
3341
3806
  }
@@ -3380,7 +3845,9 @@ function cmdView(opts) {
3380
3845
  viewServerCore(root, port, host);
3381
3846
  }
3382
3847
 
3383
- const VIEW_HTML = "<!doctype html><html lang=\"zh\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>元忆 · 用户查看平台</title><style>\nbody{font-family:system-ui,-apple-system,\"Microsoft YaHei\",sans-serif;max-width:1000px;margin:24px auto;padding:0 16px;color:#1f2328;background:#fafafa}\nh1{font-size:22px} .card{background:#fff;border:1px solid #e2e2e2;border-radius:10px;padding:16px 18px;margin:14px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)}\nbutton{background:#2563eb;color:#fff;border:0;border-radius:6px;padding:7px 14px;cursor:pointer;margin:2px;font-size:14px}\nbutton.danger{background:#dc2626} button.ghost{background:#e5e7eb;color:#1f2328}\ninput,select{padding:8px;border:1px solid #c9c9c9;border-radius:6px;margin:2px;font-size:14px;box-sizing:border-box}\ntable{border-collapse:collapse;width:100%;font-size:13px} td,th{border:1px solid #ececec;padding:6px 8px;text-align:left;vertical-align:top}\n.owner{display:inline-flex;align-items:center;gap:6px;border:1px solid #ddd;border-radius:8px;padding:5px 10px;margin:4px 6px 4px 0;background:#f6f8fa}\n.entry{border-bottom:1px solid #eee;padding:8px 0} .meta{color:#8a8a8a;font-size:12px}\n.err{color:#dc2626;margin-top:8px} .ok{color:#16a34a;margin-top:8px}\n#app{display:none} code{background:#f0f0f0;padding:1px 5px;border-radius:4px;font-size:12px}\n</style></head><body>\n<h1>元忆 · 用户查看平台 <span id=\"ver\" style=\"font-size:14px;color:#888\"></span></h1>\n<div id=\"lock\" class=\"card\">\n <p><b>输入主口令解锁</b>(口令只在本地内存派生,不落盘、不发送远端)。忘口令可在 CLI 用恢复钥匙重设:<code>yotta-memory reset-password --recovery-key &lt;钥匙&gt;</code></p>\n <input type=\"password\" id=\"pw\" placeholder=\"主口令\" style=\"width:260px\">\n <button onclick=\"unlock()\">解锁</button>\n <div class=\"err\" id=\"lockerr\"></div>\n</div>\n<div id=\"app\">\n <div class=\"card\">\n <b>AI 列表</b>(✅=已授权可读自己私密,🔒=未授权)\n <div id=\"owners\" style=\"margin-top:8px\"></div>\n </div>\n <div class=\"card\">\n <b>记忆</b>\n <input id=\"q\" placeholder=\"搜索关键词\" style=\"width:220px\" onkeydown=\"if(event.key==='Enter'){off=0;load()}\">\n <button onclick=\"off=0;load()\">搜索</button>\n <button class=\"ghost\" onclick=\"doExport()\">导出 JSON</button>\n <button class=\"ghost\" onclick=\"showRk()\">显示恢复钥匙</button>\n <span id=\"rkout\" style=\"font-size:12px;color:#888;margin-left:8px\"></span>\n <div id=\"meta\" style=\"margin-top:10px;font-size:12px;color:#666\"></div>\n <div id=\"entries\" style=\"margin-top:6px\"></div>\n <div id=\"pager\" style=\"margin-top:10px\">\n <button class=\"ghost\" id=\"prevb\" onclick=\"prevPage()\">上一页</button>\n <span id=\"pageinfo\" style=\"font-size:12px;color:#888;margin:0 8px\"></span>\n <button class=\"ghost\" id=\"nextb\" onclick=\"nextPage()\">下一页</button>\n </div>\n </div>\n <div class=\"card\">\n <b>重设口令</b><br>\n <input type=\"password\" id=\"cur\" placeholder=\"当前口令\">\n <input type=\"password\" id=\"np1\" placeholder=\"新口令\">\n <input type=\"password\" id=\"np2\" placeholder=\"确认新口令\">\n <button onclick=\"resetPw()\">重设</button>\n <span id=\"pwout\"></span>\n </div>\n</div>\n<script>\nfunction esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}\nasync function api(p,b){try{const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})});return await r.json();}catch(e){return{error:String(e)};}}\nasync function boot(){const s=await api('/api/status');document.getElementById('ver').textContent='v'+(s.version||'');if(s.unlocked){showApp();}}\nfunction showApp(){document.getElementById('lock').style.display='none';document.getElementById('app').style.display='block';loadOwners();load();}\nasync function unlock(){const d=await api('/api/unlock',{password:document.getElementById('pw').value});if(d.error){document.getElementById('lockerr').textContent=d.error;return;}showApp();}\nasync function loadOwners(){const d=await api('/api/owners');const box=document.getElementById('owners');box.innerHTML='';if(!d.owners||!d.owners.length){box.innerHTML='(无 owner)';return;}\n for(const o of d.owners){const c=document.createElement('span');c.className='owner';c.innerHTML=esc(o.owner)+(o.authorized?' ✅':' 🔒')+' <button class=\"ghost\" data-a=\"'+esc(o.owner)+'\">授权</button><button class=\"danger\" data-r=\"'+esc(o.owner)+'\">吊销</button>';box.appendChild(c);}\n box.querySelectorAll('[data-a]').forEach(function(b){b.onclick=function(){api('/api/authorize',{owner:b.getAttribute('data-a')}).then(function(){loadOwners();});};});\n box.querySelectorAll('[data-r]').forEach(function(b){b.onclick=function(){api('/api/revoke',{owner:b.getAttribute('data-r')}).then(function(){loadOwners();});};});\n}\nlet off=0,PS=50;\nasync function load(){const d=await api('/api/entries',{query:document.getElementById('q').value,offset:off,limit:PS});const meta=document.getElementById('meta');const pg=document.getElementById('pageinfo');if(meta)meta.textContent='共 '+d.count+' 条';const lim=d.limit||PS;const totalPg=Math.max(1,Math.ceil(d.count/lim));const curPg=Math.floor((d.offset||0)/lim)+1;if(pg)pg.textContent='第 '+curPg+' / '+totalPg+' 页';const box=document.getElementById('entries');box.innerHTML='';if(d.entries)for(const e of d.entries){const div=document.createElement('div');div.className='entry';div.innerHTML='<b>['+esc(e.type)+'] '+esc(e.subject)+'</b><div>'+esc(e.statement)+'</div><div class=\"meta\">'+esc(e.file)+' · owner='+esc(e.owner||'-')+' · '+esc(e.updated||e.created||'')+'</div>';box.appendChild(div);}const pb=document.getElementById('prevb'),nb=document.getElementById('nextb');if(pb)pb.disabled=(d.offset||0)<=0;if(nb)nb.disabled=!d.hasMore;}\nfunction prevPage(){if(off>=PS){off-=PS;load();}}\nfunction nextPage(){off+=PS;load();}\nasync function doExport(){const d=await api('/api/export');if(d.error){alert(d.error);return;}const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='yottamemory-view-export.json';a.click();}\nasync function showRk(){const d=await api('/api/recovery-key');document.getElementById('rkout').textContent=d.recoveryKey?('恢复钥匙: '+d.recoveryKey):(d.error||'');}\nasync function resetPw(){const np1=document.getElementById('np1').value,np2=document.getElementById('np2').value;if(np1!==np2){document.getElementById('pwout').innerHTML='<span class=\"err\">两次新口令不一致</span>';return;}\n const d=await api('/api/reset-password',{currentPassword:document.getElementById('cur').value,newPassword:np1});document.getElementById('pwout').innerHTML=d.error?('<span class=\"err\">'+esc(d.error)+'</span>'):('<span class=\"ok\">'+esc(d.text||'ok')+'</span>');}\nboot();\n</script></body></html>";
3848
+ function viewHtml() {
3849
+ return VIEW_HTML;
3850
+ }
3384
3851
 
3385
3852
  // ---- 命令包装(CLI 入口)----
3386
3853
  async function cmdInit(opts) {
@@ -3447,8 +3914,16 @@ function cmdDistill(opts) {
3447
3914
  console.log(r.text);
3448
3915
  if (r.error) process.exit(2);
3449
3916
  }
3450
- function cmdForget(fileRef) {
3451
- const r = forgetCore(fileRef);
3917
+ function cmdForget(fileRef, opts) {
3918
+ const ident = resolveIdentity(opts);
3919
+ if (ident.error) {
3920
+ console.log(ident.error);
3921
+ process.exit(3);
3922
+ }
3923
+ const r = forgetCore(fileRef, {
3924
+ selfAgent: ident.id,
3925
+ unsafe: !!(opts && opts.unsafe),
3926
+ });
3452
3927
  console.log(r.text);
3453
3928
  if (r.error) process.exit(2);
3454
3929
  }
@@ -3561,6 +4036,16 @@ function importCore(root, src) {
3561
4036
  try { data = JSON.parse(fs.readFileSync(fp, 'utf8')); }
3562
4037
  catch (e) { return { error: true, text: 'JSON 解析失败: ' + (e && e.message ? e.message : String(e)) }; }
3563
4038
  if (!data.memories || !Array.isArray(data.memories)) return { error: true, text: 'JSON 格式不正确(缺少 memories 数组)' };
4039
+ for (const item of data.memories) {
4040
+ const meta = item.meta || {};
4041
+ const t = (meta.type || 'FACT').toUpperCase();
4042
+ if (!TYPE_DIRS[t]) continue;
4043
+ const owner = String(meta.owner || '');
4044
+ const scope = meta.scope || defaultScope(t);
4045
+ if (scope === 'private' && !isSafeAgentId(owner)) {
4046
+ return { error: true, text: '拒绝: 导入的私密条目 owner 非法(只允许单段名称,禁止 /、\\、.. 和控制字符)。' };
4047
+ }
4048
+ }
3564
4049
  ensureInit(root);
3565
4050
  const encrypted = isEncrypted(root);
3566
4051
  let m = 0, skippedPriv = 0;
@@ -3624,6 +4109,7 @@ function cmdTokenNew(agentId, opts) {
3624
4109
  const root = userRoot();
3625
4110
  ensureInit(root);
3626
4111
  if (!agentId) { console.error('请指定 --agent <id>'); process.exit(2); }
4112
+ if (!isSafeAgentId(agentId)) { console.error('非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。'); process.exit(2); }
3627
4113
  const data = loadTokens(root);
3628
4114
  data.version = 1;
3629
4115
  data.tokens = data.tokens || {};
@@ -3649,6 +4135,7 @@ function cmdTokenList() {
3649
4135
  function cmdTokenRevoke(agentId) {
3650
4136
  const root = userRoot();
3651
4137
  if (!agentId) { console.error('请指定 --agent <id>'); process.exit(2); }
4138
+ if (!isSafeAgentId(agentId)) { console.error('非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。'); process.exit(2); }
3652
4139
  const data = loadTokens(root);
3653
4140
  data.tokens = data.tokens || {};
3654
4141
  if (data.tokens[agentId]) { delete data.tokens[agentId]; saveTokens(root, data); console.log('已吊销: ' + agentId); }
@@ -3755,6 +4242,7 @@ function cmdIam(agentId, opts) {
3755
4242
  const root = userRoot();
3756
4243
  ensureInit(root);
3757
4244
  if (!agentId) { console.error('请指定 <id>:yotta-memory iam <id> [--name <显示名>] [--user <用户名>] [--relationship <关系>] [--force]'); process.exit(2); }
4245
+ if (!isSafeAgentId(agentId)) { console.error('非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。'); process.exit(2); }
3758
4246
  const conflict = identityConflict(root, agentId);
3759
4247
  if (conflict && !opts.force) {
3760
4248
  console.error("错误: ID '" + agentId + "' " + conflict + '。每个 AI 智能体的 ID 必须唯一。请换一个唯一 ID,或确认是同一智能体后加 --force 覆盖。');
@@ -3775,13 +4263,18 @@ function cmdIam(agentId, opts) {
3775
4263
  console.log('已写入自我档案: ' + file);
3776
4264
  console.log('本机免 token:以后用 whoami 确认身份;远端接入需 token new --agent ' + agentId);
3777
4265
  }
3778
- function cmdWhoami() {
4266
+ function cmdWhoami(opts) {
3779
4267
  const root = userRoot();
3780
- const id = currentAgent();
4268
+ const ident = resolveIdentity(opts);
4269
+ if (ident.error) {
4270
+ console.log(ident.error);
4271
+ process.exit(2);
4272
+ }
4273
+ const id = ident.id;
3781
4274
  if (!id) {
3782
- console.log('当前未声明智能体身份(YOTTA_AGENT_ID / AGENT_ID 未设置,也未传 --agent)。');
3783
- console.log('本机:请在该智能体的 MCP 配置 env 设 YOTTA_AGENT_ID=<唯一ID>,然后 yotta-memory iam <id> 登记;或 CLI 每次带 --agent <id> / --owner <id>。');
3784
- console.log('远端:X-Agent-Id 请求头 + token(token new --agent <id>)。');
4275
+ console.log('当前未声明可信智能体身份(未传 --agent,且没有受信任的 MCP 环境身份)。');
4276
+ console.log('CLI:请显式传 --agent <唯一ID>。MCP:在客户端配置中同时设置 YOTTA_AGENT_ID=<唯一ID> 与 YOTTA_MEMORY_TRUST_ENV_AGENT=1。');
4277
+ console.log('远端:token + X-Agent-Id + X-Agent-Key(token new --agent <id>;agent_key 由 view 授权生成)。');
3785
4278
  return;
3786
4279
  }
3787
4280
  const agents = loadAgents(root).agents || {};
@@ -3831,8 +4324,19 @@ function profileGroups(root, owner) {
3831
4324
  function profileCore(opts) {
3832
4325
  opts = opts || {};
3833
4326
  const root = userRoot();
3834
- const selfAgent = opts.selfAgent !== undefined ? opts.selfAgent : currentAgent();
4327
+ const ident = resolveIdentity(opts);
4328
+ if (ident.error) return { error: true, exitCode: 3, text: ident.error };
4329
+ if (!ident.id) {
4330
+ return {
4331
+ error: true,
4332
+ exitCode: 3,
4333
+ text: '生成私密画像必须先声明身份:请传 --agent <id>;MCP 场景需设置 YOTTA_AGENT_ID=<id> + YOTTA_MEMORY_TRUST_ENV_AGENT=1。',
4334
+ };
4335
+ }
4336
+ const selfAgent = ident.id;
3835
4337
  const owner = opts.owner || selfAgent;
4338
+ const keyError = validatePrivateIdentity(root, ident, owner);
4339
+ if (keyError) return { error: true, exitCode: 3, text: keyError };
3836
4340
  if (!owner) return { error: true, exitCode: 2, text: '请先声明身份(YOTTA_AGENT_ID / AGENT_ID)或传 --owner <id>,再生成画像。' };
3837
4341
  if (selfAgent && owner !== selfAgent && owner !== 'user' && !opts.unsafe && !hasGrant(selfAgent, owner)) {
3838
4342
  return { error: true, exitCode: 3, text: '拒绝: 不能生成其它智能体 ' + owner + ' 的画像(private/' + owner + '/ 为私密区)。如需读取请 --owner user 或 --unsafe(用户显式授权)。' };
@@ -3885,8 +4389,19 @@ function contextCore(opts) {
3885
4389
  if (!roots.length) return { error: false, exitCode: 0, text: '记忆库不存在,请先运行: yotta-memory init' };
3886
4390
  const root = userRoot();
3887
4391
  const limit = opts.limit || 10;
3888
- const selfAgent = opts.selfAgent !== undefined ? opts.selfAgent : currentAgent();
4392
+ const ident = resolveIdentity(opts);
4393
+ if (ident.error) return { error: true, exitCode: 3, text: ident.error };
4394
+ if (!ident.id) {
4395
+ return {
4396
+ error: true,
4397
+ exitCode: 3,
4398
+ text: '开工上下文包含私密画像 / 边界 / 承诺,必须先声明身份:请传 --agent <id>;MCP 场景需设置 YOTTA_AGENT_ID=<id> + YOTTA_MEMORY_TRUST_ENV_AGENT=1。',
4399
+ };
4400
+ }
4401
+ const selfAgent = ident.id;
3889
4402
  const owner = opts.owner || selfAgent;
4403
+ const keyError = validatePrivateIdentity(root, ident, owner);
4404
+ if (keyError) return { error: true, exitCode: 3, text: keyError };
3890
4405
  const unsafe = !!opts.unsafe;
3891
4406
  const budget = opts.budget ? parseInt(opts.budget, 10) : 0;
3892
4407
  const focus = opts.focus ? String(opts.focus) : '';
@@ -4056,6 +4571,10 @@ function contextCore(opts) {
4056
4571
  function cmdContext(opts) {
4057
4572
  const r = contextCore(opts);
4058
4573
  console.log(r.text);
4574
+ if (r.error) {
4575
+ const reminder = migrationReminderText(userRoot());
4576
+ if (reminder) console.log('\n' + reminder);
4577
+ }
4059
4578
  if (r.exitCode) process.exit(r.exitCode);
4060
4579
  }
4061
4580
 
@@ -4122,6 +4641,7 @@ function mcpTools() {
4122
4641
  }
4123
4642
  function callTool(name, args, ctx) {
4124
4643
  const agent = ctx.agent || '';
4644
+ if (agent && !isSafeAgentId(agent)) return { text: '非法 agent ID:只允许单段名称,禁止 /、\\、.. 和控制字符。', error: true };
4125
4645
  try {
4126
4646
  if (name === 'remember') {
4127
4647
  const ownerArg = args.owner ? String(args.owner) : '';
@@ -4248,6 +4768,7 @@ function unsupportedVersion(id, pv) {
4248
4768
  return { jsonrpc: '2.0', id: id, error: { code: -32022, message: 'Unsupported protocol version', data: { supported: [MCP_PROTOCOL_MODERN], requested: pv } } };
4249
4769
  }
4250
4770
  function handleMessage(msg, ctx) {
4771
+ setRuntimeAgent(ctx && ctx.agent, ctx && ctx.agentKey);
4251
4772
  if (!msg || msg.jsonrpc !== '2.0') return { jsonrpc: '2.0', id: msg && msg.id, error: { code: -32600, message: 'invalid request' } };
4252
4773
  const id = msg.id;
4253
4774
  if (id === undefined || id === null) return null;
@@ -4294,14 +4815,16 @@ function cmdServe(opts) {
4294
4815
  const root = userRoot();
4295
4816
  ensureInit(root);
4296
4817
  function authorize(req) {
4297
- if (noAuth) return { agent: String(req.headers['x-agent-id'] || '') };
4818
+ const agentId = String(req.headers['x-agent-id'] || '');
4819
+ const agentKey = String(req.headers['x-agent-key'] || '').trim();
4820
+ if (noAuth) return { agent: agentId, agentKey: agentKey };
4298
4821
  const auth = req.headers['authorization'] || '';
4299
4822
  const m = /^Bearer\s+(.+)$/i.exec(auth);
4300
4823
  if (!m) return null;
4301
4824
  const token = m[1].trim();
4302
- const agent = String(req.headers['x-agent-id'] || '');
4825
+ const agent = agentId;
4303
4826
  const tokenMap = (loadTokens(root).tokens) || {};
4304
- if (tokenMap[agent] && tokenMap[agent].token === token) return { agent: agent };
4827
+ if (tokenMap[agent] && tokenMap[agent].token === token) return { agent: agent, agentKey: agentKey };
4305
4828
  return null;
4306
4829
  }
4307
4830
  function originAllowed(req) {
@@ -4327,7 +4850,7 @@ function cmdServe(opts) {
4327
4850
  }
4328
4851
  const auth = authorize(req);
4329
4852
  if (!auth) {
4330
- sendJson(res, 401, { jsonrpc: '2.0', error: { code: -32001, message: 'unauthorized: 需要有效 Bearer token 与 X-Agent-Id' } });
4853
+ sendJson(res, 401, { jsonrpc: '2.0', error: { code: -32001, message: 'unauthorized: 需要有效 Bearer token 与 X-Agent-Id;加密私密操作还需 X-Agent-Key' } });
4331
4854
  return;
4332
4855
  }
4333
4856
  if (req.method === 'GET') {
@@ -4372,7 +4895,7 @@ function cmdServe(opts) {
4372
4895
  console.log('记忆库: ' + root);
4373
4896
  console.log('MCP 协议: 2026-07-28(modern,server/discover)/ 2025-11-25(legacy 握手兼容)');
4374
4897
  if (noAuth) console.log('鉴权: 已关闭(--no-auth,仅限可信内网)');
4375
- else console.log('鉴权: Bearer token + X-Agent-Id(yotta-memory token new --agent <id> 生成)');
4898
+ else console.log('鉴权: Bearer token + X-Agent-Id + X-Agent-Key(token 由 token new 生成,agent_key 由 view 授权生成)');
4376
4899
  console.log('按 Ctrl+C 停止');
4377
4900
  startBackupFallback();
4378
4901
  });
@@ -4381,7 +4904,7 @@ function cmdServe(opts) {
4381
4904
  function cmdServeStdio() {
4382
4905
  const root = userRoot();
4383
4906
  ensureInit(root);
4384
- const ctx = { agent: currentAgent() };
4907
+ const ctx = { agent: currentAgent(), agentKey: trustedEnvAgentKey() };
4385
4908
  let buf = '';
4386
4909
  process.stdin.setEncoding('utf8');
4387
4910
  process.stdin.on('data', function (chunk) {
@@ -5107,7 +5630,7 @@ function consolidateCore(opts) {
5107
5630
  const root = userRoot();
5108
5631
  if (!fs.existsSync(root)) return { error: false, text: '记忆库不存在。' };
5109
5632
  const apply = !!opts.apply;
5110
- const selfAgent = opts.selfAgent || currentAgent();
5633
+ const selfAgent = resolveIdentity(opts).id;
5111
5634
  const unsafe = !!opts.unsafe;
5112
5635
  const cfg = loadConfig();
5113
5636
  const minAge = (opts.minAge !== undefined && opts.minAge !== null) ? opts.minAge : (parseInt(cfg.consolidate_min_age || '180', 10) || 180);
@@ -5272,7 +5795,7 @@ function autoMergePairCore(root, keepE, dropE, batch, selfAgent, unsafe) {
5272
5795
 
5273
5796
  function appendDedupBlock(lines, root, opts) {
5274
5797
  const apply = !!opts.apply;
5275
- const selfAgent = opts.selfAgent || currentAgent();
5798
+ const selfAgent = resolveIdentity(opts).id;
5276
5799
  const unsafe = !!opts.unsafe;
5277
5800
  lines.push('');
5278
5801
  lines.push('### 重复候选与置信度(--dedup' + (apply ? ' --apply 自动合并' : '') + ')');
@@ -5363,7 +5886,7 @@ function usage() {
5363
5886
  ['migrate', '把明文私密区迁移为密文(需主口令;迁移后打印恢复钥匙)'],
5364
5887
  ['view', '启动用户查看平台(--port/--host;口令解锁浏览/授权/吊销 AI)'],
5365
5888
  ['reset-password', '重设主口令(忘口令用恢复钥匙)'],
5366
- ['key', '管理 AI 私密读取授权缓存(list / authorize <id> / revoke <id>)'],
5889
+ ['key', '管理 agent_key binding(list / bind <id> / rotate <id> / claim <id> --to <AI_HOME> / status <id> / revoke <id>;bind/rotate 需主口令或恢复钥匙)'],
5367
5890
  ['config', '查看/设置配置(get;set memory_home <目录> / backup_dir <目录> / embedding_cmd <命令> / embedding_timeout <毫秒> / maintain_* 阈值与半衰 / consolidate_* 参数)']
5368
5891
  ]],
5369
5892
  ['平台与服务', [
@@ -5382,9 +5905,9 @@ function usage() {
5382
5905
  lines.push('');
5383
5906
  }
5384
5907
  lines.push('类型: FACT(公共共享) / PREF(偏好) / BOUND(边界) / COMMIT(承诺)');
5385
- lines.push('环境变量: YOTTA_MEMORY_HOME 临时覆盖用户级位置; YOTTA_AGENT_ID/AGENT_ID 当前 agent 标识(本机声明身份用;私密记忆必须有 owner)');
5908
+ lines.push('环境变量: YOTTA_MEMORY_HOME 临时覆盖用户级位置; MCP 身份必须同时设置 YOTTA_AGENT_ID + YOTTA_MEMORY_AGENT_KEY + YOTTA_MEMORY_TRUST_ENV_AGENT=1;CLI 用 --agent + --agent-key/--agent-key-file');
5386
5909
  lines.push('隔离: 公共 FACT 在 facts/;私密 PREF/BOUND/COMMIT 物理分目录 private/<agent_id>/<type>/,禁止 shell 直读写记忆库,一律走本命令');
5387
- lines.push('远端接入: MCP url http://<主机IP>:8787/mcp;请求头 Authorization: Bearer <token> + X-Agent-Id: <id>');
5910
+ lines.push('远端接入: MCP url http://<主机IP>:8787/mcp;请求头 Authorization: Bearer <token> + X-Agent-Id: <id> + X-Agent-Key: <agent_key>');
5388
5911
  console.log(lines.join('\n'));
5389
5912
  }
5390
5913
  async function main() {
@@ -5392,7 +5915,7 @@ async function main() {
5392
5915
  if (!args.length) { usage(); return; }
5393
5916
  const opts = {};
5394
5917
  const positional = [];
5395
- const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time']);
5918
+ const valueOpts = new Set(['--type', '--limit', '--days', '--out', '--owner', '--agent', '--agent-key', '--agent-key-file', '--threshold', '--scope', '--host', '--port', '--dir', '--name', '--user', '--relationship', '--source', '--weight', '--budget', '--password', '--new-password', '--recovery-key', '--reason', '--merge', '--model', '--subject', '--embedding', '--focus', '--embedding-timeout', '--min-age', '--min-idle', '--max-utility', '--min-group', '--period', '--to', '--id', '--time']);
5396
5919
  for (let i = 0; i < args.length; i++) {
5397
5920
  const a = args[i];
5398
5921
  if (a === '--version' || a === '-v') { console.log(VERSION); return; }
@@ -5432,6 +5955,8 @@ async function main() {
5432
5955
  else if (a === '--out') opts.out = v;
5433
5956
  else if (a === '--owner') opts.owner = v;
5434
5957
  else if (a === '--agent') opts.agent = v;
5958
+ else if (a === '--agent-key') opts.agentKey = v;
5959
+ else if (a === '--agent-key-file') opts.agentKeyFile = v;
5435
5960
  else if (a === '--scope') opts.scope = v;
5436
5961
  else if (a === '--host') opts.host = v;
5437
5962
  else if (a === '--port') opts.port = parseInt(v, 10) || 8787;
@@ -5469,9 +5994,11 @@ async function main() {
5469
5994
  }
5470
5995
  const first = positional[0];
5471
5996
  const rest = positional.slice(1);
5997
+ const runtimeIdentity = resolveIdentity(opts);
5998
+ setRuntimeAgent(runtimeIdentity.error ? '' : runtimeIdentity.id, runtimeIdentity.error ? '' : runtimeIdentity.agentKey);
5472
5999
  switch (first) {
5473
6000
  case 'init': await cmdInit(opts); break;
5474
- case 'whoami': cmdWhoami(); break;
6001
+ case 'whoami': cmdWhoami(opts); break;
5475
6002
  case 'iam': cmdIam(rest[0], opts); break;
5476
6003
  case 'config': {
5477
6004
  const sub = rest[0];
@@ -5488,7 +6015,7 @@ async function main() {
5488
6015
  case 'maintain': cmdMaintain(opts); break;
5489
6016
  case 'distill': cmdDistill(opts); break;
5490
6017
  case 'consolidate': if (opts.undo === true && rest.length) opts.undo = rest[0]; cmdConsolidate(opts); break;
5491
- case 'forget': cmdForget(rest[0]); break;
6018
+ case 'forget': cmdForget(rest[0], opts); break;
5492
6019
  case 'archive': cmdArchive(opts); break;
5493
6020
  case 'backup': {
5494
6021
  const sub = rest[0];
@@ -5525,9 +6052,12 @@ async function main() {
5525
6052
  case 'key': {
5526
6053
  const sub = rest[0];
5527
6054
  if (sub === 'list') cmdKeyList();
6055
+ else if (sub === 'bind' || sub === 'rotate') await cmdKeyBind(rest[1], opts);
5528
6056
  else if (sub === 'authorize') await cmdKeyAuthorize(rest[1], opts);
6057
+ else if (sub === 'claim') cmdKeyClaim(rest[1], opts);
6058
+ else if (sub === 'status') cmdKeyStatus(rest[1], opts);
5529
6059
  else if (sub === 'revoke') cmdKeyRevoke(rest[1]);
5530
- else { console.error('key 子命令: list / authorize <id> / revoke <id>'); process.exit(2); }
6060
+ else { console.error('key 子命令: list / bind <id> / rotate <id> / claim <id> --to <AI_HOME> / status <id> / revoke <id>'); process.exit(2); }
5531
6061
  break;
5532
6062
  }
5533
6063
  case 'serve': cmdServe(opts); break;
@@ -5640,10 +6170,11 @@ module.exports = {
5640
6170
  unwrapOwnerKey: unwrapOwnerKey,
5641
6171
  unwrapOwnerKeyRecovery: unwrapOwnerKeyRecovery,
5642
6172
  unwrapRecoveryEnc: unwrapRecoveryEnc,
5643
- writeOwnerKeyCache: writeOwnerKeyCache,
5644
6173
  revokeOwnerKeyCache: revokeOwnerKeyCache,
5645
- loadOwnerKeyCache: loadOwnerKeyCache,
5646
6174
  getOwnerKeyFor: getOwnerKeyFor,
6175
+ keyListCore: keyListCore,
6176
+ migrationRequiredInfo: migrationRequiredInfo,
6177
+ migrationReminderText: migrationReminderText,
5647
6178
  encryptMemoryText: encryptMemoryText,
5648
6179
  decryptMemoryText: decryptMemoryText,
5649
6180
  readMemoryText: readMemoryText,
@@ -5651,6 +6182,7 @@ module.exports = {
5651
6182
  loadOwnerIndex: loadOwnerIndex,
5652
6183
  saveOwnerIndex: saveOwnerIndex,
5653
6184
  viewEntriesCore: viewEntriesCore,
6185
+ viewHtml: viewHtml,
5654
6186
  ensureIndex: ensureIndex,
5655
6187
  recallPrefilter: recallPrefilter,
5656
6188
  tokenize: tokenize,
@@ -5671,8 +6203,17 @@ module.exports = {
5671
6203
  writeProfileText: writeProfileText,
5672
6204
  migrateCore: migrateCore,
5673
6205
  resetPasswordCore: resetPasswordCore,
5674
- keyAuthorizeCore: keyAuthorizeCore,
6206
+ keyBindCore: keyBindCore,
5675
6207
  keyRevokeCore: keyRevokeCore,
6208
+ keyClaimCore: keyClaimCore,
6209
+ keyStatusCore: keyStatusCore,
6210
+ writeAgentBinding: writeAgentBinding,
6211
+ unwrapAgentBinding: unwrapAgentBinding,
6212
+ writePendingAgentKey: writePendingAgentKey,
6213
+ removePendingAgentKey: removePendingAgentKey,
6214
+ readPendingAgentKey: readPendingAgentKey,
6215
+ agentKeyFilePath: agentKeyFilePath,
6216
+ setRuntimeAgent: setRuntimeAgent,
5676
6217
  promptPassword: promptPassword,
5677
6218
  initCore: initCore,
5678
6219
  backupCreateCore: backupCreateCore,