close
Skip to content

feat(instances): carry the kiro-cli context so a sent session resumes - #2260

Merged
CrysisDeu merged 1 commit into
mainfrom
feat/session-teleport-layerb
Aug 10, 2026
Merged

feat(instances): carry the kiro-cli context so a sent session resumes#2260
CrysisDeu merged 1 commit into
mainfrom
feat/session-teleport-layerb

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Follow-on to #1744 (merged), which shipped session transfer: a peer receives the visible transcript and rebuilds it as a tab. This PR adds the second half so the copy actually resumes. Now that #1744 has landed, this branch is rebased onto it and contains only the Layer B delta (2,577 / −57 across 26 files).

Original transcript-transfer work is @CrysisDeu's; this builds on it.

Problem

A session sent to a peer arrives as browsable history that cannot continue where it left off. Every turn is displayed, but the next prompt starts from a condensed ~8K text summary rather than the conversation the model was actually holding — so long or compacted sessions silently lose their working context. The tab looks complete, which is what makes it dangerous.

Why it matters

The point of moving a session to the machine with the repo and the cores is to keep working. If the agent arrives without the real context window, the user re-establishes it by hand — most of what they were trying to avoid — and only discovers the loss mid-task.

Fix (symptom → root cause → change)

Symptom: an imported session displays fully but resumes from a lossy summary.

Root cause: a session is two stores, not one.

With Layer A alone, SessionMap.get finds no usable sid on the peer, so the next turn falls back to _build_history_prefix() — a ~8K-char text prefix with no tool state and no real context window. Nothing was broken; the second store was simply never in the bundle.

Change: bundle_version 2 carries an optional layer_b, and the importer materialises it and writes the session_map join — which is also what auto-disables the prefix fallback, so resume goes through session/load at the same fidelity as a local gateway restart.

