Skip to content

v0.10.5 cycle - #188

Merged
firstintent merged 195 commits into
mainfrom
dev
Aug 29, 2026
Merged

v0.10.5 cycle#188
firstintent merged 195 commits into
mainfrom
dev

Conversation

@firstintent

Copy link
Copy Markdown
Owner

Second DSH plugin @ccteam/dsh-team: the ccteam team panel inside the DSH native web console — cross-vendor session tree, embedded chat, one-click spawn, mounted via DSH's own sidebar.footer.action + shell.overlay slots and rendered exclusively with DSH-native primitives and design tokens.

Maps to requirements.md pain points 1/7 (one entry point to the whole team; progress visibility) and tech-design §6 extension surfaces. Spec: docs-local/versions/v0-10-4/README.md (local).

🤖 Generated with Claude Code

The dsh row rendered 'Register DSH plugin' unconditionally because
mcp_registered() had no dsh arm — it answered false for every vendor
without a native MCP config file, so an operator whose ~/.dsh web
profile already carries our bundle and configured ccteam-client row
still saw the CTA and could not tell registered from unregistered.

Answer it at the layer that owns the fact: a read-only detector next to
the writer (both halves required — bundle alone leaves the plugin with
no ACP listener), surfaced through the same mcp_registered field the
five config-file vendors use, so the row renders the ✓ state and the
CTA only appears when there is something to do.
…actory build

The browser cannot reach ccteam directly: ccteam-web installs no CORS layer, so
the panel speaks only to this plugin's own /ccteam/api prefix route and the host
proxies upstream. That also keeps the REST token out of the browser.

BFF (src/bff.ts, src/sse.ts)
- One prefix route registered exactly once; the disposer goes to ctx.effect.
  DSH's webServer throws on a duplicate (kind, path), so registration is
  single-shot by construction.
- POST /ccteam/api/<method> for the five contract methods, GET /events for SSE,
  anything else 404 JSON.
- Upstream mapping verified against crates/ccteam-web rather than guessed:
  Authorization: Bearer ccteam:<hex> (a bare hex paste is normalized; the
  daemon rejects bare hex in that header), GET /api/v1/capabilities as the
  cheap vendor probe, GET /api/v1/agents/{graph,events},
  GET /api/v1/sessions/{sid}[/events], POST /api/v1/sessions/{sid}/turn, and
  the project-scoped POST /api/v1/projects/{slug}/sessions.
- Honest receipts: the queued turn (202 + queued_behind) surfaces as
  {ok,queued,queuedBehind}; failures carry errorKind, preferring the daemon's
  stable error_code. A 401 is text/plain, so parsing is content-type guarded.
- SSE fan-out keeps at most one upstream per stream, refcounted by downstream
  subscribers; the last disconnect tears it down, a drop retries with capped
  backoff while any subscriber remains, and 25s heartbeats keep proxies off.
  turn_done is emitted only from the global stream so a sid watcher (subscribed
  to both) cannot double-count.

Credential hygiene: the token lives in closure scope, is never written to
process.env, never logged, and never appears in a response or frame.

Build (tsdown.config.ts, build/)
- Host -> lib/index.js plain ESM; client -> lib/client.js in DSH's
  closure-factory format (CJS + banner/intro/footer) so the bundle calls
  window.__ModuleLoader__.load({id, factory}) with id byte-equal to the package
  name and the cordis.patch.yml row.
- Externals are the eight platform seed keys, matched by exact string; a purity
  gate fails the build on any other @deepseek-ai/* value import.
- Minimal CSS Modules support (hashed class map + tagged style injection at
  factory execution) in place of lightningcss.

Tests: 56, zero real network. fetch is injected, the fake webServer reproduces
the duplicate-route throw, and the build test executes the artifact against a
stand-in loader instead of asserting bytes.

Deviation: contract.ts gains an optional SpawnRequest.project (strictly
additive). ccteam's create-session endpoint is project-scoped and never infers
a project, so without it a spawn has nowhere to land; the host falls back to a
new defaultProject setting and otherwise refuses with an actionable error.
…sion-gated mount

The @ccteam/dsh-team client half: a hand-rolled reducer store (view stack,
badge, recents, transcripts, receipts), a contract-typed BFF client with
injectable transports, and the panel tree composed exclusively from
@deepseek-ai/dsh-client-ui-primitives + semantic --dsw tokens (CSS modules,
zero globals, zero ccteam-web imports). Mounting is version-gated: entry
button into sidebar.footer.action and panel into shell.overlay via
slots.inject, degrading whole to a body portal when either slot is missing
or rejects. Copy flows through the DSH locale service (zh/en, locale: NS
register option). Store and api unit tests.
…al/shape tests

SpawnForm gains the project seat (slugs from team.graph; exactly one =
auto-picked and hidden; last choice persisted under ccteam.console.project)
and spawn responses flow through a pure planSpawnOutcome: a sid present even
on ok:false still navigates into the session with the error stated in its
chat, no sid keeps the form up with the host's actionable error. Stream
errors now re-probe status instead of declaring unreachable, the checking
phase is initial-only (no flicker on SSE retries), SessionChat is keyed by
sid (no draft bleed across sessions), and the drag handle handles
pointercancel. New suites: version gate (slot mode, whole-mount fallback on
throw/absence/deadline, contained fallback crash), registration shape via
recording ctx, material guards (semantic-token families, no hex/rgb/static,
import allowlist, zh/en parity).
Materializing `@ccteam/dsh-client` was a one-plugin code path; the panel
`@ccteam/dsh-team` needs the same treatment, so make the path plural once
instead of adding a second copy of it.

- `CCTEAM_PLUGINS` table (bundle / package / row id / cache namespace /
  embedded bytes) drives extraction, node_modules links, `dsh.profile.bundles`
  and the patch rows. `ProfileSpec.vendor_bundles` now lists only vendor
  bundles: ccteam's own are implicit, so a third plugin is one table row and
  reaches the tenant and operator paths at once.
- Each plugin caches under `runtime/dsh/<ns>/<sha>/`, so bumping one never
  invalidates the other.
- The panel row is an OVERRIDE, never an `insert`: `@ccteam/dsh-team` ships a
  bundle patch that inserts `ccteam-team` itself, and a second insert aborts
  the whole Cordis boot on `duplicate loader entry id` — the v0.10.0 failure,
  identical in shape. Config keys stay flat (`daemonUrl`, `restToken`).
- Tenant runtimes get their own REST bearer wired in on start: reuse the token
  the identity already signs in with, mint only where the store itself would
  (`DshRestTokenResolver`, injected from ccteam-web because the token stores
  sit above this crate). An unknown user is refused, never created. The token
  is written to that identity's own profile and nowhere else — never logged,
  never in a response. A resolver failure degrades to an unconfigured panel
  rather than failing the DSH runtime.
- The operator's `~/.dsh` gets both plugins merge-only with NO credential,
  mirroring what `enrollment: None` already means there: ccteam does not put
  secrets in a home it does not own, and Settings covers it. Hosts-page
  detection now answers for every plugin, so a client-only profile reads as
  not registered and the button keeps offering.
- `plugins/pack-assets.sh` rebuilds both embedded tarballs and refuses to
  publish one whose `bundledDependencies` did not land — packing without
  installing first yields an archive that extracts fine and then cannot
  resolve `@deepseek-ai/schemastery` at runtime, invisible to every Rust test.
  It reproduces the committed dsh-client.tgz byte for byte.
- `plugins/dsh-team/package.json` gains the `bundledDependencies` block
  dsh-client already had; without it the packed panel cannot boot.

Pain point: requirements.md "跨 vendor 身份/路由" — mode 1 gets the team panel
with zero user steps. tech-design: DSH plugin materialization (K25).
…e the panel

The rc.7 scanner resolves <pkg>/package.json and silently treats any resolve
failure as 'not a client package'; an exports map without ./package.json made
the panel vanish from the web console with no error anywhere. Found on metal,
reproduced outside the app, locked with two regression tests (109/109).
…ion wakeups

`gateway::tests` shared the literal `/tmp/alpha` project dir across dozens of
tests. Under full-parallel `cargo test` they shared both a filesystem path and
the `s1` sid namespace, so unrelated tests drained each other's pending-turn
files and resume state — the near-deterministic red registered in
`.loop/verify/README.md`. Every construction site now takes its own
`tempfile::TempDir`, matching the tempdir pattern already used elsewhere in the
file. The `/tmp/alpha-<suffix>` sites go too: unique per test within a run, but
they persisted BETWEEN runs, so a previous run's residue leaked into the next.

Three timing defects that the shared path was masking, all test-side:

- `FakeAdapter::events()` handed every session's pump a clone of ONE `Notify`.
  `notify_one` gives its single permit to an arbitrary waiter, so a pump for a
  different session could wake, find nothing matching its identity, consume the
  permit and park again — stalling the pump the event was for. Wakeups are now
  keyed by thread identity, which is the fix the verify README itself named.
- `concurrent_replacement_discards_stale_resume...` sampled `fake.closes` the
  instant the conflict error arrived, but `apply_resume_dead_session` closes the
  orphan thread in a detached `tokio::spawn` BY DESIGN. Poll it to the same
  expected value, bounded so a real regression still fails.
- Both `turn_answer_*context_echo*` tests asserted `ends_with("→ alpha/s1 ...")`.
  `context_echo_line` also renders the session title, and the first message's
  auto-title is written to meta.json asynchronously — so the bare form silently
  asserted "and the title write has not landed yet". They now match the trailing
  block's head, which is what the tests are actually about.

Tests only: every change is inside `#[cfg(test)] mod tests`, no production code
and no assertion intent altered. `cargo test -p ccteam-im --lib` 627/0 on three
consecutive full-parallel runs; the two echo tests 30/30.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
… binaries

Four tests mutated process-wide `HOME`/`CCTEAM_HOME`/`DEEPSEEK_API_KEY` from
inside `#[cfg(test)] mod tests`, so they ran in the SAME process as every other
lib test at full parallelism — the AGENTS.md §六 red line. The two groups used
DIFFERENT `serial_test` keys (`#[serial]` vs `#[serial(dsh_config_env)]`), so
they did not serialize against each other: that is precisely the registered
3-in-10 flaky pair. An integration binary is its own process, which closes it.

- `model_catalog::env_resolution_prefers_ccteam_home_and_falls_back_to_home`
  → new `tests/model_catalog_env_test.rs`. It exists to cover the
  `ccteam_root_from_env` wiring, so injecting a root would have deleted the
  very thing it tests; a separate process is the only fix that keeps it.
- `spawn_spec::{tenant_web_seed_refreshes_unmodified_files_from_operator_home,
  identity_home_maps_tenants_managed_and_everyone_else_to_the_operator_home,
  config_source_prefers_tenant_home_with_credentials}`
  → existing `tests/dsh_acp_test.rs`, which already covers this surface and
  already has `isolate()` pinning BOTH homes with RAII restore. All three only
  pinned `HOME`, never `CCTEAM_HOME` — which outranks it in the root resolver,
  so an exported `CCTEAM_HOME` could have sent "isolated" writes to the real
  `~/.ccteam`. The seed test reaches the private
  `seed_or_refresh_tenant_web_config_home` through the public
  `build_web_spawn_spec` door, exactly as its six new neighbours do; no
  visibility was widened.

Manual env restore is replaced by RAII guards throughout: a panic mid-test used
to leak a tempdir-pinned HOME into the rest of the binary.

Tests only: every change is inside `#[cfg(test)]` or a `tests/` file, no
production code and no assertion dropped. Lib count 536→532 (the 4 relocated);
`dsh_acp_test` 22→25 passing with the same 4 registered default-model failures;
`model_catalog_env_test` 1/1. `cargo test -p ccteam-harness --lib` 532/0 on
three consecutive full-parallel runs.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ncovered

Narrow write-back on the card this dev session took: status line + verification
and deviation sections. Notes the newly characterized (and deliberately unfixed)
`delegation_reconcile_folds_missed_backlog_into_one_notification` race for the
planning session to adjudicate — it reproduces at the same 1-in-20 rate on
pristine dev, so it is not this card's regression.

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…g harness tests to integration bins

Verified independently by a codex checker (session s422): 7/8 claims bit-for-bit
confirmed (line numbers, test counts, fn-name diffs all reproduced); item 8 (an
unrelated pre-existing reconcile flake at ~1/20) diverged only on sample-size
noise, not a regression — checker's own conclusion.
CLI-ENVTEST-1. CI's deterministic-baseline job went red twice in one day on
commits that only touched a backlog file: `ccteam-cli`'s
`web_chat_newproject_scaffolds_registers_and_cd_works` read a `config.yaml`
that was not where it left it. `ccteam-cli` has no lib target, so every
`#[cfg(test)]` module under `src/` runs in ONE process; `web_chat_bridge`'s
tests `set_var`'d HOME/CCTEAM_HOME and restored them, `commands.rs`'s peek
tests did the same with CCTEAM_HOME/CCTEAM_MUX_BACKEND under a different
lock, and the loser of that race wrote its files into a stranger's tempdir.

Fixed at the layer that reads the environment, not at the symptom:

- `DaemonArgs::ccteam_root` — the sibling `registry` never had. `registry`
  already injected `projects_root`; the root half was still read from
  `CCTEAM_HOME` at each use site. One resolver (`daemon_ccteam_root`) now
  decides it once per daemon run and every root-relative path takes it as an
  argument: credentials, the bot registry, `config.yaml`, the gateway's
  project-creation + persistence roots, and the durable outbound ledger.
  `None` keeps the env-derived production root, so `ccteam start` is
  unchanged.
- `peek_session_by_name_with(backend, name)` — the mux backend is handed in;
  the `CCTEAM_MUX_BACKEND` read stays at the CLI boundary in
  `ccteam_harness::from_env`. `resolve_peek_session_name` splits the pure
  name-resolution rule out so it can be asserted directly.

The three web-chat tests and both peek tests now touch no environment at all
and keep their assertions (the peek pair gained strength: exact session-name
equality, and the capture contract asserted through a recording double rather
than inferred from an error string). Nothing had to move to `tests/*.rs`.
`/newproject`'s `pre_trust_project` is the one step that still resolves off
`$HOME`; it is disabled once per process via the helper AGENTS.md §六
prescribes — monotone, never restored, so it cannot race a reader.

`test_hygiene.rs` makes the rule mechanical: it scans `#[cfg(test)]` regions
under `crates/ccteam-cli/src/**` for `set_var`/`remove_var` and fails on any
hit. Its own scanner is tested against production code, a comment and a
string literal, and the guard was confirmed to fail on a temporarily injected
`set_var`.

Refs AGENTS.md §五 (治病根 — one layer, once), §六 (env-mutating tests).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…eek backend, env-mutation guard

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
ccteam rebuilds the operator's `package.json` and `cordis.patch.yml` from a
parsed model on every daemon start and every `register-mcp` call, so
serializing normalizes formatting they never asked to change — `serde_json`
sorts object keys, `serde_yaml` renders a flow mapping as a block one and
prefers single quotes. Comparing the result to disk as BYTES therefore calls a
file "changed" that says exactly what it said before, and rewrites their
profile every run.

Ask the question by meaning instead: parse both sides and compare the
documents. Equal ⇒ return without touching the file, so a second registration
leaves every byte and the mtime alone (and `set_private_file` skips the chmod
when the file is already 0600). Semantic preservation of the operator's rows is
the requirement; byte-identical formatting of a file ccteam legitimately edits
is not — the first ccteam write does normalize that file, and `serde_yaml`
drops YAML comments, which the write site now says out loud. A
comment-preserving round-trip needs a CST-level YAML parser and nothing in this
workspace's dependency graph has one; no crate was added for it.

When a write IS needed it now goes through a sibling temp file and a rename
(the previous path truncated in place), with the destination's mode carried
onto the temp file so the credential-carrying patch file is never briefly
world-readable.

The ownership gate is untouched.

Maps to: requirements.md "ccteam does not rewrite the user's files";
tech-design.md §2.2 (DSH profile materialization).
CLI-ENVTEST-1 addendum. Two things: the peek test that fixing this card
broke on CI, and the same defect everywhere else in the workspace.

CI first. `run_peek_uses_state_tmux_session_for_meta_project` failed on
dev@be368490 after 5s in `RmuxBackend connect_or_start`. The claim in the
previous commit that name resolution is pure was wrong:
`resolve_chat_session_name` enumerates live sessions through a backend built
from `CCTEAM_MUX_BACKEND`, so dropping the old `=tmux` pin moved it onto the
default rmux backend and it went looking for a daemon on the runner. Fixed
where the env is read, not in the test: `resolve_chat_session_name_with`
takes the backend, `run_peek_with_backend` threads ONE backend through both
the lookup and the capture (they used to build two), and the test hands in
the recording double with a session list. It now asserts the rule structurally
— live panes exist but none is this slug's, so peek falls back to
`ProjectState::tmux_session` — plus a new case for the two branches the old
test never reached: a live chat session wins, and an explicit sid answers
without listing at all.

Then the sweep. The guard now walks every `crates/*/src/**` rather than only
ccteam-cli's, and found 17 call sites in 6 files. Each fixed at the layer
that reads the env:

- `ccteam-core` `write_project_settings_in(paths, …)` — `bootstrap_project`
  was handed `paths` and then let `effective_hook_sh_path` re-read the root
  from `CCTEAM_HOME` two frames down, so a caller whose `paths` disagreed with
  the env stamped a hook path from the wrong home.
- `ccteam-harness` `resolve_socket_path_from(override, codex_home, home)` —
  pure; its test now covers all three tiers instead of only the override.
- `ccteam-im` `Gateway::reply_wait` + `set_reply_wait` — the paneless
  first-event wait is resolved once at construction and carried on the
  gateway (and on `UnlockedTurnPlan`) instead of re-read from env on every
  turn by three separate call sites.
- `ccteam-im` `ask_permission_with_ttl(…, ttl_secs)` and
  `permission_prompt_timeout_secs_from(raw)` — the TTL is a parameter; the
  env read stays in the wrapper.
- `ccteam-im` `hub_base_from(raw)` — pure; the test now covers both the
  default and the override without unsetting anything.
- `ccteam-im` `daemon_boots_and_exits_on_max_runtime` — uses the
  `ccteam_root` + `registry` injections instead of pinning HOME/CCTEAM_HOME.

No test moved to `tests/*.rs`: every one of them was made env-free, so the
baseline rises rather than falls. Three `env_lock` helpers are gone with the
mutations they guarded.

The scanner also had a real bug: it sliced sources by byte index, which
panics the moment it meets a multi-byte character — and this workspace is
full of CJK comments. It is byte-only now.

Refs AGENTS.md §五 总纲 (同形扫一遍), §六 (env-mutating tests).

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ixed, CI green

Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ll, AGENTS/state/history/backlog distillation
@firstintent firstintent changed the title v0.10.4 cycle v0.10.5 cycle Aug 29, 2026
@firstintent
firstintent marked this pull request as ready for review August 29, 2026 15:24
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T15:29:19.960289Z 6dad1c2 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@firstintent
firstintent merged commit 38abbff into main Aug 29, 2026
3 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dad1c2a31

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +274 to +275
if (enrollment() === undefined && isLoopbackUrl(daemonUrl())) {
void enrollmentBootstrap.ensure()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bootstrap credentials only after the daemon is ready

On a fresh DSH installation where the host services are already available but no daemon or token file exists, this fire-and-forget enrollment attempt runs before the later web-face callback installs/starts the engine. restToken() therefore calls the read-once token bootstrap while secrets/web-token is absent and permanently caches undefined; enrollment fails, and subsequent workbench requests and MCP tools remain unauthenticated even after the daemon becomes ready, until the plugin process is restarted. Chain enrollment/token bootstrap after supervisor.ensure() succeeds, or allow a failed pre-start token read to be retried.

AGENTS.md reference: AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

Comment on lines +239 to +241
let existing = list_in(root)
.into_iter()
.find(|c| c.owner == owner && c.scope == scope && c.label.as_deref() == label);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize enrollment get-or-mint operations

When two POST /api/v1/enroll {ensure:true} requests for the same owner/scope/label run concurrently, both can observe no existing record here and each mint a different credential file. Both callers receive valid bearers, while later ensures arbitrarily select one of the duplicate records, violating the advertised one-slot/idempotent contract and leaking credentials across repeated concurrent boots. Protect the lookup-plus-mint/revoke sequence with a lock scoped to the enrollment store.

AGENTS.md reference: AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

Comment on lines +673 to +674
const port = parsed.port !== '' ? parsed.port : parsed.protocol === 'https:' ? '443' : '80'
return ['start', '--web-bind', `${parsed.hostname}:${port}`, '--json']

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve hostnames before constructing the daemon bind

If a user configures a local URL such as http://localhost:7333, isLoopbackUrl classifies it as locally supervised, but this produces --web-bind localhost:7333. The Rust launcher later parses that value as SocketAddr, which accepts numeric IP addresses rather than hostnames, so both auto-start and the Start button fail instead of launching the bundled engine. Resolve the hostname to a bindable address or reject/normalize such URLs before building the CLI arguments.

AGENTS.md reference: AGENTS.md:L20-L20

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant