fix(security): sleeper 旗標不再是任意人格的萬用鑰匙(S1)

問題:`requireOwner()` 看到 sleeper 旗標就直接 return,把驗證整個外包給
PreToolUse hook;而 hook 認得出這支 CLI 靠的是檔名正則。把 scripts/ 複製出去、
CLI 改個名字,hook 全程回 pass,任何程序都能用那個旗標對**任意人格**
remember/recall/emotion --apply——等於完整讀寫權。

修法:CLI 自己驗,證據取自 hook 唯一寫得下、程序偽造不了的東西——
session 檔(`.runtime/sessions/<id>.json`)裡的 sleeper pin。pin 由 hook 依
`event.agent_type` 寫入,沒經過 hook 的程序拿不到。

- `pinAgent()` 多記角色(`{ persona, role, pinned_at }`),`pinOf()` 相容舊的純字串格式;
  舊格式沒有角色,一律不算 sleeper 授權,hook 下次 first-touch 時會補上。
- 新增 `sleeperPins(sessionId, slug)`:本 session 中 pin 在該人格上的 sleeper。
- `requireSleeperPin()` 進 `sleeperAccess()`(requireOwner/requireMember 都會經過):
  沒有對應的 sleeper pin 就 die,連 sleeper 租約都不會留下。
  有帶 `--agent-id` 時額外要求它就是那個被 pin 的 agent。
- 連帶更新讀 pins 的三處:resolveScope、`leave` 的 guest pin 清理、SubagentStop hook。

persona-sleeper sub agent 的正常流程不受影響:hook 的 first-touch pinning 在它
第一個指令就寫好 pin,之後每個收尾指令都驗得過。

測試:新增 10 項(實際把 scripts/ 複製成別的檔名重現攻擊)——改名後 hook 確實
不表態、無 pin 時 remember/recall/emotion 都被 CLI 擋下且不留租約、pin 在別的
人格上不能跨過去、guest pin 與舊格式 pin 都不算授權、真有 pin 時照常放行。
345 → 355 項全過。
This commit is contained in:
2026-07-31 09:19:20 +00:00
parent 9e7a9cf8e2
commit b1e77b01a1
5 changed files with 177 additions and 17 deletions
+3
View File
@@ -453,6 +453,9 @@ node scripts/persona.mjs sleep --session <PERSONA_SESSION> --release # 收工
- **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。 - **一次睡多個人格**:可用 `sleep --personas A,B` 批次處理;主程序只是把流程排成一串,**每個人格仍各自判斷、各自收尾、各自回 JSON**。
- **回傳值刻意很窮**`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。 - **回傳值刻意很窮**`{persona, ok, slept_at, steps, sync, kept_lock}`——只有狀態。
回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。 回傳值本身就是一條會繞過隔離的通道,所以在 CLI 這一層封死,不靠提示詞自律。
- **`--as-sleeper` 由 CLI 自己驗**:它代表「我是 persona-sleeper 型 sub agent」,而型別只有 hook 看得到。
CLI 不把這件事外包給 hook(hook 認 CLI 靠檔名,改名就繞過去了),而是直接查 hook 寫在
`.runtime/sessions/<id>.json` 裡的 sleeper pin:本 session 沒有 pin 在目標人格上的 sleeper 就拒絕。
- **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手; - **鎖**:沒有活鎖 → 取 5 分鐘的 sleeper 租約;同 session → 直接睡;死鎖 → 可接手;
**別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。 **別的程序活鎖住 → 拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。
- 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。 - 順序上的硬相依:關係時間戳早於裁短期、push 早於 release、reindex 晚於固化。
+1 -1
View File
@@ -16,7 +16,7 @@ if (!agentId || (!isGuest && !isSleeper)) process.exit(0);
const data = pl.loadSession(sessionId); const data = pl.loadSession(sessionId);
const pins = data.pins || {}; const pins = data.pins || {};
const slug = pins[agentId]; const slug = pl.pinOf(data, agentId)?.persona;
if (slug !== undefined) { if (slug !== undefined) {
delete pins[agentId]; delete pins[agentId];
data.pins = pins; data.pins = pins;
+53 -9
View File
@@ -2838,7 +2838,8 @@ export function resolveScope(sessionId, agentId, agentType) {
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest"); const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper"); const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
if (isSleeperAgent) { if (isSleeperAgent) {
const pinned = (data.pins || {})[agentId || ""]; const pin = pinOf(data, agentId);
const pinned = pin?.persona || undefined;
return { return {
role: "sleeper", role: "sleeper",
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人 // 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
@@ -2847,6 +2848,7 @@ export function resolveScope(sessionId, agentId, agentType) {
host, host,
guests, guests,
pinned, pinned,
pinnedRole: pin?.role ?? null,
rooms: [], rooms: [],
session: data, session: data,
}; };
@@ -2862,7 +2864,8 @@ export function resolveScope(sessionId, agentId, agentType) {
session: data, session: data,
}; };
} }
const pinned = (data.pins || {})[agentId || ""]; const pin = pinOf(data, agentId);
const pinned = pin?.persona || undefined;
return { return {
role: "guest", role: "guest",
allowed: pinned ? [pinned] : guests, allowed: pinned ? [pinned] : guests,
@@ -2870,18 +2873,52 @@ export function resolveScope(sessionId, agentId, agentType) {
host, host,
guests, guests,
pinned, pinned,
pinnedRole: pin?.role ?? null,
rooms: data.rooms || [], rooms: data.rooms || [],
session: data, session: data,
}; };
} }
export function pinAgent(sessionId, agentId, slug) { /**
* 讀出某個 sub agent 的 pin。
*
* 舊格式是純字串(只有人格、沒有角色),新格式是 `{ persona, role, pinned_at }`。
* 角色要記下來的理由:`--as-sleeper` 的授權只能靠 pin,而 pin 是不是 sleeper 開的,
* 只有寫 pin 的當下(hook 看得到 `agent_type`)知道。
*/
export function pinOf(data, agentId) {
const raw = (data?.pins || {})[agentId ?? ""];
if (!raw) return null;
if (typeof raw === "string") return { persona: raw, role: null };
return { persona: raw.persona || null, role: raw.role || null };
}
export function pinAgent(sessionId, agentId, slug, role = null) {
const data = loadSession(sessionId); const data = loadSession(sessionId);
data.pins ??= {}; data.pins ??= {};
if (data.pins[agentId] !== slug) { const cur = pinOf(data, agentId);
data.pins[agentId] = slug; if (cur && cur.persona === slug && cur.role === role) return;
data.pins[agentId] = { persona: slug, role, pinned_at: nowIso() };
saveSession(sessionId, data); saveSession(sessionId, data);
}
/**
* 本 session 裡「真的存在、而且 pin 在 `slug` 上」的 persona-sleeper sub agent。
*
* 這是 `--as-sleeper` 唯一可信的授權依據:pin 只由 PreToolUse hook 依 `agent_type` 寫入,
* 而 `agent_type` 是任何被啟動的程序自己看不到、也偽造不了的東西。
* CLI 不能改用「hook 有沒有擋我」當證明——hook 認 CLI 靠檔名正則,改個檔名就繞過去了。
*/
export function sleeperPins(sessionId, slug = null) {
const data = loadSession(sessionId);
const out = [];
for (const agentId of Object.keys(data.pins || {})) {
const pin = pinOf(data, agentId);
if (!pin || pin.role !== "sleeper" || !pin.persona) continue;
if (slug && pin.persona !== slug) continue;
out.push({ agent_id: agentId, ...pin });
} }
return out;
} }
/** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */ /** 回傳 { decision: "allow"|"deny"|"pass", reason }。"pass" = 不表態,交回原本流程。 */
@@ -2921,8 +2958,15 @@ export function guardDecide(event) {
); );
} }
} }
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人 // first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]); // pin 同時是 CLI 端 `--as-sleeper` 的授權憑證,所以角色一定要一起寫進去
// (舊格式那種沒有角色的 pin 也在這裡補上)。
{
const target = scope.pinned || info.personas[0];
if (agentId && target && (scope.pinned !== target || scope.pinnedRole !== "sleeper")) {
pinAgent(sessionId, agentId, target, "sleeper");
}
}
// sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定 // sleeper 的範圍已經由它自己的 pin 決定,不能再套用 host 的判定
// (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。 // (否則主人格 load 著別人時,sleeper 連自己的收尾指令都會被擋)。
} else if (scope.role === "guest") { } else if (scope.role === "guest") {
@@ -3003,7 +3047,7 @@ export function guardDecide(event) {
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`, `這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
); );
} }
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug); if (agentId && (!scope.allowed || scope.pinnedRole !== "sleeper")) pinAgent(sessionId, agentId, slug, "sleeper");
if (MUTATING_TOOLS.has(tool)) { if (MUTATING_TOOLS.has(tool)) {
return deny( return deny(
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI` + `睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI` +
@@ -3030,7 +3074,7 @@ export function guardDecide(event) {
} }
// 3) guest 唯讀 + first-touch pinning // 3) guest 唯讀 + first-touch pinning
if (scope.role === "guest") { if (scope.role === "guest") {
if (!scope.pinned && agentId) pinAgent(sessionId, agentId, slug); if (agentId && (!scope.pinned || scope.pinnedRole !== "guest")) pinAgent(sessionId, agentId, slug, "guest");
if (MUTATING_TOOLS.has(tool)) { if (MUTATING_TOOLS.has(tool)) {
return deny( return deny(
`guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`, `guest 人格 \`${slug}\` 在 sub agent 中為唯讀;要留下記憶請 \`persona.mjs remember --scope inbox\`(下次它自己載入時再固化)。`,
+35 -4
View File
@@ -115,10 +115,41 @@ function requireSession(flags) {
/** /**
* 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。 * 睡眠 sub agent 的寫入權:它替某個人格收尾,手上沒有 exclusive 鎖,只有 5 分鐘的 sleeper 租約。
* 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕), * 第一個收尾指令會把租約取起來(`acquireSleepLease` 內含鎖檢查:目標正被別的程序載入著就拒絕),
* 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 由 PreToolUse hook 把關, * 之後每個指令續租,最後由 `sleep` 還掉。`--as-sleeper` 先過 `requireSleeperPin()`
* 只有 persona-sleeper 型的 sub agent 能用 * 只有本 session 真的有一個 pin 在目標人格上的 persona-sleeper sub agent 才拿得到租約
*/ */
/**
* `--as-sleeper` 的身分驗證——**CLI 自己驗,不外包給 hook**。
*
* 這個旗標的意思是「我是 persona-sleeper 型的 sub agent,正在替 <slug> 收尾」。
* 誰是 sub agent、是哪一型,只有 PreToolUse hook 看得到(`event.agent_type`),
* 而 hook 認得出這支 CLI 靠的是檔名正則——把 `persona.mjs` 複製出去改個名字,
* hook 就完全不表態,`--as-sleeper` 等於對任意人格的完整讀寫權。
*
* 所以唯一可信的證據是 hook 在 session 檔(`.runtime/sessions/<id>.json` 的 `pins`
* 裡留下的 sleeper pin:它由 hook 依 `agent_type` 寫入,沒經過 hook 的程序拿不到。
* 這裡照那份資料驗:本 session 要有一個 pin 在目標人格上的 sleeper,驗不過就 die。
*/
function requireSleeperPin(slug, sessionId) {
const pins = pl.sleeperPins(sessionId, slug);
if (!pins.length) {
const others = pl.sleeperPins(sessionId).map((p) => p.persona);
die(
`\`--as-sleeper\` 不成立:本 session 沒有 pin 在 \`${slug}\` 上的 persona-sleeper sub agent` +
`${others.length ? `(目前有 pin 的是 ${JSON.stringify([...new Set(others)])}` : ""}` +
"pin 由 PreToolUse hook 依 sub agent 的型別寫入,冒不得;" +
"要請人格收尾請走 /jsc-persona:persona-sleep,它會替那個人格開一個 sleeper。",
);
}
const agentId = str(CURRENT_FLAGS["agent-id"]);
if (agentId && !pins.some((p) => p.agent_id === agentId)) {
die(`\`--agent-id ${agentId}\` 不是本 session 中 pin 在 \`${slug}\` 上的睡眠 sub agent。`);
}
return pins[0];
}
function sleeperAccess(slug, sessionId, agentId) { function sleeperAccess(slug, sessionId, agentId) {
requireSleeperPin(slug, sessionId);
const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId); const mine = pl.liveSleepers(slug).find((s) => s.session_id === sessionId);
if (mine) { if (mine) {
pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null); pl.heartbeatSleepLease(slug, sessionId, mine.agent_id ?? null);
@@ -1240,8 +1271,8 @@ commands.leave = ({ flags }) => {
delete data.guests[guest]; delete data.guests[guest];
const room = str(flags.room) || info.room; const room = str(flags.room) || info.room;
pl.dropGuestLease(guest, session, room); pl.dropGuestLease(guest, session, room);
for (const [agentId, slug] of Object.entries(data.pins || {})) { for (const agentId of Object.keys(data.pins || {})) {
if (slug === guest) delete data.pins[agentId]; if (pl.pinOf(data, agentId)?.persona === guest) delete data.pins[agentId];
} }
if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式 if (!Object.keys(data.guests).length) data.theater = false; // 沒有客人就退出劇場模式
pl.saveSession(session, data); pl.saveSession(session, data);
+84 -2
View File
@@ -1311,7 +1311,7 @@ check("sub agent 代睡:sleeper 租約可以完成整套收尾", (() => {
// sleeper 的權限邊界(PreToolUse guard // sleeper 的權限邊界(PreToolUse guard
const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra }); const sleeperEvent = (extra) => ({ session_id: S_SLEEP2, agent_id: A_SLEEPER, agent_type: "jsc-persona:persona-sleeper", ...extra });
pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon"); pl.pinAgent(S_SLEEP2, A_SLEEPER, "epsilon", "sleeper");
check("sleeper 讀自己的檔案 → 放行", check("sleeper 讀自己的檔案 → 放行",
guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass"); guard(sleeperEvent({ tool_name: "Read", tool_input: { file_path: `${H}/epsilon/SOUL.md` } })) === "pass");
check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下", check("sleeper 讀別的人格(含叫它來的主人格)→ 攔下",
@@ -1332,7 +1332,7 @@ check("sleeper 被 pin 住之後不得換人睡",
tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny"); tool_input: { command: `node persona.mjs sleep --persona alpha --session ${S_SLEEP2}` } })) === "deny");
// 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host // 迴歸:主人格載入著 alpha 時請 epsilon 去睡——sleeper 的範圍看它自己的 pin,不看 host
const hostSleeper = "agent-sleeper-host"; const hostSleeper = "agent-sleeper-host";
pl.pinAgent(S_HOST, hostSleeper, "epsilon"); pl.pinAgent(S_HOST, hostSleeper, "epsilon", "sleeper");
const hostSleeperEvent = (extra) => const hostSleeperEvent = (extra) =>
({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra }); ({ session_id: S_HOST, agent_id: hostSleeper, agent_type: "jsc-persona:persona-sleeper", ...extra });
check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾", check("主人格已載入別的人格時,sleeper 仍能跑自己的判斷式收尾",
@@ -1359,6 +1359,88 @@ check("sleeper 可以寫 diary 型別的長期記憶", (() => {
check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)", check("主程序自己帶 --as-sleeper → 攔下(那是 sub agent 的身分)",
guard({ session_id: S_HOST, tool_name: "Bash", guard({ session_id: S_HOST, tool_name: "Bash",
tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny"); tool_input: { command: `node persona.mjs consolidate --persona beta --session ${S_HOST} --as-sleeper` } }) === "deny");
// --- S1 迴歸:`--as-sleeper` 的授權要由 CLI 自己驗,不能外包給 hook ---------- //
// hook 認得出這支 CLI 靠檔名正則(/persona\.(mjs|js|py)\b/):把 scripts/ 複製出去、
// persona.mjs 改名成 p.mjshook 就整路不表態。若 CLI 不自己驗 pin`--as-sleeper`
// 等於任何程序對任意人格的完整讀寫權。
const ROGUE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "persona-rogue-"));
const ROGUE_CLI = path.join(ROGUE_DIR, "p.mjs");
for (const f of fs.readdirSync(HERE)) {
if (f.endsWith(".mjs")) fs.copyFileSync(path.join(HERE, f), path.join(ROGUE_DIR, f === "persona.mjs" ? "p.mjs" : f));
}
const rogue = (args) => spawnSync(process.execPath, [ROGUE_CLI, ...args], { encoding: "utf8" });
check("改名後的 CLI 不再被 hook 認出來(所以 CLI 必須自己驗)",
pl.cliInvocation(`node ${ROGUE_CLI} remember --persona beta --session ${S_HOST} --as-sleeper`) === null &&
guard({ session_id: S_HOST, tool_name: "Bash",
tool_input: { command: `node ${ROGUE_CLI} remember --persona beta --session ${S_HOST} --as-sleeper` } }) === "pass");
check("沒有 sleeper pin 時 `--as-sleeper` 寫別人的記憶 → CLI 自己擋下", (() => {
const res = rogue(["remember", "--persona", "beta", "--session", S_HOST, "--as-sleeper",
"--role", "user", "--text", "偷寫進去的"]);
const st = path.join(H, "beta", "memory", "short-term.jsonl");
const body = fs.existsSync(st) ? fs.readFileSync(st, "utf8") : "";
return res.status !== 0 && String(res.stderr).includes("--as-sleeper") && !body.includes("偷寫進去的");
})());
check("沒有 sleeper pin 時 `--as-sleeper` 讀別人的記憶 → CLI 自己擋下", (() => {
const res = rogue(["recall", "--persona", "beta", "--session", S_HOST, "--as-sleeper", "--query", "記憶"]);
return res.status !== 0 && String(res.stderr).includes("persona-sleeper");
})());
check("沒有 sleeper pin 時 `--as-sleeper` 改別人的情緒 → CLI 自己擋下", (() => {
const before = JSON.stringify(pl.loadEmotion("beta").levels);
const res = rogue(["emotion", "--persona", "beta", "--session", S_HOST, "--as-sleeper",
"--apply", "joy=+40"]);
return res.status !== 0 && JSON.stringify(pl.loadEmotion("beta").levels) === before;
})());
check("`--as-sleeper` 也拿不到 sleeper 租約(驗不過就不該留下痕跡)",
pl.liveSleepers("beta").length === 0);
check("pin 在別的人格上的 sleeper 不能拿 `--as-sleeper` 碰第三個人格", (() => {
// 這個 session 有一個 pin 在 epsilon 上的 sleeper,但目標是 beta
const res = rogue(["recall", "--persona", "beta", "--session", S_SLEEP2, "--as-sleeper", "--query", "x"]);
return res.status !== 0 && String(res.stderr).includes("beta");
})());
check("guest pin 不能當成 sleeper pin 用(角色要對得上)", (() => {
const sess = "sess-pinrole-9191";
pl.pinAgent(sess, "agent-guest-x", "beta", "guest");
const res = rogue(["recall", "--persona", "beta", "--session", sess, "--as-sleeper", "--query", "x"]);
return res.status !== 0 && pl.sleeperPins(sess, "beta").length === 0;
})());
check("舊格式(純字串、沒有角色)的 pin 不算 sleeper 授權", (() => {
const sess = "sess-legacypin-9292";
const data = pl.loadSession(sess);
data.pins = { "agent-legacy": "beta" }; // 舊版寫下的格式
pl.saveSession(sess, data);
const parsed = pl.pinOf(pl.loadSession(sess), "agent-legacy");
const res = rogue(["recall", "--persona", "beta", "--session", sess, "--as-sleeper", "--query", "x"]);
return parsed.persona === "beta" && parsed.role === null &&
pl.sleeperPins(sess, "beta").length === 0 && res.status !== 0;
})());
check("真的有 sleeper pin 時,`--as-sleeper` 照常放行(沒弄壞正常流程)", (() => {
const sess = "sess-realsleeper-9393";
// hook 走一遍 first-touch pinning:它看得到 agent_type,所以會寫下 sleeper 角色
const decision = pl.guardDecide({
cwd: HERE, session_id: sess, agent_id: "agent-sleeper-real",
agent_type: "jsc-persona:persona-sleeper", tool_name: "Bash",
tool_input: { command: `node persona.mjs candidates --persona beta --session ${sess}` },
}).decision;
const pins = pl.sleeperPins(sess, "beta");
const res = cli(["candidates", "--persona", "beta", "--session", sess, "--as-sleeper"]);
const held = pl.liveSleepers("beta").some((s) => s.session_id === sess);
pl.dropSleepLease("beta", sess, null);
return decision === "pass" && pins.length === 1 && pins[0].agent_id === "agent-sleeper-real" &&
res.status === 0 && held;
})());
check("hook 的 first-touch pinning 會補上角色(舊 pin 也升級得了)", (() => {
const sess = "sess-upgradepin-9494";
const data = pl.loadSession(sess);
data.pins = { "agent-up": "beta" };
pl.saveSession(sess, data);
pl.guardDecide({
cwd: HERE, session_id: sess, agent_id: "agent-up", agent_type: "jsc-persona:persona-sleeper",
tool_name: "Read", tool_input: { file_path: `${H}/beta/SOUL.md` },
});
return pl.sleeperPins(sess, "beta").length === 1;
})());
fs.rmSync(ROGUE_DIR, { recursive: true, force: true });
check("關係節點換 id 不會長出同名的第二個節點", (() => { check("關係節點換 id 不會長出同名的第二個節點", (() => {
cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]); cli(["load", "--persona", "alpha", "--session", S_HOST, "--takeover"]);
cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]); cli(["relation", "node", "--session", S_HOST, "--name", "水井管理員", "--closeness", "40"]);