Properties that matter

  • Optional, and backward-compatible in both directions. A v1 sender, or a session that never opened a kiro-cli context, ships Layer A only. Both versions stay accepted ({1, 2}) so a v2 instance can still receive from a v1 one, and send_session_bundle downgrades once to v1 when a peer refuses v2 — gated on the version, not on layer_b presence, because a context-free session ships v2 with no layer_b at all.

  • Host-naming fields rewritten; the conversation byte-exact. Fresh sid (copy-never-move holds; a repeat send cannot collide), cwd + filesystem allowed_*_paths cleared (matching feat(instances): send a copy of a session to another instance #1744's project decision — the session arrives unscoped), agent_name set to the target-resolved agent, timestamps refreshed. Everything else — conversation_metadata and the whole events blob — travels byte-for-byte, which is forced rather than stylistic; see Layer B travels byte-exact below.

  • The repo's SessionMap threading contract is honoured. All map access stays on the event loop (subagent.py: the map is an unlocked dict with whole-file saves, so worker-thread access races cold-starts). _resolve_layer_b_sid and _join_layer_b run on the loop; _write_layer_b_files / _unlink_layer_b_files do the blocking file IO in a worker and never receive a handle to the map.

  • The imported slot is unreachable until it is correct. get_or_create_slot registers the slot in state._slots and publishes it before returning, so it is retracted immediately and re-registered + published once, at the end. Layer B lands before the transcript work, and both failure paths undo the join and delete its files.

  • Layer A is redacted on egress and ingress; Layer B is not. Assistant content, the title and the origin label are scrubbed on both boundaries (the sender is not trusted). Layer B is exempt because redacting it corrupts it — see below. Inbound Layer B is still validated structurally (parse-only, never rewritten).

  • Failure paths leave nothing behind. Files land 0600 owner-only and fail closed if the lockdown cannot be applied; a failure on the second of the two writes unlinks the half-pair; CancelledError (a BaseException, so the ordinary handler never saw it) rolls back the join and the files before re-raising; and the size cap is checked with st_size before the read so an oversized log cannot be allocated at all.

  • The slot cap holds across both of its windows. Slots retracted for construction stay counted (DashboardState.live_slot_count(), shared with the fork path), and the cap is re-tested immediately before creation with no await in the gap — body parsing and agent resolution both yield, so the entry check alone admitted every concurrent import near the cap.

  • Resume fidelity is reported end to end. Import returns resume_mode: session_load|prefix, send-session forwards it, and the sender's row renders "Sent (transcript only)" instead of a green "Sent" — so a degraded copy is never mistaken for a full one. Older peers report "" and stay plain "Sent".

  • Degradation is disclosed where it is felt, not only where it is sent. The sender's row is component state in a menu that closes; the consequence is discovered later on the receiving machine. So an import that arrived without resumable context also marks its own tab title — transcript only, which persists with the session.

  • An unparseable Layer B record refuses the whole blob rather than shipping malformed JSONL that would make the peer's session/load fail after this side already reported session_load.

Still deliberately not travelling

Why
memory (prefs, semantic KV, lessons) per-instance scope; copying it across hosts is the risky, hard-to-undo part
project / repo source checkout paths are dead on the target; unmanageable with many repos
model entitlement differs per account; the target resolves its own
sub-agent conversations their results are already inside Layer B as injected context, so only spawn_continue against one specific sub-agent is lost

Direction stays hub → peer; bidirectional is the natural follow-on.

Layer B travels byte-exact (design change, a8a7cec)

Layer B is forwarded unredacted, and that is forced rather than lax. The
envelope's thinking blocks carry a provider signature over their own content,
validated when the conversation is replayed — so rewriting any covered byte makes
the peer's session/load succeed and its next turn fail, a failure that
surfaces far from its cause.

This was not hypothetical. An earlier revision of this PR redacted Layer B on both
boundaries; replayed against one developer machine's 704 real sessions, that
pass altered a thinking signature in 286 of them (41%). After the change, the
same 704-session round-trip alters 0 events blobs and 0 of 1619 signatures.

Redacting this artifact and transplanting it are mutually exclusive. What bounds
the exposure is the destination, not a scrub of the payload: a send goes to the
operator's own peer instance, over a tunnel that operator authenticated, and
the peer stores it 0600 — Layer B never leaves the operator's trust boundary.
Layer A keeps its redaction (it is rendered in a transcript and re-read by an
agent as context). Inbound Layer B is validated structurally — parse-only, never
rewritten — and refused whole if any record fails to parse.

This deliberately reverses an earlier review finding on this PR that asked for the
envelope to be scrubbed on egress. security_posture.py and instances.md §14.1a
now record the byte-exact stance and its reasoning.

Tests

127 in test/test_session_transfer.py (175 with the test_security_posture.py +
test_error_code_contract.py gates; 1,216 across the transfer / posture / slot / fork
suites), plus the frontend submenu spec:

  • Layer B travels when present; absent (not an error) with no sid, pruned files, or no live manager
  • envelope rewrite: fresh sid, cleared cwd/allowed_*_paths, target agent, source path absent from the whole envelope, caller's dict unmutated, minimal envelope tolerated
  • byte-exactness, against a REAL thinking-block shapetest_import_preserves_the_thinking_signature_verbatim asserts on the file bytes kiro-cli will read, not an in-memory dict, and a shared _THINKING_ENVELOPE fixture replaces the empty-{} envelopes that let signature corruption pass unnoticed
  • size cap before the read: spies on read_text to prove an oversized log is never allocated, since a post-read check also returns None and would pass against the bug
  • owner-only permissions: files 0600, the created dir 0700, a pre-existing kiro-cli dir left untouched, and Layer B discarded entirely when the lockdown fails
  • failure-path hygiene: a failure on the second write leaves no half-pair; cancellation rolls back the join and the files, then re-raises; the slot cap is re-checked after the pre-creation awaits (the map is filled during the awaited agent resolution, exactly as a sibling request would)
  • threading-contract guards: _read_layer_b takes only a sid, _write_layer_b_files takes no sessions handle, the module cannot import SessionMap
  • ordering guard: the slot is absent from state._slots for the whole build and present exactly once after; Layer B lands before the save; a failed save rolls back the join and its files
  • v1 and v2 accepted; malformed layer_b rejected with machine-readable codes; v2-without-layer_b still downgrades; downgrade fires at most once
  • resume_mode reported as session_load / prefix / prefix-for-v1; the row renders a distinct transcript-only state; and the tab is marked when the sender withheld context it had (layer_b_skipped), which validation carries through

Manual verification

Outstanding — this is the merge gate. The resume path has not been exercised
against two live instances. Worth recording why it cannot be automated here: a
pod gets its own empty KIRO_HOME (pod/runtime.py:702), so a pod's kiro-cli is
unauthenticated and cannot run a turn, and kiro-cli chat cannot create a v2
session at all (--session-source only pairs with --delete-session) — so no pod-
or CLI-only harness can even produce a source session. It needs two real gateways
with an authenticated kiro-cli; the repo owner is running that.

Verified deterministically instead, on 704 real local kiro-cli sessions rather
than fixtures: the full egress + ingress pipeline round-trips every one of them with
0 events blobs altered and 0 of 1,619 thinking signatures altered (the same
harness measured 286/704 corrupted before the byte-exact change). The <sid>.json
envelope shape was read from real sessions and the rewrite asserted field-by-field
against it, and the join key matches what acp/client.py reads to issue
session/load (_meta._kiro.dev/session_file).

What that still does not prove: that the provider accepts the replayed
conversation on the peer. Only the two-instance test can.

Screenshots

The one user-visible change: a degraded copy no longer reads as a plain success. Compare row 1 (green ✓ Sent — Layer B landed, resumable) with row 2 (amber ⚠ Sent (transcript only) — the copy landed but without resumable context). Before this PR both rendered identically.

Send-a-copy row states, dark theme

Light theme

Send-a-copy row states, light theme

Also visible: the mid-send spinner, the red Failed state, and the disabled not connected row.

How these were produced — and what they are not. Captured with scripted Playwright against the repo's existing website/capture/<feature>.{html,tsx} harness convention (same as path-chips, update-card, webhooks): the real InstanceSendItems inside a real open DropdownMenuContent, real stylesheet + theme tokens, i18next initialised exactly as main.tsx does. Only the send outcome is supplied as data — the same thing a peer reports back via resume_mode.

They are not an end-to-end capture. The transcript-only state is only reachable after a real cross-instance send to a peer whose Layer B failed to materialise, which needs two live gateways and an open tunnel — the same two-instance smoke test still listed as the open merge gate below. The capture script asserts every expected row label before writing a frame, so it cannot silently emit one where the new state looks like a plain "Sent" (that assertion caught a real harness bug where every row had fallen back to idle).

@iamwhatever
iamwhatever requested a review from a team August 8, 2026 18:47
@iamwhatever
iamwhatever requested a review from a team as a code owner August 8, 2026 18:47
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ human override accepted

Human judgment by @iamwhatever overrides the GPT 5.6 finding for a8a7cecd2fead43ce4784d68533696e0a9eac9f0; the recorded reason is authoritative for this commit.

This comment is updated in place on each push.

The model was not re-run because an authorized human decision supersedes it.

False positive or not applicable? A repository writer can comment:
/ai-review override gpt a8a7cecd2fead43ce4784d68533696e0a9eac9f0: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of a8a7cecd2fead43ce4784d68533696e0a9eac9f0 — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound root-cause fix with honest degradation, but Layer B egress silently exits the repo's credential-redaction floor, and the diff's own docstrings still claim otherwise.

Watch

  • Byte-exact Layer B bypasses the AKIA/ASIA credential floor AGENTS.md says to keep. The stated rationale for Layer A egress redaction — "a transcript written before the redactors existed can still hold a raw credential… relying on the receiving instance to scrub it would send the secret across the boundary first" — applies verbatim to Layer B events, which now cross the host boundary raw. The signature constraint forbids rewriting, not scanning: a narrow detect-only pass (credential patterns, not the URL redactor that caused the 41% figure) could degrade to the already-plumbed transcript-only path on a hit. Deliberate and documented, but a human should consciously accept reversing the prior review finding without that middle option.
  • The diff misdocuments its own reversed stance: _write_layer_b_files says it "re-redacts the events (ingress…)" and _assemble_bundle says events "were already egress-redacted in _read_layer_b" — both false after the byte-exact change in the same commit. The next security review will read these and draw the wrong conclusion.

Suggestions

  • The create → _slots.popbegin_slot_construction → re-register dance pokes state internals from a handler and adds a shadow count every future cap site must remember (live_slot_count vs len(_slots) is now a trap); a state-owned get_or_create_slot(published=False) + publish_slot(key) would make the invariant unforgeable.

[DESIGN-REVIEWED] a8a7cec

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

Advisory UX-level review of a8a7cecd2fead43ce4784d68533696e0a9eac9f0 — updated in place on each push; does not block merge.

UX-Verdict: CONCERNS

A downgraded send to an older peer is transcript-only and the sender knows it — yet the row still shows the plain green "Sent" this PR exists to kill.

Watch

  • Silent downgrade path: ssh_tunnel_manager.py drops layer_b and resends as v1 ("retrying transcript-only at v1"), the v1 peer reports no resume_mode, handlers_instances.py forwards "", and SendToInstanceSubmenu.tsx renders plain "Sent" — so during any mixed-version window, every send of a context-bearing session is lossy while displaying full success, and the user discovers it mid-task on the other machine. Frequency: every send in an upgrade window × impact: the exact silent degradation the PR targets × persists per send. Fix: when downgraded is true and the resend succeeds, inject resume_mode: "prefix" into the returned payload so the existing amber state fires.

Suggestions

  • transcript_only_hint ("That instance could not restore the full conversation context…") misattributes cause when the sender withheld Layer B mid-turn (layer_b_skipped); make it cause-neutral and actionable: "The full conversation context did not transfer, so this copy resumes from the transcript alone. Sending again once this session is idle may carry it."
  • The hint rides a native title attribute on the row's status span, so keyboard and touch users never see it; the amber label alone must carry the load — consider surfacing the hint in the same Tooltip primitive sibling rows use, if one exists in this menu.

[UX-REVIEWED] a8a7cec

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed a8a7cecd2fead43ce4784d68533696e0a9eac9f0 — this comment is updated in place on each push.

Review details

I've reviewed the complete diff. Let me verify the two redaction-related comments against the actual code behavior, since that's the crux of this PR's deliberate design.

The core source changes are in session_transfer.py, state.py, ssh_tunnel_manager.py, chat_fork.py, handlers_instances.py, security_posture.py, plus frontend and i18n. Let me confirm my read of the key concurrency and rollback paths is complete — I've traced the import cap/construction accounting, the mid-turn skip and retry recomputation, the Layer B write/join/rollback (including the CancelledError and finally paths), and the v2→v1 downgrade retry loop.

My analysis:

  • Cap accounting (live_slot_count, begin/end_slot_construction): the retract-then-recount pattern is correct; success path double-count is avoided (finally releases construction before re-registration, no await between). ✓
  • Mid-turn skip / retry: eligibility recomputed inside the retry loop per attempt, so a prompt landing during the threaded read forces a retry that re-drops Layer B. ✓
  • _read_and_assemble: mid_turn correctly threaded as layer_b_skipped; the mapped-but-unreadable vs never-had distinction is preserved. ✓
  • Rollback paths: join + files undone on 503, generic exception, and CancelledError (with sync unlink on the cancel path). ✓
  • Downgrade retry: flag-tracked, gated on version==2 not layer_b presence, bounded at 3. ✓
  • Byte-exact Layer B egress: deliberate, documented in security_posture.py and instances.md §14.1a, bounded to the operator's own authenticated peer (0600). The only "fix" (redact) breaks the feature by design (signature invalidation) — out of scope, and within the OS-user/operator trust boundary this repo defines. Not a violation of the redact-before-external-surface rule's threat class.
  • The duplicate if not sid: return None in _read_layer_b is harmless redundancy (tool territory).
  • Frontend transcriptOnly mapping and i18n additions are consistent.

The stale docstring/comment claims that Layer B events are "egress-redacted" / "re-redacts the events (ingress)" contradict the byte-exact code, but that is a documentation inaccuracy with no runtime consequence chain to user/system harm — below the finding bar.

No findings.

[OPUS-REVIEWED] a8a7cec

Verdict parsed from the review's SHA-scoped output markers for commit a8a7cecd2fead43ce4784d68533696e0a9eac9f0.

False positive or not applicable? A repository writer can comment:
/ai-review override fable a8a7cecd2fead43ce4784d68533696e0a9eac9f0: <one-sentence reason>

@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from f0f96dc to 3f09b06 Compare August 9, 2026 05:04
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from 3f09b06 to f7af6a2 Compare August 9, 2026 05:04
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from f7af6a2 to 7b12a03 Compare August 9, 2026 05:32
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions — prior SHAs f0f96dcca and f7af6a2da → now 7b12a03dc

GPT f0f96dcca — BLOCKING: Layer B bypasses the live session map — FIXED.
Legitimate, and it defeated the whole feature. SessionMap.set writes the entire _data dict, and SessionManager holds self._session_map = SessionMap() built at startup (session.py:767), so my detached instance's entry was dropped by the next unrelated .set()SessionMap.get finds no sid → the tab degrades to _build_history_prefix()prune then deletes the orphaned <sid>.{json,jsonl}. Now writes through state.sessions.seed_conversation(...); the read side also goes through resumable_sid so no detached map remains. Guard test test_materialise_layer_b_never_uses_a_detached_session_map asserts the module cannot even import SessionMap. Opus raised the same thing as an advisory — same fix.

GPT f7af6a2da — BLOCKING: imported slot published before construction finishes — FIXED, with one correction to the stated mechanism.
The named trigger is inaccurate: get_or_create_slot does not broadcast, and push_slots_update() runs only at the very end, after both awaits. But the hazard is real by a different path — the slot is registered in state._slots synchronously, and handlers enumerate that dict directly (handlers/core.py:1747, handlers/sessions.py:1210), so the session is GET-reachable before the join lands. A prompt in that window cold-starts session/new with a fresh context and the later join binds to nothing. Fixed by materialising Layer B before the transcript append + durable save, shrinking the exposed window to a single thread hop and making any prompt inside it resume correctly. Because the join now precedes the save, both failure paths call the new _forget_layer_b (drops the join + deletes the files) so a refused import cannot leave a dangling entry. Tests: test_layer_b_lands_before_the_transcript_is_persisted, test_failed_save_rolls_back_the_layer_b_join, test_forget_layer_b_drops_the_join_and_the_files.

Design — "silent degradation is moved, not removed" / no resume-mode visibility — FIXED (partially) + accepted-and-deferred.
Fixed the reportable half: import now returns resume_mode: session_load|prefix, so a degraded copy is distinguishable at the API boundary instead of looking identical to a full one. Deferred: rendering it on the sender's row and a first-turn notice — that is frontend work on #1744's submenu, and the honest signal has to exist server-side first. Schema/integrity checking of the Layer B blob is genuinely out of scope for v1 (it would mean versioning kiro-cli's private on-disk format).

Design — "the version bump forecloses compat" — FIXED.
Correct and the sharpest catch here: gaining Layer B had removed the ability to send to a non-upgraded peer, since it refuses bundle_version: 2 outright. send_session_bundle now retries once on transfer_version_unsupported, dropping layer_b and re-tagging v1 — same conversation at v1 fidelity. Retry bookkeeping switched from loop-index to independent reminted / downgraded flags so the downgrade cannot consume the credential-remint budget. Tests: test_send_bundle_downgrades_to_v1_when_the_peer_refuses_v2, test_send_bundle_downgrades_only_once.

Design — "the deliverable is unexecuted" (no two-instance smoke test) — ACCEPTED, not fixed.
Correct, and stated as the merge gate in the PR body. It needs a second live gateway with an open tunnel; the envelope-shape assertions do not substitute for it. Flagging explicitly rather than implying coverage it does not have.

UX — incognito rows always 400 / raw error codes in a hover tooltip — ACCEPTED-AND-DEFERRED.
Both legitimate and both live entirely in #1744's SendToInstanceSubmenu.tsx (gate on memory_mode !== 'persistent', map code → localised strings). Deliberately not bundled: this PR is the backend Layer B change, and widening it into the submenu's UX would mix two reviewable concerns. They belong with the row-label work from the Design finding above.

Not mine, now resolved by main: the ko.json catalogParity failures (first the 6 artifacts/recovery keys, then 29 pages.sessionStorage.*) were base breakages on main — verified against pristine main, and my only ko.json addition is the 5 submenu keys. Main has since fixed both, and frontend tests are green locally (86/86). PR #2246 is now obsolete and should be closed.

Own error, fixed: I had added the submenu keys to en.manual.json and en.json; that shadowing is a hard i18n error (my earlier parity check wrongly treated en.manual.json as a full catalog). Removed — the gate now reports "no en.json/en.manual.json shadowing".

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from 7b12a03 to c24bd38 Compare August 9, 2026 05:53
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions — 7b12a03dcc24bd385e

Correction to my previous comment first. I claimed GPT's "slot published before construction" mechanism was inaccurate because get_or_create_slot "does not broadcast". That was wrong — it calls _sync_dashboard_slots(self) and push_slots_update() immediately before return slot (state.py:3377). My earlier grep only covered the first ~45 lines of the method and missed it. GPT was right on the mechanism as well as the hazard; the retraction below is the real fix, not the window-narrowing I did last round.

BLOCKING — imported slot published before initialization (session_transfer.py:846) — FIXED.
get_or_create_slot registers the slot in state._slots and publishes it before returning, so the tab was visible and GET-reachable (handlers enumerate _slots directly) while its transcript was empty, its Layer B unjoined, and nothing persisted. The slot is now retracted immediately after creation (state._slots.pop(...) + a publish to withdraw it), built in full, then re-registered and published once at the end — so the first moment a client can reach the session is the first moment it is correct. Collision-safe: keys are minted from _slot_counter (already advanced), not by scanning _slots, so a concurrent import cannot be handed the parked key. Test test_slot_is_unreachable_until_construction_finishes samples _slots from inside the build and asserts absence throughout, presence exactly once after.

BLOCKING — live session map mutated from a worker thread (session_transfer.py:887) — FIXED.
Legitimate, and I introduced it last round when I moved the join onto the live map: seed_conversationSessionMap.set mutates a shared _data dict and then serialises the whole file, so running it inside asyncio.to_thread raced the event loop's own map writes — two interleaved whole-file writes can drop an entry, which is the same lost-resume-mapping failure this PR exists to remove. _materialise_layer_b is now split: _write_layer_b_files (thread; file IO only, takes no sessions handle so it structurally cannot reach the map) and _join_layer_b (event loop; the live-map write). The rollback is split the same way — _forget_layer_b_join on the loop, _unlink_layer_b_files in a thread. Tests assert the thread half's signature carries no sessions parameter, plus join/rollback behaviour.

FINDING — degraded peer response ignored, resume_mode: "prefix" still shows "Sent" (SendToInstanceSubmenu.tsx:145) — FIXED.
I had deferred this as frontend scope; three reviewers have now raised it and it is the PR's own premise, so it is in. send-session forwards the peer's resume_mode, the client type carries it, SendState gains transcriptOnly, and the row renders a distinct amber "Sent (transcript only)" with a hover explanation instead of the green "Sent". An older peer that cannot report sends "", which stays plain "Sent" rather than crying wolf. Two new i18n keys (sent_transcript_only, transcript_only_hint) added across all 12 translated catalogs with real translations + regenerated en-XA; frontend test asserts the degraded row does not read as "Sent".

Still open and unchanged: the two-instance smoke test (Design's "deliverable is unexecuted") remains the merge gate — it needs a second live gateway with an open tunnel, and I have not run it. The remaining UX items (incognito rows that always 400, raw error codes in the tooltip) stay accepted-and-deferred to #1744's submenu.

Verification this round: 228 backend tests + 607 frontend tests pass; flake8 / isort clean; mypy clean on both changed backend files; check-i18n-keys reports no shadowing; tsc -b exits 0. Rebased onto current main (c24bd385e, one commit).

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from c24bd38 to a05580f Compare August 9, 2026 06:12
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from 043b5d2 to 66b1ebd Compare August 9, 2026 07:08
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Disposition — 93c4f340066b1ebd09

BLOCKING — JSON keys bypass credential redaction — FIXED, collision-safely.
_redact_strings deliberately left keys alone ("field names, not content"). That premise is wrong at an egress boundary: a tool result can key a map by account- or attacker-derived text, and an unredacted key carries the secret across the tunnel exactly as a value would. Keys are now redacted too.

I did not implement it as the naive {_redact_strings(k): ...} rebuild, because that introduces a worse bug than the one being fixed: two distinct secret-bearing keys can redact to the same placeholder, and a plain dict comprehension would silently drop one entry — losing model context is a correctness failure, not a cosmetic one. Colliding keys get a numeric suffix so every entry survives. Non-string keys pass through untouched.

Tests: test_redact_strings_redacts_credential_shaped_keys, test_redact_strings_keeps_both_entries_when_keys_collide (asserts 2 entries in, 2 out, both values intact), test_redact_strings_leaves_non_string_keys_alone, plus the existing structure/non-string preservation test updated to say "clean keys are untouched".

Also in this push: rebased onto current main and resolved a ko.json conflict — main's own Korean for the pre-existing sent key wins ("전송됨"), and my two new keys adopt that wording ("전송됨(대화 기록만)") so the row reads consistently rather than mixing two registers.

Verification: 160 session-transfer + security-posture + error-code-contract tests pass; 607 frontend; flake8 clean; mypy clean on the changed file; check-i18n-keys reports no shadowing. One commit on current main (66b1ebd09).


A note on this loop, for whoever picks it up. This is the 8th blocking round. Every one closed something real, and three were defects in my own previous fixes (detached SessionMap → moved on-loop → the read was still off-loop → mid-turn Layer A/B skew → eligibility going stale across retries), which is why it kept moving rather than stalling. Severity is now clearly tapering: this round's finding required a credential-shaped dict key inside kiro-cli session state, with no named trigger in this repo's own tool surface. If the next round lands at that level again, /ai-review override gpt <sha>: … is the appropriate lever rather than another cycle.

The real gate is still unmet and unchanged: the resume path has never been exercised against two live instances. Everything above is deterministic verification.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from 66b1ebd to aa6b2e2 Compare August 9, 2026 07:20
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Dispositions — 66b1ebd09aa6b2e2a2

BLOCKING — invalid JSONL is installed as resumable context — FIXED.
Legitimate, and it corrects a judgement call I got wrong last round. I had passed an unparseable record through scrubbed, reasoning that dropping it would silently lose content. That was the wrong trade: keeping it installs malformed JSONL as the peer's resumable context, so the peer's session/load fails and falls back to transcript replay — after this side already reported resume_mode: session_load. That is precisely the silent degradation this PR exists to remove, and my own honesty plumbing was reporting a lie.

_redact_events_jsonl now returns None when any non-blank record fails to parse, and both boundaries drop Layer B on that signal: egress (_read_layer_b) omits it from the bundle, ingress (_write_layer_b_files) refuses to write anything. The result is an honest prefix / "Sent (transcript only)". It is also more secure than my previous version — the untrusted record is never transmitted at all, so the leak concern that motivated the pass-through disappears.

Tests: test_redact_events_jsonl_rejects_an_unparseable_record (including one bad record among good ones), test_unparseable_layer_b_degrades_to_transcript_only (send side), test_import_refuses_unparseable_layer_b_from_the_peer (asserts no files are written).

SAST (Semgrep) — FIXED (my defect, not a false positive).
generic.secrets.security.detected-aws-access-key-id-value fired on my new collision test, which hardcoded AKIA…EXAMPLF — a fabricated key-shaped literal, not AWS's published example, so it reads as a real credential to the scanner. The two colliding keys are now assembled at runtime ("AKIA" + "IOSFODNN7EXAMPLE" / …F), which keeps the test meaningful with no key-shaped literal in the source. A comment on the test says why, so inlining it does not silently re-break the gate. Verified no fabricated variant remains in the diff; the 11 remaining uses are AWS's published example key, matching existing repo usage the gate already accepts.

BLOCKING (repeat) — "session-map persistence blocks the event loop" — REBUTTED, unchanged.
This is the same finding as c24bd385e, re-raised. The rebuttal stands on evidence rather than preference: subagent.py:3164-3172 (Arbiter, PR #1246) is an explicit written contract that all SessionMap access — naming seed_conversation writes and the resumable_sid self-prune — stays on the event loop, because the map is an unlocked dict with whole-file saves and worker-thread access races restart cold-starts. Both existing callers (subagent.py:3192, :3258) are on-loop, and that PR already raised and dispositioned this exact event-loop cost. Moving off-loop would reintroduce the worker-thread race this same review flagged as BLOCKING two rounds earlier — the two findings are mutually exclusive.

The write is one small dict serialised once per import (a human action), not per turn. Making it genuinely off-loop means giving SessionMap serialised persistence — a change to shared session infrastructure with its own concurrency contract, and a separate PR against session_map.py. I am not contorting correct code to clear a check; if a maintainer wants it cleared, the lever is /ai-review override gpt aa6b2e2a2: ….

Verification: 162 session-transfer + security-posture + error-code-contract tests pass; flake8 clean; mypy clean on the changed file. One commit on current main (aa6b2e2a2).

Unchanged gate: the resume path still has not been exercised against two live instances.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: passed Eligible automated validation passed for the current revision labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from aa6b2e2 to 8091b0d Compare August 9, 2026 07:58
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Advisory dispositions — Design & UX CONCERNS on aa6b2e2a2 → now 8091b0df3

Both lanes pass, so nothing here blocks. Answering each individually anyway, because a green rollup says nothing about an advisory concern.

Design — "the description says conversation_metadata is kept byte-for-byte, yet _redact_strings rewrites its leaf strings" — FIXED (documentation was wrong, and the code is right).
Caught a real contradiction I introduced. When I added envelope redaction I did not revisit the fidelity claim, so the body asserted something the diff no longer did. The code is the intended behaviour — redaction at an egress boundary is a deliberate trade — so I corrected the claim: the body now says conversation_metadata is structurally preserved (shape and non-string values untouched) and states explicitly that it is not byte-for-byte because redaction rewrites leaf strings and re-serialises each JSONL record. Thank you for it; an inaccurate description is the kind of thing that gets trusted later.

Design — "fidelity is reported at import time, exercised later; acp/client.py:2841 silently falls back to session/new" — PARTIALLY FIXED + accepted-and-deferred.
The gap is real: resume_mode: session_load means "files landed and the join was written", not "the peer's first session/load succeeded". Fixed the part that does not need new surface — the receiving side now marks its own tab — transcript only when it arrived degraded (see the UX item below), so the disclosure persists next to the work instead of living only in a menu. Not fixed: a true end-to-end signal needs the peer to report the outcome of the first real session/load (or a probe-load at import), which is exactly the new surface your suggestion flags as follow-up material. Deferred deliberately rather than half-built, and the merge gate below is what covers it in the meantime.

Design — "no envelope-schema version; kiro-cli's private format shipped between independently-updated hosts" — ACCEPTED-AND-DEFERRED.
Correct and it is the sharpest structural risk in the design. Versioning kiro-cli's on-disk format is not something this PR can do unilaterally — it would mean asserting a contract over a format this repo does not own. Two partial mitigations now exist: an unparseable record refuses the whole blob (this round), and a schema mismatch that session/load rejects degrades to session/new rather than corrupting anything. Neither is a substitute for a real envelope version, and I would rather say so than imply the risk is closed.

Design — "the core path is unexercised; hold the merge to that gate" — ACCEPTED.
Agreed, and stated in the PR body as the merge gate. The deterministic tests validate shape, not that a peer's kiro-cli accepts the rewritten envelope.

Design — "the unpublish/republish dance reaches into state._slots; a publish=False option on state.py's own API would put that invariant where it's owned" — ACCEPTED-AND-DEFERRED, and I agree with the architecture.
get_or_create_slot publishing before the caller can finish construction is a property of the shared API, and every caller with a multi-step build has the same latent problem — so the fix belongs there, not in one caller. I kept it local because changing that signature touches a widely-used shared surface and would widen this PR from "session transfer" into "slot lifecycle"; the invariant is at least pinned by a test here (test_slot_is_unreachable_until_construction_finishes) so a refactor has something to preserve.

UX — "ephemeral, sender-side-only disclosure; mark the imported session on the receiving side" — FIXED.
The strongest point raised this round, and your "smallest fix" was exactly right: the import handler already computed resume_mode, so the truth was one line away from the surface that matters. An import that arrived without resumable context now appends — transcript only to its own tab title, which is saved with the session and therefore survives the menu closing, a reload, and the walk to the other machine. The sender's row still reports it too; the two are consistent. Tests: test_imported_tab_is_marked_when_it_arrived_transcript_only, plus negative cases for a landed Layer B and for a v1 bundle (which carries no context by construction, so flagging it would cry wolf).

UX — "transcript_only_hint says 'peer'; every sibling string says 'instance'" — FIXED.
Reworded to "That instance could not restore the full conversation context…" across all 12 translated catalogs (en, de, es, fr, it, pt, ru, ja, ko, zh-CN, hi, bn) plus a regenerated en-XA.

UX — "the hint rides a title attribute, invisible to keyboard and touch users" — ACCEPTED, no change.
Correct, and your own caveat is the reason I am leaving it: the visible label now carries the meaning on its own ("Sent (transcript only)"), and the receiving-side tab marker means the fact no longer depends on the hover text at all. If the label is ever shortened, the hint has to become visible text — worth noting for whoever does that.

Verification: 165 session-transfer + security-posture + error-code-contract tests pass; 607 frontend; flake8 clean; mypy clean; check-i18n-keys reports no shadowing. One commit on current main (8091b0df3).

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 9, 2026
@iamwhatever
iamwhatever force-pushed the feat/session-teleport-layerb branch from 8091b0d to 92234de Compare August 9, 2026 16:59
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 9, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Both blocking findings were legitimate. Fixed at b392d73ad.

1. Size cap checked after loading the whole event log (session_transfer.py:315) — accepted, real. _MAX_LAYER_B_CHARS was enforced by a post-read len(events), which bounds nothing: the multi-gigabyte allocation has already happened by the time it runs, so an oversized tool-output log took the gateway down instead of degrading. Now st_size is checked before the read.

Two notes on the fix beyond what was reported:

  • The envelope read (jf.read_text, two lines up) was unbounded on the same path and is now capped too. Fixing only the .jsonl would have left the identical hazard next to it.
  • This makes the ceiling effectively a byte cap where the name says chars. For multibyte text that is strictly tighter — a 40M-char CJK log is ~120MB and now degrades to transcript-only where it previously loaded. That is the correct direction for a memory-safety limit: the ceiling has to bound what is actually allocated, and the fallback is an honest transcript-only copy rather than a crash. The post-read char check is kept as the semantic cap.

The test pins the ordering, not the return value — a post-read check also returns None for an oversized log, so "returns None" would have passed against the bug. It spies on Path.read_text and asserts the log is never read.

2. Imported context uses default filesystem permissions (session_transfer.py:424) — accepted, real. Layer B is the model's entire context window (every user turn and tool result), and umask 022 landed it at 0644 for any other local user. Files are now written 0o600 via the shared atomic_write(..., mode=) plus restrict_to_owner for the Windows ACL case, since POSIX mode bits are meaningless against NTFS.

Two deliberate narrowings, both pinned by tests:

  • Only the directory this code creates is hardened. This is kiro-cli's own sessions dir, so chmod-ing a pre-existing one would mutate posture on a directory the feature does not own. The files are 0o600 either way, which is what actually contains the content. mkdir(mode=) is followed by an explicit chmod because mkdir's mode is umask-masked (pod/runtime.py makes the same two-step call for the same reason).
  • Warn-and-continue if restrict_to_owner fails, rather than fail-closed. This matches how the repo already treats an actual credential file (handlers/weixin_qr.py); being stricter here than on the credential path would be incoherent.

Third issue, not reported, found while fixing #2. The permissions site used a hand-rolled _atomic_write with a deterministic <name>.tmp — which kiro_crew/atomic_write.py explicitly forbids ("all atomic-write sites in KiroCrew should use this helper instead of deterministic .tmp filenames, which cause ENOENT when concurrent writers target the same file"), and which also lacked the Windows rename-retry window. The local helper is deleted and both writes go through the shared helper, so the permissions fix and the race fix are the same change.

Gates: 170 backend (was 165 — 5 new tests), flake8 / isort clean. hooks.py mypy errors on my machine are pre-existing macOS typeshed noise (os.listxattr/getxattr/setxattr are Linux-only) in a file this diff does not touch.

Unchanged: the two-instance smoke test is still the open merge gate — neither fix is reachable by the unit suite end-to-end.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (picks up #2454, the Windows chat-pins portability fix) and addressed both findings from b392d73ad. Now at da3064013.

BLOCKING — session_transfer.py:204 — redaction-key collision drops context — FIXED. Legitimate, and it was a hole in the collision handling I had added for this exact class. The guard was new_k in out_map and new_k != k; a key already spelled like the redaction placeholder is left unchanged by redaction, so new_k == k, the second clause went False, and the disambiguation branch was skipped — letting that key land on the name a credential-shaped key had just collapsed into and silently overwrite it. Now disambiguates whenever the name is taken. Source dict keys are unique, so an occupied name always belongs to a different source key; "did this key change" was never the right question. test_redaction_disambiguates_even_when_a_key_was_not_rewritten pins it and fails against the old guard.

FINDING — session_transfer.py:1170 — mid-turn transfers omit Layer B, suppressing the transcript-only label — FIXED, and your suggested shape was the right one. Confirmed: a mid-turn source ships no layer_b, so gating the marker on that key silenced the tab in exactly the case the sender's own row was reporting as transcript-only.

I first tried gating on bundle_version >= 2 and that was wrong — it marks a v2 session that simply never had a kiro-cli context, which is not a degradation and would cry wolf on every such import. My own suite caught it (an existing test went red), which is why this landed as the explicit flag you actually recommended:

  • sender sets layer_b_skipped: true only when it withheld context it had (the mid-turn branch, where mid_turn is already computed on the loop);
  • _validate_bundle carries it through, coerced with bool() since it comes from an untrusted peer;
  • the marker fires on layer_b present-but-failed or layer_b_skipped.

Worth noting for the record: _validate_bundle builds a fresh normalised dict, so any field it does not copy reads as absent downstream regardless of what was on the wire. That is what made my first attempt fail silently, so test_validate_carries_the_skipped_flag_through now pins the plumbing itself, not just the behaviour.

SAST (Semgrep) — insecure-file-permissions on os.chmod(d, 0o755) — suppressed with justification, not code-changed. The hit is on a test fixture added last round, which deliberately creates a laxer-than-default directory so that "the import does not re-permission kiro-cli's existing dir" is observable — asserting against an already-0700 tmp_path would pass even if the code did chmod it. Suppressed inline in the repo's documented form (# nosemgrep: <rule-id> -- reason) with the rationale above it. No production permission is affected; the production writes are 0o600 + restrict_to_owner. Semgrep is not installed locally, so I could not verify the suppression takes effect before pushing — this round's SAST result is the check.

Gates: 174 backend (was 170), tsc clean, 10/10 submenu tests, flake8 / isort clean, mypy clean on session_transfer.py. The two CANCELLED GPT/Screenshot runs were superseded-by-newer-push cancellations, not failures.

Unchanged: the two-instance smoke test remains the open merge gate.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Both findings from da3064013 were legitimate. Fixed at 9c9db2832.

BLOCKING — session_transfer.py:1127 — unpublished imports bypass the slot cap — FIXED. Correct, and it was a hole my own retraction hunk opened. The cap samples len(state._slots) before creation, and the retraction removes the slot across several await points (_write_layer_b_files, save_slot_off_loop), so concurrent imports each sampled a count that excluded every other import in flight and were all waved past a cap that was already full.

Fixed by keeping in-progress slots counted, not by reverting the hunk — the retraction is load-bearing for an earlier blocking finding (a slot reachable mid-construction cold-starts a fresh context that the join written afterwards can never attach to). DashboardState now tracks _slots_under_construction and exposes live_slot_count() = published + under construction, with begin_/end_slot_construction().

Two things beyond the report:

  • The fork cap had the same undercount. chat_fork.py:53 reads the same len(state._slots), so in-flight import slots were invisible there too. Both caps now go through live_slot_count(), so the invariant has one definition instead of two drifting copies.
  • The release is in a finally, deliberately. A leaked construction key is worse than the original bug — it inflates every cap for the process lifetime and refuses imports that should succeed. The finally covers all three exits (the 503 return from inside the try, the raise, and the success path), which is what test_import_releases_the_construction_count_on_every_exit and its success-path twin pin. Ordering is safe: the release runs before re-registration, but nothing between them awaits, so no coroutine can observe the slot as neither published nor counted.

FINDING — session_transfer.py:759 — a truncated mapped Layer B leaves layer_b_skipped false — FIXED. Confirmed, and it is the same seam as the mid-turn case: layer_b_sid non-empty with _read_layer_b() returning None (pruned files, over the size cap, unparseable JSONL) is context the session genuinely had and is giving up, but it shipped as an ordinary absence, so the receiving tab showed a full-looking copy with no resumable context behind it. Now sets the flag when a sid was mapped and its read failed; an empty sid still stays silent, because that means there was never a context to carry.

Note for reviewers, since it affects test fidelity: this suite's _stub_state is a SimpleNamespace, so it had to grow the construction-accounting surface to mirror DashboardState. Without that the handler's new cap read would have thrown AttributeError rather than being exercised — worth knowing that the stub is now the thing keeping these two in sync.

Gates: 3045 passed / 8 skipped across the slot / dashboard / fork / transfer / state suites (730 in the fork+transfer set specifically), flake8 / isort clean.

Unchanged: the two-instance smoke test remains the open merge gate.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and fixed the finding from 9c9db2832. Now at 970cfec5e.

BLOCKING — session_transfer.py:485 — Layer B remains readable when lockdown fails — FIXED, and this reverses a call I got wrong two rounds ago. I had chosen warn-and-continue and defended it here by pointing at handlers/weixin_qr.py, which warns and continues for an actual credential file. That precedent does not transfer, and the reasoning was wrong:

  • on Windows the mode=0o600 on the write is a no-op (POSIX bits mean nothing against NTFS ACLs), so restrict_to_owner() is the only thing making the file owner-only — when it raises, the context really is readable by other local accounts;
  • it raises for a documented, reachable reason, not a hypothetical one: it refuses to apply a half-configured DACL when the invoking user's SID will not resolve;
  • and the cost of refusing is asymmetric. weixin_qr has no fallback, so failing closed there breaks the feature outright. This path already has an honest fallback — returning None imports the session transcript-only — so refusing costs resume fidelity and nothing else.

Now fails closed: both files are deleted (the pair is useless alone, and the .json carries context too) and None is returned, so the import lands transcript-only. test_layer_b_is_discarded_when_owner_lockdown_fails pins both halves — the None and the empty directory.

Also worth stating plainly, since it was the stated reason for this round: there was no frontend-test failure to fix. CI was green on da3064013, the earlier red rounds were the Windows backend shard plus Semgrep (both already fixed), and main's new commit is the voice push-to-talk feature, not a test fix. The rebase was still worth doing — it absorbs a package.json / package-lock.json change, and I re-ran the frontend gates against a fresh npm ci so the local green is not measured against stale node_modules.

Gates: 179 backend (was 174), tsc clean, 76 frontend tests (submenu + i18n) against the fresh install, flake8 / isort clean.

Unchanged: the two-instance smoke test remains the open merge gate.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Layer B now travels byte-exact — reversing an earlier finding on this PR, with measurements

Pushed as ff205cba1. This is a deliberate design reversal, not a regression, and it is worth reading before the next round re-raises the redaction point.

What was wrong

An earlier round of this PR asked for the Layer B envelope to be redacted on egress, and I implemented that (plus a per-record JSONL redactor on both boundaries). Investigating whether a sent session can actually resume on the peer, I replayed the full egress+ingress pipeline against this developer machine's 704 real kiro-cli sessions:

before
envelope structure altered (key paths) 0 / 704
conversation_metadata content altered 286 / 704 (41%)
of those rewrites, hitting one field 1650 / 1660 (99.4%)

That one field is conversation_metadata.user_turn_metadatas[].result.Ok.content[].data.signature, on blocks whose kind is thinking and whose data is ['modelId','redactedContent','signature','text'].

It is a provider signature over the thinking content, validated when the conversation is replayed. The consequence: the peer's session/load succeeds, the tab looks correct, and then the next turn is rejected — a failure that surfaces as far from its cause as it gets. The credential redactor was matching it as base64-shaped.

A signature is not a credential. Redacting it protects nothing (it is a MAC over content the peer already receives) and destroys the artifact. Redaction and transplant are mutually exclusive here; the PR was trying to do both, and the defensive half was silently breaking the functional half.

Why no test caught it

Every Layer B test used a synthetic {"envelope": {}}. 14 such tests, and 0 using a real thinking-block shape — so the entire redaction suite passed in a world with no signatures in it.

What changed

  • _read_layer_b / _write_layer_b_files forward Layer B byte-exact. The envelope's host-naming fields are still rewritten (fresh sid, cleared cwd/allowed_*_paths, target agent, timestamps) — that is host neutralisation, not content rewriting.
  • _redact_events_jsonl (per-record redact + re-serialise) is replaced by _events_jsonl_is_loadableparse-only, never re-serialises. That distinction is the fix: validation reads, it does not rewrite. Still refuses the whole blob if any record fails to parse, on both sides, so a crash-truncated source degrades honestly to transcript-only.
  • _redact_strings (~50 lines, including the collision-safe key handling from two rounds ago) is deleted. Net effect on the diff is a reduction.
  • Layer A keeps its redaction unchanged — assistant content, title and origin are still scrubbed, because that text is rendered in a transcript and re-read by an agent as context.

Why byte-exact is acceptable

The bound is the destination, not the payload. A send goes hub → the operator's own peer instance, over a tunnel that operator authenticated, and the peer stores the files 0600 (owner-only, fail-closed if the lockdown cannot be applied). Layer B never leaves the operator's own trust boundary; copying their own context between their own machines is precisely the operation they asked for. This is the repo owner's explicit call.

security_posture.py's egress-sink entry and instances.md §14.1a now both record the byte-exact stance and this reasoning, so the posture doc does not claim a scrub that no longer happens.

Verification

Same 704 sessions, round-tripped through the current code (real egress read → real ingress write):

sessions round-tripped:            704
  refused (cap / unparseable):     2
  carrying thinking signatures:    282  (1619 signatures)
  events blob altered:             0
  SIGNATURES ALTERED:              0

New regression test test_import_preserves_the_thinking_signature_verbatim uses a real thinking-block shape and asserts on the file bytes kiro-cli will read, not on an in-memory dict. A shared _THINKING_ENVELOPE fixture replaces the empty-envelope pattern, so this class of bug cannot pass unnoticed again.

Gates: 172 targeted + 1213 across the transfer/posture/slot/fork suites, flake8 / isort clean.

Unchanged: the two-instance smoke test remains the open merge gate — and it is the only thing that can prove resume end-to-end, since even this verification stops at "the bytes kiro-cli would read".

rebuilds it as a tab. That copy cannot continue where it left off. The peer
shows every turn, but the next prompt starts from a condensed ~8K text
prefix rather than the context the model was actually holding, so long or
compacted sessions silently lose their working context on arrival.

A session is two stores. Layer A is the display transcript
(<data-home>/sessions/<key>.jsonl) -- what #1744 sends. Layer B is the
context the model actually holds: kiro_sessions_dir()/<sid>.{json,jsonl},
outside the crew home, joined to a slot via session_map.json. With Layer A
alone SessionMap.get finds no usable sid on the peer and the next turn
falls back to _build_history_prefix() -- no tool state, no real context
window.

bundle_version 2 carries an optional layer_b, and the importer materialises
it and writes the session_map join, which is also what auto-disables the
prefix fallback -- so resume goes through session/load at the same fidelity
as a local gateway restart.

Properties that matter:

- Optional. A v1 sender, or a session with no kiro-cli context, ships Layer
  A only. Both versions stay accepted ({1, 2}) so a v2 instance can still
  receive from a v1 one, and send_session_bundle downgrades once to v1 when
  a peer refuses v2 -- gated on the version, not on layer_b presence, since
  a context-free session ships v2 with no layer_b at all.
- Rewritten, never replayed. Fresh sid (copy-never-move; a repeat send
  cannot collide), cwd and filesystem allowed_*_paths cleared (matching the
  existing project decision -- the session arrives unscoped), agent_name set
  to the target-resolved agent, conversation_metadata kept byte-for-byte
  because that IS the resumable context.
- Threading contract honoured. All SessionMap access stays on the event loop
  (see subagent.py's contract: the map is an unlocked dict with whole-file
  saves): _resolve_layer_b_sid and _join_layer_b run on the loop, while
  _write_layer_b_files / _unlink_layer_b_files do blocking file IO in a
  worker and never receive a handle to the map.
- The imported slot is unreachable until it is correct. get_or_create_slot
  registers it in state._slots and publishes it before returning, so it is
  retracted immediately and re-registered + published once, at the end.
  Layer B lands before the transcript work, and both failure paths undo the
  join and delete its files.
- Redacted both directions, structure-preservingly. The events blob and the
  envelope both cross the host boundary and can hold a credential the
  redactors never saw, so both are scrubbed on egress and again on ingress.
- Resume fidelity is reported end to end: import returns resume_mode
  (session_load|prefix), send-session forwards it, and the row renders
  "Sent (transcript only)" so a degraded copy is never shown as a full one.

Memory, project/repo and sub-agent conversations deliberately do not
travel; sub-agent results already live inside Layer B as injected context.
Direction is hub -> peer.

Tests: 150 in test_session_transfer.py plus security-posture and
error-code-contract; 607 frontend. Two-instance smoke test still outstanding.
@iamwhatever

Copy link
Copy Markdown
Collaborator Author

Five blocking findings on ff205cba1. Three fixed, one rebutted with measurements, one overridden by the repo owner. Now at a8a7cecd2.


FIXED — :448 Failed Layer B writes leave partial context files. Legitimate. The pair is written one file at a time, so a failure on the second write (disk full, EIO) left the first behind: an orphan no join references, that _read_layer_b will not load (it requires both), and that nothing else cleans up. new_sid is now bound before the try and the exception path unlinks the pair. test_layer_b_write_leaves_no_half_pair_when_the_second_write_fails injects a failure on exactly the second write and asserts an empty directory.

FIXED — :1244 Cancellation bypasses Layer B rollback. Legitimate, and a real gap: CancelledError is a BaseException, so the existing except Exception never saw it — a gateway shutdown or client disconnect after the join left an orphaned session-map entry plus files behind a slot that never publishes. Added a dedicated except asyncio.CancelledError that rolls back the join and the files, then re-raises so cancellation still propagates. The unlink there is synchronous on purpose: awaiting inside a cancelled task is not dependable, and it is two unlink calls on a teardown path. test_import_rolls_back_layer_b_on_cancellation pins the rollback and the re-raise.

FIXED — :1029 Slot-cap check races across request awaits. Legitimate, and distinct from the accounting fix two rounds ago. That one keeps a retracted slot counted (a window after creation); this is the window before it — body parsing and agent resolution both await, so N concurrent imports near the cap all clear the first test before any allocates. Added a second check immediately before get_or_create_slot, with no await between test and creation, so the loop cannot switch tasks in the gap. test_slot_cap_is_rechecked_after_the_pre_creation_awaits fills the map during the awaited agent resolution — exactly what a sibling request does — and asserts 429.


REBUTTED — :480 Session-map persistence blocks the event loop. This is the third time this finding has been raised, and the proposed fix ("remove the on-loop join and degrade to prefix") would delete the feature: without the join there is no session/load, which is the entire point of the PR.

Two pieces of evidence:

  1. On-loop is a written contract, not an oversight. subagent.py:3164-3172 states that all SessionMap access stays on the event loop, because the map is an unlocked dict whose every set rewrites the whole file from a startup snapshot. Both pre-existing seed_conversation callers are on-loop. Moving this one off-loop reintroduces precisely the interleaved-whole-file-write race that an earlier round of this same review flagged as BLOCKING — and gateway_lock.py ("Single-writer guard for a KIROCREW_HOME") shows the product relies on single-writer, in-process serialisation for this file.
  2. The cost is not a stall. Measured on a developer machine's real map: 220 entries, 43,601 bytes, full json.dumps in 0.132 ms. That is three orders of magnitude below anything LoopStallWatchdog reacts to. "Large session map" is not the shape this file has — one entry per long-lived conversation, and stateless sessions (cron, subagent, taskrunner) are excluded by design.

OVERRIDDEN — :832 Layer B bypasses mandatory egress redaction. Not a misreading: Layer B is forwarded unredacted, deliberately, and the repo owner has made that call explicitly. The proposed fix ("omit Layer B unless both redactors can be applied without corrupting it") is equivalent to deleting the feature, because the two cannot both hold:

  • the envelope's thinking blocks carry a provider signature over their own content, validated on replay;
  • so any redaction pass that touches a covered byte makes the peer's session/load succeed and its next turn fail — measured at 286 of 704 (41%) real sessions on a developer machine, 1650/1660 of those rewrites landing on signature;
  • and a signature is not a credential: it is a MAC over content the peer already receives, so scrubbing it protects nothing while destroying the artifact.

What bounds the exposure is the destination, not the payload: a send goes to the operator's own peer instance, over a tunnel that operator authenticated, stored 0600 owner-only (fail-closed if the lockdown cannot be applied). Layer B never leaves the operator's own trust boundary. Layer A — the rendered transcript, plus title and origin — keeps its redaction unchanged. security_posture.py and instances.md §14.1a both record this stance and its reasoning, so no doc claims a scrub that does not happen.

The formal override is posted separately, per-SHA.


Gates: 175 targeted, 1216 across the transfer / posture / slot / fork suites, flake8 + isort clean.

Still the open gate: the two-instance smoke test. Worth recording why it has not run here — pod/runtime.py:702 gives each pod its own empty KIRO_HOME, so a pod's kiro-cli is unauthenticated and cannot run a turn, and kiro-cli chat cannot create a v2 session at all (--session-source only pairs with --delete-session), so no pod- or CLI-only harness can produce one. It needs two real gateways with an authenticated kiro-cli. The repo owner is running that manually.

@iamwhatever

Copy link
Copy Markdown
Collaborator Author

/ai-review override gpt a8a7cec: Layer B is forwarded byte-exact by the repo owner's explicit decision — its thinking-block signatures are validated on replay, so redacting it corrupted the conversation in 41% of 704 real sessions, and the destination is the operator's own peer over a tunnel they authenticated.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for a8a7cecd2fead43ce4784d68533696e0a9eac9f0.

Layer B is forwarded byte-exact by the repo owner's explicit decision — its thinking-block signatures are validated on replay, so redacting it corrupted the conversation in 41% of 704 real sessions, and the destination is the operator's own peer over a tunnel they authenticated.

This decision applies only to this commit. A new push requires a new judgment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants