feat(dashboard): opt-in per-session project directory - #8997
feat(dashboard): opt-in per-session project directory#8997chenmingwei23 wants to merge 1 commit into
Conversation
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All the helper symbols the diff leans on ( First-Principles-Verdict: CONCERNS
Not justified as shipped
What this change shipsInventory (9 items) — 8 justifiedIntent: let a user running unrelated concurrent work stop new chat sessions from sharing one project directory — an ADDITION (opt-in feature).
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 82abfc3 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Real harm, opt-in default-off, built on existing seams (slot.project, Suggestions
[DESIGN-REVIEWED] 82abfc3 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @chenmingwei23 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
607ce5f to
98f3e1c
Compare
98f3e1c to
6c0f045
Compare
6c0f045 to
974c019
Compare
974c019 to
c2cf195
Compare
c2cf195 to
87bfa30
Compare
Stopping this PR: converting to draftThis PR is being converted to draft and stood down. The reason is a design finding about the mechanism, recorded here so the follow-up starts from it rather than rediscovering it. The opt-in per-session project directory is a compound operation: validate a configured root, create a directory under it, assign that directory to the slot, and persist the assignment. Those four steps have to hold together atomically, but they run from a request handler that owns none of them as a unit -- there is no single owner holding a lock across all four. Across twelve review rounds the mechanism drew six blocking findings, all inside roughly 220 lines of directory derivation (
Findings 1 through 4 were each fixed at the call site, and each fix revealed the next -- the signature of one unwritten contract rather than six independent defects. Findings 5 and 6 are the two currently open, and neither is closable at the call site, because the call site is not where atomicity lives. The settings surface and the config keys are sound; the plumbing carries none of the six findings. But shipping the plumbing alone lands a config key with zero consumers, which reads as dead code and is behaviourally identical to not shipping it. So the honest resolution is not a partial merge. The derivation needs to be rebuilt behind a single owner that performs validate-create-assign-persist as one unit on the slot layer. That is tracked as a separate design issue, linked below. Refs #8432 |
|
Rebuild tracked as #9218 (labeled |
25d6120 to
18c9484
Compare
Retracting the stand-down: both remaining findings are closed at the call siteI stood this PR down and converted it to a draft on the strength of one claim: that GPT's last two findings -- symlink-safe directory creation, and durable persistence of the assignment -- were not closable at the call site, and that the derivation therefore had to be rebuilt behind a single atomic owner. That claim was wrong. Both are closed here, in the two functions that already existed, and each fix is pinned by a test that fails when the fix is reverted. Retracting on the record rather than quietly re-opening, because the stand-down was public. Head is now Finding 1 -- root-symlink race in
|
18c9484 to
f2c2cf9
Compare
Round on
|
Round on
|
Round on
|
Round on
|
Round on
|
The board went green, and one advisory finding was a real defect in my own text
Head is now Fixed: the shipped help text said the wrong placeDesign Review and First Principles both caught that The help text now reads "Empty = a '.sessions' folder inside the workspace directory, created for you", the PR description above says the same and explains why it is one level down, and Accepted as a declared residual: the fallback is only a debug logDesign Review is right that every degrade path -- reused key, ineligible root, Not fixed here, and not because it is wrong. Surfacing it is a new user-visible surface -- a slot field or a raised log level -- rather than a correction to a shipped path, and the degrade posture deliberately matches the sibling Declined here, and it needs a human: drop
|
Board green on
|
|
@chenmingwei23 Audited at 25d6120. The head has since moved to fb0d85c and now also touches Nothing here has landed on main. Two rebase costs appeared since the audited merge base:
Please also reconcile with open #7161, which rewrites
If the plan is still the rebuild tracked in Issue #9218, please note that here so we can follow it there. Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Round on
|
Off by default: new sessions resolve their project exactly as before until dashboard.new_workspace_per_session is turned on. The root is config-file only, matching dashboard.default_project, because no dashboard-writable setting takes a path. Any failure to resolve or create the directory falls back to the shared default so a session still opens. Refs #8432
|
/ai-review override gpt 82abfc3: Same-user TOCTOU on the per-session project directory create path -- an actor able to rename directories there already holds the user's privileges, and the Opus lane assessed this same candidate family on this same path and judged it not blocking. Recording the reasoning so the override is auditable. The finding is that the create/open sequence for a session's project directory can be raced so the opened directory is not the one that was created. The mechanism is real and the write-up is accurate. What it requires is a process running as the same user, racing a directory it can already rename, in a tree that user owns. Such an actor does not gain anything from this path that it does not already have, which is the bar this repository applies to a reachable-trigger claim. The second lane reviewed the same surface and reached the opposite verdict, describing the candidates as same-user races against the per-session project directory create path and declining to block on them. This override records a judgement between two disagreeing lanes rather than dismissing an uncontested finding. The remedy the lane asks for is a contract change: the validated identity has to survive across persistence so a managed configured root can be re-pinned to its original canonical parent after a restore from stored metadata. That is a real design, and it is the right one if this class ever needs closing -- it is written up in the local backlog. It does not belong in this branch, whose subject is an opt-in per-session project directory and which is otherwise green. An earlier attempt to satisfy the lane locally, by re-pinning at the bind site, introduced two descriptor-lifecycle defects of its own and took the blocking count from one to three. That attempt is reverted; this head carries the original, smaller surface. |
Human judgment recorded@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
|
@iamwhatever answering your review question:
Short answer: a per-session directory does not travel, and the one path where that First, a caveat on terms, because I do not want to answer a different question than Turns executed on a peer crew ( What does happen locally: a newly created remote-bound slot is a new slot, so it does Resume from history. The field is persisted ( This is worth being precise about: that asymmetry is not introduced here. A project Fork. Two related things already on record, so you can see where the edges are: the Nothing in the diff changed for this answer. Could you re-review when you have a |
|
@iamwhatever this is out of draft now, which is probably why it did not show up as Your question about session teleporting is answered in The diff has not moved since that answer. Head is 82abfc3, with 56 checks green and |
What is the problem?
Every new chat session lands in the same shared project directory. A user running
several unrelated pieces of work concurrently gets their notes, scratch files and
session context mixed together, because nothing about session creation is
per-session: the project a new slot resolves to comes from
dashboard.default_project, else from the workspace directory.The only workaround today is to open a session and manually repoint it at another
directory through the project endpoint, every single time.
Why this issue matters to the user
The reporter's case is the common one, not an edge case: concurrent unrelated
activities. Manual repointing is easy to forget and easy to get wrong, and the
failure is silent - you do not find out that two pieces of work shared a
directory until their files are already interleaved. Forgetting it once is enough
to lose the separation for that session's whole lifetime.
How our fix solves it
THIS DEFAULTS OFF.
dashboard.new_project_per_sessionisFalse, so installingthis change alters nothing: a config that never mentions the key resolves a new
session's project through exactly the same chain as before.
Chaining from the symptom to the root cause:
session resolves to the same directory.
carries two identities -
slot.workspace(a NAME, derived from the agentbinding, whose name-to-directory mapping is global) and
slot.project(a realdirectory) - and only the second is per-session.
slot.project, which is alreadyper-session, already has a validated setter, and is already what becomes the
agent's cwd.
Why the config keys are project-named rather than workspace-named. The request
says "workspace", but in this codebase a workspace is a distinct concept with its
own name-to-directory mapping and its own memory and knowledge base. This change
sets
slot.project. Naming the keys after what they actually set avoids shipping aname that would need a compatibility alias later, and a config key is one of the
few things here that is genuinely expensive to rename once it is in user config
files.
Answering the four questions this change has to answer, all from existing code
rather than invented:
WHERE the directory goes. Under an existing configured root, never anywhere
new.
dashboard.session_project_rootnames it; empty falls back to a.sessionsfolder inside the workspace directory the session already resolves to, created
through the same pinned routine the session directories use. It is one level down
rather than the workspace directory itself because the session name becomes the
directory name, so creating at that top level lets a session called
package.jsonput a directory on that path. A CONFIGURED root must ALREADYEXIST, matching how
dashboard.default_projectis treated inthe sibling branch, so a typo disables the feature instead of scattering
directories. Nothing is written outside the root: the derived path is asserted to
be an immediate child of the resolved root, and
mkdiris called withoutparents.HOW it is named, and why an existing directory is never taken over. From
slot.key- the MINTED key, not the request body'sname- via the existing_safe_dir_name. Two properties matter, and the second is the one that makes thefeature safe:
_safe_dir_nameis a sanitizer, not a validator. It maps separators to_butdoes not reject
.., so the containment assertion is what stops an escape, notthe name.
slot.projectis persisted on thetranscript's metadata line and rehydrated from there, so a restored session is
served from metadata and never re-derives. A candidate path that already exists
therefore belongs to an EARLIER session whose key was reused - a caller-supplied
name, a channel key, or a minted key reproduced by a closed session plus a
same-second restart - and adopting it would hand a fresh session that session's
files. It is refused, and the caller falls back to the shared default.
Exclusive creation is also what lets this ship with NO delete path: nothing is
cleared, reused, or overwritten.
WHAT happens when creation FAILS. The session still opens, degraded. Every
failure mode - a root that is empty, missing, not a directory or sensitive; a name
that would escape the root; an existing candidate; an
OSErrorfrom themkdirona read-only or full disk - returns
"", which the caller reads as "no per-sessiondirectory" and falls through to the shared default. This is not a new posture: the
sibling configured-default branch already skips an ineligible path to fall back
"instead of wedging every new slot".
WHO cleans up. Nobody, and that is a real cost this PR names rather than
solves. A directory per session is unbounded growth. This change deliberately
contains NO delete path - a reaper written in the same change as a create path is
how data loss ships - so cleanup is proposed below instead.
Deliberate deviations from the issue's literal request, each with its reason:
catalogParity.test.tsrequires every locale (14 of them). I will not fabricatetranslations for 13 languages, and
en.jsonis generated and must not behand-edited (
website/docs/i18n-catalog.md). The setting is config-file only fornow, exactly like its closest sibling
dashboard.default_project, which alsoships with no UI control.
every key the dashboard config PUT accepts is a bool, an int or a small enum, and
that handler's write branch does no path handling at all. This change does not
make itself the first dashboard-writable path setting.
reporter flagged, and it lives on
slot.workspace, which is agent-derived -repointing it per session would collide with agent bindings. The reporter's own
view was that shared memory is the safer default.
config-baseline.jsonis regenerated because the repo commits a schema snapshotand asserts byte-parity with its generator, so adding two config fields invalidates
it. That is a consequence of the change, which is why the diff touches a fifth
file.
What review changed, and one thing I got wrong twice
Four rounds of blocking findings, all inside this diff:
name. Thedashboard's own new-chat path sends no name, so
namewasNone,_safe_dir_name(None)raised, the broadexceptswallowed it, and with thetoggle ON behaviour was identical to OFF on the primary path. Now derives from
slot.key.os.geteuid()was evaluated at collection time, which aborts the whole shard onWindows where the attribute does not exist. Guarded with
os.name != "posix".Separately, SAST flagged a hardcoded
0o700; the test now captures and restoresthe mode pytest created, which removes the finding and is more correct.
via
_slot_index_from_key, which only checks that the second segment is a digitand never validates the trailing timestamp - so
worker-1-stablepassed an"auto-minted only" test while being entirely reusable. That is relying on what a
predicate is called instead of reading what it validates.
slot.projectis notpersisted, having checked
open_slots.json(which carries session keys only) andconcluded a global absence. It is persisted - on the transcript's metadata line,
rehydrated in
chat_persistence, withprojectinhistory's owned-field list.I verified one persistence mechanism and treated that as verifying the
inference. Because restore comes from metadata rather than re-derivation,
refusing an existing directory is safe, and exclusive creation replaces the
key-classifying gate entirely - covering every reusable-key case without this
call site classifying keys at all.
field by bare attribute access raised
AttributeErrorinside the requesthandler, so
POST /api/chat/slotsreturned 500 whenevercfg.dashboardwas apartial stand-in. Measured: 36 sites across 19 test files patch
KiroCrewConfig.loadwith a minimaldashboard=SimpleNamespace(...)carryingonly the fields the surrounding code reads. That is why the read now uses
getattrwith the dataclass defaults, and why the fix belongs in the handlerrather than in 19 test doubles - a missing field reading as OFF is the
documented default. I had attributed this red to a flaky event-loop class for
three board cycles; see the harvest below.
is_sensitive_pathwasapplied to the root before the
mkdirand to the result after it, but never tothe candidate before it - so a session key of
security_policy.jsonunder thedata home minted a DIRECTORY on a governance trust root, and the post-check then
returned
""having already done the damage. Nothing here deletes, so thedirectory would stay and block policy loading. I had applied the
pre-check/post-check reasoning to containment only, never to sensitivity. Now
rejected before the
mkdir; verified thatis_sensitive_pathmatches the pathwhile it still does not exist, which is the case that matters. This is also
downstream of dropping the key-shape gate in round 4: a caller-supplied name
becomes the slot key, so the name reaching that line is caller-steerable, and I
widened who could steer it without re-examining the guards that depend on it.
is the most useful thing in this history.
POST /api/chatalso creates a slot,so the setting applied on the create endpoint and not on a send to an unknown
slot. Adding it there produced two defects in two rounds. First it broke six
fail-closed tests:
test_slot_create_default_agentpatchesKiroCrewConfig.loadto raiseOSError, and my unguardedawaitpropagated itas a 500 from an endpoint contracted to answer 400/409 - and separately, the
round-5 hardening of
new_project_per_sessionhad leftcfg.dashboarditselfbare, which the send path exposed because it sees config stand-ins with no
dashboardattribute at all. I had fixed the leaf and assumed the chain. Then,with those fixed, the reviewer found the derivation was using the wrong
workspace: an auto-created slot's workspace is
"default"regardless of theagent the request names, so with an unconfigured root the directory landed under
the default workspace's tree and that wrong location persisted.
rather than my judgement. Deriving needs the workspace; for an auto-created slot
the workspace comes from the requested agent's bindings; but
resolve_agent_bindingsrequires the project directory it is given to be "thesame directory Kiro Crew passes as the kiro-cli cwd", because passing a
directory the session does not run in reintroduces the silent
agent-substitution bug that lookup exists to prevent. Bindings need the final
project, and the project needs the bindings' workspace. So the send path now
deliberately does NOT derive, with that reasoning at the call site and a test
pinning both the absence and the reason. It degrades to the shared default,
which is today's behaviour. The create endpoint, where the workspace is resolved
before the derivation, is unaffected.
_apply_per_session_projectawaits the derivation and the conflict scan without holding a lock across them,
so a concurrent project POST could set an explicit selection while it waited,
and the unconditional
slot.project = per_sessionat the end would overwritethat selection with the derived default - the weaker value clobbering the
stronger. Now a compare-and-set: re-read the field after the awaits and commit
only if it is still the empty value the derivation started from; a concurrent
writer wins. This is the ROOT of the whole class - findings 1, 5, 7 and this one
are all a value trusted at commit time that was only valid before an await - and
unlike the earlier per-instance fixes it closes the class at the commit point.
Two blocking findings I am not acting on, each with the measurement rather than a
preference. The first is the send path above: the choice was between a directory
under the wrong workspace root and the setting not applying there, and the second
degrades to current behaviour while the first persists a wrong location.
The second is preserving the per-session project across agent and workspace
switches. The workspace-switch handler refuses outright once a session has messages
if slot.total_messages > 0returns 409 "Cannot change workspace after messageshave been sent. Open a new session instead." - so the switch is only reachable on a
session that has written nothing, and there are no files to re-share. The residual is
an empty orphaned directory and the setting no longer applying after a switch: disk
cost and incompleteness rather than the data loss the finding is anchored to.
Re-deriving under the new workspace is also a design decision about what a workspace
switch MEANS, and belongs in its own change. Both are filed as follow-ups below.
What tests we did
test/test_session_project_dir.py, 30 tests, run aspython3 -m pytest -n0 test/test_session_project_dir.py: 30 passed. black, isortand flake8 clean on all four Python files changed. mypy reports 2 errors, both in
transcribe.py, a file this change does not touch.Three handler-level tests drive the real
api_chat_slot_create, because several ofthe findings above were in what the call site passed or read, which no unit test of
the helper can see: a nameless create must get its own directory named from the
minted key; a create whose directory already exists must NOT adopt it - parametrized
over
reused-nameandworker-1-stable, the second being the name that defeated theearlier gate, with a pre-seeded file asserted to survive; and a create with a PARTIAL
config must still return 200 with the feature reading as OFF.
Two further tests cover what those cannot. One asserts structurally that the send
path does NOT derive and that the reason is recorded at the call site, so a later
reader does not close the apparent gap by reintroducing the wrong-workspace bug; it
also asserts the create endpoint still does apply it. The other calls the helper
directly with a config object that has no
dashboardattribute, the shape thatturned into a 500 in finding 7.
Mutation-verified, classified by reading the runner's own failure line:
getattrto bare attribute accessassert 500 == 200, and reproduces the original CI failureexist_ok=Trueslot.keytonameassert '' == '.../chat-1-...'exceptdegradationPermissionError, the correct observable for a guard whose absence crashesassert '' == '.../roots'Trueassert True is Falsemkdirsensitive checkassert [PosixPath('.../security_policy.json')] == []cfg.dashboardinstead of the guarded chainAttributeError, and reproduces the CI regressionThree survivors are disclosed rather than hidden, because each says something about
the code:
deleting both surfaced an escape; that was wrong, and measuring it is what showed
why.
_safe_dir_namecannot emit a path separator (it maps/,\and:to_), so the join is an immediate child by construction; andmkdirdoes NOTfollow a symlink at the final component - dangling or not it raises
FileExistsError, which exclusive creation already refuses. So containment isnow depth against a change to either of those facts, not an active guard. It stays
because a sanitizer that starts passing separators through, or a move away from
exclusive creation, would make it load-bearing again with no other signal.
isdir(base)guard reddens nothing, becausemkdirwithoutparentsfails on a missing or non-directory root anyway.exceptreturns the same
"".All three share one cause worth stating plainly: the broad
except Exceptionis thebackstop that makes individual guards unobservable, and it is also what let finding
1 above ship as a silent no-op. It is kept because the sibling
default_project_diruses the same breadth for the same reason, and because a session must open even on
an unanticipated failure - but every guard it masks now carries a comment recording
that it was measured unobservable, so a later reader neither deletes it as dead nor
trusts it as pinned.
On the CI reds, split by measurement rather than by guess.
Backend Tests (3.12, 3)and
(Windows) (3)were MINE: the 500 above, established by reverting only my threesource files and re-running the named test class (3 failed with my change, 3 passed
without, 190 pass in that whole file now).
Backend Tests (3.12, 4)is NOT mine:its failure is
test_snapshot.py::TestNotificationCopyWhenNoLiveFileExistsasserting a concurrency ordering, it does not reproduce serially, and it passes
25/25 both with my change and with my change reverted - so nothing in this diff
moves it. I am not fixing that one here.
Any other suggestions on the work
suggestion was to ask on session delete with a Yes / No / Never answer, and he
called the zip-to-archive idea scope creep himself. Any reaper should be its own
change, so a delete path is never introduced alongside a create path. Note that
exclusive creation means a stale directory is never silently reused, so the cost
of not having a reaper is disk growth rather than cross-session leakage.
produced properly. No backend change is needed beyond adding the key to the
dashboard config GET and PUT.
larger, separate decision about
slot.workspaceand agent bindings.workspace-versus-bindings circularity broken first - most likely by having that
endpoint resolve the workspace explicitly before the slot is used, rather than
relying on
get_or_create_slot's default. Worth doing, but not as a side effect ofthis change.
decision, and is the one blocking finding I declined above. Today a workspace
switch repoints the project at the new workspace's directory, which is defensible;
the switch is also refused with a 409 once the session has messages. Worth settling
deliberately rather than inside this change.
mechanism, but adjacent, and whoever builds that UI is the natural owner of the
toggle above.
Pattern harvest
Rule candidate: a call inserted into a long handler must sit after every input it
reads is FINAL, and "final" is found by locating where each input is last assigned -
not by reading the surrounding lines. My insertion read a workspace that the handler
never assigns and a config the handler deliberately loads late and tolerantly. Two
separate defects, one ordering cause.
Rule candidate: when adding a call into a shared request handler, derive the
regression surface from the ROUTE rather than from which files look related. I picked
two files that had failed recently, both passed, and CI then failed six tests in
three files I had not run. Grepping the route name across the test tree produced 28
files in one command - that list is the sweep, and it is cheap enough that guessing
was never worth it.
Rule candidate: when hardening an attribute read against partial stand-ins, guard the
whole chain, not the leaf. I replaced
cfg.dashboard.new_fieldwithgetattr(cfg.dashboard, "new_field", default)and leftcfg.dashboarditself bare,so the same class of
AttributeErrorcame back one level up on a different call path.A helper reached from more than one entry point sees more shapes of its arguments than
any single call site does.
Rule candidate: when a function validates a path and then creates it, every
validation must run BEFORE the side effect, not merely somewhere in the function. I
had a pre-check/post-check pair for containment and only a post-check for
sensitivity, so the sensitive path was created and then rejected - and with no delete
path, rejecting after the fact leaves the damage in place. List the checks against
the single line that mutates the filesystem and ask of each one which side of it it
falls on.
Rule candidate: before calling a CI red flaky or inherited, read WHICH test failed
and check whether it exercises a surface this change touches. I dismissed a failing
shard as a flaky event-loop class for three board cycles; its annotation named a 500
on
/api/chat/slots, the exact endpoint being modified. The evidence I had cited wasreal but about different files, so it never bore on this failure. "Does not reproduce
serially" is only evidence once it is the failing test that was re-run.
Rule candidate: checking one persistence mechanism does not establish that a field
is unpersisted.
open_slots.jsoncarrying only session keys was true and did notsupport "slot.project is not persisted" - the per-slot fields ride the transcript
metadata line. Before building on an absence, enumerate the write sites for the
field (grep every assignment and every serializer) rather than reading the one store
you happened to open.
Rule candidate: a broad
excepton a resolution helper converts caller errors intoits own "unavailable" return value, so a wrong argument at the call site ships as a
silent no-op and every guard inside the helper stops being observable to mutation.
When a helper degrades by returning a sentinel, validate the caller's argument at the
boundary and test the call site through its real entry point.
Refs #8432