Repository navigation
Project coordination — Phase 1: project_group_id plumb + projects.json store - #1
Closed
SaRedfiche wants to merge 2212 commits into
Closed
SaRedfiche wants to merge 2212 commits into
SaRedfiche wants to merge 2212 commits into
Conversation
…irodotdev#9232) The replay corpus recorded by kirodotdev#9161 predates the wire_title field kirodotdev#9073 added to every tool_call event 3 minutes later, so main's own corpus no longer matches main's own render. Regenerated with scripts/update_acp_frame_snapshots.py; the only delta is the new wire_title key.
…irodotdev#9225) session/set_mode boots the switched-to agent's MCP servers, the same (re-)initialization that gives session/new and session/load their 90s budget, but both set_mode call sites used the generic 30s _REQUEST_TIMEOUT. A switched-to server pending OAuth holds the response for its full 30s wait, so the 30s budget races it exactly as it would session start. Pass the already-resolved session-start budget to both set_mode calls. Closes kirodotdev#9185
…irodotdev#9237) The store's _open_lock opened its lock file with open(path, "w"), which truncates on open. On Windows a truncating open of a lock file whose first byte another holder already locked under msvcrt.locking raises a sharing violation instead of waiting, so a second contending acquirer crashes with a bare OSError before it reaches file_lock -- the bind it was serialising is never mutually excluded. POSIX flock tolerates the truncate, so the defect was Windows-only. Fix mirrors dashboard/handlers/mcp.py: touch + open r+ (writable, no truncate).
…le (kirodotdev#9250) The three PID lock helpers opened their lock file with `open(path, "w")` and only then acquired the lock. `"w"` truncates AT OPEN, so the truncation happens before the lock is held. On Windows the acquire routes to `msvcrt.locking`, and a truncating open of a lock file whose first byte another holder already locked raises a sharing violation instead of waiting -- so the contending acquirer crashes with a bare OSError before it ever reaches `file_lock`, and the serialisation the lock exists to provide never happens. POSIX `flock` tolerates the truncate, which is why this was invisible on Linux and reddened only the Windows shards. Issue kirodotdev#9248, which named `session_pid.py` as the site most likely to bite in practice: PID files are read by other processes precisely while they contend. It named two sites and asked whoever picked it up to re-run the grep rather than trust the list; doing so found a third, `_periodic_pid_sweep`, which is the worst of them because it runs on a timer while `_track_session_pid` contends for the same lock. All three now `touch(exist_ok=True)` and open `"r+"` -- writable, which `msvcrt.locking` requires, but not truncating. Same shape as `dashboard/handlers/mcp.py`'s `_McpFileLock`, which was already written this way, and as `work_ledger._open_lock`. Truncation is the direct, platform-independent observable, so the tests seed the lock file, take and release the lock, and require the bytes to have survived: they fail on every platform if the truncating open comes back, rather than only on Windows. `webhooks.py` carries the same pattern and is deliberately left alone -- issue kirodotdev#9248 asks for one change per subsystem so each gets its own review. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ls (kirodotdev#9257) test_two_conductors_binding_one_worker_at_once_yield_exactly_one_binding fails on Backend Tests (Windows) (4) intermittently and reports only "assert 2 == 3". Its bind helper caught only WorkLedgerError, so anything else (a bare OSError from a Windows sharing violation, per kirodotdev#9250's own docstring) killed the thread silently, appended nothing, and shortened the count -- discarding the one fact that identifies the cause. Record one outcome per thread keyed by conductor, catch BaseException and keep the type + message, and flag a thread still alive after join(timeout). Put the per-thread outcomes in both assertion messages so the next Windows failure names its exception instead of a count. The == 1 and == 3 assertions are unchanged: this adds diagnosis, not tolerance.
…rodotdev#9260) A task card is progress decoration and the 30s elapsed-time refresh re-sends it for as long as a tool runs, so on a several-minute tool phase it is the only thing appending to the stream. Both Slack pipelines rotated the stream when Slack refused that append: the message the reader was watching was stopped mid-answer and the rest of the reply continued in a new one, which reads as a reply that failed followed minutes later by an unexplained second reply. _append_task now skips the refused card and keeps the stream. _append_stream still rotates when real text is refused, which is the moment a rotation is worth its price, and that replacement stream now opens with a continuation marker so the two messages read as one answer.
…behind a stuck pane (kirodotdev#9141) A remote crew pane can sit on "loading pane" with nothing in gateway-launch.log to explain it. The pane journal added in kirodotdev#8274/kirodotdev#8813 covers the viewport's own warm paths, but the one warm-writer it does not own -- connectInstanceInto, shared by tab clicks and the auto-connect fan-out -- was silent, and three state changes that put a pane into that bucket left no line at all. connectInstanceInto now journals `warm` / `warm-declined` / `warm-failed` tagged `via=select|auto-connect`, in the same shape the viewport uses. A connect that answers `connected` with no port or no token leaves the OLD warm entry standing (a dead port), so the tab still renders an iframe and the user sees only "loading"; that is now the one `warm-declined` line it was missing. InstancesViewport journals three more moments: - `evict`: the K-cap LRU eviction, the only teardown the user never asked for. The next click re-warms the pane as a brand-new load, so an evicted pane that then fails reads, without this line, as a pane that was fine until it suddenly was not. - `watchdog-armed`: the 15s load watchdog starting. A pane that spins forever WITHOUT ever producing `load-timeout` is a pane whose watchdog never armed, and only the absence of this line says so. - `ready-unattributed`: an `mc-embedded-ready` from a loopback origin the parent cannot map to a warm pane -- the handshake dropped because the origin map has not caught up with a port change or an eviction. Journaled only for this message type and only for a loopback origin; the child sends it at most six times per load, so it cannot flood. No token material reaches the log: `paneLog` redacts secret-named fields and the new test pins that the token string never appears. Co-authored-by: Joe Guo <zejiangg@amazon.com>
This reverts commit ecf50ca (kirodotdev#9158). Fable 5.1 is listed ACTIVE on Bedrock in this account and its us. inference profile exists, but it is not actually invokable from these lanes: every first call is rejected at zero tokens. The three advisory lanes hid that behind --fallback-model, so they reported success while modelUsage named us.anthropic.claude-opus-4-8, and the GPT lanes' adjudication pass carries no fallback, so it failed outright and turned the required GPT 5.6 Review check red on four pull requests inside sixteen minutes. Listing a model as available was not evidence that the role may invoke it. Re-landing the bump needs a real InvokeModel check first, and the advisory lanes need it too: their green was the fallback, not Fable 5.1. The two corrected labels for the Opus-4.8-running claude-review.yml lane go back to saying Fable 5 with this revert. That is pre-existing wording, wrong but harmless, and it returns with the retry rather than holding up an urgent revert.
This reverts commit 259db39. Restore the Windows Kiro delegation contract: the macOS settings probe must not prevent an explicitly classified official Kiro backend from starting on a fresh Windows install. Kiro CLI remains a trusted runtime dependency; its internal sandbox is outside this Crew-side validation. Revert the whole PR for immediate recovery, including its UI-stream deadline and debug-only precise-memory-info switch. Remove the obsolete stalled-stream test together with its subsequent determinism changes. Validation on this branch: 140 backend tests passed, 5 platform skips; 23 Electron native-logging tests passed. Changed Python files pass isort and flake8; the staged diff passes the whitespace check.
…ile (kirodotdev#9279) The two writers of hooks.json — webhooks.locked() and the register_hook MCP tool (mcp_tools/control.py) — both opened their shared lock sidecar with open(lock_path, "w") and only then acquired the lock. "w" truncates AT OPEN, so the mutation happens before the lock that is supposed to guard it is held. On Windows the acquire routes to msvcrt.locking, and a truncating open of a lock file whose first byte another holder already locked raises a sharing violation instead of waiting — the contending acquirer fails before it ever reaches the lock, and the mutual exclusion the lock exists to provide silently does not happen. POSIX flock tolerates the truncate, which is why the defect is invisible on Linux. These two sites are ONE unit: both resolve to the SAME <data-home>/hooks.json.lock from two different modules, which is exactly the cross-process contention the defect needs — webhooks.locked() is documented public precisely because register_hook must share its lock discipline. Fixing one without the other would leave the pair half-locked and the Windows failure fully live. Both sites now touch(exist_ok=True) and open "r+" — writable, which msvcrt.locking requires, but not truncating. Same shape as work_ledger._open_lock (PR kirodotdev#9237), session_pid.py (PR kirodotdev#9250), and dashboard/handlers/mcp.py's _McpFileLock, which was written this way from the start. Truncation is the direct, platform-independent observable, so the tests seed the lock file, take and release the lock, and require the bytes to have survived: they fail on every platform if a truncating open comes back. Part of the kirodotdev#9248 sweep (one subsystem per PR, per the issue); the remaining subsystems get their own follow-up issues. Co-authored-by: dwu96 <dwu96@amazon.com> Co-authored-by: Kiro Crew <noreply@kiro.dev>
…irodotdev#9262) A runner that submits to a pool and returns the unwaited concurrent.futures.Future hands the work back, so the SDK holds the only reference to it for one instant. _close_quietly already asked that reference to cancel -- as hygiene -- and discarded the answer, while _undriven_result had already worded the record as work that 'may still be running ... and cannot stop'. So a run whose work provably never started was recorded as work a retry could overlap. _stop_returned_work now makes that stop deliberate, takes it before the verdict is worded, and answers three ways: True only for a concurrent.futures.Future whose cancel() returned True, which that class documents as the call never running; False when the work is already executing; None when no guarantee is available. An asyncio.Task can answer cancel() with True and finish anyway, so a truthy cancel is never read as a stop. Refs kirodotdev#7814 Co-authored-by: Kiro Crew <noreply@kiro.dev>
kirodotdev#9275) The 18 lock context managers in issue_radar's store.py and crew_store.py opened the lock sidecar with open(lock_path, "w") and only then handed the descriptor to platform_compat.file_lock(..., exclusive=True). "w" truncates at open, before the lock is held. On Windows the acquire routes to msvcrt.locking, and a truncating open of a lock file whose first byte another holder already locked raises a sharing violation instead of waiting, so the contending acquirer fails before it reaches the lock and the mutual exclusion silently does not happen. POSIX flock tolerates it, which is why it is invisible on Linux. Each site now ensures the parent dir, lock_path.touch(exist_ok=True), then open(lock_path, "r+") -- writable (msvcrt.locking needs it) but not truncating -- acquiring on that handle exactly as before. Same shape and rationale as work_ledger._open_lock (kirodotdev#9237) and session_pid.py (kirodotdev#9250). Refs kirodotdev#9248
POST …/{id}/import takes a host path to an audio file, transcribes it with
the gateway's own batch speech-to-text, and dispatches each line through the
same admission transaction live speech uses, so an imported recording is
persisted to the transcript and reaches the agents exactly as if spoken.
Every dispatched line requires the SESSION OBJECT admitted at import start —
a meeting id is a name, not an identity — so a meeting stopped, deleted, and
recreated under the same id mid-import gets a 410 (meeting_session_replaced)
instead of the old recording's lines.
A recording that would split into more than MAX_IMPORT_LINES lines is
refused whole with 413 (recording_too_long) rather than silently truncated:
a capped import that returns success while the recording's tail is missing
is data loss the user cannot see.
Original feature authored by Kai Mitsuzawa (kaizawa97). Rebase onto main,
locale conflict resolution, the session-identity fix, and the overflow
rejection by Kiro Crew.
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…irodotdev#7096) Apply reasoning effort overrides post-claim on warm-pool processes via provider.change_effort, keeping the warm-pool hit path active for slots carrying reasoning effort overrides. Fixes kirodotdev#6248
…on (kirodotdev#7249) cron_add/cron_update (MCP) accept a 'folder' argument (name or id; a missing name is created through POST /api/cron-folders, the dashboard's own endpoint, so the create shares the Schedule page's lock and in-memory list). 'kirocrew cron add' gains --folder (existing folders only). App manifests can set 'folder' on a cron entry (a NAME, resolved at registration; unresolved degrades to ungrouped with a warning and is re-applied on the next enable, so the assignment survives the disable/enable delete-and-recreate cycle). Shared read-only resolver in cron.py (load_cron_folders / lookup_cron_folder_id): cron_folders.json stays dashboard-owned; no non-dashboard surface writes it directly. Co-authored-by: Aurélien Andrey <aurand@amazon.fr>
…tant (kirodotdev#7265) `generate_token` mints `exp = now + min(LINK_WINDOW_SECS, session_ttl)` so a link can never authenticate past the session it grants. Both Slack surfaces that report that window still printed the unclamped constant, so `/kirocrew dashboard 1m` promised "Click within 5m" about a link that dies in one — a 5x overstatement of a window the user has to act inside. Report the clamped value at both reporters: the DM built in `allowlist.send_dashboard_link` and the ephemeral block built in `events._handle_dashboard`. For a full-length caller both strings are byte-identical to before, because the clamp only ever tightens. Original change authored by Leon (@leonlaiyc). Co-authored-by: Leon <276034861+leonlaiyc@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
Add a default-off Lexical composer path with atomic inline paste nodes, editor-neutral selection and focus controls, shared clipboard policy, lazy-load rollback, and focused regression coverage while preserving the textarea fallback.
…#8528) A pod isolates KIROCREW_HOME and KIRO_HOME but deliberately keeps the real HOME, and kiro-cli derives its MCP OAuth artifact directory from the spawned process's own $HOME. So a pod REUSED the operator's machine-level grants (a provider card read Connected from a grant minted on the real machine) and a grant minted INSIDE a pod outlived pod down as a real, durable credential. Both break test fidelity and the destroy-grants-after-each-smoke-test requirement. Two halves, one shared resolver: - mcp_grant.kiro_oauth_cache_dir() -- the single default all four callers (mint, status, disconnect, mcp_discovery's remote probe) reach with no explicit cache_dir -- now resolves through the new config.paths.kiro_oauth_cache_home(), which honours KIROCREW_OS_HOME and rejects the same unsafe targets as kiro_home()'s KIRO_HOME. This moves the pod gateway's own grant reads onto the pod's tree. - acp.client._apply_pod_home_remap(), applied identically at both spawn transports, remaps a pod-spawned kiro-cli child's HOME/USERPROFILE to that same directory. kiro-cli offers no env override for just its OAuth cache, so remapping the child's HOME is the only way to move its own WRITES. Gated on KIROCREW_POD=1 plus positive membership in ACP_BACKENDS_POD_HOME_REMAP -- its own set, not a reuse of the internal-sandbox one, because "carries its own OS sandbox" and "relocating HOME moves its credential store" are different questions (harness-parity H6). Sign-in still works: pod.runtime._seed_pod_os_home create-only stages the AGENT RUNTIME's own identity store -- the per-platform paths derived from identity_stores.store_mappings(), which is where the harness actually resolves its access token -- into the pod tree at boot. No host .aws/sso/cache contents are copied: an earlier revision staged those and it was deleted, so a pod's .aws/sso/cache starts EMPTY and holds only grants that pod itself mints. A pod therefore cannot be pre-authorized to a provider nobody consented to inside it, by construction rather than by a filename glob. A boot viability probe (agent_sdk.pod_child_probe) is the last gate before serving: it spawns the child through the same confinement production uses (env scrub, sandbox wrap, cgroup scope, rlimit preexec) and routes an unbootable child through _refuse, so a signed-out or dead child makes pod up fail loudly with a terminal exit code instead of serving a broken pod. AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE are deliberately NOT exported into the pod child. An earlier revision pinned both at the real home so a pod turn could still reach the operator's profiles; that made each name an alias for a path the sensitive-path keystone fences, and three review rounds each closed one spelling of the retrieval. The alias was deleted at its source instead -- security.py records the full reasoning, including why a name-based deny rule is defensible for the variables that hold a SECRET and not for the ones that hold a PATH. The real passwd home stays fenced: security.py's matchers run in the gateway process against its own Path.home(), and the remap only ever mutates the dict handed to the child spawn, never os.environ. The os-home tree nests under the pod home, so cleanup_home's existing pod down sweep reclaims a pod's grants with everything else.
…irodotdev#8546) The Crew Members page landed on an empty column every visit and forgot which member was open when the user navigated away and back. Now: - A visit that names no member opens the remembered one, else the first row in display order — never the empty column. - The last member opened is remembered per browser (mc-members-last-member, via safeStorage), so leaving for Sessions/Settings and returning, or a reload, lands on the same conversation. A remembered member that was deleted or renamed falls back to the first row without an error. - The open member rides the URL (?member=<name>): a reload keeps it and a link lands on one. Switching members REPLACES the entry — the page holds one history entry however many members are visited, so Back leaves it in one press, the Sessions sidebar's rule; only the below-md roster->thread step pushes. The value is the exact crew name, not the slug — the slug is lossy and would misroute Oncall to oncall. A link naming a member that is gone takes the same fallback and says so in a warn-toned status above the thread, leading with the swap ("Showing X — “Y” is no longer on the roster."; 13 catalogs + en-XA): the user asked for someone specific, and a silently mounted other thread is the misroute this page exists to prevent. The stand-in opened by that fallback is the page's choice, not the user's, so it does NOT become the memory — one dead link cannot overwrite the member the user had chosen. - Below md the page stays a two-level list->detail navigation: no ?member= IS the roster, so no auto-open there (same rule as SidePanelLayout's remembered tab). A stale ?member= returns to the roster with the same notice above the list. The header back pops the entry the roster pushed (no duplicate roster entry in history) and drops the param in place for a deep link, which has no roster behind it. Tests cover default, restore, stale fallback (memory: silent; URL: with notice on both sides of md, retired on the next open, memory preserved), URL-over-memory, click writes URL+memory, a driven-history walk over two members that leaves the page in one Back, and the below-md back semantics (the useNavigate mock records AND performs against the MemoryRouter).
…dotdev#8611) The crew member DM session's kirocrew-dashboard MCP entry carried only the session key (and, since kirodotdev#8837, the bound port). The server resolves *which gateway* to call from its data home, so on any install where KIROCREW_HOME is set -- a pod, a second profile -- it presented the member's identity to the default home's gateway, which has no such member slot and refused every verb as caller_unidentified while tools/list looked healthy. Carry the same home override every managed Crew server already carries (_managed_mcp_env), through the same helper so the two cannot drift. On a default install the helper returns {} and the emitted entry is unchanged. The per-member conversation index (kirodotdev#8612) no longer rides in this PR; it travels with its consumer, kirodotdev#8613, which is stacked on this branch.
…and offload async callers (kirodotdev#4767) (kirodotdev#8906) KiroCrewConfig.save() was an unlocked whole-document replace: its rename could land inside an update_config_locked holder's read-modify-write (CLI writer, boot refresh, second gateway), silently discarding the other writer's change -- every field in the document exposed. Two halves, landing together per the recorded failure modes of the first attempt (kirodotdev#4371): HALF 1 -- lock the writer. The sidecar acquire in update_config_locked is extracted into _config_write_lock() and save() now writes under it: the SAME <config>.lock sidecar, beside the resolved symlink target, so both writer families contend on one lock and the lifecycle stays one shared file (no new residue -- the Windows orphan concern from kirodotdev#4371). The write itself stays atomic + mode-preserving via write_config_atomically, and the overlay-subtraction content is untouched. save()'s docstring now states the staleness contract plainly: it publishes an in-memory snapshot, so it is ONLY for single-flow callers with no suspension between load and save (CLI one-shots, the boot default-config write); read-modify-write flows belong on update_config_locked. HALF 2 -- dashboard writers become DELTA read-modify-writes, off-loop. Every .save() call site was enumerated and classified. Coroutine callers no longer call save() at all: each persists the keys it owns as a delta mutate through update_config_locked -- read, re-check, and write inside ONE flock hold -- dispatched off the loop via run_config_write (the transaction shape its docstring prescribes) or, where the asyncio config lock is already held, via the module's cancellation-draining _drained_to_thread: - dashboard/handlers/updates.py api_log_level: delta on agent.log_level - dashboard/handlers/files.py workspace create/update/delete: delta on the one workspaces entry, with the state-dependent preconditions (name/dir collision, default-workspace and agent references) re-run against the document as read inside the lock and surfaced as 4xx conflicts. The copy_from tree is staged only after ALL path validation passes, installed inside the locked mutate (atomic os.replace, or a merge copy when the destination pre-exists), and every cleanup of a staged tree runs in a worker, never inline on the loop - dashboard/handlers/agents.py agents-sync + agent-create: delta on the agents entries (adds re-checked, package/aim prunes re-derived, name conflicts re-checked against the in-lock document), via _drained_to_thread under the held asyncio lock - dashboard/handlers/onboarding_import.py: both former manual-lock sites route through run_config_write; the state PUT is a delta on dashboard.import_onboarded; the unused local _get_config_lock wrapper removed - cli_server.py gateway-boot default-config write: asyncio.to_thread (file absent, nothing to lose, no loop-side writers yet) Sync CLI/wizard callers (cli_commands, cli_config, cli_cloud, cloud/wizard) stay on save(): single-flow, no suspension between load and save. crew_chat.py st.save()/self.save() are CrewStore, and mochi stats_service self.save() is its own persistence -- not KiroCrewConfig. Tests: red-before-green concurrency pair (save waits on a held sidecar; a locked writer landing inside save's critical window survives), sidecar lifecycle (only the one shared .lock beside config.json; symlinked config locks beside the target), in-lock precondition re-check suite for the workspace handlers (stale snapshot passes, fresh on-disk document refuses, unrelated entries survive the delta), staging-lifecycle tests (no destination mutation or staged residue on conflict or failed validation), a runtime off-loop probe on the log-level handler, and a self-tested AST ratchet forbidding any coroutine from calling KiroCrewConfig.save() inline. Closes kirodotdev#4767 Co-authored-by: Nick Bowers <nrb@amazon.com>
…rodotdev#8957) Text-to-speech needed an install before it could say anything: Piper wants a binary and a voice model on disk, Polly wants AWS credentials, so turning on auto-speak on a fresh machine produced silence and a settings panel with two paths that both start with homework. Add a third provider, `system`, and make it the default. It drives the speech engine the operating system already ships -- `say` on macOS, System.Speech through Windows PowerShell 5.1, `espeak-ng` on Linux when present -- so auto-speak works with nothing downloaded and nothing configured. Linux is the one platform where the engine can be absent; that is reported as unavailable with the package to install rather than failing silently at synthesis time. The engine is resolved through `trusted_system_bin`, not PATH, so a shim in an agent-writable directory cannot be handed model output, and the spoken text never reaches argv: `say` and `espeak-ng` read it on stdin, and the Windows path writes it to a temp file that a constant base64 -EncodedCommand script reads, so no quoting decision is ever made about model output. This provider deliberately does NOT route through `wrap_argv`. Windows has no sandbox backend, so a wrap fails closed on the one platform whose built-in engine users depend on, and there is nothing to confine in an OS component resolved from a directory the user cannot write. Piper keeps its wrap: it runs a downloaded model through a third-party binary. A test pins the distinction. Also fixes two things the new provider surfaced: `_resolve_piper_binary` looked for `~/piper-venv/bin/piper` on every platform, so a correct Windows install was never found (`Scripts\piper.exe`), and the module documented Piper as having no Windows build, which the `piper-tts` wheel has published since 1.3.0.
…start (kirodotdev#4093) (kirodotdev#8982) POST /api/chat/slots/{slot}/note replies 200 with visibleDeferred: true for a note held during a running turn, but both halves of the hold lived only in _ChatSlot._deferred_notes — a gateway restart between the 200 and the next turn silently voided the delivery promise. The hold now persists through the slot's own metadata line under one invariant: retirement is ROW-DERIVED. Every note carries an id; the flush stamps each delivered inject row with it (meta.noteId) and records rebind-dropped ids on the slot; the full save — reading the on-disk and live holds UNDER the history lock — retires exactly the entries whose rows are in the window it writes (or whose ids were dropped) and keeps everything else. Row and retirement land in one atomic file replace; the drop records are consumed only AFTER that write commits, since a dropped note's row never exists and the record is its only retirement path. - enqueue (durable-before-200): asyncio.to_thread + update_metadata_if, everything read inside the lock-time guard; the write MERGES by note id (union_deferred_notes), pins the posted note (ensure=) so a racing turn-end flush cannot yield a 200 with no durable copy, and pins the TARGET to the history key authorized at enqueue, re-verified under the store lock — a cron/workflow rebind in the persist window is refused (uniform not-found shape) instead of writing app content into a foreign transcript's metadata. The union never evicts: at the 2x ceiling the NEW note is refused (429). On EVERY failure branch, a rollback that finds the note already drained means a flush DELIVERED it — the 200 stands, because any error would make the caller re-post a line the user already saw. Other failures roll back BY IDENTITY and answer a retryable 503 (including an UNREADABLE record) - deferred notes are bounded at the ENQUEUE boundary (413 over 4000 chars); the durable copy is persisted VERBATIM, never truncated - both restore paths replay the hold up to the durable CEILING (2x the live cap — every durable entry is a 200-acknowledged note); restored notes are sanitized fail-closed (no session stamp -> dropped; over-bound content -> dropped; malformed context half dropped alone) - the flush never writes metadata; a crash between flush and save re-delivers on restore (at-least-once) - docs updated in the same commit (App Kit api-reference, session.md, endpoint docstring): do not re-post after a restart; 503 = retry, 413/429 = boundary refusals, 404 = ownership/rebind refusal _pending_context (the /context queue) stays memory-only: only the context halves embedded in held notes ride the same metadata shape naturally. Closes kirodotdev#4093 Co-authored-by: Nick Bowers <nrb@amazon.com>
…dev#9032) The knowledge store already models a namespace label on items (with an index), and the dashboard already lists, assigns and browse-filters by it. Search was the one verb the label never reached: HybridRetriever and local_knowledge_search scoped only by source_id. Add an optional namespace filter to the search seed legs (keyword + vector), mirroring the existing source_id scoping. namespace is a plain items.namespace column match -- a relevance/organisation filter, not a security boundary -- and composes with source_id. The graph leg stays unfiltered, as it is for source_id. Refs kirodotdev#8266 Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
…irodotdev#9070) * fix(portability): keep the export archive inside the crew directory `create_export_zip` walks `workspace`, `plan_memory` and `skills` with `rglob("*")` and skips an entry that `is_symlink()`, so that an export never carries bytes from outside the crew directory. That skip is blind to the escape that costs the most: `rglob` DESCENDS a directory link, and the file on the far side is an ordinary one — `is_symlink()` false — so it is written straight into the archive. On Windows the link is typically a junction, which `is_symlink` does not report at all, and a junction needs none of the privilege a directory symlink needs there. The two filters below the skip do not catch it either. `_is_excluded` and `is_sensitive_path` both read the LEXICAL path, which runs through the link's own name and therefore looks like ordinary crew content — so a sensitive file behind a link passes the sensitivity check that exists to stop exactly this. Measured on unpatched main with a real junction: `workspace/memory/linked/ not-ours.md` appeared in the export's namelist, sourced from outside the crew directory. The consequence class is content the user then hands to someone else, since an export archive is made to be moved off the host. Fixed by requiring the RESOLVED path to stay under the resolved crew directory — the repo's existing idiom (`apps/backend.py:651`). Resolving is what covers both shapes at once: it follows every reparse point on the way down and answers where the bytes actually live. `mc` is resolved once so a legitimately linked `$KIROCREW_HOME` does not reject the whole export. The `is_symlink()` skip is kept as-is; this adds a layer rather than replacing one. The import side of this module is untouched. `test_export_skips_symlinks` cannot cover this — it plants a link that IS the entry, and it `pytest.skip`s where symlinks cannot be created, i.e. on the platform where the junction spelling lives. The new tests use `conftest.make_dir_link` and need no privilege. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portability): archive the descriptor that was validated `create_export_zip` walked `workspace/`, `plan_memory/` and `skills/` with `rglob("*")`, which DESCENDS a directory link while the file on the far side is an ordinary one, so the walk's own `is_symlink()` skip never fired for it. Neither by-name filter below that skip is a containment test either: `_is_excluded` is a rule about the archive name, and `is_sensitive_path` asks whether a path is a PROTECTED location -- it does resolve links, so a linked `~/.aws` was caught -- never whether it is inside the crew directory. An ordinary file of the user's, reached through a link dropped in the workspace, was sensitive to none of them and went into an archive the user hands on. A resolve-and-compare would answer that for an instant only: `ZipFile.write` takes a NAME and opens it again, so the file that was checked and the file that is read are two separate lookups, and a running gateway gives agent tools write access to that workspace while an export can be triggered. `_open_verified` inverts the order -- open first, then ask the kernel where the open thing is via `pinned_fs.fd_real_path` -- and `_add_from_fd` streams the entry from that descriptor, so check and use address one object. A hardlink alias, which no path-based guard can see, is refused on the descriptor's link count; `force_zip64=True` keeps a source over `ZIP64_LIMIT` archiving as `ZipFile.write` used to. That settles what may be ARCHIVED. It does not settle what may be TOUCHED, and on Windows those are different questions: resolving a path whose component is a link to a UNC share IS an outbound SMB authentication, so a refusal computed afterwards has already paid the cost it exists to prevent. No pathname check fixes it -- every by-name check is a check-to-open window and an adversary that can plant the link chooses when. The names have to be held rather than inspected. The enumeration was the last place that still inspected them. Pinning the candidate's ancestors closed the swap race, but `rglob` had already descended a PRE-PLANTED junction, and `is_file()` and `is_sensitive_path()` had already resolved what it yielded, before any of it ran. Measured on this module, exporting a workspace holding one planted junction: 14 resolving calls went out through it -- `os.path.realpath` and `ntpath._getfinalpathname` -- while the export correctly archived nothing from behind it. Nothing being archived was never the question. So the walk descends and verifies in one motion instead. `_walk_pinned` pins a directory with `platform_compat.pin_directory` before listing it -- the handle omits `FILE_SHARE_DELETE`, so a held directory can be neither renamed nor deleted, nor can anything above it, and the open refuses to follow a reparse point, so a junction at the name fails there instead of being traversed -- then classifies each child from the listing Windows already returned, where the attributes and reparse tag arrive inline and cost no lookup THROUGH the child. A child directory is descended only by pinning it in turn, so the path the kernel walks to reach any component runs entirely through components already opened and verified, starting at the crew root itself. This is the pattern `aws_control/backend/storage.py` already uses to hold the path a sandboxed CLI writes through. The leaf goes through the new `platform_compat.open_file_no_reparse`, which opens a reparse point AS ITSELF rather than following it, so the refusal and the open are one operation; POSIX keeps the `O_NOFOLLOW` open it already had, named. `_pin_ancestors` and `_open_inside` are gone rather than kept alongside this: the walk holds the same chain, for the whole subtree instead of re-walking it per file, so keeping them would be two mechanisms doing one job. The walk starts at the RESOLVED root, since `$KIROCREW_HOME` may itself be a link and `pin_directory` refuses a reparse point at a name -- pinning the configured spelling would return an empty export on such a host. The exported set is unchanged: on a tree covering nesting, `EXCLUDE_DIRS`, `EXPORT_EXCLUDE`, `.pid` and `skills/auto`, the archive is byte-identical to the one the previous walk produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ty (kirodotdev#9071) An Issue Radar crew driven by an auto-nudge cycle reaches its ledger tools through the same _run_chat entry a human turn uses, which resolves identity as effective_session_key(slot) and hands it to the one shared publish_turn_identity writer. So a nudge turn and a direct turn on the same slot resolve the SAME session identity, and the strict gate lets the crew through -- the property kirodotdev#5905 assumed was broken. This is the positive twin of the existing subagent refusal test: a subagent resolves to its PARENT slot and is refused; an auto-nudge crew is the same principal re-entered on a timer and is admitted. Nothing pinned that the nudge fire path keeps routing through the shared writer, so a future refactor of _fire_dashboard_nudge could silently reintroduce the confusing refusal. These regression tests assert the property (same identity, one shared writer), not the call ordering. Also prunes the file from the black shrinking-baseline now that it is black-clean.
…ev#9149) The Connections gallery was merged on main but held behind an opt-in `connections_ui: true`, so a shipped build reached it only after a hand-edit of the instance's config.json. Flip that default: the gallery is now on for every install that never touched the flag, while `connections_ui: false` remains the escape hatch that empties the Services panel and puts chat back as the only authorize prompt. The key discovery is that holding GitHub back cost NO new mechanism. A per-provider launch gate already exists on both sides of the seam -- `launch_gate_passed` in src/kiro_crew/connections/registry.json, filtered by `get_visible_providers()` in registry.py and by `CONNECTION_PROVIDERS` in website/src/pages/connections/registry.ts -- and GitHub is already `launch_gate_passed: false` there because its OAuth-app registration under the kirodotdev org is outstanding. So the launch set is the six providers those filters already produce (Notion, Linear, Atlassian, Stripe, Vercel, GitLab), and the flip adds zero production lines for per-provider gating. `test_only_gated_launch_services_are_visible` already pins GitHub's exclusion on the backend; this change adds the matching assertion on the frontend's exported list. The predicate keeps the old "never guess from a sloppy value" rule and points it at the safe direction, which is now off. An absent key is ON, an exact `true` is ON, and everything else present -- including a hand-edited `"false"` -- is OFF, so an opt-out cannot silently fail to take. A config that has not been read yet (undefined, and a failed fetch) stays OFF: the opt-out lives in that config, so opening before reading it would flash a gallery the user disabled and fire its status queries on their behalf. That preserves today's loading behavior exactly rather than introducing a new transient. KNOWN DEPENDENCY, not fixed here: on a live gateway the flag never reaches the browser at all, because GET /api/config/kirocrew strips unknown top-level keys (`_masked_config_dict` pops `_extra_sections`). The default-on half of this change works regardless -- it is the absent-key path. The `connections_ui: false` escape hatch only becomes functional once fix/connections-ui-flag-roundtrip makes the key schema-known. Landing this before that fix therefore ships a gallery with no working opt-out, which is why the flip is merge-gated on a human re-test. Prose that asserted the feature was unreleased is updated in the same pass so the code no longer contradicts itself: the hook docstring, CapabilitiesPage's flag docstring, ConnectionsPage's `servicesEnabled` docstring and its empty-list comment, and McpTab's mint-engine comment. ConnectionsPage's parameter default stays `false` so a caller that omits the prop cannot open a gallery by omission. Tests: ConnectionsUiGate.test.ts is rewritten for the new default (absent key opens, explicit false closes, unread config closes, sloppy values close) and gains a launch-set block asserting GitHub is absent and every offered provider passed its gate. McpTab.test.tsx re-points its baseline mock at the shipped default, converts the chat-prose fallback case to an explicit `connections_ui: false`, and adds a case proving the in-place sign-in reaches a managed row with no flag key at all. No user-facing strings were added, so no i18n catalog changes.
…irodotdev#7820) (kirodotdev#9189) A GitHub issue-prefill link is redacted in chat as `[REDACTED: suspicious URL to github.com]`, which kirodotdev#7820 reports as a false positive. Two earlier rounds of this change waived the aggregate query-LENGTH signal for that shape -- first on the validated shape alone, then additionally pinned to this project's own tracker. Both are withdrawn. Neither was safe, and the difference between them was only who reads the payload. What reaches `redact_exfiltration_urls` is MODEL-AUTHORED text: injected content steers the model into emitting a prefill URL whose `body` carries percent-encoded private context; the waiver skips the length check; the link renders as the familiar "file an issue" affordance; the user submits it; and the issue is PUBLIC, so the attacker reads it. Pinning the repository does not close that -- this project's tracker is world-readable by design. A URL's shape says nothing about who authored it, and a marker placed IN the text travels in the channel the injection controls. So no validation performed on a model-emitted URL can establish provenance, and that is a property of the class rather than of any one spelling of the check. - Remove the frontend waiver: `isPrefilledIssueUrl` and the `EXFIL_ISSUE_*` constants in `website/src/utils/sanitize.ts` (they landed for kirodotdev#7824). The aggregate-length signal there is unconditional again. This is the production change in this commit. - The BACKEND needed no deletion after rebasing onto kirodotdev#9183, which split `security.py` into a package: `security/exfil.py` was re-derived from a base that never carried the waiver, so its length gate is already unconditional. What lands there is the reason it must stay that way, recorded at the gate so the next person with a long legitimate URL narrows the heuristic for every host instead of carving out a shape. - Deliver kirodotdev#7820's feature through the TRUSTED STRUCTURED CHANNEL the product already ships, rather than through the redactor. `diagnostics._issue_url` assembles the prefill query from structured fields (version, channel, what-happened, context, install) with `urlencode`; it travels as `BundleResult.github_issue_url` on a JSON response no redactor scans; and `ReportProblemModal` / `ReportProblemCard` render their own `<a href={result.github_issue_url}>`. Nothing on that path is text a model wrote, so nothing on it needs a waiver. `diagnostics.py` already documented this split, with `terminal_issue_url` as the bounded variant for paths that DO get relayed through prose. - Repoint the parity guard from pinning a carve-out to pinning its ABSENCE on both halves, reading each enforcing source at run time. The backend sweep runs over the whole `security/` package rather than one module, because a waiver reintroduced in any of the nine modules or in the facade would be just as live -- the single-file path this replaces was left pointing at a deleted file by kirodotdev#9183, which is exactly the failure mode. The length comparison is asserted at its own site on each side, so a waiver spelled with fresh identifiers still fails, and the structured seam is asserted present because deleting the waiver is only free while that seam exists. Red-first against the pre-fix head: a prefill link to this project's own tracker with an encoded payload rendered as a live link. The withdrawn allow-list held GitHub's classic prefill keys, while the trusted builder uses issue-FORM keys, which that list never contained -- so the waiver only ever served links the model typed, the exfiltration surface and nothing else. Refs, not Closes: kirodotdev#7820 asks for a narrower heuristic and also reports `monitorportal.amazon.com`. Both are still redacted in chat, and a test pins them as getting the same verdict. Narrowing aggregate query length is a per-host security-ceiling decision on its own merits, and a per-shape escape hatch is not a substitute for it. Refs kirodotdev#7820
) The image viewer opens with a SET -- dispatchLightbox() collects every img[data-lightbox-image] in the nearest [data-image-scope] -- but the only way through it was ArrowLeft/ArrowRight. On a phone there is no keyboard, so every image after the first was unreachable and nothing on screen said the set existed. A horizontal drag now pages through the set. The one-finger overlay gesture locks an AXIS once it crosses the slop, then either pulls down to dismiss (unchanged) or sideways to page, so a diagonal drag can no longer do both. Four product decisions, each matching an existing decision in the same component rather than inventing one: * Commit on DISTANCE only, like the dismiss path, and at a shorter threshold (64px vs 96px): paging is reversible, a dismiss destroys the viewing context, so paging can commit on less travel. * Rubber-band at index 0 and at the last image, reusing the divisor the upward dismiss drag already used -- a silent no-op at the ends reads as a broken gesture. * Show position in the set ("2 of 4") on an aria-live pill. Without an affordance the gesture is invisible, and this is also the first thing to announce the image change to a screen reader. * Own the gesture only at fit zoom, exactly as dismiss does; above fit a horizontal drag is already pan, handled on the <img>. Owning the horizontal axis here is safe because the app-wide nav drawer yields to any element whose computed touch-action is none, which the overlay already set to take page zoom. The keyboard path, the pinch, the double-tap and the desktop click-to-close are untouched. Closes kirodotdev#5799
…kirodotdev#9663) Five full backend runs (92,261 tests each) and five full frontend runs on a macOS host, from inside a Kiro Crew agent session, with an audit hook that attributes every host write, spawn, connect and kill to the test that made it and a per-test census of duration, RSS, threads and descriptors. Zero backend flakes in 5 x 92k; two frontend flakes; 227 deterministic failures and four 120-second hangs that every Linux run had been blind to; and host writes the earlier audits could not see. Every fix carries a test that goes red when it is reverted, and the classes are written into testing-conventions "Side effects" so they cannot recur unnoticed. Host state the suite touched, and what pins it now: - monkeypatch.undo() in a test body unwound the whole floor (KIROCREW_HOME, KIROCREW_PROFILE, KIROCREW_TELEMETRY, the service guard). kirodotdev#9614 moved the pins onto the private _floor_monkeypatch; this adds the missing half: the monkeypatch fixture is re-declared at the rootdir to depend on it, so the floor is set up first and torn down last by construction rather than by autouse ordering. test/conftest.py's autouse pins ride the same instance. - A metric emitted at IMPORT (ToolHookResult.allow() as a default argument) built the recorder from the operator's real config before any pin existed and exported to the real ~/.kiro/crew/metrics every minute for the life of each worker. KIROCREW_TELEMETRY=0 is now pinned for the whole process in pytest_configure, and every module whose collection builds the recorder is recorded and asserted empty. - The mc-maint sweep resolved config_dir() when it RAN, after the queuing test's pin was gone: 60+ mkdirs of the real home per run, plus a rmtree and a marker aimed at it. cleanup_stale_sandbox_profiles takes the home its caller resolved, with no default that could reopen the on-pool resolution; SessionManager resolves it once at construction. - Five tests dropped the override to test the default home and let config_dir() create the real ~/.kiro/crew, and a resolver-only stand-in still rewrote the real ~/.kirocrew.breadcrumb beside it. The teardown hook reads paths._resolved_home before any fixture unwinds and fails the test after the floor has torn down; the floor wraps the breadcrumb writer to refuse while Path.home() is the operator's; each test fakes the host home instead of only the resolver. A class- or module-scoped setup runs outside the floor and now pins what it resolves itself. - Fifteen __pycache__ trees per run from imports by path: sys.pycache_prefix and PYTHONPYCACHEPREFIX point at ~/.cache/kirocrew/pycache. - KIROCREW_PROFILE=amazon inherited from the agent session failed 150+ builtin-app tests closed because the pin lived in test/conftest.py, which those testpaths never see. It is a rootdir floor now. macOS, where the Linux-green suite had 227 failures and four hangs: - _kill_session closed the PTY master before ending the shell; on macOS/BSD close() waits for the reader's outstanding read, so four terminal tests hit the 120 s timeout on every run. The process tree is hung up (SIGHUP, the one signal an interactive shell honours) and terminated before the master is closed; the tests run in under a second. - Linux-shaped tests, one rule each: the frame recorder's 75 logic tests pin its Linux-only ACL gate open; O_TMPFILE prompt tests skip on the production capability flag; /dev/fd/N is compared by open+fstat identity; unlink() on a directory is EPERM here, so _discard_untracked_files recognises it; a simulated O_BINARY bit is derived from the live O_* constants (landed in kirodotdev#9632); a --copies venv cannot relocate a non-framework shared-lib CPython; the darwin workspace binding's pass_fds entry is pinned away in the snapshot-descriptor test; a pinned sys.platform needs a pinned start id; and codex-review.yml's two sed expressions become one 1,8{} block, because BSD sed opens a numeric range only on its exact line. - test_handlers_system_macos_paths reached 8.8.8.8:80 through _local_ip(); stubbed at the seam the Linux siblings already use. Frontend: AutoNudgePopover's watch list is three promise hops past act(render), so the positive test flaked and every negative was vacuous; the block now waits for the fetch to be answered. App's startup-video gate read an interruption latch that an effect sets a commit after the changelog shows, while changelogDecided lands a microtask later; the gate reads the live conditions in the same commit too.
…gh sendTurn; drop api.steerChat (kirodotdev#9593) Chat-core RFC P2, batch C of kirodotdev#9570. The last two hand-rolled dashboard senders call the transport's `sendTurn` and branch on its receipt, so the deadline and the six-way classification are the core's: - `hooks/useSceneInteraction.tsx` (scene agent popover composer): the running/idle fork -- `api.steerChat` mid-turn, `api.sendChat` plus a local `readSendReceipt` otherwise -- becomes ONE call, `sendTurn({ message, slot, steer: !!src?.running })`; `steer` is a flag of the same endpoint (kirodotdev#8689), not a different receipt shape. refused -> report on the composer, hand the payload back transport-error -> report on the composer, hand the payload back unknown -> composer back to idle, nothing claimed (as before) response-late -> report on the composer with the core's delivery-unconfirmed copy, hand the payload back (NEW: the old path awaited without bound; nothing proves the gateway saw the request, so silence would discard the text -- same policy as ChatPage) dispatched/queued -> 'sent' tick + mini-thread echo, as before A refused steer used to surface through the outer catch (steerChat parsed via `j` and threw on a non-2xx); it is now a `refused` receipt with the same user-visible result, and a `{ok:false}` steer inside a 200 -- which `j` passed through silently -- is now reported too. - `App.tsx` feature-request send: `refused` -> error row with the server's reason; `transport-error` -> error row; `unknown` and `response-late` stay silent (the request was or may have been accepted; the row is the pill's only signal). - The composer's failed-send line migrates from a hand-written `role="status"` div to `ErrorNotice variant="inline"` (same string), `askAgent` off with a No hand-off comment naming the restored draft (errors-use-error-notice: this diff touches its condition). - `api.steerChat` had no remaining caller and is deleted; `sendChat`'s comment now describes the steer flag it carries for the transport, and `sendChat` runs `checkSessionExpired` on its raw response so a 403 auth challenge on any transport send keeps the silent-refresh / banner recovery the `j`-parsed steer helper had. Tests: the scene coverage file mocks the transport's wire (`api.sendChat`) so the REAL classifier runs; it asserts `steer` is forwarded from the running state, adds a refused-steer case and a deadline (`response-late`) case. The feature-request test pins that the send rides the transport (deadline signal on the wire). Dead `steerChat` stubs removed from four test files. RFC 4.2 row split into the three kirodotdev#9570 batches. Refs kirodotdev#9570
… view) (kirodotdev#8947) Selecting text in a Crew Members thread (or a split-view pane) offered Copy only: AssistantMessage's selection toolbar draws the actions its host hands it, and ChatPage was the only host passing onQuote/onAsk — quote's FlyingQuote-into-composer and ask's open-/side-and-seed were ChatPage-local. Extract both into one chat-core seam, chat-core/composer/selectionActions (useSelectionQuoteAsk + quoteIntoDraft + seedSideChat), and make ChatPage and ChatPane both consume it — no second implementation. Hosts differ only in what they own: the composer draft, and how a Side Chat surface for a slot is brought on screen (openSideChat). Quote is always offered; Ask exactly when the host provides an opener (capability by omission, like onOpenFull). - app-sdk: ChatMessageList/MessageRenderContext gain onQuote/onAsk; the default assistant row passes them through. - ChatPane: wires the seam to its own composer (FlyingQuote lands in the pane) and takes an openSideChat prop. - Split view: SessionGridView threads ChatPage's opener, which re-binds the activity panel to the pane's slot (switchSlot) before opening the side tab. - Members page: the detail drawer gains a Side Chat view bound to the member's slot (reuses existing "Side Chat"/"Details" strings — no new copy). - SideChat: the side-seed event now names its slot; a SideChat bound to another slot ignores it, and the seed poll waits for the named slot's composer (data-side-chat-slot) rather than any Side Chat's. - ChatPage: only change is consuming the hook (behaviour unchanged) plus the one-prop grid wiring. Tests (mutation-oriented pins): selectionActions unit tests, ChatPane selection-actions test, MembersPage Side Chat drawer test, SideChat slot-aware seed, ChatPage Ask seed regression. Capture harness website/scripts/capture-members-selection-quote-ask.mjs with three asserted frames under temp-screenshots/members-selection-quote-ask/.
…irodotdev#9668) Guard (A) in create_session fails closed when a requested agent is absent from the session's advertised modes, so the session never silently runs the backend's own default in its place. But it only ever inspects the agent that set_mode will activate -- an explicit override, or on KAS the injected default. On kiro-cli, mode_agent is None for an ordinary session, so the agent actually chosen by the --agent spawn flag reaches no check at all. load_session has the same shape: its check reads `agent`, and its only caller passes `agent=agent or None`. That agent's spec can fail to load silently. A live probe of kiro-cli settles what the session/new response says in each case: a spec that loads is reported as `currentModeId` AND listed in `availableModes`, while a spec the backend refuses is absent from the list and `currentModeId` names kiro-cli's own default instead. The refusal is silent -- no JSON-RPC error, a normal sessionId, the process runs on. Nothing downstream notices: the set_mode response is never read back, currentModeId is never re-compared, and mcp_session_report only logs (its docstring forbids reading a missing report as "not mounted"). The user-visible result is a session with none of Kiro Crew's control plane, while the global provider mcp.json -- which Kiro Crew pins off only on specs it writes itself -- stays merged. So a third-party MCP server declared there keeps working while every Kiro Crew tool the injected prompt names answers "A tool with the name '<tool>' does not exist". For learn_add that reads to the user as the agent reporting its memory is unavailable and the lesson was not saved, when in fact the whole server is absent. Guard (A2) applies the same check to the spawn-flag agent on both session-start paths, before set_mode, terminating the session it created so the backend does not hold an orphan. `currentModeId` is read as proof in both directions: naming the spawn agent admits, naming anything else fails closed even when no advertised list came back. The compatibility escape is narrow -- only a response that names NO current mode falls back to the advertised list -- so older kiro-cli and the offline fake backend are unaffected. Scoped to the backend whose argv carries --agent, by a positive identity test (harness-parity H5). Six regression tests, mutation-verified: neutering the guard fails the three fail-closed cases, and admitting a current-mode mismatch fails exactly the substituted-current-mode test. Co-authored-by: Zejiang Guo <zejiangg@amazon.com>
Video and unrecognized formats were rejected before download, so an inbound file the agent could have acted on arrived as a note instead of bytes. Keep them as byte-identical temporary files under a 50 MB cap, hand the agent the local path plus the original name, type and size, and reuse the existing per-turn cleanup ownership. Opaque bytes are never parsed or executed automatically. A sender picks filename and mimetype independently, so an opaque file could arrive named "photo.png" while declaring application/octet-stream. The ACP encoder types a prompt path by suffix alone, so such a path would reach the image sink without the content-signature check the IMAGE branch enforces -- emitted as image/png on the strength of a sender-supplied name. An inlineable image suffix is therefore stripped from the temporary path before ownership transfers, mirroring the retype the IMAGE branch already performs, and a rename that fails drops the attachment rather than emitting the original path. A contract test pins the suffix set against the encoder's own table. Co-authored-by: Jindong Hu <hjindong@amazon.com>
…chooser (kirodotdev#6737) A neutral, bookmarkable session list at /sessions inside the main dashboard shell (nav rail + top bar stay available). Rows navigate to the full /chat/<key> experience; unlike bare /chat the page never auto-selects or auto-creates a session. Full-bleed rows on phone; schedule-style inset with bordered group cards on desktop. Filter chips (All / Unread / status tags in use), search, recency groups (Today / Yesterday / Earlier), New session. Original work by Helena Stafford (@helenastafford). Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
…lt-free (kirodotdev#9673) Two reds on `Backend Tests (Windows) (4)`. Only one is a Windows fact. `work_ledger._read_json_record` reads with a bare `read_text`, and a STRICT read there is lock-free across writers: `_refuse_if_worker_holds_open_item` reads the prior item under the WORKER's binding lock while that item's own conductor may be replacing it under a different item lock. On Windows a read of a file another handle holds open for write raises `PermissionError`, so one correct concurrent writer turns a strict read into a bare `OSError`. The guard catches only `WorkLedgerError`, so it escapes `apply_conductor_action`, and the dashboard route maps `OSError` to a 503 `ledger_write_failed` "try again" -- telling a conductor to retry a binding that is legitimately taken until the item closes. A permanent refusal reported as transient is a product defect, so the fix is in the store: route the read through `atomic_write.read_bytes_with_retry`, the read-side twin of `replace_with_retry` that `members.py` already uses for this exact window. A `PermissionError` that outlives the bounded retry still escapes, so the fail-closed contract holds; on POSIX the helper re-raises on the first attempt. The second red is not Windows-specific at all. `test_reconcile_and_publish._finding` mints each PR url from `abs(hash(fp)) % 9000 + 1`. `hash()` on a str is salted per interpreter process, so roughly one process in 9000 gives two fingerprints the same url; the sweep then skips both and the test reads `assert [] == ['aa']`. Each xdist shard is its own process drawing its own salt, which is the whole reason it looks like one shard is special. Reproduced on Linux with `PYTHONHASHSEED=17494`, where `aa` and `bb` both map to `pull/1344`. A sha256 digest is salt-free, so the mapping is identical in every process. Tests: a new `test_a_contended_item_read_still_refuses_with_already_bound` drives the guard through `windows_sim.read_sharing_violation` and asserts the refusal stays `already_bound`; reverting only the retry line makes it fail with the injected `PermissionError`. The four existing fail-closed tests move their fault from `Path.read_text` to `Path.read_bytes` through one shared `_fault_record_read` helper, since that is the call the record reader makes; their assertions are unchanged and still pass on POSIX, where the helper does not retry.
…9739) glab api pipes JSON write bodies over stdin with --input - but sets no Content-Type header. Strict self-hosted GitLab instances reject such a request with HTTP 415 Unsupported Media Type, so Issue Radar write operations (label add/remove, state changes) fail there. Add --header "Content-Type: application/json" to the glab argv whenever a body is present. Read paths without a body are unchanged, and the GitHub transport is untouched. Closes kirodotdev#9723
…v#9500) A security fix that makes the proof of concept stop reproducing has done half a job. The other half is the half a security change actually gets rejected for: the deny fence grows a rule that also refuses `gh pr view --json`, or a guard is written against one host's spelling of a path, and the tool the fix protected becomes one nobody can use. This adds the corpus of legitimate operations that must survive a fix, and the gate that re-checks them. - ledger.py gains a fifth table, `golden_paths`, behind an additive migration: every DDL statement is CREATE ... IF NOT EXISTS, so an existing v1 database gains the table on the same pass and opens, and the version bump is the last write -- an interrupted upgrade leaves the version behind the tables, which retries, rather than ahead of them. The bump is an UPDATE (the version row is a singleton) guarded by `<`, so a database written by a newer checkout is left alone rather than walked backwards. Every existing function signature is unchanged. A corpus import is ONE transaction, so an interrupted one commits nothing: a subset is a fence nobody chose and a silent one, since the rows that never landed are invisible. That is why the import does not call `add_golden_path` in a loop -- that function owns a transaction per row -- and why the INSERT is split into a helper owning none. `source_finding_id` rejects a bool explicitly, because `bool` subclasses `int` and SQLite stores `true` as 1, which would silently attribute a golden path to finding 1. Every text field that is present must BE a JSON string: a list, number or null is refused, never str()-coerced or defaulted, because "['gh', 'pr']" stored as a golden path makes the gate classify a command nobody runs, and "platform": null widened to "any" would gate every host with a row meant for one. Rows carry the same propose/approve split as lessons, and approval is write-once so the reviewer who admitted an operation stays on the record. The table is the EDITING SURFACE, not the gate: an audit proposes a row, a human approves it, and it reaches the gate when it lands in the committed export through review. The CLI has three verbs -- propose, approve, import -- mirroring the propose/approve pair the RFC gives lessons and the committed-file relationship it gives rules-of-engagement.json; no verb records one active, attributed row from the command line, since that would be the approval without its write-once record. There is deliberately NO CLI verb that retires a golden path: the RFC gates deactivation exactly like activation, and the flip stays the same human row edit rules and lessons already use. Nothing reads the table as a gate, so the ledger ships no host-filtered reader for it. - verify_fix.py is the two-step gate. Step 1 runs verify_finding.py by path -- never a copy of its judgement -- and requires `rejected`. Step 2 classifies every `shell` row of the COMMITTED golden-paths.json with the tool gate's WHOLE deny composite -- the four checks hooks.on_tool_call applies to a shell command, in its order (path fence, sensitive-command tier, exfiltration auditor, deny-rule catalog), the same tier table scripts/deny_diff.py declares -- and names the tier that refused. Measuring the catalog alone would go green on a fix that tightened any of the other three. A tree missing a tier is coverage lost and reported unavailable, not skipped. A corpus file that is absent, does not load, or holds no row is 20, not a pass: zero rows checked would fold to holds by construction, which is the vacuous green a broken installation would report on every fix -- and the refusal scripts/deny_diff.py already makes of an empty corpus. The gate reads the committed file and NOT the ledger's table, as the RFC rules ("both gates read the file and nothing else"). The ledger is per-host and invisible to CI, so a gate that read it could not be enforced where it is declared blocking; and the table is mutable by anything that can reach the database, so a gate that read it could be steered by a row flip -- retire the one row the fix broke, and the gate goes green with the fix unchanged. The file is read from beside the skill, not from the worktree under review, so the change being judged cannot rewrite the gate that judges it. And no ARGUMENT can shrink the corpus: there is no flag naming another corpus file or another platform -- the file is the one beside the script and the platform is the host's own -- because a caller who could name either could name a smaller check. Rows are named by their position in the file. The file is validated by ledger.py's own loader, the same check import-golden-paths applies, so the gate and the import cannot judge one row by two rules -- and the loader accepts ONE shape, the object the export is written in, so a second shape nothing writes is not a corpus. The sibling ships in this same bundle, so an absent one is a broken installation rather than a state to accommodate: still exit 20, because a proof that was not re-run says nothing about the fix, and checked here rather than left to the spawn, since the interpreter exists and a nonexistent script argument makes the child exit 2 -- the verifier's own code for "I rejected your input". The sibling `ledger.py` is the same class: one that is absent or will not import is exit 20 through the same JSON payload, never a traceback and exit 1, which is no verdict in the contract. One printer, `emit()`, owns the payload shape and the stderr lines for every path out of `main()`. Step 2 re-classifies every `shell` golden path in the export whose platform matches the host against the FIXED code; none may be refused. NOTHING out of the corpus is executed, and that rule decides the design. A row is text in a JSON file, and the same text once imported sits in a table whose CLI ledger.py states plainly is not an authentication boundary -- `--approved-by` is an unverified caller assertion -- so a row is untrusted text written by whoever could edit the file or reach the database. Every other consumer only READS such a row; running one as argv would turn a file edit into command execution with the operator's access, which no containment fixes because the escalation is in treating the row as permission. So `shell` rows are CHECKED (classified, never run) and `flow`/`cron` rows are RECORDED and reported for a human to exercise: an MCP tool has no command line, a chat turn needs a gateway, and firing a schedule has effects no deadline bounds. Parsing a schedule here would settle nothing either -- a parse in this process never consults the fixed code, so its answer is a constant no fix can change -- so the corpus's own well-formedness is asserted in the test suite, at review time, where a corpus-authoring mistake belongs. Exit codes are the interface -- 0 holds, 10 the proof still reproduces, 30 a golden path is refused, 20 unverifiable -- and precedence is 10 > 30 > 20 > 0. The load-bearing property is the last one: 0 is unreachable while any check this script OWNS went unsettled, because an unclassified golden path is not a permitted one. A human row never moves the exit code, because it was never claimed as this script's check. The fence is read in a CHILD process with the worktree's own `src` leading PYTHONPATH, because the point is to classify against the FIXED code: an in-process import would bind whatever copy of the package the interpreter already loaded, which for a test runner inside the repository is the unfixed one. A leading PYTHONPATH entry is a preference, not a guarantee -- a checkout without kiro_crew/security would import the INSTALLED package -- so the probe proves where kiro_crew.security came from and reports the composite unavailable (exit 20) unless that file resolves beneath the worktree's src. A fence borrowed from somewhere else is not a fence that agreed. The probe's payload is shape-checked before any field is read off it, so a malformed answer is 20 rather than a traceback exiting outside the contract. Two containment details are deliberate. The ledger path is resolved ONCE and passed to the verifier child explicitly -- the only thing this script wants the ledger for: the child's HOME is the worktree and the ledger's default path is HOME-relative, so a child left to its own default would read a different database. And the deadline kills the direct child only, matching verify_finding: a process-tree kill has no portable spelling in the standard library, and a verifier whose own teardown works on one host would be the platform lock-in this corpus exists to catch. `--worktree` must be a git checkout, which is the blast-radius bound the re-run proof depends on. - golden-paths.json is the committed export the gate reads: 37 rows, each with a reason. scripts/deny_diff.py's usage line and the fixture's _comment name this file and the adopting change that retires the fixture (kirodotdev#9678); wiring the gate into the fixer lane is kirodotdev#9707. The rows cover read-only gh, git read/write/publish, the sanctioned test invocation in both host spellings, the lint and type gates, the publish preflight, the monitor-loop MCP tools, chat-start-one-turn via the offline stub-ACP harness, and three shipped cron shapes. Two rows are the read-only gh shapes that were false-positive refused while this was being designed: two `gh pr view --json` calls joined by `;`, and a PR read combined with a run list. Tests - test_security_conductor_verify_fix.py (73): the verdict ladder end to end, driven as a subprocess with siblings staged beside it and the corpus written one directory up, so the absent-verifier and absent-corpus cases are testable at all; that the gate reads the committed file and not the ledger, from both sides (a row only in the ledger does not gate; a row retired in the ledger still does) and from the worktree (a checkout shipping an emptier corpus is still judged by the skill's own; no flag names another corpus or the other host); that a worktree without the package does not borrow the installed fence, and that a fence under a symlinked src is still the worktree's; that no corpus row is executed, proved by a witness file each row would create if it ran, plus a structural guard that the golden-path walk carries no spawn at all and only two call sites spawn anything; the platform filter in both directions; seven malformed probe payloads each pinned to the specific guard it trips; the ledger path handed to the child; and one class that asks the REAL `is_denied` about every shipped shell row, which turns the corpus into a live regression gate at PR time. - test_security_conductor_ledger.py: the new table's columns, its identity (kind + command + platform, each part proved load-bearing), write-once approval, retirement keeping its approver, corpus import that is all-or-nothing on both validation and interruption, a racing identity collision counted as skipped rather than fatal, and the v1 migration. - Smoke-tested end to end against the real sibling verifier and the real deny fence, with no stubs: a finding whose PoC no longer reproduces plus a permitted `gh pr view --json; gh run list` row returns 0 and reports the flow and cron rows for a human, and adding one genuinely refused row returns 30 naming the rule that ate it. - Red-before proven by reverting forty guards one at a time, including the single accepted corpus shape, the string-typed text fields, the file-not-ledger read, the skill's-own-copy read, the absence of a corpus or platform flag, the fence-provenance check and its prefix boundary, the absence of a one-step add verb, the unreadable-, unloadable- and empty-corpus verdicts, the four-tier composite, the missing-tier verdict, the absence of a retirement verb, both exit-30 and exit-20 paths, the human-corpus separation, the structural no-spawn guard, the platform filter, the migration bump, the single import transaction, the bool rejection, and the live-fence gate; every one turns its test red. Refs kirodotdev#9195, kirodotdev#9332 Co-authored-by: Joe Guo <zejiangg@amazon.com>
…t-model setting (kirodotdev#9099) A partition that does not serve the `auto` sentinel rejects every session that starts on it with "Your account does not have access to model 'auto'". The generic entitlement message then advised "set agent.model to 'auto'", which is the value that just failed, and the row offered Continue, which replays the identical rejection. Reported from a region whose account is served only gpt/deepseek/minimax/glm/qwen models (0.4.1). - `_format_acp_error`: when the rejected id IS `auto`, say the automatic choice is not offered in this region and route the user to the session model picker and to Settings -> Chat -> Default Model (plus any per-agent pin). The non-auto branch keeps its wording. - `chat_runner`: the terminal error row carries `meta.kind = model_unentitled` with `rejected_model` and `advertised`, decided from the exception's tags (same evidence the formatter used), never from the prose. - `ErrorCard`: a `model_unentitled` row offers "Choose a model" (opens the composer's model picker) and "Change default model" (deep link to the chat.default-model setting) instead of Continue. Panes without a picker render the row as prose. Strings added to en.json + pseudolocale. No automatic model substitution: the user picks, and the wire is unchanged. Tests: new test_model_unentitled_meta.py (6), formatter wording test (mutation-verified: fails on the pre-change formatter), ErrorCard tests (3). Co-authored-by: Zejiang Guo <zejiangg@amazon.com>
kirodotdev#9751) `_capped_spill` re-imported `threading` inside the function, which the module already imports unconditionally at line 24. The inner statement re-bound the same `sys.modules` singleton, so removing it changes nothing at runtime and removes the false hint that this function needs its own import. No test patches `threading` on this module, so the local-to-global name promotion is unobservable. Two provably-equivalent structural edits alongside it: - `_await_inflight_spawn`'s tail tested `cur is not None` twice and re-read `.starting` to derive what the preceding arm's negation already proved. It now returns early on `cur is None`, so each later test reads once. `.starting` is written only at construction, and both reads were inside the same `_lock` hold, so the single read cannot differ. - `_start_app_backend_body` guarded `manifest.backend` against None when computing `backend_type`, having already dereferenced `manifest.backend.entryPoint` unguarded 326 lines earlier in the same function. `AppManifest.backend` is a non-Optional `BackendConfig` with no `__bool__`, so the else arm was unreachable. The rest is comment hygiene in the same file, per AGENTS.md. A block above `_PID_ANCESTRY_MAX_DEPTH` described "consecutive alive polls" that no constant holds and `_survived_spawn` does not count; it is gone, and the surviving block above those constants now states when a healthy backend actually pays the full window (only where listener ownership cannot be proven) instead of claiming it never does. Five docstring passages narrated superseded code and now state current behaviour in present tense, keeping the rationale. Two named the wrong thing: `_wait_for_pids` cited `kill_pid` where the caller uses `kill_pid_pinned`, and `_start_backends_concurrently` claimed boot costs one grace window regardless of app count, which ignores the `_BOOT_SPAWN_MAX_WORKERS` cap. `comment-history-baseline.json` drops this file from 7 markers to 6, which `check_comment_history.py --write-baseline` records; the gate is a downward ratchet and refuses a diff that retires a marker without lowering it. The six remaining hits are present-tense statements about runtime state ("a record that is no longer the tracked one"), not change history, so they stay listed.
Adds the umbrella spec the two existing monitoring specs sit under, so a new monitored kind has a contract to implement rather than an existing watch to copy. Seven layers, one owner each, and six of them never learn what is being watched. The consequential contracts: - The probe signature is PLURAL from day one. A per-subject interface cannot be batched later without changing every implementation and every caller, and the difference is roughly 150 process invocations against one query for fifty subjects. A probe that cannot batch loops internally so the caller never encodes the difference. - An observation is a NAMED entry with a severity and a reset scope, never a bare fingerprint. A hash cannot be deduplicated per condition, coalesced with a sibling, or re-asserted, because nothing can tell whether two hashes describe the same condition. - The decision layer is a pure function whose clock arrives as a value, and it is level-triggered. Edge triggering loses any condition that stayed true across a wake that did not happen. The re-alert window makes that affordable and the budget makes it safe: a notification pipeline aimed at humans needs no token budget because a paged human self-limits. - Persisted state holds delivery bookkeeping only, versioned with a migration per bump. Subject state belongs in the disposable evidence file. - An out-of-session driver is a detector, never a reactor. A cron turn has no owning slot, so its tool calls hit deny-by-default and time out while the job still records healthy. Also records the rules that must be enforced by code rather than by prose, including one the two current implementations already disagree on: the status tool collapses superseded check attempts to the newest per identity while the structured provider treats each row independently and maps CANCELLED to failed, so it can wake on a failure that no longer exists. Status is per layer, verified at 2f9ed97, because most of this is the target rather than a description of what runs today.
…b Crew summary (kirodotdev#9437) The Members page's right column was a closable DetailPanel drawer — a hand-built lookalike of the chat page's right column. It is now the chat page's own tabbed SidePanel, permanent on wide windows (no close control, no header toggle), whose first tab is the member's Crew summary carrying what the drawer showed (identity + status, counters, driving sessions, auto patrol, recent activity, wake sources, configuration, memory note, edit exits). The + menu is the chat panel's (Files / Artifacts / Terminal / Browser / Side chat …) against the member's DM slot; the strip is bucketed per member so it follows the roster selection. SidePanel gains `leadingTab` (a host-owned, non-closable, non-draggable tab ahead of the pinned block whose body the host renders), an optional `onClose` (absent ⇒ no close control, Escape inert) and `extraReserveW` (a sibling column's live width kept clear on drag). `usePanelTabs` takes `opts.leadingId` so a fresh strip opens on the leading tab and an emptied strip falls back to it. File / artifact open and file save move out of useChatPageResourcesController into the shared `usePanelDocumentActions` so both hosts run one implementation. Narrow windows (roster + thread + panel cannot seat) keep the panel as an overlay the header button opens. Closes kirodotdev#9432 Review round 1 (GPT): no agent hand-off on the panel read-error notice (the ChatPane below holds the DM draft as unsaved local state); Side chat withheld on this page until its composer draft persists or SidePanel keeps its body mounted (MEMBERS_WITHHELD_VIEWS, test-pinned). Review round 2 (GPT): the panel binds to the member slot record only from the CURRENT snapshot (slotsLoaded) and only when it is the member's own (mode === member), so a record left over across a reconnect can never root Files / Terminal in a pre-restart project (test-pinned). Design round: the unconfirmed-window withheld set is derived from VIEW_DATA_SOURCE (every classified view + terminal + app) instead of enumerated, so a new ViewKind is withheld there by construction; test pins it against the classification. Rebased over kirodotdev#8947 (Quote / Ask on selected text): the drawer-hosted Side Chat main added is replaced by the panel's own Side tab — `openMemberSideChat` focuses it (revealing the overlay on narrow windows) for the confirmed slot only — and Side chat is offered again, since kirodotdev#8947 moved its composer draft into the per-slot chat-core store (the unmount-loss reason it was withheld for is gone). MembersPage.sideChat.test.tsx rewritten for the panel. Fix-up: the Side tab is a dynamic tab (title as text, no aria-label) so the sideChat tests read it by accessible name; the drawer-only `side_chat_draft_waiting` key is removed from every catalog (dead-keys ratchet).
kirodotdev#9415) No shipped seed scenario could paginate. The slot-detail fast path answers has_more=false unless a session has size-rotated archive/ segments, and rotation only fires once a transcript outgrows a 10 MiB budget, so every scenario built from a handful of messages leaves "load earlier" structurally unreachable and a paging trace cannot be performed through the real routes. sessions-long-history seeds that state: one pinned session whose oldest 60 rows sit below a real rotation boundary. The initial slot load returns the 8 live rows with has_more=true and next_before=60, and paging with that cursor returns rows out of the archive. Both shipped files are real ConversationLog output. The segment is _maybe_rotate output at the production budgets, and the live transcript is compaction output holding rows append wrote, so the fixture cannot encode a rotation shape the writer does not produce. Only the rotate segment and the live transcript are kept; the ~10 MiB compact segment the recipe produces is discarded, which is the state a home reaches once archive retention reclaims it. That keeps the fixture at ~20 KB of package data, inside the 64 KB per-fixture cap. The contract test pins the design point through the real readers and pins the shipped segment's header and row shape against a rotation driven in-test, so a change to the rotation format turns red instead of leaving the fixture describing a shape the product no longer writes.
…ks (kirodotdev#8784) Chat code blocks pin their copy/edit buttons to the top-right corner only. Once a block's rendered content grows past a fixed height threshold, scrolling to grab those buttons costs a trip back to the top -- exactly for the blocks most worth copying (long generated configs, multi-file snippets). Measure the block's content height with the existing useMeasuredHeight hook and repeat the same action row (copy, plus any caller-supplied headerActions such as the edit pencil) in a footer once the block exceeds that threshold. The row markup is factored into a small CodeBlockActions component shared by both call sites, so the header and footer duplicate stay visually identical without a copy-pasted JSX block. Review findings addressed across this PR: - max-two-buttons-per-row (blocking): the header's Run + Edit + Copy is pre-existing (legacy status), but the footer is a NEW row, so mirroring the header there put 3 siblings in a fresh row. CodeBlock now takes a separate optional footerActions prop (defaults to headerActions); EditableCodeBlock passes a trimmed Edit-only set to the footer, keeping Run header-only. - Footer copy reported success even when the clipboard write failed (errors-use-error-notice, blocking): copy() now awaits copyCode and only confirms on a true result, matching the established pattern in TailnetMobileCard. - Footer-triggered edit collapsed the block out from under the click (the max-h-[480px] editor replacing a much taller block with no scroll compensation). Fixed with scrollIntoView, gated on the wrapper's top actually being above the viewport so a mid-viewport HEADER edit doesn't also yank the page up for no reason the click gave it. - Inclusive Language (woke): "grandfathered" -> "legacy status". Also: the footer's border was always painted, leaving a permanently empty strip under every tall block at rest. It's border-transparent until the same hover/focus reveal that shows the buttons, so the row still reserves its height but paints nothing until there's something to show. Screenshots captured from an isolated CodeBlock render (short block vs. a 30-line tall block) with the action row hovered, committed under the repo-root temp-screenshots/ convention. Closes kirodotdev#8227
…choice can change later (kirodotdev#8497) The Agent Template dropdown is the create modal's highest-stakes-looking field: first-time users stall on it, assuming the pick is permanent. A one-line note under the dropdown says the template can be switched anytime after creating. Create-only via TemplateField's editLaterNote prop — the editor renders the same field without it, since there the editable fields are themselves the answer. Localized in all 13 catalogs. Split out of kirodotdev#8497's original combined diff at the maintainer's request; the Session Color removal now rides its own PR.
…mated) (kirodotdev#9493) Installed theme packs can supply their own chat-footer loader art via loader/*.png|webp|gif|svg. One image renders on its own; 2-8 are cycled by the stock carousel. Animated WebP/APNG/GIF and animated SVG self-animate in the <img>, so a pack can ship a single fully-authored loop. SVG rides the same <img>-secure-mode asset path as logo.svg (served under _THEME_ASSET_CSP: default-src 'none'; sandbox), so scripts are disabled and external references are not fetched by the browser -- no HTML-serving route, no content sanitizer, no egress surface.
Introduce the project-coordination grouping tag on _ChatSlot, plumbed through every persistence site so it round-trips save->rehydrate: - _ChatSlot.__slots__ + __init__ default - SLOT_OWNED_META_KEYS (flows into derived ROWS_ONLY_DEFERRED_META_KEYS) - both metadata write sites (chat_persistence) - all four rehydrate sites (chat_persistence x2, channel_slots, chat_handlers) - fork copy (chat_fork) - cleared + rollback-restored on workspace switch (chat_handlers) Field only; store/tagging/collision/panel follow in later commits.
Cover the properties the pre-merge gate flagged unverified: - save->rehydrate via restore_recent_sessions (primary read path) - write round-trip via ConversationLog.update_metadata/get_metadata - clearable-by-absence (absent metadata -> "") - str() coercion of a non-string persisted value - SLOT_OWNED_META_KEYS membership + derived ROWS_ONLY_DEFERRED_META_KEYS - fork inheritance of the tag 7 tests, green under .venv pytest.
Add ProjectStore — the {id, name, repos[]} record table a session's
project_group_id points at. Mirrors the crons.json durability pattern
(advisory flock + content-digest sync + atomic tmp->rename write) without
the cron scheduler machinery:
- create idempotent BY ID (names are not unique)
- concurrent-create TOCTOU closed (sync + in-lock existence check)
- delete ALWAYS proceeds (no cross-store refuse-if-nonempty; dangling id
is a reader concern per design 4.1)
- repos[] auto-derived rollup: observe_repo appends deduped, append-only
- malformed file/records degrade to empty, logged, never fatal; a present
but non-list 'projects' value is normalized (no crash on {"projects":null})
Reads are cache-only (documented invariant); only the write path picks up
an external change. Contract kept to {id,name,repos[]} + create/delete/
observe — no rename/exists/created_at surface (unused). 16 store tests green.
GPT 5.6 Review —
|
SaRedfiche
pushed a commit
that referenced
this pull request
Sep 24, 2026
…irodotdev#12915) * fix(chat): wire the paste-block sidecar into ChatPane and SideChat ChatInput collapses a large paste into a `[ Paste #N · M lines ]` token only when its host passes `onPasteBlocksChange`. ChatPage did; the split view / member DM pane (ChatPane) and the side panel (SideChat) rendered the composer without it, so a big paste stayed raw text there. Both hosts now keep the blocks with the same lifetime as their text: - ChatPane: pane-local `pasteBlocks` state, parked and restored with the text through chatPaneDrafts (a third sessionStorage store), expanded for the model in doSend and doSteer, kept as tokens on the bubble with `meta.pastes` + saveStoredPaste so the row renders a chip, handed back with the text on a refused send (carryPastes re-numbers a colliding block), and put back EXPANDED by a queue-card cancel because the stash carries no blocks. - SideChat: blocks live in the in-memory side-chat draft entry next to the text (a remount restores both; neither outlives the page, matching the store's documented intent), expanded in `send` on both the send and steer branch, pruned whenever the text is written. Tests: page-level ChatPane and SideChat scenes (paste, pill, send, expanded wire text, cleared composer), a rebind and a refused-send restore for the pane, a remount for the side panel, and unit coverage of the pane store's new paste field + carryPastes. Capture harness entry + script for the PR evidence. Closes kirodotdev#11337 * fix(chat): park pane drafts as one value; carry pastes with a synced ref Review rounds 1-2 on the sidecar wiring: - The pane store held text, files and paste blocks under three keys with independent byte caps, so an over-budget slot could lose its block while the text kept the token — a reload then sent the token literally. The parked draft is now ONE stored value (`mc-pane-drafts`: {text, files, pastes}) under one cap: a slot is evicted whole or not at all. Drafts a tab parked under the two-store layout (a text string per slot, paths under `mc-pane-file-drafts`) are folded into the unified shape on the first read after a reload, so an upgrade mid-session loses nothing. - Two recoveries landing in one React batch both read the render-time `pasteBlocksRef`, so the second `setPasteBlocks` dropped the first's blocks while both tokens stayed in the text. `carryIntoComposer` advances the ref synchronously per recovery before scheduling state, so the second carries on top of the first; the rebind path advances it the same way. - `carryPastes` moves to pasteTokens.ts as the one owner of the kept-ids -> remapCarriedBlocks -> concat rule; ChatPage's two inline spellings now call it. - `readSideChatPastes` had no product consumer; removed. The SideChat tests observe block state through the composer and the wire instead (a fresh paste numbers from #1 again once the blocks are gone). - SideChat's too-long message counts the EXPANDED question; when a collapsed paste is part of it the message now says so (`question_too_long_with_paste`, all catalogs), since the composer visibly holds one line and a pill. Tests: a two-refusals-in-one-batch scene on the pane (fails without the ref sync: two `#1` tokens), whole-slot eviction, legacy fold and malformed-value drop on the store, the paste-aware too-long message.
SaRedfiche
pushed a commit
that referenced
this pull request
Oct 5, 2026
…ger off (kirodotdev#15851) ## Problem A Linux system install writes a gateway unit that bakes `XDG_RUNTIME_DIR=/run/user/<uid>` and `DBUS_SESSION_BUS_ADDRESS` into its environment, so every agent runtime scope is created under that account's `user@<uid>.service`. With `Linger=no` logind stops the account's manager when its last login session ends, and systemd takes every runtime down with it: in-flight turns and subagents get SIGTERM and new spawns fail with "Failed to connect to bus" until the account logs back in. The gateway, running under the system manager, stays up and reports nothing, so the install gives no sign that agent work needs linger to survive a disconnect. ## Why it matters The system-scope install is the path a user picks precisely so the gateway outlives their SSH sessions. Without linger that promise is silently broken for the agent runtimes, and `kirocrew service install` prints nothing about it even though the user-scope remedy already documents `loginctl enable-linger`. ## Not a goal The architectural fix for defect #1/#3/#4 in the issue -- running the runtime scopes under the system manager vs. making linger a hard requirement, plus the disconnect fallback and reconnect recovery -- is a placement decision the maintainer routed to needs-investigation. This change implements only the install-time warning the maintainer named as a small, separable piece. It never changes the install outcome. ## What changed - `service/linux.py`: add `linger_warning_for_service_account()` (and a `_linger_enabled` probe reading `loginctl show-user <user> -p Linger`) that returns an operator warning when linger is off for the resolved service account, naming the `sudo loginctl enable-linger <user>` fix. Returns None when linger is on or cannot be determined, so the install reports nothing it is not sure of. - `service/controller.py`: print that warning after a successful system install, next to the AppArmor and headless-auth notices. Non-fatal. ## Tests - `test_linger_warning_fires_when_linger_is_off` / `_silent_when_linger_is_on` / `_silent_when_undeterminable` cover the probe's three outcomes. - `test_install_systemd_prints_linger_warning` / `_silent_when_linger_ok` cover the controller wiring. - Full `test_service.py` (413 passed, 3 skipped), black/isort/flake8/mypy and the comment-history gate all clean. Fixes kirodotdev#15678 Co-authored-by: kirocrew-agent <agent@kirocrew.local>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Phase 1 of project coordination — grouping sibling chat sessions that work the same project, so in-flight work is visible and same-file/same-worktree collisions can be surfaced later. This MR lands the two foundational, independently-verifiable pieces; the tagging API, collision signals, notify, and panel are later steps.
What
project_group_idslot field (85b2ebb26,c255a4a1a) — an opaque per-slot tag plumbed through save/rehydrate (x4 sites), fork inheritance, and workspace-switch clear+rollback. Round-trip tested. (Already gated GO onc255a4a1a.)projects.jsonstore —ProjectStore(b04ccbad4) — the machine-owned record table ({id, name, repos[]}) aproject_group_idpoints at. Mirrors the crons.json durability pattern (advisoryflock, blake2b_sync_for_writeread-modify-write, atomic tmp→rename) with none of the scheduler machinery.Store design: machine-owned, fail-loud
The only writer is
ProjectStore(json.dumps + atomic write); we do not accept hand-authored records. So the store defends its own invariants and does not coerce foreign input:observe_repoappends a deduped, order-preserving repo rollup.atomic_writefails (incl.observe_repo's nestedrepos), so a failed save never leaves an uncommitted mutation cached.OSError; an unparseable / wrong-shape file raisesProjectStoreCorrupt;from_dictenforces the exact{id,name,repos}shape (unknown key → corrupt)._last_digestis set only after a fully successful load, so a failed load never poisons change-detection.The store is intentionally unwired this MR — its validated creation interface (the tagging API) is the next step and is its only intended caller.
Verified
./.venv/bin/pytest test/test_project_store.py test/test_project_group_id.py), incl. cross-instance TOCTOU dedup, transactional-rollback (create/observe/delete), fail-loud (corrupt/unreadable/malformed/unknown-key), and the digest-poison regression.6f5d6a985): GO — GPT / Opus / Design PASS; First-Principles CONCERNS (the acknowledged unwired-store split).9211b8df, 10 scanners): 0 findings referenceproject_store.py; this diff adds 0 actionable findings.docs/scan-log.md.Upgrade notes
None — additive Python + a new JSON store with zero consumers yet. No IaC, no new dependencies, lock/config files unchanged.
Accepted follow-ups (land with the tagging API)
_MAX_REPOS/ per-repo-length cap onobserve_repo._CommitTokenidentity used forproject/workspace— revisit when a concurrent tagging writer exists.