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/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/server/dashboard/src/lib/cixServer.ts b/server/dashboard/src/lib/cixServer.ts new file mode 100644 index 0000000..f7a8d04 --- /dev/null +++ b/server/dashboard/src/lib/cixServer.ts @@ -0,0 +1,31 @@ +// Derive a cix CLI server alias from the browser host. The CLI stores each +// server as one entry under `server.` and parses that config key by +// splitting on dots, so the alias must be dot-free (and whitespace-free, and +// non-empty) — see validateServerName / parseServerKey in the CLI. We fold the +// host (including any non-default port, so distinct ports get distinct aliases) +// to [a-z0-9-]: "cix.example.com" -> "cix-example-com", +// "localhost:21847" -> "localhost-21847". +// +// Shared by the API-key "Connect the cix CLI" popup and the home-page +// onboarding card so the two never drift. +export function cixServerAlias(host: string): string { + const alias = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return alias || 'default'; +} + +// Build the one-paste command that registers a server (URL + key as a single +// `server.` entry) in the cix CLI config and makes it the default. +// Values are shell-safe (a URL, a `cix_` key, an [a-z0-9-] alias), so no +// quoting is needed — matching the CLI README's unquoted examples. `key` +// defaults to a `` placeholder for previews where no secret is revealed. +export function cixConnectCommand(origin: string, host: string, key = ''): string { + const alias = cixServerAlias(host); + return ( + `cix config set server.${alias}.url ${origin} && ` + + `cix config set server.${alias}.key ${key} && ` + + `cix config set default_server ${alias}` + ); +} diff --git a/server/dashboard/src/lib/useCopy.ts b/server/dashboard/src/lib/useCopy.ts new file mode 100644 index 0000000..fa9db82 --- /dev/null +++ b/server/dashboard/src/lib/useCopy.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +// Last-resort copy when the async Clipboard API isn't available — happens on +// plain HTTP deploys (non-localhost) and inside some embedded webviews. +// document.execCommand('copy') is deprecated but universally implemented as of +// 2026; keeping it as a fallback turns "no way to copy" into "always works". +function legacyCopy(text: string): boolean { + if (typeof document === 'undefined') return false; + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch { + ok = false; + } + document.body.removeChild(ta); + return ok; +} + +// useCopy — one-click clipboard with a transient "copied" flag and graceful +// fallback. navigator.clipboard requires a secure context (HTTPS or localhost); +// on bare-IP / HTTP deploys it throws, so we fall back to document.execCommand +// through a transient textarea. On total failure we surface a toast telling the +// user to copy manually. `copied` flips true for `resetMs` after a success so +// callers can show a checkmark without re-implementing the timer. +export function useCopy(resetMs = 2000): { + copied: boolean; + copy: (text: string) => Promise; +} { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + + const copy = useCallback( + async (text: string): Promise => { + let ok = false; + try { + if (window.isSecureContext && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + ok = true; + } else { + ok = legacyCopy(text); + if (!ok) throw new Error('legacy copy failed'); + } + } catch { + toast.error('Could not copy automatically.', { + description: 'Select the text, ⌘A / Ctrl-A, then copy.', + }); + return false; + } + setCopied(true); + if (timer.current) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), resetMs); + return ok; + }, + [resetMs] + ); + + return { copied, copy }; +} diff --git a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx index 4b56de6..0f05d8c 100644 --- a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx +++ b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Check, Copy, Loader2, KeyRound, AlertTriangle } from 'lucide-react'; +import { Check, Copy, Loader2, KeyRound, AlertTriangle, Terminal } from 'lucide-react'; import { toast } from 'sonner'; import { ApiError } from '@/api/client'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert'; @@ -15,6 +15,7 @@ import { } from '@/ui/dialog'; import { Input } from '@/ui/input'; import { Label } from '@/ui/label'; +import { cixConnectCommand } from '@/lib/cixServer'; import { useCreateApiKey } from '../hooks'; // Last-resort copy when the async Clipboard API isn't available — happens @@ -49,7 +50,8 @@ export function CreateApiKeyDialog() { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const [revealed, setRevealed] = useState(null); - const [copied, setCopied] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const [copiedCmd, setCopiedCmd] = useState(false); const inputRef = useRef(null); const create = useCreateApiKey(); @@ -65,7 +67,8 @@ export function CreateApiKeyDialog() { function reset() { setName(''); setRevealed(null); - setCopied(false); + setCopiedKey(false); + setCopiedCmd(false); create.reset(); } @@ -81,23 +84,49 @@ export function CreateApiKeyDialog() { } } - async function copyToClipboard() { - if (!revealed) return; - // navigator.clipboard requires a secure context (HTTPS or localhost). On - // bare-IP / HTTP deploys it throws — fall back to document.execCommand - // through a transient textarea so users still get one-click copy. + // copyText copies one string to the clipboard, surfacing a toast on failure. + // navigator.clipboard requires a secure context (HTTPS or localhost). On + // bare-IP / HTTP deploys it throws — fall back to document.execCommand + // through a transient textarea so users still get one-click copy. + async function copyText(text: string): Promise { try { if (window.isSecureContext && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(revealed); + await navigator.clipboard.writeText(text); } else { - if (!legacyCopy(revealed)) throw new Error('legacy copy failed'); + if (!legacyCopy(text)) throw new Error('legacy copy failed'); } - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + return true; } catch { toast.error('Could not copy automatically.', { - description: 'Click the field, ⌘A / Ctrl-A to select, then copy.', + description: 'Select the text, ⌘A / Ctrl-A, then copy.', }); + return false; + } + } + + // One-paste command that registers THIS server (URL + the freshly minted key, + // as a single `server.` entry) in the cix CLI config and makes it the + // default. The dashboard is served same-origin from cix-server, so + // window.location.origin is exactly the base URL the CLI must talk to. Shared + // with the home-page onboarding card via cixConnectCommand so they never drift. + const connectCmd = cixConnectCommand( + window.location.origin, + window.location.host, + revealed ?? '' + ); + + async function copyKey() { + if (!revealed) return; + if (await copyText(revealed)) { + setCopiedKey(true); + window.setTimeout(() => setCopiedKey(false), 2000); + } + } + + async function copyConnectCmd() { + if (await copyText(connectCmd)) { + setCopiedCmd(true); + window.setTimeout(() => setCopiedCmd(false), 2000); } } @@ -105,11 +134,14 @@ export function CreateApiKeyDialog() { { - // Once a key is revealed, only the explicit "I've saved it" button - // (or close-X) may dismiss the dialog. Outside-click and Escape are - // blocked at the DialogContent layer below; we still gate state-resets - // here so a sibling dismiss path can't wipe the key silently. - if (!next && revealed) return; + // Accidental dismissal of the reveal screen (outside-click, Escape) is + // blocked at the DialogContent layer below via preventDefault, so it + // never reaches here. The only close paths that DO reach onOpenChange + // while a key is revealed are explicit user intent — the top-right X + // button (the "I've saved it" button sets state directly) — so honor + // every close and always reset. (An earlier guard returned here when a + // key was revealed, which also swallowed the X click: the dialog could + // then only be dismissed by reloading the page.) setOpen(next); if (!next) reset(); }} @@ -165,8 +197,8 @@ export function CreateApiKeyDialog() { onFocus={(e) => e.currentTarget.select()} onClick={(e) => e.currentTarget.select()} /> - + +

+ Paste this in a terminal with the cix CLI installed. It saves the + server to ~/.cix/config.yaml as the default, then{' '} + cix search "…" works. +

+ + + ); +} diff --git a/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx new file mode 100644 index 0000000..d51ad5d --- /dev/null +++ b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx @@ -0,0 +1,220 @@ +import { useState, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronDown, ChevronRight, KeyRound, Sparkles } from 'lucide-react'; +import { Card } from '@/ui/card'; +import { Badge } from '@/ui/badge'; +import { cn } from '@/lib/cn'; +import { cixConnectCommand } from '@/lib/cixServer'; +import { CommandBlock } from './CommandBlock'; + +// Collapse state lives in localStorage so a returning user who dismissed the +// onboarding card keeps it dismissed. Expanded by default (key absent → false). +const COLLAPSE_KEY = 'cix.home.connect-claude.collapsed'; + +function readCollapsed(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(COLLAPSE_KEY) === '1'; + } catch { + return false; + } +} + +function writeCollapsed(v: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(COLLAPSE_KEY, v ? '1' : '0'); + } catch { + /* swallow — privacy mode; state just resets on reload */ + } +} + +// The cix CLI one-line installer (README §3 "Install the CLI"). +const INSTALL_CMD = + 'curl -fsSL https://raw.githubusercontent.com/dvcdsys/code-index/main/install.sh | bash'; + +// Claude Code marketplace + plugin install, as `claude plugin` console +// commands (run in a terminal) rather than the in-session `/plugin` slash +// commands — so the whole onboarding flow stays in the shell next to the CLI +// steps. `marketplace add` takes a GitHub owner/repo shorthand; `install` +// takes plugin@marketplace. Installing via the CLI applies on the next +// `claude` session start, so there's no `/reload-plugins` equivalent to run. +const PLUGIN_CMDS = [ + 'claude plugin marketplace add dvcdsys/code-index', + 'claude plugin install cix@code-index', +].join('\n'); + +// Optional local-project indexing + connection check. +const INDEX_CMDS = ['cd /path/to/your/project', 'cix init', 'cix status # wait for: Status: ✓ Indexed'].join( + '\n' +); + +// User-facing skills/commands the plugin exposes — the "success" checklist. +const SKILL_COMMANDS = [ + '/cix:search', + '/cix:def', + '/cix:refs', + '/cix:init', + '/cix:status', + '/cix:summary', +]; + +export function ConnectClaudeCodeCard() { + const [collapsed, setCollapsed] = useState(readCollapsed); + + function toggle() { + setCollapsed((prev) => { + const next = !prev; + writeCollapsed(next); + return next; + }); + } + + // Live, per-host connect command so the alias + URL match this exact + // deployment — the same derivation the API-key popup uses. The real key is + // shown once in that popup; here we keep the `` placeholder. + const connectCmd = cixConnectCommand(window.location.origin, window.location.host); + + return ( + + + + {!collapsed && ( +
+ + + + + + Open the{' '} + + + API Keys + {' '} + page, create a key, then copy the “Connect the cix CLI” command from the popup. + It looks like this for this server (with your real key filled in): + + } + > + + + + + + + + Optional} + hint={ + <> + Skip this if you only work with{' '} + + workspaces + {' '} + or server-side{' '} + + external projects + + . cix status alone + still confirms the CLI reached the server. + + } + > + + + + +
+ {SKILL_COMMANDS.map((c) => ( + + {c} + + ))} +
+
+
+ )} +
+ ); +} + +function Step({ + n, + title, + hint, + badge, + children, +}: { + n: number; + title: string; + hint: ReactNode; + badge?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {n} +
+
+
+

{title}

+ {badge} +
+

{hint}

+ {children} +
+
+ ); +} diff --git a/server/dashboard/src/modules/home/HomePage.tsx b/server/dashboard/src/modules/home/HomePage.tsx index b119798..b20866b 100644 --- a/server/dashboard/src/modules/home/HomePage.tsx +++ b/server/dashboard/src/modules/home/HomePage.tsx @@ -4,6 +4,7 @@ import { useServerStatus } from '@/lib/useServerStatus'; import { Card, CardDescription, CardHeader, CardTitle } from '@/ui/card'; import { cn } from '@/lib/cn'; import { MODULES } from '../registry'; +import { ConnectClaudeCodeCard } from './ConnectClaudeCodeCard'; // One-line pitch per module — kept here (not on the Module type) so the // sidebar stays terse and only the landing page carries the prose. @@ -54,6 +55,8 @@ export default function HomePage() { )} + +

Modules

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..733c1df 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,20 @@ 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 + ) : ( + + )} + = bcrypt.MinCost && c <= bcrypt.MaxCost { + return c + } + } + if testing.Testing() { + return bcrypt.MinCost + } + return defaultBcryptCost +} + +// dummyHash is a real bcrypt hash, computed once at the active BcryptCost. It is +// fed to CompareHashAndPassword on the user-not-found login path to burn the +// same CPU a genuine check would, so response timing can't be used to enumerate +// accounts. Deriving it from BcryptCost — rather than hard-coding a $2a$12$… +// literal — keeps the mitigation accurate when the cost is overridden and keeps +// the not-found path cheap under test. +var dummyHash = mustDummyHash() + +func mustDummyHash() []byte { + h, err := bcrypt.GenerateFromPassword([]byte("user-enumeration-timing-equaliser"), BcryptCost) + if err != nil { + // BcryptCost is clamped to bcrypt's valid range, so this is unreachable. + panic(fmt.Sprintf("users: precompute dummy bcrypt hash: %v", err)) + } + return h +} var ( ErrNotFound = errors.New("user not found") @@ -47,6 +96,10 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time DisabledAt *time.Time + // LocalProjectDisabled, when true, forbids the user from creating local + // projects and from indexing/reindexing. Search of already-indexed + // projects and workspace creation stay allowed. Admins are exempt. + LocalProjectDisabled bool } // Service wraps the users table. Stateless — safe to share across handlers. @@ -156,7 +209,7 @@ type UserWithStats struct { func (s *Service) ListWithStats(ctx context.Context) ([]UserWithStats, error) { now := time.Now().UTC().Format(time.RFC3339Nano) const q = ` - SELECT id, email, role, must_change_password, created_at, updated_at, disabled_at, + SELECT id, email, role, must_change_password, created_at, updated_at, disabled_at, local_project_disabled, (SELECT MAX(created_at) FROM sessions WHERE user_id = users.id), (SELECT COUNT(1) FROM sessions WHERE user_id = users.id AND expires_at > ?), (SELECT COUNT(1) FROM api_keys WHERE owner_user_id = users.id AND revoked_at IS NULL) @@ -172,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 { @@ -210,24 +265,25 @@ 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. CompareHashAndPassword on a known-bad hash - // burns the same cost as a real login. - _ = bcrypt.CompareHashAndPassword([]byte("$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali"), []byte(password)) + // via response time. dummyHash carries the active cost, so this + // burns the same CPU as a real login (see dummyHash). + _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) return User{}, ErrInvalidLogin } return User{}, fmt.Errorf("scan user: %w", err) @@ -240,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 { @@ -323,6 +380,35 @@ 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. +// +// 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 { + 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 { @@ -363,7 +449,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...) @@ -380,15 +466,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) 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