From 679945d1af5716bee5bf5bac034054b24f01ebef Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:38:40 +0100 Subject: [PATCH 1/3] feat(server): per-user local_project_disabled flag (block create + index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an admin-controlled, per-user switch that forbids a user from creating local projects and from indexing/reindexing, while keeping search of their already-indexed projects and workspace creation available. Admins are always exempt. - DB: users.local_project_disabled INTEGER NOT NULL DEFAULT 0 + idempotent migration #14. Default 0 backfills existing and new users to "allowed" (backward compatible). - Carry the column through every users.User builder, including the two hand-rolled auth-path JOINs that populate ac.User: apikeys.Authenticate (CLI/API-key path) and sessions.Get (dashboard path) — missing either is a silent enforcement bypass. - Enforcement: requireLocalProjectActions guard (access.go) gates CreateProject and index begin/files/finish with 403; admins and CIX_AUTH_DISABLED exempt. index/cancel (cleanup) and index/status + search (read) stay open. - Admin API: local_project_disabled on userPayload (/auth/me + /admin/users) and as a PATCH /admin/users/{id} field; SetLocalProjectDisabled setter. - OpenAPI: User + UpdateUserRequest schema fields, 403 docs; regen dashboard types; admin Users table gains a per-user toggle ("Always" for admins). - Tests: migration backfill + setter round-trip; httpapi gating (403 on create/index for a restricted user, read/workspace stay allowed, admin exempt, re-enable restores). docs/AUTH_REVIEW.md updated. Co-Authored-By: Claude Opus 4.8 --- doc/openapi.yaml | 34 +++- .../components/UserLocalProjectToggle.tsx | 50 ++++++ .../modules/users/components/UsersTable.tsx | 14 ++ server/internal/apikeys/apikeys.go | 38 ++--- server/internal/db/db.go | 23 +++ server/internal/db/db_test.go | 63 ++++++++ server/internal/db/schema.go | 7 +- server/internal/httpapi/access.go | 27 ++++ server/internal/httpapi/auth.go | 44 +++--- server/internal/httpapi/auth_test.go | 145 +++++++++++++++++- server/internal/httpapi/server.go | 12 ++ server/internal/sessions/sessions.go | 14 +- server/internal/users/users.go | 65 ++++++-- server/internal/users/users_test.go | 51 ++++++ 14 files changed, 524 insertions(+), 63 deletions(-) create mode 100644 server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx diff --git a/doc/openapi.yaml b/doc/openapi.yaml index e9943b0..1f87ebc 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -739,6 +739,14 @@ paths: $ref: "#/components/schemas/Project" "401": $ref: "#/components/responses/Unauthorized" + "403": + description: | + The caller's account has `local_project_disabled=true` + (cannot create local projects). Admins are exempt. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "409": description: A project with this `host_path` already exists content: @@ -1091,6 +1099,15 @@ paths: $ref: "#/components/schemas/IndexBeginResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + description: | + The caller's account has `local_project_disabled=true` + (cannot index/reindex), or the caller can read the project but + is not its owner. Admins are exempt from the former. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "404": $ref: "#/components/responses/NotFound" "409": @@ -2907,7 +2924,7 @@ components: User: type: object - required: [id, email, role, must_change_password, created_at, updated_at, disabled] + required: [id, email, role, must_change_password, created_at, updated_at, disabled, local_project_disabled] properties: id: type: string @@ -2934,6 +2951,15 @@ components: type: string format: date-time nullable: true + local_project_disabled: + type: boolean + description: | + When true, the user may not create local projects or + index/reindex any project (both return 403). Search of + already-indexed projects and workspace creation remain + allowed. Admins are always exempt regardless of this flag. + Defaults to false; existing users created before this field + was introduced are false (allowed) for backward compatibility. UserWithStats: allOf: @@ -3051,6 +3077,12 @@ components: description: | When true, the user can no longer authenticate. Refused for the last enabled admin when set to true. + local_project_disabled: + type: boolean + description: | + When true, the user may not create local projects or + index/reindex any project. Search and workspace creation stay + allowed; admins are always exempt. Has no last-admin guard. RuntimeConfig: type: object diff --git a/server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx b/server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx new file mode 100644 index 0000000..7a58222 --- /dev/null +++ b/server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx @@ -0,0 +1,50 @@ +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Switch } from '@/ui/switch'; +import { useUpdateUser } from '../hooks'; + +// Inline toggle for the per-user "can create local projects / index" permission. +// The stored flag is `local_project_disabled` (true = forbidden); the switch is +// presented in the positive sense ("Allowed") so it reads naturally, so checked +// === !local_project_disabled. The server is the source of truth — on error we +// surface a toast and the next refetch resets the control. +export function UserLocalProjectToggle({ + userId, + localProjectDisabled, + disabled = false, +}: { + userId: string; + localProjectDisabled: boolean; + disabled?: boolean; +}) { + const update = useUpdateUser(); + const allowed = !localProjectDisabled; + + async function onChange(nextAllowed: boolean) { + const nextDisabled = !nextAllowed; + if (nextDisabled === localProjectDisabled) return; + try { + await update.mutateAsync({ + id: userId, + body: { local_project_disabled: nextDisabled }, + }); + toast.success('Permission updated', { + description: nextDisabled + ? 'Local project creation & indexing disabled' + : 'Local project creation & indexing enabled', + }); + } catch (err) { + const detail = err instanceof ApiError ? err.detail : String(err); + toast.error('Could not update permission', { description: detail }); + } + } + + return ( + void onChange(v)} + disabled={disabled || update.isPending} + aria-label="Allow creating local projects and indexing" + /> + ); +} diff --git a/server/dashboard/src/modules/users/components/UsersTable.tsx b/server/dashboard/src/modules/users/components/UsersTable.tsx index 592ef00..521dd6c 100644 --- a/server/dashboard/src/modules/users/components/UsersTable.tsx +++ b/server/dashboard/src/modules/users/components/UsersTable.tsx @@ -13,6 +13,7 @@ import { formatDateTime, formatRelative } from '@/lib/formatDate'; import { DeleteUserDialog } from './DeleteUserDialog'; import { DisableUserButton } from './DisableUserButton'; import { UserRoleSelect } from './UserRoleSelect'; +import { UserLocalProjectToggle } from './UserLocalProjectToggle'; export function UsersTable({ users, @@ -28,6 +29,9 @@ export function UsersTable({ Email Role + + Local projects + Created Last login Sessions @@ -50,6 +54,16 @@ export function UsersTable({ + + {u.role === 'admin' ? ( + Always + ) : ( + + )} + ?), (SELECT COUNT(1) FROM api_keys WHERE owner_user_id = users.id AND revoked_at IS NULL) @@ -221,18 +225,20 @@ func (s *Service) ListWithStats(ctx context.Context) ([]UserWithStats, error) { var ( u User mcp int + localProjectDisabled int createdAt, updatedAt string disabledAt sql.NullString lastLogin sql.NullString activeSessions, apiKeys int ) if err := rows.Scan( - &u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, + &u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &localProjectDisabled, &lastLogin, &activeSessions, &apiKeys, ); err != nil { return nil, err } u.MustChangePassword = mcp == 1 + u.LocalProjectDisabled = localProjectDisabled == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) u.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) if disabledAt.Valid { @@ -259,19 +265,20 @@ func (s *Service) ListWithStats(ctx context.Context) ([]UserWithStats, error) { func (s *Service) Authenticate(ctx context.Context, email, password string) (User, error) { email = normalizeEmail(email) row := s.DB.QueryRowContext(ctx, - `SELECT id, password_hash, role, must_change_password, created_at, updated_at, disabled_at, email + `SELECT id, password_hash, role, must_change_password, created_at, updated_at, disabled_at, local_project_disabled, email FROM users WHERE email = ? COLLATE NOCASE`, email) var ( - u User - hash string - mcp int - disabledAt sql.NullString - createdAt string - updatedAt string - emailOut string + u User + hash string + mcp int + localProjectDisabled int + disabledAt sql.NullString + createdAt string + updatedAt string + emailOut string ) - if err := row.Scan(&u.ID, &hash, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &emailOut); err != nil { + if err := row.Scan(&u.ID, &hash, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &localProjectDisabled, &emailOut); err != nil { if errors.Is(err, sql.ErrNoRows) { // Match the timing of a hash-compare to mitigate user-enumeration // via response time. dummyHash carries the active cost, so this @@ -289,6 +296,7 @@ func (s *Service) Authenticate(ctx context.Context, email, password string) (Use } u.Email = emailOut u.MustChangePassword = mcp == 1 + u.LocalProjectDisabled = localProjectDisabled == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) u.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) if disabledAt.Valid { @@ -372,6 +380,27 @@ func (s *Service) SetDisabled(ctx context.Context, id string, disabled bool) err return nil } +// SetLocalProjectDisabled toggles the per-user restriction on creating local +// projects and indexing/reindexing. No last-admin guard: admins are exempt +// from the restriction at enforcement time, so the flag is meaningful only +// for regular users and never locks anyone out of administration. +func (s *Service) SetLocalProjectDisabled(ctx context.Context, id string, disabled bool) error { + v := 0 + if disabled { + v = 1 + } + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.DB.ExecContext(ctx, + `UPDATE users SET local_project_disabled = ?, updated_at = ? WHERE id = ?`, v, now, id) + if err != nil { + return fmt.Errorf("update local_project_disabled: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + // Delete removes a user (cascades to sessions + api_keys via FK). // Refuses to delete the last active admin. func (s *Service) Delete(ctx context.Context, id string) error { @@ -412,7 +441,7 @@ func (s *Service) guardLastAdmin(ctx context.Context, id string) error { // --- helpers --- -const listSelect = `SELECT id, email, role, must_change_password, created_at, updated_at, disabled_at FROM users` +const listSelect = `SELECT id, email, role, must_change_password, created_at, updated_at, disabled_at, local_project_disabled FROM users` func (s *Service) scanOne(ctx context.Context, where string, args ...any) (User, error) { row := s.DB.QueryRowContext(ctx, listSelect+" "+where, args...) @@ -429,15 +458,17 @@ type rowScanner interface { func scanUserRow(r rowScanner) (User, error) { var ( - u User - mcp int - createdAt, updatedAt string - disabledAt sql.NullString + u User + mcp int + localProjectDisabled int + createdAt, updatedAt string + disabledAt sql.NullString ) - if err := r.Scan(&u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt); err != nil { + if err := r.Scan(&u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &localProjectDisabled); err != nil { return User{}, err } u.MustChangePassword = mcp == 1 + u.LocalProjectDisabled = localProjectDisabled == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) u.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) if disabledAt.Valid { diff --git a/server/internal/users/users_test.go b/server/internal/users/users_test.go index 7768cf3..44bc212 100644 --- a/server/internal/users/users_test.go +++ b/server/internal/users/users_test.go @@ -118,6 +118,57 @@ func TestDelete_LastAdminBlock(t *testing.T) { } } +// TestLocalProjectDisabled_DefaultAndRoundTrip verifies the new per-user flag: +// new users default to allowed (false), the setter toggles it, and the value +// survives every User builder (GetByID, List, Authenticate). +func TestLocalProjectDisabled_DefaultAndRoundTrip(t *testing.T) { + ctx := context.Background() + s := newTestService(t) + + u, err := s.Create(ctx, "a@b.com", "password1234", RoleUser, false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if u.LocalProjectDisabled { + t.Fatalf("new user should default to allowed (LocalProjectDisabled=false)") + } + + // Disable, then confirm it is visible through GetByID. + if err := s.SetLocalProjectDisabled(ctx, u.ID, true); err != nil { + t.Fatalf("SetLocalProjectDisabled(true): %v", err) + } + got, err := s.GetByID(ctx, u.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if !got.LocalProjectDisabled { + t.Errorf("GetByID: LocalProjectDisabled = false, want true") + } + + // Authenticate must also carry the flag (it builds User from its own SELECT). + auth, err := s.Authenticate(ctx, "a@b.com", "password1234") + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !auth.LocalProjectDisabled { + t.Errorf("Authenticate: LocalProjectDisabled = false, want true") + } + + // Re-enable round-trips back to false. + if err := s.SetLocalProjectDisabled(ctx, u.ID, false); err != nil { + t.Fatalf("SetLocalProjectDisabled(false): %v", err) + } + got, _ = s.GetByID(ctx, u.ID) + if got.LocalProjectDisabled { + t.Errorf("after re-enable: LocalProjectDisabled = true, want false") + } + + // Unknown id → ErrNotFound. + if err := s.SetLocalProjectDisabled(ctx, "no-such-id", true); !errors.Is(err, ErrNotFound) { + t.Errorf("SetLocalProjectDisabled(unknown) err = %v, want ErrNotFound", err) + } +} + func TestInvalidRole(t *testing.T) { s := newTestService(t) _, err := s.Create(context.Background(), "a@b.com", "password1", "superadmin", false) From 61d13ae7461c45ea16e5c28917cc20d7ec6ac6cb Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:45:40 +0100 Subject: [PATCH 2/3] docs(skills): make cix-workspace multi-server aware + align install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cix-workspace skill and its investigator sub-agent assumed a single cix backend. The cix CLI supports several named servers (--server , CIX_SERVER), and a workspace + all its repos live on exactly one server — so a cross-project workflow that mixes servers silently returns empty or wrong-repo results. - cix-workspace SKILL: new "which server hosts the workspace?" section; thread --server through Step 0/1/2, the sub-agent fan-out prompt, the quick reference, and the TL;DR. Replace the raw curl per-project search (which hardcodes one server's URL/key) with `cix search -n --server `, which resolves the right backend from config. - cix-workspace-investigator: every cix call must carry --server when the workspace is on a non-default server (tools list, hard rule 1, the "where your project lives" preamble). - skills/README.md: align the plugin install snippet with the dashboard onboarding card — `claude plugin …` console commands, drop the obsolete /reload-plugins. - Sync canonical skills into the plugin bundle (sync-skills.sh); plugin bats suite green (15/15). The cix skill already documented multi-server; no change needed there. Co-Authored-By: Claude Opus 4.8 --- .../cix/agents/cix-workspace-investigator.md | 37 ++++++-- plugins/cix/skills/cix-workspace/SKILL.md | 84 ++++++++++++++++--- skills/README.md | 11 +-- skills/cix-workspace/SKILL.md | 84 ++++++++++++++++--- .../agents/cix-workspace-investigator.md | 37 ++++++-- 5 files changed, 210 insertions(+), 43 deletions(-) diff --git a/plugins/cix/agents/cix-workspace-investigator.md b/plugins/cix/agents/cix-workspace-investigator.md index 6295327..561f959 100644 --- a/plugins/cix/agents/cix-workspace-investigator.md +++ b/plugins/cix/agents/cix-workspace-investigator.md @@ -27,11 +27,24 @@ of two shapes. Behave very differently depending on which: nothing useful — there's nothing to read locally. The cix server has the files, chunks, and symbols; you reach them only through the `cix` CLI. -**How to tell which you have:** run `cix list` once, then `grep` for the -exact identifier the main agent gave you. +**If the main agent gave you a server alias, use it on EVERY cix call.** +The `cix` CLI can have several named servers configured, and a workspace +(plus all its repos) lives on exactly one of them. The main agent will +tell you which — e.g. "this project is on server `corporate`". When it +does, add the global `--server ` flag to *every* `cix` command +below, alongside `-n `. Without it, cix talks to the +*default* server, where your assigned project doesn't exist, and every +call comes back empty (which looks like "nothing found" but is really +"wrong server"). If no alias was given, you're on the default server — +don't invent one. + +**How to tell which shape your project is:** run `cix list` once (on the +right server), then `grep` for the exact identifier the main agent gave +you. ```bash -cix list | grep -F "" +cix list --server | grep -F "" +# (drop --server if the main agent didn't name one — default server) ``` - A line starting with `[✓] /` → local working tree. @@ -47,6 +60,11 @@ the code. You have a read-only toolkit for code investigation inside the assigned project: +> **Server flag.** If the main agent named a server (`--server `), +> append it to *every* command in this list, e.g. +> `cix search "" -n --server `. The examples +> below omit it for brevity; add it whenever you were given an alias. + - **`cix search "" -n `** — semantic / hybrid lookups *inside the assigned project*. **Always pass `-n `** (the identifier from `cix list`); without it, cix searches whatever project @@ -72,11 +90,14 @@ re-index. ## Hard rules — non-negotiable -1. **Stay inside the assigned project.** Every `cix` invocation MUST carry - `-n `. Without it, cix searches the cwd's project — that's - the main session's repo, not yours. Don't read or query other workspace - repos. If a finding requires looking elsewhere, surface it as an - uncertainty for the main agent to fan out further. +1. **Stay inside the assigned project — and on the assigned server.** + Every `cix` invocation MUST carry `-n `, plus + `--server ` if the main agent named one. Without `-n`, cix + searches the cwd's project (the main session's repo, not yours); + without the right `--server`, it queries the wrong backend and returns + empty. Don't read or query other workspace repos. If a finding requires + looking elsewhere, surface it as an uncertainty for the main agent to + fan out further. 2. **Never hunt the filesystem for a remote-only project.** No `find /`, no `locate`, no `ls -R ~`, no recursive Grep across `/`. If `cix list` shows the project as `github.com/…@…`, the files do diff --git a/plugins/cix/skills/cix-workspace/SKILL.md b/plugins/cix/skills/cix-workspace/SKILL.md index 2eb983d..f894d3e 100644 --- a/plugins/cix/skills/cix-workspace/SKILL.md +++ b/plugins/cix/skills/cix-workspace/SKILL.md @@ -31,6 +31,36 @@ to implementation before you can answer all three with evidence. --- +## First: which server hosts the workspace? + +The `cix` CLI can be configured with **several named servers** (a local +box, a remote corporate backend, …). Each server hosts its **own** set of +workspaces and projects — a workspace named `platform` on one server is +unrelated to anything on another. Every `cix` command targets the +**default** server unless you pass the global `--server ` flag (or +set `CIX_SERVER`). + +```bash +cix config show # lists configured servers; * marks the default +``` + +**A workspace and all its repos live on exactly one server.** So before +you run the workflow, decide which server you're on, then be **consistent**: +pass the *same* `--server ` to *every* command in the flow — +`cix ws`, workspace search, per-project drill-down, and the sub-agent +fan-out. Mixing servers mid-workflow (orient on server A, drill down on +the default) silently returns empty or wrong-repo results, because the +project simply doesn't exist on the other server. + +**Agent rule:** use the default server (no flag) unless the user names a +specific server, or the primary project's workspace isn't on the default. +Never guess an alias — run `cix config show` to see the configured names. +Once you know the alias, thread it through the whole workflow. The +examples below omit `--server` for readability; add it to **every** command +when the target workspace is on a non-default server. + +--- + ## When to reach for workspace search | Signal in the user's request | What to do | @@ -57,10 +87,16 @@ The goal-driven loop. Don't shortcut it. Each step is fast. ### Step 0 — orient ```bash -cix ws # list workspaces; find the one your primary is in +cix config show # which servers exist? which is default? +cix ws # list workspaces on the (default) server cix ws # describe — confirm repos are indexed (✓ count) ``` +If `cix ws` doesn't list the workspace your task is about, it may live on +a different server — re-run with `--server ` (the alias from +`cix config show`) and keep that flag on every later command. Lock in the +server here, before searching. + If the workspace shows `stale_fts_repos` in any search response later, trust the dense ranking less — see the troubleshooting section. @@ -107,21 +143,28 @@ deeper read, not as the full answer. For repos other than the primary, you have two options: **A. Quick scan (≤ 2 repos to investigate):** use single-project -search directly. +search directly via the CLI, scoped with `-n` to the project. Pass the +`project_path` from the workspace-search `projects[]` panel verbatim: ```bash -# Search inside one specific project -curl -G -H "Authorization: Bearer $CIX_KEY" \ - --data-urlencode "q=rate limit middleware handler" \ - --data-urlencode "min_score=0" \ - "$CIX_URL/api/v1/projects/$(project_hash)/search" +# Search inside one specific project (-n = exact project ID from cix list / +# the workspace projects[] panel). --server keeps it on the same backend +# the workspace lives on — REQUIRED when that's not the default server. +cix search "rate limit middleware handler" -n --min-score 0 +cix search "rate limit middleware handler" -n --server corporate --min-score 0 ``` The per-project default `min_score` is `0.2` — light floor that keeps abstract NL queries non-empty. For drill-down on a natural- -language question ("how does X work end-to-end"), pass `min_score=0` +language question ("how does X work end-to-end"), pass `--min-score 0` explicitly to be safe. For strict code-symbol matching, pass `0.4+`. +> Prefer the CLI over a raw `curl … /api/v1/projects/{hash}/search`: the +> CLI resolves the right server (and its key) from your config, so you +> can't accidentally point `$CIX_URL`/`$CIX_KEY` at a *different* server +> than the workspace. If you do hand-roll curl, make sure the URL + key +> belong to the server that hosts this workspace. + **B. Fan-out to sub-agents (≥ 3 repos, or you need a thorough read):** spawn one `cix-workspace-investigator` sub-agent per relevant repo, in parallel. See the dedicated [Sub-agent fan-out pattern](#sub-agent-fan-out-pattern) @@ -366,6 +409,14 @@ entry in `projects[]`. Paste that string verbatim into the sub-agent prompt, and tell the sub-agent explicitly which shape it is so it doesn't waste calls grepping a tree that isn't there. One repo per spawn. +**Pass the server alias too.** If the workspace is on a non-default server, +the sub-agent's `cix search -n ` calls must carry the *same* +`--server ` — otherwise they hit the default server, where the +project doesn't exist, and come back empty. State it plainly in the prompt: +"This project lives on server `corporate`; add `--server corporate` to every +`cix` call." If you're on the default server, say so (or omit it) so the +sub-agent doesn't invent a flag. + #### 3. Seed chunks **with your commentary** This is the part most often done badly. Don't just paste raw chunk @@ -404,6 +455,8 @@ Seed chunks from workspace search: shared utilities. Panel-level notes: +- Server: this project is on `corporate` — pass `--server corporate` on + every cix call (it does NOT exist on the default server). - Workspace ranked this project #1 with a clear lead (project_score 1.000 vs next 0.860). High confidence this is the right repo. - bm25_score=8.5, dense_score=0.54 — strong on both signals, not a @@ -590,6 +643,11 @@ language to see what stack it actually uses, then refine. ## Quick command reference ```bash +# Servers (run first if more than one backend is configured) +cix config show # list servers; * marks the default +cix ws --server corporate # any command takes the global --server +# (CIX_SERVER=corporate also selects it; --server wins over the env var) + # List workspaces cix ws cix ws list --json @@ -628,12 +686,16 @@ Flags: When the user's task plausibly spans more than one repo: +0. `cix config show` → if more than one server, decide which one hosts + the workspace and thread the **same** `--server ` through every + command below (default server → no flag needed). 1. `cix ws` → find the workspace, then `cix ws ` describe it. 2. Workspace search with a **short, term-rich** query. 3. Read `projects[]` → that's your scope (Q1 answered). -4. For each repo in scope, either single-project search or spawn a - `cix-workspace-investigator` sub-agent — in parallel, with seed - chunks AND your interpretive commentary on what to trust. +4. For each repo in scope, either single-project search (`cix search + -n `) or spawn a `cix-workspace-investigator` sub-agent + — in parallel, with seed chunks, the server alias, AND your + interpretive commentary on what to trust. 5. Synthesize the sub-agent reports → plan changes per repo, with order constraints (Q2 + Q3 answered). 6. Ask the user to confirm the scope and plan before implementing. diff --git a/skills/README.md b/skills/README.md index 47950b2..0bf9efa 100644 --- a/skills/README.md +++ b/skills/README.md @@ -61,12 +61,13 @@ follows instructions and reports. ### Install Easiest path is the **`cix` Claude Code plugin** (v0.2.0+) — both the -skill and the sub-agent are bundled and installed together: +skill and the sub-agent are bundled and installed together. Run these in +a terminal (not inside a Claude Code session); the install applies the +next time you start `claude`, so there's no reload step: -``` -/plugin marketplace add dvcdsys/code-index -/plugin install cix@code-index -/reload-plugins +```bash +claude plugin marketplace add dvcdsys/code-index +claude plugin install cix@code-index ``` Or manually: diff --git a/skills/cix-workspace/SKILL.md b/skills/cix-workspace/SKILL.md index 2eb983d..f894d3e 100644 --- a/skills/cix-workspace/SKILL.md +++ b/skills/cix-workspace/SKILL.md @@ -31,6 +31,36 @@ to implementation before you can answer all three with evidence. --- +## First: which server hosts the workspace? + +The `cix` CLI can be configured with **several named servers** (a local +box, a remote corporate backend, …). Each server hosts its **own** set of +workspaces and projects — a workspace named `platform` on one server is +unrelated to anything on another. Every `cix` command targets the +**default** server unless you pass the global `--server ` flag (or +set `CIX_SERVER`). + +```bash +cix config show # lists configured servers; * marks the default +``` + +**A workspace and all its repos live on exactly one server.** So before +you run the workflow, decide which server you're on, then be **consistent**: +pass the *same* `--server ` to *every* command in the flow — +`cix ws`, workspace search, per-project drill-down, and the sub-agent +fan-out. Mixing servers mid-workflow (orient on server A, drill down on +the default) silently returns empty or wrong-repo results, because the +project simply doesn't exist on the other server. + +**Agent rule:** use the default server (no flag) unless the user names a +specific server, or the primary project's workspace isn't on the default. +Never guess an alias — run `cix config show` to see the configured names. +Once you know the alias, thread it through the whole workflow. The +examples below omit `--server` for readability; add it to **every** command +when the target workspace is on a non-default server. + +--- + ## When to reach for workspace search | Signal in the user's request | What to do | @@ -57,10 +87,16 @@ The goal-driven loop. Don't shortcut it. Each step is fast. ### Step 0 — orient ```bash -cix ws # list workspaces; find the one your primary is in +cix config show # which servers exist? which is default? +cix ws # list workspaces on the (default) server cix ws # describe — confirm repos are indexed (✓ count) ``` +If `cix ws` doesn't list the workspace your task is about, it may live on +a different server — re-run with `--server ` (the alias from +`cix config show`) and keep that flag on every later command. Lock in the +server here, before searching. + If the workspace shows `stale_fts_repos` in any search response later, trust the dense ranking less — see the troubleshooting section. @@ -107,21 +143,28 @@ deeper read, not as the full answer. For repos other than the primary, you have two options: **A. Quick scan (≤ 2 repos to investigate):** use single-project -search directly. +search directly via the CLI, scoped with `-n` to the project. Pass the +`project_path` from the workspace-search `projects[]` panel verbatim: ```bash -# Search inside one specific project -curl -G -H "Authorization: Bearer $CIX_KEY" \ - --data-urlencode "q=rate limit middleware handler" \ - --data-urlencode "min_score=0" \ - "$CIX_URL/api/v1/projects/$(project_hash)/search" +# Search inside one specific project (-n = exact project ID from cix list / +# the workspace projects[] panel). --server keeps it on the same backend +# the workspace lives on — REQUIRED when that's not the default server. +cix search "rate limit middleware handler" -n --min-score 0 +cix search "rate limit middleware handler" -n --server corporate --min-score 0 ``` The per-project default `min_score` is `0.2` — light floor that keeps abstract NL queries non-empty. For drill-down on a natural- -language question ("how does X work end-to-end"), pass `min_score=0` +language question ("how does X work end-to-end"), pass `--min-score 0` explicitly to be safe. For strict code-symbol matching, pass `0.4+`. +> Prefer the CLI over a raw `curl … /api/v1/projects/{hash}/search`: the +> CLI resolves the right server (and its key) from your config, so you +> can't accidentally point `$CIX_URL`/`$CIX_KEY` at a *different* server +> than the workspace. If you do hand-roll curl, make sure the URL + key +> belong to the server that hosts this workspace. + **B. Fan-out to sub-agents (≥ 3 repos, or you need a thorough read):** spawn one `cix-workspace-investigator` sub-agent per relevant repo, in parallel. See the dedicated [Sub-agent fan-out pattern](#sub-agent-fan-out-pattern) @@ -366,6 +409,14 @@ entry in `projects[]`. Paste that string verbatim into the sub-agent prompt, and tell the sub-agent explicitly which shape it is so it doesn't waste calls grepping a tree that isn't there. One repo per spawn. +**Pass the server alias too.** If the workspace is on a non-default server, +the sub-agent's `cix search -n ` calls must carry the *same* +`--server ` — otherwise they hit the default server, where the +project doesn't exist, and come back empty. State it plainly in the prompt: +"This project lives on server `corporate`; add `--server corporate` to every +`cix` call." If you're on the default server, say so (or omit it) so the +sub-agent doesn't invent a flag. + #### 3. Seed chunks **with your commentary** This is the part most often done badly. Don't just paste raw chunk @@ -404,6 +455,8 @@ Seed chunks from workspace search: shared utilities. Panel-level notes: +- Server: this project is on `corporate` — pass `--server corporate` on + every cix call (it does NOT exist on the default server). - Workspace ranked this project #1 with a clear lead (project_score 1.000 vs next 0.860). High confidence this is the right repo. - bm25_score=8.5, dense_score=0.54 — strong on both signals, not a @@ -590,6 +643,11 @@ language to see what stack it actually uses, then refine. ## Quick command reference ```bash +# Servers (run first if more than one backend is configured) +cix config show # list servers; * marks the default +cix ws --server corporate # any command takes the global --server +# (CIX_SERVER=corporate also selects it; --server wins over the env var) + # List workspaces cix ws cix ws list --json @@ -628,12 +686,16 @@ Flags: When the user's task plausibly spans more than one repo: +0. `cix config show` → if more than one server, decide which one hosts + the workspace and thread the **same** `--server ` through every + command below (default server → no flag needed). 1. `cix ws` → find the workspace, then `cix ws ` describe it. 2. Workspace search with a **short, term-rich** query. 3. Read `projects[]` → that's your scope (Q1 answered). -4. For each repo in scope, either single-project search or spawn a - `cix-workspace-investigator` sub-agent — in parallel, with seed - chunks AND your interpretive commentary on what to trust. +4. For each repo in scope, either single-project search (`cix search + -n `) or spawn a `cix-workspace-investigator` sub-agent + — in parallel, with seed chunks, the server alias, AND your + interpretive commentary on what to trust. 5. Synthesize the sub-agent reports → plan changes per repo, with order constraints (Q2 + Q3 answered). 6. Ask the user to confirm the scope and plan before implementing. diff --git a/skills/cix-workspace/agents/cix-workspace-investigator.md b/skills/cix-workspace/agents/cix-workspace-investigator.md index 6295327..561f959 100644 --- a/skills/cix-workspace/agents/cix-workspace-investigator.md +++ b/skills/cix-workspace/agents/cix-workspace-investigator.md @@ -27,11 +27,24 @@ of two shapes. Behave very differently depending on which: nothing useful — there's nothing to read locally. The cix server has the files, chunks, and symbols; you reach them only through the `cix` CLI. -**How to tell which you have:** run `cix list` once, then `grep` for the -exact identifier the main agent gave you. +**If the main agent gave you a server alias, use it on EVERY cix call.** +The `cix` CLI can have several named servers configured, and a workspace +(plus all its repos) lives on exactly one of them. The main agent will +tell you which — e.g. "this project is on server `corporate`". When it +does, add the global `--server ` flag to *every* `cix` command +below, alongside `-n `. Without it, cix talks to the +*default* server, where your assigned project doesn't exist, and every +call comes back empty (which looks like "nothing found" but is really +"wrong server"). If no alias was given, you're on the default server — +don't invent one. + +**How to tell which shape your project is:** run `cix list` once (on the +right server), then `grep` for the exact identifier the main agent gave +you. ```bash -cix list | grep -F "" +cix list --server | grep -F "" +# (drop --server if the main agent didn't name one — default server) ``` - A line starting with `[✓] /` → local working tree. @@ -47,6 +60,11 @@ the code. You have a read-only toolkit for code investigation inside the assigned project: +> **Server flag.** If the main agent named a server (`--server `), +> append it to *every* command in this list, e.g. +> `cix search "" -n --server `. The examples +> below omit it for brevity; add it whenever you were given an alias. + - **`cix search "" -n `** — semantic / hybrid lookups *inside the assigned project*. **Always pass `-n `** (the identifier from `cix list`); without it, cix searches whatever project @@ -72,11 +90,14 @@ re-index. ## Hard rules — non-negotiable -1. **Stay inside the assigned project.** Every `cix` invocation MUST carry - `-n `. Without it, cix searches the cwd's project — that's - the main session's repo, not yours. Don't read or query other workspace - repos. If a finding requires looking elsewhere, surface it as an - uncertainty for the main agent to fan out further. +1. **Stay inside the assigned project — and on the assigned server.** + Every `cix` invocation MUST carry `-n `, plus + `--server ` if the main agent named one. Without `-n`, cix + searches the cwd's project (the main session's repo, not yours); + without the right `--server`, it queries the wrong backend and returns + empty. Don't read or query other workspace repos. If a finding requires + looking elsewhere, surface it as an uncertainty for the main agent to + fan out further. 2. **Never hunt the filesystem for a remote-only project.** No `find /`, no `locate`, no `ls -R ~`, no recursive Grep across `/`. If `cix list` shows the project as `github.com/…@…`, the files do From ae8254cf82cabd5dee0c9e8b7e0b8936f43c30a0 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:54:36 +0100 Subject: [PATCH 3/3] test(server): cover index/files 403 + document local_project_disabled edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the per-user restriction: - Add an index/files 403 case to the gating test (the guard is identical to begin/finish, but explicit coverage closes the gap). - Document the role-independent persistence edge: the flag is not cleared on promote-to-admin (admins just ignore it) and re-activates on demotion — intentional. Noted at SetLocalProjectDisabled and the dashboard "Always" cell. - Document the mid-indexing edge: flipping the flag during an in-flight index session strands it; index/cancel stays open and the session TTLs out. Noted at requireLocalProjectActions. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/src/modules/users/components/UsersTable.tsx | 4 ++++ server/internal/httpapi/access.go | 8 ++++++++ server/internal/httpapi/auth_test.go | 7 +++++++ server/internal/users/users.go | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/server/dashboard/src/modules/users/components/UsersTable.tsx b/server/dashboard/src/modules/users/components/UsersTable.tsx index 521dd6c..733c1df 100644 --- a/server/dashboard/src/modules/users/components/UsersTable.tsx +++ b/server/dashboard/src/modules/users/components/UsersTable.tsx @@ -55,6 +55,10 @@ export function UsersTable({ + {/* Admins ignore local_project_disabled (always allowed), so + we show "Always" instead of a toggle. The stored flag is + left untouched — if this admin is later demoted to user it + takes effect again. */} {u.role === 'admin' ? ( Always ) : ( diff --git a/server/internal/httpapi/access.go b/server/internal/httpapi/access.go index c2887f8..baf77d9 100644 --- a/server/internal/httpapi/access.go +++ b/server/internal/httpapi/access.go @@ -32,6 +32,14 @@ func (s *Server) callerIdentity(r *http.Request) (userID string, isAdmin bool) { // rejection it writes the response and returns false: // // if !s.requireLocalProjectActions(w, r) { return } +// +// Mid-indexing edge: the flag is checked per request, so flipping it on while +// a user has an in-flight index session (begin succeeded) makes the next +// index/files or index/finish 403 and strands that session. This is benign — +// index/cancel is deliberately NOT gated (see IndexCancel) so the user/CLI can +// still release the run lock, and an abandoned session expires on its own TTL. +// We accept the rare stranded-session window rather than special-casing +// in-flight runs. func (s *Server) requireLocalProjectActions(w http.ResponseWriter, r *http.Request) bool { if s.Deps.AuthDisabled { return true diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go index 43c7bb3..0e65431 100644 --- a/server/internal/httpapi/auth_test.go +++ b/server/internal/httpapi/auth_test.go @@ -620,6 +620,13 @@ func TestLocalProjectDisabled_GatesCreateAndIndex(t *testing.T) { t.Errorf("restricted index/begin = %d, want 403", code) } }) + t.Run("restricted: index/files is 403", func(t *testing.T) { + // Same guard as begin/finish — the gate runs before the body is read, + // so an empty body still 403s. Covers the mid-protocol upload step. + if code := do(aliceCookie, http.MethodPost, "/api/v1/projects/"+aliceProj+"/index/files", []byte("{}")); code != http.StatusForbidden { + t.Errorf("restricted index/files = %d, want 403", code) + } + }) t.Run("restricted: index/finish is 403", func(t *testing.T) { if code := do(aliceCookie, http.MethodPost, "/api/v1/projects/"+aliceProj+"/index/finish", []byte(`{"run_id":"x"}`)); code != http.StatusForbidden { t.Errorf("restricted index/finish = %d, want 403", code) diff --git a/server/internal/users/users.go b/server/internal/users/users.go index 4e22753..79ac35d 100644 --- a/server/internal/users/users.go +++ b/server/internal/users/users.go @@ -384,6 +384,14 @@ func (s *Service) SetDisabled(ctx context.Context, id string, disabled bool) err // projects and indexing/reindexing. No last-admin guard: admins are exempt // from the restriction at enforcement time, so the flag is meaningful only // for regular users and never locks anyone out of administration. +// +// The column is independent of role: promoting a restricted user to admin +// does NOT clear it (admins simply ignore it via the enforcement guard), so a +// later demotion back to user re-activates the stored restriction. This is +// intentional — the flag records an explicit admin decision about that +// account, and a round-trip through admin shouldn't silently erase it. The +// dashboard renders "Always" for admin rows to reflect the exemption without +// implying the stored value changed. func (s *Service) SetLocalProjectDisabled(ctx context.Context, id string, disabled bool) error { v := 0 if disabled {