@cocorograph/hub-agent 0.7.99 → 0.7.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +87 -42
- package/src/codex-stream-bridge.mjs +20 -9
- package/src/sibling-inject.mjs +65 -16
- package/src/sibling-token.mjs +44 -0
package/package.json
CHANGED
|
@@ -2442,27 +2442,62 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2442
2442
|
* @returns {Array<{kind: string, cwd: string, session_id: string|null, idle_ms: number}>}
|
|
2443
2443
|
*/
|
|
2444
2444
|
describeLiveSessions(cwd) {
|
|
2445
|
-
|
|
2445
|
+
return this._liveCandidatesForCwd(cwd, false).map(({ session, attached }) =>
|
|
2446
|
+
this._describeCandidate(session, attached),
|
|
2447
|
+
)
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* cwd 内の live セッション候補を `{session, attached}` の配列で返す共通スキャン。
|
|
2452
|
+
* this.sessions (attached=browser 接続中) と _liveBySession (attached=false=soft-detached の warm)
|
|
2453
|
+
* の **union** を走査し、両 Map に載り得る同一セッションは seen で重複排除する。attached は
|
|
2454
|
+
* 「this.sessions に在籍しているか」で決める。
|
|
2455
|
+
*
|
|
2456
|
+
* ⚠️ describeLiveSessions(計数) と sendToCwd/sendToStream(配送) をこの同一集合に揃えることで、
|
|
2457
|
+
* 従来「this.sessions のみ計数 / union 配送」の非対称が生んでいた undercount 由来のサイレント
|
|
2458
|
+
* 誤配送 (detached-busy な旧セッションへ配送しつつ計数上は1本に見える TOCTOU) を排除する。
|
|
2459
|
+
*
|
|
2460
|
+
* @param {string} [cwd] 指定時はその cwd のみ
|
|
2461
|
+
* @param {boolean} [requireSessionId=false] sessionId 未確定の fresh を除外するか
|
|
2462
|
+
* @returns {Array<{session: object, attached: boolean}>}
|
|
2463
|
+
*/
|
|
2464
|
+
_liveCandidatesForCwd(cwd, requireSessionId = false) {
|
|
2446
2465
|
const out = []
|
|
2447
2466
|
const seen = new Set()
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
if (
|
|
2467
|
+
const collect = (iter, attached) => {
|
|
2468
|
+
for (const session of iter) {
|
|
2469
|
+
if (seen.has(session)) continue
|
|
2470
|
+
// reap 済み (_closed) セッションは _liveBySession に残留し得るため除外する。union 走査で
|
|
2471
|
+
// これを拾うと、reconcileWarmBounds で畳まれた古い session が宛先として復活してしまう。
|
|
2472
|
+
if (session._closed) continue
|
|
2473
|
+
if (cwd && session.cwd !== cwd) continue
|
|
2474
|
+
if (requireSessionId && !session.sessionId) continue
|
|
2475
|
+
seen.add(session)
|
|
2476
|
+
out.push({ session, attached })
|
|
2455
2477
|
}
|
|
2456
|
-
out.push({
|
|
2457
|
-
kind: "claude",
|
|
2458
|
-
cwd: session.cwd,
|
|
2459
|
-
session_id: session.sessionId || null,
|
|
2460
|
-
idle_ms: at ? now - at : -1,
|
|
2461
|
-
})
|
|
2462
2478
|
}
|
|
2479
|
+
// attached を先に走査して attached=true で登録、その後 warm を attached=false で補完する。
|
|
2480
|
+
collect(this.sessions.values(), true)
|
|
2481
|
+
collect(this._liveBySession.values(), false)
|
|
2463
2482
|
return out
|
|
2464
2483
|
}
|
|
2465
2484
|
|
|
2485
|
+
/** describeLiveSessions と同形の 1 セッション記述子 (sendToCwd/sendToStream の結果整形に共用)。 */
|
|
2486
|
+
_describeCandidate(session, attached) {
|
|
2487
|
+
const now = Date.now()
|
|
2488
|
+
let at = 0
|
|
2489
|
+
for (const t of session.lastActivityByStream?.values?.() ?? []) {
|
|
2490
|
+
if (t > at) at = t
|
|
2491
|
+
}
|
|
2492
|
+
return {
|
|
2493
|
+
kind: "claude",
|
|
2494
|
+
cwd: session.cwd,
|
|
2495
|
+
session_id: session.sessionId || null,
|
|
2496
|
+
idle_ms: at ? now - at : -1,
|
|
2497
|
+
attached,
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2466
2501
|
/**
|
|
2467
2502
|
* 指定 cwd で現在ライブな (browser 接続中、または detach 猶予内の) SDK チャット
|
|
2468
2503
|
* session_id を返す。無ければ null。
|
|
@@ -2563,24 +2598,40 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2563
2598
|
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
2564
2599
|
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
2565
2600
|
*
|
|
2601
|
+
* ⚠️ 宛先解決 (2026-07-23, 統一再設計 1-1): 従来の「最終活性が最も新しいものを選ぶ」
|
|
2602
|
+
* ヒューリスティックを廃止し、**attached (前面=browser 接続中) 優先 + 曖昧は fail-safe** に
|
|
2603
|
+
* 変更した。union 候補のうち attached があれば attached 層、無ければ warm 層から選び、層内が
|
|
2604
|
+
* 1 本なら配送・複数なら推測せず ambiguous を返す。これにより「detached-busy な旧セッション A と
|
|
2605
|
+
* 同 cwd の新規 attached B が併存するとき、計数上は B 1本に見えつつ配送は活性の新しい A に
|
|
2606
|
+
* 落ちる」サイレント誤配送を、B (前面) への確定配送へ是正する (真に曖昧な稀ケースのみ
|
|
2607
|
+
* ambiguous で fail-safe。誤配送はしない)。返り値は sendToStream と同形の status オブジェクト。
|
|
2608
|
+
*
|
|
2566
2609
|
* @param {string} cwd
|
|
2567
2610
|
* @param {string} text
|
|
2568
|
-
* @returns {
|
|
2611
|
+
* @returns {{status:"delivered", session:object} | {status:"not_found"} | {status:"ambiguous", candidates:object[]}}
|
|
2569
2612
|
*/
|
|
2570
2613
|
sendToCwd(cwd, text) {
|
|
2571
|
-
if (!cwd || !text) return
|
|
2572
|
-
//
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
const
|
|
2576
|
-
|
|
2577
|
-
|
|
2614
|
+
if (!cwd || !text) return { status: "not_found" }
|
|
2615
|
+
// sessionId 未確定の fresh も配送対象にするため requireSessionId=false。
|
|
2616
|
+
const cands = this._liveCandidatesForCwd(cwd, false)
|
|
2617
|
+
if (cands.length === 0) return { status: "not_found" }
|
|
2618
|
+
const attached = cands.filter((c) => c.attached)
|
|
2619
|
+
// attached があれば attached 層のみ、無ければ warm 層で解決する (前面優先)。
|
|
2620
|
+
const tier = attached.length ? attached : cands
|
|
2621
|
+
if (tier.length > 1) {
|
|
2622
|
+
return {
|
|
2623
|
+
status: "ambiguous",
|
|
2624
|
+
candidates: tier.map((c) => this._describeCandidate(c.session, c.attached)),
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
const { session, attached: att } = tier[0]
|
|
2628
|
+
session.sendMessage({ role: "user", content: text }).catch((err) => {
|
|
2578
2629
|
this.logger?.error(
|
|
2579
2630
|
{ cwd, err: err?.message },
|
|
2580
2631
|
"sendToCwd: sendMessage threw unexpectedly",
|
|
2581
2632
|
)
|
|
2582
2633
|
})
|
|
2583
|
-
return
|
|
2634
|
+
return { status: "delivered", session: this._describeCandidate(session, att) }
|
|
2584
2635
|
}
|
|
2585
2636
|
|
|
2586
2637
|
/**
|
|
@@ -2608,34 +2659,28 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2608
2659
|
*/
|
|
2609
2660
|
sendToStream(cwd, sessionId, text) {
|
|
2610
2661
|
if (!cwd || !sessionId || !text) return { status: "not_found" }
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
if (session.cwd !== cwd || session.sessionId !== sessionId) continue
|
|
2618
|
-
matches.push(session)
|
|
2619
|
-
}
|
|
2662
|
+
// union 走査 (2026-07-23, 統一再設計 1-1): warm (soft-detached) セッションも session_id
|
|
2663
|
+
// ピンの対象にする。従来は this.sessions のみで、detach 済みだが生きているセッションを
|
|
2664
|
+
// 明示 session_id で指しても not_found になっていた (describeLiveSessions/sendToCwd と非対称)。
|
|
2665
|
+
const matches = this._liveCandidatesForCwd(cwd, false).filter(
|
|
2666
|
+
(c) => c.session.sessionId === sessionId,
|
|
2667
|
+
)
|
|
2620
2668
|
if (matches.length === 0) return { status: "not_found" }
|
|
2621
|
-
const describe = (s) => {
|
|
2622
|
-
let at = 0
|
|
2623
|
-
for (const t of s.lastActivityByStream?.values?.() ?? []) {
|
|
2624
|
-
if (t > at) at = t
|
|
2625
|
-
}
|
|
2626
|
-
return { kind: "claude", cwd: s.cwd, session_id: s.sessionId, idle_ms: at ? now - at : -1 }
|
|
2627
|
-
}
|
|
2628
2669
|
if (matches.length > 1) {
|
|
2629
2670
|
// 同一 session_id の別 session が複数 = resume 競合。推測せず ambiguous (誤爆防止)。
|
|
2630
|
-
return {
|
|
2671
|
+
return {
|
|
2672
|
+
status: "ambiguous",
|
|
2673
|
+
candidates: matches.map((c) => this._describeCandidate(c.session, c.attached)),
|
|
2674
|
+
}
|
|
2631
2675
|
}
|
|
2632
|
-
|
|
2676
|
+
const { session, attached } = matches[0]
|
|
2677
|
+
session.sendMessage({ role: "user", content: text }).catch((err) => {
|
|
2633
2678
|
this.logger?.error(
|
|
2634
2679
|
{ session_id: sessionId, err: err?.message },
|
|
2635
2680
|
"sendToStream: sendMessage threw unexpectedly",
|
|
2636
2681
|
)
|
|
2637
2682
|
})
|
|
2638
|
-
return { status: "delivered", session:
|
|
2683
|
+
return { status: "delivered", session: this._describeCandidate(session, attached) }
|
|
2639
2684
|
}
|
|
2640
2685
|
|
|
2641
2686
|
/**
|
|
@@ -1407,19 +1407,30 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1407
1407
|
* ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
|
|
1408
1408
|
* src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
|
|
1409
1409
|
*
|
|
1410
|
+
* ⚠️ 返り値 (2026-07-23, 統一再設計 1-1): claude 版と契約を揃えるため boolean から status
|
|
1411
|
+
* オブジェクトへ変更した。codex は計数(describeLiveSessions)と配送が同一集合 (_liveSessions())
|
|
1412
|
+
* を見るため claude のような undercount 由来のサイレント誤配送は構造的に起きず、宛先選定は
|
|
1413
|
+
* 従来どおり「会話ターン境界時刻が最新」のヒューリスティックを維持する (挙動不変)。
|
|
1414
|
+
*
|
|
1410
1415
|
* @param {string} cwd
|
|
1411
1416
|
* @param {string} text
|
|
1412
|
-
* @returns {
|
|
1417
|
+
* @returns {{status:"delivered", session:object} | {status:"not_found"}}
|
|
1413
1418
|
*/
|
|
1414
1419
|
sendToCwd(cwd, text) {
|
|
1415
|
-
if (!cwd || !text) return
|
|
1420
|
+
if (!cwd || !text) return { status: "not_found" }
|
|
1421
|
+
// 案B: distinct session で走査(多対一 Map の重複を除く)。
|
|
1422
|
+
const cands = this._liveSessions().filter((s) => s.cwd === cwd)
|
|
1423
|
+
// attached (前面=detach 未要求) 優先 (2026-07-23 統一再設計 1-1, cross-review 指摘4):
|
|
1424
|
+
// detach 済み warm セッションが lastTurnAtMs 上たまたま新しくても、attached な現行セッションが
|
|
1425
|
+
// あればそちらへ配送する (browser を離れた/畳まれかけの旧会話 ghost への誤配送を防ぐ)。
|
|
1426
|
+
// claude と違い codex は同一 cwd に複数 attached が併存し得る (別スレッドの新会話 / ターン
|
|
1427
|
+
// 実行中) ため fail-safe ambiguous にはせず、tier 内は会話ターン境界時刻が最新のものを選ぶ
|
|
1428
|
+
// (閲覧 keepalive で動く lastActivityMs は使わない = 見ているだけの古い会話が本命を追い越さない)。
|
|
1429
|
+
const attached = cands.filter((s) => !s.detachRequested)
|
|
1430
|
+
const tier = attached.length ? attached : cands
|
|
1416
1431
|
let best = null
|
|
1417
1432
|
let bestAt = -1
|
|
1418
|
-
|
|
1419
|
-
for (const session of this._liveSessions()) {
|
|
1420
|
-
if (session.cwd !== cwd) continue
|
|
1421
|
-
// 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
|
|
1422
|
-
// lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
|
|
1433
|
+
for (const session of tier) {
|
|
1423
1434
|
const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
|
|
1424
1435
|
if (at >= bestAt) {
|
|
1425
1436
|
bestAt = at
|
|
@@ -1431,7 +1442,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1431
1442
|
{ cwd, live_sessions: this.describeLiveSessions() },
|
|
1432
1443
|
"codex sendToCwd: no live session for cwd (ask-sibling will fail)",
|
|
1433
1444
|
)
|
|
1434
|
-
return
|
|
1445
|
+
return { status: "not_found" }
|
|
1435
1446
|
}
|
|
1436
1447
|
// detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
|
|
1437
1448
|
// detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
|
|
@@ -1456,7 +1467,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1456
1467
|
)
|
|
1457
1468
|
},
|
|
1458
1469
|
)
|
|
1459
|
-
return
|
|
1470
|
+
return { status: "delivered", session: this._describe(best) }
|
|
1460
1471
|
}
|
|
1461
1472
|
|
|
1462
1473
|
/**
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
* ファイル IPC 前例 (tui-permission-bridge.mjs 等、`/tmp/cockpit_*` + fs.watch +
|
|
11
11
|
* atomic write) と同じパターンに揃える。
|
|
12
12
|
*
|
|
13
|
-
* ⚠️ セキュリティ設計 (2026-07-04
|
|
13
|
+
* ⚠️ セキュリティ設計 (2026-07-04 承認 / 2026-07-23 統一再設計 1-2 で緩和): この機構は
|
|
14
14
|
* 「生きている自律エージェント (シェル/API/MCP ツールへのフルアクセス権を持つ)
|
|
15
15
|
* へ外部プロセスから新規指示を注入する」という強い書き込み権限を持つため、
|
|
16
|
-
* `src/sibling-token.mjs` の
|
|
16
|
+
* `src/sibling-token.mjs` の capability トークンによる検証を必須とする。
|
|
17
|
+
* 認可粒度は当初「ターゲット cwd 所有」だったが、claude↔claude / codex↔codex を含む
|
|
18
|
+
* クロス cwd 連携を可能にするため「**いずれかの cwd の有効トークン所有 (= 同一ユーザーの
|
|
19
|
+
* spawn 済みセッション)**」へ緩和した (`verifySiblingTokenAny`)。無認証プロセス (npm
|
|
20
|
+
* postinstall 等) はどの cwd のトークンも持たないため排除される核心は維持。ターゲット
|
|
21
|
+
* 解決は引き続き要求の cwd でスコープする (誤 cwd 着弾・別 workspace 情報漏洩を防ぐ)。
|
|
17
22
|
* トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
|
|
18
23
|
*
|
|
19
24
|
* 契約: request file `<request_id>.json` は op で 3 形態 (2026-07-19 / T06271):
|
|
@@ -25,6 +30,8 @@
|
|
|
25
30
|
* 新 agent だけが処理する (probe を挟まず単一リクエストで TOCTOU を排除)。
|
|
26
31
|
* 宛先は session_id(claude) / stream_id・thread_id(codex) を cwd 一致下に確定解決。
|
|
27
32
|
* - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
|
|
33
|
+
* - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
|
|
34
|
+
* 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
|
|
28
35
|
* 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
|
|
29
36
|
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?}` を書く。
|
|
30
37
|
*/
|
|
@@ -33,7 +40,7 @@ import { watch } from "node:fs"
|
|
|
33
40
|
import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
|
|
34
41
|
import path from "node:path"
|
|
35
42
|
|
|
36
|
-
import {
|
|
43
|
+
import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
|
|
37
44
|
|
|
38
45
|
export const SIBLING_REQUESTS_DIR =
|
|
39
46
|
process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
|
|
@@ -117,6 +124,7 @@ export class SiblingInjectBridge {
|
|
|
117
124
|
// 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
|
|
118
125
|
// 起きない** (probe→注入の TOCTOU を排除)。
|
|
119
126
|
const { op, cwd, target_cli_kind, text, message, token, target_session_id } = body || {}
|
|
127
|
+
const isListAll = op === "list-all"
|
|
120
128
|
const isList = op === "list"
|
|
121
129
|
const isExplicit = op === "inject"
|
|
122
130
|
let result
|
|
@@ -126,12 +134,47 @@ export class SiblingInjectBridge {
|
|
|
126
134
|
: isExplicit
|
|
127
135
|
? !target_session_id || !message // 明示は id と message 必須 (空指定 fail-open を防ぐ)
|
|
128
136
|
: !text // 通常は top-level text 必須 (=旧契約)
|
|
129
|
-
if (
|
|
137
|
+
if (isListAll) {
|
|
138
|
+
// op:"list-all" (2026-07-23 統一再設計 1-2: クロス cwd 宛先探索): 全 cwd・両 CLI の
|
|
139
|
+
// ライブセッションを列挙する。cwd / target_cli_kind / 本文は不要 (token のみ必須)。
|
|
140
|
+
// 認可は verifySiblingTokenAny (同一ユーザーの spawn 済みセッションであれば全 cwd を
|
|
141
|
+
// 見渡せる。orchestrate リーダーが別プロジェクトの sub の宛先アドレスを得るため)。
|
|
142
|
+
if (!token) {
|
|
143
|
+
result = { ok: false, error: "invalid_request" }
|
|
144
|
+
} else if (!verifySiblingTokenAny(token)) {
|
|
145
|
+
this.logger?.warn({ request_id }, "sibling-inject: invalid sibling token (list-all)")
|
|
146
|
+
result = { ok: false, error: "invalid_token" }
|
|
147
|
+
} else {
|
|
148
|
+
result = {
|
|
149
|
+
ok: true,
|
|
150
|
+
live_sessions: [
|
|
151
|
+
...(this.claudeBridge?.describeLiveSessions?.() ?? []),
|
|
152
|
+
...(this.codexBridge?.describeLiveSessions?.() ?? []),
|
|
153
|
+
],
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (!cwd || badKind || badBody) {
|
|
130
157
|
result = { ok: false, error: "invalid_request" }
|
|
131
|
-
} else if (!
|
|
132
|
-
|
|
158
|
+
} else if (!verifySiblingTokenAny(token)) {
|
|
159
|
+
// 2026-07-23 統一再設計 1-2: 認可を「ターゲット cwd 所有」→「いずれかの cwd の有効トークン
|
|
160
|
+
// 所有 (= 同一ユーザーの spawn 済みセッション)」へ緩和し、クロス cwd 連携を解禁する。
|
|
161
|
+
// cwd は引き続きターゲット解決のスコープとして使う (誤 cwd 着弾・情報漏洩は解決側で担保)。
|
|
162
|
+
this.logger?.warn({ cwd, request_id }, "sibling-inject: invalid sibling token, request denied")
|
|
133
163
|
result = { ok: false, error: "invalid_token" }
|
|
134
164
|
} else {
|
|
165
|
+
// クロス cwd 監査 (2026-07-23, cross-review round2 minor): 認可は同一ユーザーの any-cwd
|
|
166
|
+
// トークンで通るが、注入先の cwd がトークン発行元の cwd と異なる (= クロス cwd 注入) 場合は
|
|
167
|
+
// 「どの cwd のセッションから来たか」を info ログに残す (認可判定には使わない、追跡用)。
|
|
168
|
+
// op:list / list-all は非破壊なので対象外、注入系 (op:inject / 通常) のみ記録する。
|
|
169
|
+
if (!isList) {
|
|
170
|
+
const fromCwd = siblingTokenCwd(token)
|
|
171
|
+
if (fromCwd && fromCwd !== cwd) {
|
|
172
|
+
this.logger?.info?.(
|
|
173
|
+
{ request_id, from_cwd: fromCwd, to_cwd: cwd, target_cli_kind, op: op || "normal" },
|
|
174
|
+
"sibling-inject: cross-cwd injection (audit)",
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
135
178
|
const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
136
179
|
if (isList) {
|
|
137
180
|
// 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
|
|
@@ -177,21 +220,27 @@ export class SiblingInjectBridge {
|
|
|
177
220
|
}
|
|
178
221
|
}
|
|
179
222
|
} else {
|
|
180
|
-
// 宛先省略 (通常):
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
223
|
+
// 宛先省略 (通常): sendToCwd が union 候補から attached 優先で 1 本に確定解決する。
|
|
224
|
+
// status:"delivered" → 注入 / "ambiguous" → 複数候補で fail-safe / "not_found" → 0 本。
|
|
225
|
+
//
|
|
226
|
+
// ⚠️ アトミック化 (2026-07-23, 統一再設計 1-1): 旧実装は describeLiveSessions.length の
|
|
227
|
+
// 計数ゲートと sendToCwd の配送を分離しており、warm セッションを取りこぼす計数
|
|
228
|
+
// (claude: this.sessions のみ) をすり抜けて配送だけが warm へ落ちる TOCTOU
|
|
229
|
+
// (サイレント誤配送 + delivered_to に実配送先でない sameCwd[0] を報告する成功偽装) が
|
|
230
|
+
// あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
|
|
231
|
+
// **実配送先 (r.session)** を返す。
|
|
232
|
+
const r = bridge?.sendToCwd?.(cwd, text) ?? { status: "not_found" }
|
|
233
|
+
if (r.status === "delivered") {
|
|
234
|
+
result = { ok: true, delivered_to: r.session ?? null }
|
|
235
|
+
} else if (r.status === "ambiguous") {
|
|
184
236
|
result = {
|
|
185
237
|
ok: false,
|
|
186
238
|
error: "ambiguous_target",
|
|
187
|
-
candidates:
|
|
188
|
-
live_sessions:
|
|
239
|
+
candidates: r.candidates,
|
|
240
|
+
live_sessions: r.candidates,
|
|
189
241
|
}
|
|
190
242
|
} else {
|
|
191
|
-
|
|
192
|
-
result = sent
|
|
193
|
-
? { ok: true, delivered_to: sameCwd[0] ?? null }
|
|
194
|
-
: { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
|
|
243
|
+
result = { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
|
|
195
244
|
}
|
|
196
245
|
}
|
|
197
246
|
}
|
package/src/sibling-token.mjs
CHANGED
|
@@ -56,6 +56,50 @@ export function verifySiblingToken(cwd, token) {
|
|
|
56
56
|
return typeof expected === "string" && expected === token
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 提示されたトークンが **いずれかの cwd** の有効な sibling トークンと一致するか検証する
|
|
61
|
+
* (2026-07-23, 統一再設計 1-2: クロス cwd 連携の認可緩和)。
|
|
62
|
+
*
|
|
63
|
+
* 従来の `verifySiblingToken(cwd, token)` は「ターゲット cwd 自身のトークン」を要求するため
|
|
64
|
+
* 同一 cwd 内の相互依頼しか通せなかった。claude↔claude / codex↔codex を含むクロス cwd 連携
|
|
65
|
+
* (orchestrate のリーダー→別プロジェクトのサブ等) を可能にするため、認可の粒度を
|
|
66
|
+
* 「**ターゲット cwd 所有**」から「**この hub-agent が spawn したいずれかのセッション所有**
|
|
67
|
+
* (= 同一ユーザー)」へ緩和する。
|
|
68
|
+
*
|
|
69
|
+
* ⚠️ トレードオフ (ユーザー承認済み 2026-07-23): 注入先の cwd 境界を越えられるようになるが、
|
|
70
|
+
* hub-agent は 1 ユーザー専用デーモンであり、全 cwd のセッションは同一ユーザー自身のもの
|
|
71
|
+
* なので信頼境界を跨がない。守るべき核心 = 「hub-agent が env にのみ渡したトークンを持たない
|
|
72
|
+
* 無認証プロセス (npm postinstall 等) の注入を防ぐ」は、**いずれかの cwd の有効トークン一致**を
|
|
73
|
+
* 必須にすることで維持される (無関係プロセスはどの cwd のトークンも持てない)。ターゲット
|
|
74
|
+
* セッションの解決自体は呼び出し側が渡す cwd で引き続きスコープされる (誤 cwd 着弾防止・
|
|
75
|
+
* 別 workspace の情報漏洩防止は sendToStream/_diagnose 側で担保)。
|
|
76
|
+
*
|
|
77
|
+
* @param {string} token
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
export function verifySiblingTokenAny(token) {
|
|
81
|
+
if (!token || typeof token !== "string") return false
|
|
82
|
+
for (const expected of tokensByCwd.values()) {
|
|
83
|
+
if (expected === token) return true
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 提示されたトークンがどの cwd に発行されたものかを逆引きする (2026-07-23, クロス cwd 監査用)。
|
|
90
|
+
* 一致する cwd が無ければ null。verifySiblingTokenAny でクロス cwd 注入を許した際、
|
|
91
|
+
* 「どの cwd のセッションから来たか」を監査ログに残すために使う (認可の可否判定には使わない)。
|
|
92
|
+
* @param {string} token
|
|
93
|
+
* @returns {string|null}
|
|
94
|
+
*/
|
|
95
|
+
export function siblingTokenCwd(token) {
|
|
96
|
+
if (!token || typeof token !== "string") return null
|
|
97
|
+
for (const [cwd, expected] of tokensByCwd.entries()) {
|
|
98
|
+
if (expected === token) return cwd
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
/** テスト用: トークンストアを全クリアする。 */
|
|
60
104
|
export function _resetSiblingTokens() {
|
|
61
105
|
tokensByCwd.clear()
|