diff --git a/.eslintrc.base.json b/.eslintrc.base.json deleted file mode 100644 index 41b622f1..00000000 --- a/.eslintrc.base.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/eslintrc", - "rules": { - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/explicit-function-return-type": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "curly": "error", - "eqeqeq": ["error", "always", { "null": "ignore" }], - "no-throw-literal": "error" - } -} diff --git a/.githooks/pre-push b/.githooks/pre-push index 6a3a55f4..aa04b9e8 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,9 +1,5 @@ #!/usr/bin/env bash -# Committed pre-push hook. Runs the fast lint gate (`make fmt clippy`) before -# any push so formatting/clippy failures are caught locally instead of on CI. -# Tests are deliberately excluded — they are too slow for a push gate; run -# `make check` or `scripts/cicdprep.sh` before opening a PR. -# +# Committed pre-push hook. Runs the fast lint gate (`make fmt clippy`) before any push so formatting/clippy failures are caught locally # Enable once per clone: make install-hooks (sets core.hooksPath=.githooks) # Bypass in an emergency: git push --no-verify set -euo pipefail @@ -15,7 +11,7 @@ cd "$repo_root" echo "pre-push: running lint checks (fmt + clippy)…" if ! make fmt clippy; then echo - echo "pre-push: lint checks failed — push aborted." >&2 + echo "pre-push: lint checks failed - push aborted." >&2 echo "Fix the issues above, or bypass with 'git push --no-verify' (not recommended)." >&2 exit 1 fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a9d6615d..c0f22055 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -78,6 +78,3 @@ updates: - dependency-name: "actions/*" cooldown: default-days: 7 - semver-major-days: 30 - semver-minor-days: 14 - semver-patch-days: 3 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 860874e0..c47a522c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -53,8 +53,6 @@ jobs: restore-keys: ${{ runner.os }}-cargo- # The frontend is typed against these; generate before anything compiles. - # bindings/ is committed — the script regenerates and fails on any - # checksum difference vs the checkout (modified or newly exported types). - name: Generate TypeScript bindings and verify they are committed run: | scripts/check-bindings-fresh.sh || { diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc new file mode 100644 index 00000000..0c6e2b63 --- /dev/null +++ b/.oxlintrc.jsonc @@ -0,0 +1,243 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "eslint", + "typescript", + "unicorn", + "oxc", + "react", + "react-perf", + "jsx-a11y", + "promise" + ], + "categories": { + "correctness": "error", + "suspicious": "error", + "perf": "error", + "pedantic": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + + "options": { "typeAware": true }, + + "env": { "es2022": true }, + + "settings": { + "react": { "version": "19.0" } + }, + + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + "**/out/**", + "**/*.d.ts", + "webcomponents/src/generated/**", + "vscode-extension/src/generated/**", + "vscode-extension/shared/**", + "vscode-extension/.vscode-test/**", + "**/.claude/**", + "target/**", + "docs/_site/**", + "docs/assets/js/**", + "agnt-plugin/**", + "coder-module/**", + "bindings/**", + "shared/**", + "scripts/**", + "zed-extension/**", + "opr8r/**" + ], + + "rules": { + "eslint/no-unused-vars": ["error", { + "args": "after-used", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", + "ignoreRestSiblings": true + }], + "eslint/no-void": ["error", { "allowAsStatement": true }], + "typescript/no-explicit-any": "error", + "eslint/curly": ["error", "all"], + "eslint/eqeqeq": ["error", "always", { "null": "ignore" }], + "typescript/explicit-function-return-type": "off", + "typescript/explicit-module-boundary-types": "off", + // Was webview-ui-only under eslint; no reason for that scoping. + "typescript/consistent-type-assertions": ["error", { + "assertionStyle": "as", + "objectLiteralTypeAssertions": "never" + }], + + // ---- Disabled: these fire on deliberate repo-wide conventions ---- + // React 19 automatic runtime ("jsx": "react-jsx"). + "react/react-in-jsx-scope": "off", + // Collides with the ^_ unused-arg convention above. + "eslint/no-underscore-dangle": "off", + // Sequential awaits are deliberate in vscode-extension orchestration. + "eslint/no-await-in-loop": "off", + // Fires on every `(await res.json()) as T`; recommended or strict either. + "typescript/no-unsafe-type-assertion": "off", + "react/rules-of-hooks": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-no-useless-fragment": "error", + "jsx-a11y/anchor-ambiguous-text": "error", + "typescript/ban-ts-comment": "error", + "typescript/adjacent-overload-signatures": "error", + "typescript/consistent-type-exports": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/prefer-enum-initializers": "error", + "typescript/prefer-literal-enum-member": "error", + "typescript/prefer-optional-chain": "error", + "eslint/prefer-const": ["error", { "destructuring": "all" }], + "eslint/no-else-return": ["error", { "allowElseIf": false }], + "eslint/one-var": ["error", "never"], + "eslint/no-use-before-define": ["error", { + "functions": false, + "classes": false, + "variables": false + }], + "eslint/no-array-constructor": "error", + "eslint/no-case-declarations": "error", + "eslint/no-constructor-return": "error", + "eslint/no-inner-declarations": "error", + "eslint/no-prototype-builtins": "error", + "eslint/no-self-compare": "error", + "eslint/no-label-var": "error", + "eslint/no-labels": "error", + "eslint/no-extra-label": "error", + "eslint/no-lone-blocks": "error", + "eslint/no-proto": "error", + "eslint/no-regex-spaces": "error", + "eslint/no-script-url": "error", + "eslint/no-sequences": "error", + "eslint/default-case-last": "error", + "eslint/radix": "error", + "eslint/prefer-arrow-callback": "error", + "eslint/prefer-exponentiation-operator": "error", + "eslint/prefer-numeric-literals": "error", + "eslint/prefer-regex-literals": "error", + "eslint/prefer-rest-params": "error", + "oxc/no-const-enum": "error", + "unicorn/new-for-builtins": "error", + "unicorn/no-document-cookie": "error", + "unicorn/no-instanceof-array": "error", + "unicorn/no-useless-switch-case": "error", + "unicorn/prefer-array-index-of": "error", + "unicorn/prefer-date-now": "error", + "unicorn/prefer-node-protocol": "error", + "unicorn/prefer-number-properties": ["error", { "checkInfinity": true }], + "unicorn/require-post-message-target-origin": "off", + "eslint/no-console": "error", + "eslint/no-fallthrough": "error", + "eslint/no-redeclare": "error", + "eslint/prefer-template": "error", + "react/jsx-curly-brace-presence": "error", + "react/no-danger": "error", + "typescript/consistent-type-imports": "error", + "typescript/no-empty-interface": "error", + "typescript/no-inferrable-types": "error", + "typescript/no-invalid-void-type": "error", + "typescript/prefer-function-type": "error", + + // ---- type-aware (tsgolint); these are pedantic so must be listed ---- + "typescript/no-misused-promises": ["error", { + "checksVoidReturn": { "attributes": false } + }], + "typescript/only-throw-error": "error", + "typescript/require-await": "error", + "typescript/prefer-promise-reject-errors": "error", + "typescript/restrict-plus-operands": "error", + "typescript/return-await": ["error", "in-try-catch"], + "typescript/switch-exhaustiveness-check": ["error", { + "allowDefaultCaseForExhaustiveSwitch": true, + "considerDefaultExhaustiveForUnions": true + }], + "typescript/prefer-nullish-coalescing": ["error", { + "ignoreTernaryTests": true, + "ignorePrimitives": { "string": true } + }], + "typescript/no-deprecated": "warn", + + // ---- type-aware deferred (noisy or structurally wrong here) ---- + // bindings/ types are ts-rs-generated and assert more than the wire + // guarantees, so defensive runtime checks look "unnecessary". + "typescript/no-unnecessary-condition": "off", + "typescript/strict-boolean-expressions": "off", + "typescript/no-confusing-void-expression": "off", + "typescript/promise-function-async": "off", + "typescript/prefer-readonly-parameter-types": "off", + "typescript/strict-void-return": "off", + + // no-unsafe-* is error only in vscode-extension/src (eslint parity there). + "typescript/no-unsafe-argument": "off", + "typescript/no-unsafe-assignment": "off", + "typescript/no-unsafe-call": "off", + "typescript/no-unsafe-member-access": "off", + "typescript/no-unsafe-return": "off", + + // ---- react-perf: new class of finding, land as warn ---- + "react-perf/jsx-no-new-object-as-prop": "warn", + "react-perf/jsx-no-new-array-as-prop": "warn", + "react-perf/jsx-no-new-function-as-prop": "warn", + "react-perf/jsx-no-jsx-as-prop": "warn" + }, + + "overrides": [ + { + "files": ["ui/src/**/*.ts", "ui/src/**/*.tsx"], + "env": { "browser": true, "es2022": true } + }, + { + "files": ["webcomponents/src/**/*.ts", "webcomponents/src/**/*.tsx"], + "env": { "browser": true, "es2022": true } + }, + { + "files": ["webcomponents/src/**/*.test.ts", "webcomponents/src/**/*.test.tsx"], + "globals": { "Bun": "readonly" }, + "rules": { "eslint/no-console": "off" } + }, + { + "files": ["vscode-extension/src/**/*.ts"], + "env": { "node": true, "es2022": true }, + "rules": { + // Parity with the recommendedTypeChecked config this replaces + "typescript/no-unsafe-argument": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-return": "error", + "typescript/no-require-imports": "error", + "typescript/no-namespace": "error" + } + }, + { + "files": ["vscode-extension/test/**/*.ts"], + "env": { "node": true, "mocha": true, "es2022": true }, + "rules": { + "eslint/no-console": "off", + "unicorn/consistent-function-scoping": "off", + "typescript/unbound-method": "off", + "typescript/no-misused-promises": "off" + } + }, + { + "files": [ + "webcomponents/scripts/**", + "vscode-extension/scripts/**", + "vscode-extension/src/webhook-server.ts", + "vscode-extension/src/walkthrough.ts" + ], + "rules": { "eslint/no-console": "off" } + }, + { + "files": [ + "vscode-extension/webview-ui/**/*.ts", + "vscode-extension/webview-ui/**/*.tsx" + ], + "env": { "browser": true, "es2022": true } + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index c959a31b..8ea5bb66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,11 +15,14 @@ ## Code Style Aim for functional software development with a focus on stateless, single responsibility focus. -ABSOLUTELY NO UNNECESSARY CODE COMMENTS WITHIN FUNCTIONS OR CONFIGURATION. Minimize use of comments entirely; they should be terse and used judiciously, ideally one line tops. Data types come from rust; typescript and docs binds are generated from low-level rust types annotated with comments that embed as descriptions into configuration and reference files. Favor falsey defaults ; lets aim not to enforce `default=true` or some other javascript-truthy default value. +### Comments + +Code comments are terse, short and punctual. Comments should not refer to implementation or current wip status. + ## Plans & Specs Location Write superpowers plans to `superpowers/plans/` and design specs to `superpowers/specs/` (repo root, not hosted). @@ -59,6 +62,11 @@ make install-hooks # sets core.hooksPath=.githooks If any of these fail, fix the issues before proceeding. Do NOT use `#[allow(...)]` attributes to silence warnings unless there's a documented reason (e.g., code used only in tests). +#### Strict Linting + +Linting is strictly enforced; the rules are tighter than other software. Linting warnings are errors; address them as part of design. +Always running lint step when finished working in a directory. Fix all found linting problems before declaring work done. + ### Subproject Validation When changes touch subprojects, those must also pass validation: @@ -124,7 +132,7 @@ Full command list: `docs/cli/` (auto-generated). ## Architecture -Grouped map of `src/` (not exhaustive — `ls src/` for the full list): +Grouped map of `src/` (not exhaustive - `ls src/` for the full list): ``` src/ @@ -167,7 +175,7 @@ Execution mode is declared per issue type (`mode` in the issuetype schema): - **Paired** (e.g. SPIKE, INV): require human interaction, track "awaiting input" ### Parallelism Rules -- Effective max agents = max(1, min(`agents.max_parallel`, cpu_cores − `agents.cores_reserved`)) +- Effective max agents = max(1, min(`agents.max_parallel`, cpu_cores - `agents.cores_reserved`)) - Same repo is sequential unless `git.use_worktrees = true`, which allows up to `agents.max_agents_per_repo` agents in per-ticket worktrees - Paired agents run one at a time per operator attention @@ -175,7 +183,7 @@ Execution mode is declared per issue type (`mode` in the issuetype schema): ## State Management Persistent state lives under `paths.state` (default `.tickets/operator/`); -`state.json` holds queue/agent state — schema documented at `/schemas/state/`. +`state.json` holds queue/agent state - schema documented at `/schemas/state/`. Per-ticket worktrees default to `~/.operator/worktrees`. ## Ticket Workflow @@ -300,10 +308,5 @@ When adding or changing UI: change a brand color in `tokens.css` (web surfaces follow automatically); reference semantic tokens in new web CSS; map a role to ANSI in the TUI; and leave the webview deferring to the editor theme. -**Icons.** Every SVG icon follows the Operator icon standard - a single -monochrome `` on a 24×24 canvas with no `fill`/`stroke`/`width`/`height`, -so it tints from `currentColor` and sizes to its container on all four -surfaces. Governed directories: `icons/`, `docs/assets/icons/`, -`ui/public/icons/`, and each collection's `icon.svg`. Enforced by -`cargo test --test svg_icon_standard`; the rules and rationale are in -`docs/design-system/`. +**Icons.** Every SVG icon follows the Operator icon standard - a single monochrome `` on a 24×24 canvas with no `fill`/`stroke`/`width`/`height`, so it tints from `currentColor` and sizes to its container on all four surfaces. Governed directories: `icons/`, `docs/assets/icons/`, `ui/public/icons/`, and each collection's `icon.svg`. +Enforced by `cargo test --test svg_icon_standard`; the rules and rationale are in `docs/design-system/`. diff --git a/Cargo.toml b/Cargo.toml index 05ab84a4..86128af8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,11 +124,22 @@ flate2 = "1" unsafe_code = "deny" [lints.clippy] +all = { level = "warn", priority = -2 } pedantic = { level = "warn", priority = -1 } # Nursery lints for cohesion cognitive_complexity = "warn" -redundant_clone = "warn" +redundant_clone = "deny" + +# Clone and borrow discipline +clone_on_copy = "deny" +unnecessary_to_owned = "deny" +borrowed_box = "deny" +explicit_auto_deref = "deny" +borrow_deref_ref = "deny" +deref_addrof = "deny" +needless_borrow = "deny" +clone_on_ref_ptr = "warn" # Allow noisy pedantic lints that don't add value here module_name_repetitions = "allow" @@ -181,6 +192,13 @@ needless_for_each = "allow" needless_continue = "allow" # Wildcard matches are intentional for future-proofing match_wildcard_for_single_variants = "allow" +# Legacy style cleanup is outside ownership lint enforcement +redundant_else = "allow" +needless_raw_string_hashes = "allow" +doc_markdown = "allow" +uninlined_format_args = "allow" +single_match_else = "allow" +nonminimal_bool = "allow" # Platform-specific notifications [target.'cfg(target_os = "macos")'.dependencies] diff --git a/Dockerfile b/Dockerfile index 019a1cd4..d1becffb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,11 @@ LABEL org.opencontainers.image.title="Operator" \ ARG TARGETARCH # Substrate Operator needs to launch agents: git (VCS ops), tmux (session -# wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / -# codex / gemini) and its auth are supplied by the user via a derived image or env vars +# wrapper), ca-certificates (TLS to LLM/kanban APIs), openssh-client (every +# ssh and coder target launch, and git over SSH remotes), curl (in-pod reachability checks). +# The LLM CLI (claude / codex / gemini) and its auth are supplied by the user via a derived image or env vars RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git tmux \ + && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client tmux \ && rm -rf /var/lib/apt/lists/* # CI stages the prebuilt release binaries as {operator,opr8r}-linux-${TARGETARCH} diff --git a/Dockerfile.local b/Dockerfile.local index 0ead793e..540c902a 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -57,11 +57,13 @@ LABEL org.opencontainers.image.title="Operator" \ org.opencontainers.image.licenses="MIT" # Substrate Operator needs to launch agents: git (VCS ops), tmux (session -# wrapper), ca-certificates (TLS to LLM/kanban APIs). The LLM CLI (claude / -# codex / gemini) and its auth are supplied by the user via a derived image or -# a mount + env vars -- not baked in here. +# wrapper), ca-certificates (TLS to LLM/kanban APIs), openssh-client (every +# ssh and coder target launch, and git over SSH remotes), curl (in-pod +# reachability checks). The LLM CLI (claude / codex / gemini) and its auth are +# supplied by the user via a derived image or a mount + env vars -- not baked +# in here. RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git tmux \ + && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client tmux \ && rm -rf /var/lib/apt/lists/* # Both halves ship: agent sessions launched by the operator server call the diff --git a/agnt-plugin/alert.js b/agnt-plugin/alert.js index 674a9e5d..e20da9eb 100644 --- a/agnt-plugin/alert.js +++ b/agnt-plugin/alert.js @@ -1,4 +1,4 @@ -// operator-alert — POST /api/v1/alerts +// operator-alert - POST /api/v1/alerts import { callOperator } from "./lib/operator-client.js"; class AlertTool { diff --git a/agnt-plugin/create-ticket.js b/agnt-plugin/create-ticket.js index cbf9cf25..4c577cfb 100644 --- a/agnt-plugin/create-ticket.js +++ b/agnt-plugin/create-ticket.js @@ -1,4 +1,4 @@ -// operator-create-ticket — POST /api/v1/tickets +// operator-create-ticket - POST /api/v1/tickets import { callOperator } from "./lib/operator-client.js"; class CreateTicketTool { diff --git a/agnt-plugin/export.js b/agnt-plugin/export.js index 5d86a253..71ace0a1 100644 --- a/agnt-plugin/export.js +++ b/agnt-plugin/export.js @@ -1,4 +1,4 @@ -// operator-export-workflow — POST /api/v1/tickets/{id}/workflow-export?format=... +// operator-export-workflow - POST /api/v1/tickets/{id}/workflow-export?format=... import { callOperator } from "./lib/operator-client.js"; class ExportWorkflowTool { diff --git a/agnt-plugin/launch.js b/agnt-plugin/launch.js index 05a5f846..c9b1d361 100644 --- a/agnt-plugin/launch.js +++ b/agnt-plugin/launch.js @@ -1,4 +1,4 @@ -// operator-launch-agent — POST /api/v1/tickets/{id}/launch +// operator-launch-agent - POST /api/v1/tickets/{id}/launch import { callOperator } from "./lib/operator-client.js"; class LaunchAgentTool { diff --git a/agnt-plugin/queue.js b/agnt-plugin/queue.js index e1778b91..d85b8cc3 100644 --- a/agnt-plugin/queue.js +++ b/agnt-plugin/queue.js @@ -1,4 +1,4 @@ -// operator-queue-status — GET /api/v1/queue/status +// operator-queue-status - GET /api/v1/queue/status import { callOperator } from "./lib/operator-client.js"; class QueueStatusTool { diff --git a/agnt-plugin/run-step.js b/agnt-plugin/run-step.js index 084d6e65..2fabedfa 100644 --- a/agnt-plugin/run-step.js +++ b/agnt-plugin/run-step.js @@ -1,11 +1,9 @@ -// operator-run-step — the node type emitted by `operator workflow export --format agnt`. +// operator-run-step - the node type emitted by `operator workflow export --format agnt`. // // Each exported node represents one issuetype step and carries -// { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks -// Operator to run it via the launch endpoint. Operator sequences its own steps -// internally, so the per-step nodes are a faithful visualization of the ticket's -// shape; executing them drives the one underlying Operator ticket (the launch -// endpoint's relaunch path tolerates a ticket that is already in progress). +// { ticket, step, prompt, ... } in its config. This tool reads `ticket` and asks Operator to run it via the launch endpoint. +// Operator sequences its own steps internally, so the per-step nodes are a faithful visualization of the ticket's shape; +// executing them drives the one underlying Operator ticket (the launch endpoint's relaunch path tolerates a ticket that is already in progress). import { callOperator } from "./lib/operator-client.js"; class RunStepTool { diff --git a/bindings/AgentProfile.ts b/bindings/AgentProfile.ts index 4e3a8b10..4b1f9509 100644 --- a/bindings/AgentProfile.ts +++ b/bindings/AgentProfile.ts @@ -24,8 +24,7 @@ provider: string, */ model: string, /** - * System prompt. Operator has no first-class system prompt, so this is - * preserved opaquely across import (see [`Delegator::unmapped_core`]). + * System prompt. This is preserved opaquely across import (see [`Delegator::unmapped_core`]). */ system_prompt?: string | null, /** @@ -41,24 +40,18 @@ mcp_servers: Array, */ tools: Array, /** - * Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). - * `None` = a locally launchable agent, not bound to a remote platform. + * Declarative reference to a remote, named agent. `None` = a locally launchable agent, not bound to a remote target. */ remote_agent?: RemoteAgentRef | null, /** - * Operator-owned extension fields (typed). `None` when the agent carries no - * Operator-specific configuration. + * Operator-owned extension fields (typed). `None` when the agent carries no Operator-specific configuration. */ x_operator?: XOperator | null, /** - * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, - * `creditLimit`, ...). Operator never interprets this — pure pass-through. + * AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, `creditLimit`, ...). */ x_agnt?: JsonValue | null, /** - * OpenAI-owned extension fields, opaque (`instructions`, `tools`, - * `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a - * second platform — never interpreted. This field is the whole per-tool cost - * of adding `OpenAI`: a passthrough bag, no mapping logic. + * OpenAI-owned extension fields, opaque (`instructions`, `tools`, `tool_resources`, `metadata`, thread refs, ...). */ x_openai?: JsonValue | null, }; diff --git a/bindings/BootstrapSubmitResponse.ts b/bindings/BootstrapSubmitResponse.ts index 054bbe70..f38f6e17 100644 --- a/bindings/BootstrapSubmitResponse.ts +++ b/bindings/BootstrapSubmitResponse.ts @@ -6,6 +6,10 @@ import type { BootstrapState } from "./BootstrapState"; */ export type BootstrapSubmitResponse = { /** - * The state after submission — `Complete` on success. + * The state after submission - `Complete` on success. */ -state: BootstrapState, }; +state: BootstrapState, +/** + * The account name created by bootstrap. + */ +username: string, }; diff --git a/bindings/CoderConfig.ts b/bindings/CoderConfig.ts index 7d530807..78ef4355 100644 --- a/bindings/CoderConfig.ts +++ b/bindings/CoderConfig.ts @@ -2,12 +2,12 @@ /** * Coder workspace target: lifecycle + alias provisioning around the shared - * SSH remote-launch path. There is no `enabled` field — presence in + * SSH remote-launch path. There is no `enabled` field - presence in * `[[targets]]` is the enablement. */ export type CoderConfig = { /** - * Coder template child workspaces are created from (an allowlist — + * Coder template child workspaces are created from (an allowlist - * never per-ticket input) */ template: string, @@ -25,7 +25,7 @@ token_env: string, */ name_prefix: string, /** - * Project root inside the workspace (None = workspace $HOME) + * Project root inside the workspace (None = /home/coder/{project}) */ workdir?: string | null, /** @@ -42,6 +42,6 @@ create_timeout_secs: bigint, */ callback_url?: string | null, /** - * Passthrough `-p` template parameters for `coder create` + * Passthrough `--parameter` template parameters for `coder create` */ parameters?: { [key in string]: string }, }; diff --git a/bindings/CreateAccessKeyRequest.ts b/bindings/CreateAccessKeyRequest.ts index 92b23a04..6d1a29f9 100644 --- a/bindings/CreateAccessKeyRequest.ts +++ b/bindings/CreateAccessKeyRequest.ts @@ -14,7 +14,7 @@ name: string, */ scopes: Array, /** - * Days until the key expires. Expiry is mandatory — there is no + * Days until the key expires. Expiry is mandatory - there is no * non-expiring key. */ expires_in_days: bigint, }; diff --git a/bindings/CreateAccessKeyResponse.ts b/bindings/CreateAccessKeyResponse.ts index 64f89d17..9eb01246 100644 --- a/bindings/CreateAccessKeyResponse.ts +++ b/bindings/CreateAccessKeyResponse.ts @@ -3,7 +3,7 @@ import type { AccessKeySummary } from "./AccessKeySummary"; /** * A newly created access key. **The secret appears here and nowhere else, - * ever** — only its hash is stored, so it cannot be shown again. + * ever** - only its hash is stored, so it cannot be shown again. */ export type CreateAccessKeyResponse = { /** diff --git a/bindings/CurrentSessionResponse.ts b/bindings/CurrentSessionResponse.ts index 89846972..d6cb5644 100644 --- a/bindings/CurrentSessionResponse.ts +++ b/bindings/CurrentSessionResponse.ts @@ -7,7 +7,7 @@ import type { Scope } from "./Scope"; */ export type CurrentSessionResponse = { /** - * Account name — always `admin`, the single human account. + * Account name - always `admin`, the single human account. */ subject: string, /** diff --git a/bindings/Delegator.ts b/bindings/Delegator.ts index 1e862b50..52af7cfd 100644 --- a/bindings/Delegator.ts +++ b/bindings/Delegator.ts @@ -50,9 +50,9 @@ model_server: string | null, * (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). * * Export-only: Operator has no runtime client for those platforms, so a - * delegator carrying this CANNOT be launched locally — resolution errors out + * delegator carrying this CANNOT be launched locally - resolution errors out * (see `delegator_resolution`). It is stored, listed, serialized into an - * `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + * `AgentProfile`, and - for `platform == "agnt"` - surfaced in the * `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose * `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the * `id` must be the agent's UUID, not its display name). `None` = ordinary, diff --git a/bindings/ForgotPasswordRequest.ts b/bindings/ForgotPasswordRequest.ts new file mode 100644 index 00000000..e328468e --- /dev/null +++ b/bindings/ForgotPasswordRequest.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request recovery instructions without revealing whether an account exists. + */ +export type ForgotPasswordRequest = { username: string, }; diff --git a/bindings/ForgotPasswordResponse.ts b/bindings/ForgotPasswordResponse.ts new file mode 100644 index 00000000..a3f7227a --- /dev/null +++ b/bindings/ForgotPasswordResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Generic recovery guidance for a self-hosted Operator deployment. + */ +export type ForgotPasswordResponse = { message: string, }; diff --git a/bindings/GithubProjectInfoDto.ts b/bindings/GithubProjectInfoDto.ts index a73e6866..3896067c 100644 --- a/bindings/GithubProjectInfoDto.ts +++ b/bindings/GithubProjectInfoDto.ts @@ -5,7 +5,7 @@ */ export type GithubProjectInfoDto = { /** - * `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key + * `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) - used as the project key */ node_id: string, /** diff --git a/bindings/GithubProjectsConfig.ts b/bindings/GithubProjectsConfig.ts index 86acaaf8..359fd203 100644 --- a/bindings/GithubProjectsConfig.ts +++ b/bindings/GithubProjectsConfig.ts @@ -6,7 +6,7 @@ import type { ProjectSyncConfig } from "./ProjectSyncConfig"; * * The owner login (user or org) is specified as the `HashMap` key in * `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node - * IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly + * IDs (e.g., `PVT_kwDOABcdefg`) - opaque, stable identifiers used directly * by every GitHub Projects v2 mutation without needing a lookup. * * **Distinct from `GitHubConfig`** (the git provider used for PR/branch @@ -23,7 +23,7 @@ enabled: boolean, /** * Environment variable name containing the GitHub token (default: * `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or - * `read:project`) scope, NOT just `repo` — see the disambiguation + * `read:project`) scope, NOT just `repo` - see the disambiguation * guide in the kanban github docs. */ api_key_env: string, diff --git a/bindings/GithubSessionEnv.ts b/bindings/GithubSessionEnv.ts index 9dc5afb5..c8dc10ff 100644 --- a/bindings/GithubSessionEnv.ts +++ b/bindings/GithubSessionEnv.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * GitHub Projects session env body — includes the actual secret to set in env. + * GitHub Projects session env body - includes the actual secret to set in env. */ export type GithubSessionEnv = { token: string, api_key_env: string, }; diff --git a/bindings/JiraCredentials.ts b/bindings/JiraCredentials.ts index 0dd4a455..529f814a 100644 --- a/bindings/JiraCredentials.ts +++ b/bindings/JiraCredentials.ts @@ -4,7 +4,7 @@ * Ephemeral Jira credentials supplied by a client during onboarding. * * These are never persisted to disk by the onboarding endpoints that take - * this struct — the actual secret stays in the env var named in + * this struct - the actual secret stays in the env var named in * `api_key_env` once set via `/api/v1/kanban/session-env`. */ export type JiraCredentials = { diff --git a/bindings/JiraSessionEnv.ts b/bindings/JiraSessionEnv.ts index 66f8729e..f109147e 100644 --- a/bindings/JiraSessionEnv.ts +++ b/bindings/JiraSessionEnv.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Jira session env body — includes the actual secret to set in env. + * Jira session env body - includes the actual secret to set in env. */ export type JiraSessionEnv = { domain: string, email: string, api_token: string, api_key_env: string, }; diff --git a/bindings/KanbanConfig.ts b/bindings/KanbanConfig.ts index dffef89a..fb7d6e5e 100644 --- a/bindings/KanbanConfig.ts +++ b/bindings/KanbanConfig.ts @@ -26,7 +26,7 @@ linear: { [key in string]: LinearConfig }, * * NOTE: This is the *kanban* GitHub integration (Projects v2), distinct * from `GitHubConfig` which is the *git provider* used for PRs and - * branches. The two use different env vars and different scopes — see + * branches. The two use different env vars and different scopes - see * `docs/getting-started/kanban/github.md` for the full disambiguation. */ github: { [key in string]: GithubProjectsConfig }, diff --git a/bindings/LaunchTicketRequest.ts b/bindings/LaunchTicketRequest.ts index d6e13c33..3c3e087a 100644 --- a/bindings/LaunchTicketRequest.ts +++ b/bindings/LaunchTicketRequest.ts @@ -9,15 +9,15 @@ export type LaunchTicketRequest = { */ delegator: string | null, /** - * LLM provider to use (e.g., "claude") — legacy fallback when no delegator + * LLM provider to use (e.g., "claude") - legacy fallback when no delegator */ provider: string | null, /** - * Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator + * Model to use (e.g., "sonnet", "opus") - legacy fallback when no delegator */ model: string | null, /** - * Ad-hoc model server to target (e.g. "ollama-local") — legacy fallback when + * Ad-hoc model server to target (e.g. "ollama-local") - legacy fallback when * no delegator. Injects the server's base URL / API key env at spawn. */ model_server: string | null, diff --git a/bindings/LinearSessionEnv.ts b/bindings/LinearSessionEnv.ts index 81c50d94..b12baec5 100644 --- a/bindings/LinearSessionEnv.ts +++ b/bindings/LinearSessionEnv.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Linear session env body — includes the actual secret to set in env. + * Linear session env body - includes the actual secret to set in env. */ export type LinearSessionEnv = { api_key: string, api_key_env: string, }; diff --git a/bindings/ListKanbanStatusesRequest.ts b/bindings/ListKanbanStatusesRequest.ts index fdbb2b50..e525fcd4 100644 --- a/bindings/ListKanbanStatusesRequest.ts +++ b/bindings/ListKanbanStatusesRequest.ts @@ -7,7 +7,7 @@ import type { OpenspecSourceDto } from "./OpenspecSourceDto"; /** * Request to list workflow statuses/columns for a specific project using - * ephemeral creds (onboarding wizard — before any config is persisted). + * ephemeral creds (onboarding wizard - before any config is persisted). */ export type ListKanbanStatusesRequest = { provider: KanbanProviderKind, /** diff --git a/bindings/LoginRequest.ts b/bindings/LoginRequest.ts index c36a2883..d9197899 100644 --- a/bindings/LoginRequest.ts +++ b/bindings/LoginRequest.ts @@ -4,6 +4,10 @@ * Password login, exchanged for an opaque server-side session cookie. */ export type LoginRequest = { +/** + * The account name. Required even while Operator supports one human account. + */ +username: string, /** * The admin password. Never persisted in plaintext or logged. */ diff --git a/bindings/LoginResponse.ts b/bindings/LoginResponse.ts index 2e02a8ff..127d9f7c 100644 --- a/bindings/LoginResponse.ts +++ b/bindings/LoginResponse.ts @@ -3,7 +3,7 @@ import type { Scope } from "./Scope"; /** * Successful login. The session itself rides in a `Set-Cookie` header, not in - * this body — a body-borne session identifier would be readable by script. + * this body - a body-borne session identifier would be readable by script. */ export type LoginResponse = { /** diff --git a/bindings/MatrixedConfig.ts b/bindings/MatrixedConfig.ts index ab2c51cc..1944b1b3 100644 --- a/bindings/MatrixedConfig.ts +++ b/bindings/MatrixedConfig.ts @@ -10,7 +10,7 @@ export type MatrixedConfig = { */ delegators: Array, /** - * Prompt variations (M) — Handlebars templates, minimum 2 + * Prompt variations (M) - Handlebars templates, minimum 2 */ prompt_variations: Array, /** diff --git a/bindings/OAuthErrorResponse.ts b/bindings/OAuthErrorResponse.ts index a32e6392..cbcab847 100644 --- a/bindings/OAuthErrorResponse.ts +++ b/bindings/OAuthErrorResponse.ts @@ -3,7 +3,7 @@ import type { OAuthErrorCode } from "./OAuthErrorCode"; /** * Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can - * interpret it — notably `authorization_pending` and `slow_down`, which a + * interpret it - notably `authorization_pending` and `slow_down`, which a * device-flow client polls against. */ export type OAuthErrorResponse = { diff --git a/bindings/OpenspecConfig.ts b/bindings/OpenspecConfig.ts index ca8978b2..0b3e96b4 100644 --- a/bindings/OpenspecConfig.ts +++ b/bindings/OpenspecConfig.ts @@ -4,7 +4,7 @@ * `OpenSpec` provider configuration (experimental, pull-only) * * The instance name is the `HashMap` key in `KanbanConfig.openspec`. There - * are no credentials — the provider reads local markdown under `root_path`. + * are no credentials - the provider reads local markdown under `root_path`. */ export type OpenspecConfig = { /** diff --git a/bindings/OpenspecSourceDto.ts b/bindings/OpenspecSourceDto.ts index a00f36fd..822e13a9 100644 --- a/bindings/OpenspecSourceDto.ts +++ b/bindings/OpenspecSourceDto.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * `OpenSpec` source location supplied during onboarding. Not a credential — + * `OpenSpec` source location supplied during onboarding. Not a credential - * `OpenSpec` reads local markdown; there is no secret to validate or store. */ export type OpenspecSourceDto = { diff --git a/bindings/PipelineConfig.ts b/bindings/PipelineConfig.ts index 4b69cf04..9e87a7df 100644 --- a/bindings/PipelineConfig.ts +++ b/bindings/PipelineConfig.ts @@ -6,7 +6,7 @@ import type { PipelineStage } from "./PipelineStage"; * Configuration for pipeline steps: iterate a list of items through ordered * stages with no barrier (each item flows through all stages independently). * - * The step graph stays linear — a pipeline step still has exactly one + * The step graph stays linear - a pipeline step still has exactly one * `next_step`. The fan-out (N items x M stages) lives entirely inside this one * step; iteration is an intra-step concern, never a step-to-step edge. */ diff --git a/bindings/PipelineStage.ts b/bindings/PipelineStage.ts index 463bec91..a5667357 100644 --- a/bindings/PipelineStage.ts +++ b/bindings/PipelineStage.ts @@ -2,7 +2,7 @@ import type { JsonValue } from "./serde_json/JsonValue"; /** - * A single stage in a pipeline — deliberately flat (not a recursive + * A single stage in a pipeline - deliberately flat (not a recursive * `StepSchema`): "prompt + optional agent/model/schema" only. It has no * `next_step`/`review_type`/`on_reject`, so a stage cannot reopen the * step-graph linearity question. diff --git a/bindings/RemoteAgentRef.ts b/bindings/RemoteAgentRef.ts index 683399f5..611e4e64 100644 --- a/bindings/RemoteAgentRef.ts +++ b/bindings/RemoteAgentRef.ts @@ -3,7 +3,7 @@ /** * A declarative reference to a remote, named agent hosted by another platform. * - * `platform` is the hosting service (`"agnt"`, `"openai"`) — deliberately + * `platform` is the hosting service (`"agnt"`, `"openai"`) - deliberately * distinct from the core `provider`/`llm_tool` (the model or coding CLI). These * agents are API/memory-native and live on the remote side; Operator has no * runtime client for them, so a delegator carrying one is **export-only** and diff --git a/bindings/ResetPasswordRequest.ts b/bindings/ResetPasswordRequest.ts new file mode 100644 index 00000000..586d3faa --- /dev/null +++ b/bindings/ResetPasswordRequest.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Change the account password after proving knowledge of the current one. + */ +export type ResetPasswordRequest = { username: string, current_password: string, new_password: string, }; diff --git a/bindings/ResetPasswordResponse.ts b/bindings/ResetPasswordResponse.ts new file mode 100644 index 00000000..0d03992e --- /dev/null +++ b/bindings/ResetPasswordResponse.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result of changing the account password. + */ +export type ResetPasswordResponse = { changed: boolean, }; diff --git a/bindings/RestApiConfig.ts b/bindings/RestApiConfig.ts index c80234d5..0f27f64b 100644 --- a/bindings/RestApiConfig.ts +++ b/bindings/RestApiConfig.ts @@ -9,9 +9,7 @@ export type RestApiConfig = { */ enabled: boolean, /** - * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so - * the server — which reports the project directory name — is not reachable - * from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + * Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. */ host: string, /** @@ -23,9 +21,6 @@ port: number, */ cors_origins: Array, /** - * Externally reachable base URL (e.g. `https://operator.example.com`). - * - * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, - * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + * Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. */ public_url: string | null, }; diff --git a/bindings/SectionDefinition.ts b/bindings/SectionDefinition.ts index 3b0bb8d3..ccf09058 100644 --- a/bindings/SectionDefinition.ts +++ b/bindings/SectionDefinition.ts @@ -2,6 +2,6 @@ import type { SectionId } from "./SectionId"; /** - * Declarative section metadata — shared between TUI and `VSCode`. + * Declarative section metadata - shared between TUI and `VSCode`. */ export type SectionDefinition = { id: SectionId, label: string, prerequisites: Array, }; diff --git a/bindings/SectionHealth.ts b/bindings/SectionHealth.ts index 422f6754..7b74734e 100644 --- a/bindings/SectionHealth.ts +++ b/bindings/SectionHealth.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Health state of a section — controls the header color. + * Health state of a section - controls the header color. */ export type SectionHealth = "Green" | "Yellow" | "Red" | "Gray"; diff --git a/bindings/SessionSummary.ts b/bindings/SessionSummary.ts index 21fedc39..37023316 100644 --- a/bindings/SessionSummary.ts +++ b/bindings/SessionSummary.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * An active or expired browser session. Carries no session identifier — the + * An active or expired browser session. Carries no session identifier - the * cookie value is never readable back out, only the session's `id` for * revocation. */ diff --git a/bindings/SetKanbanSessionEnvResponse.ts b/bindings/SetKanbanSessionEnvResponse.ts index 92cb749d..3cc3e132 100644 --- a/bindings/SetKanbanSessionEnvResponse.ts +++ b/bindings/SetKanbanSessionEnvResponse.ts @@ -4,7 +4,7 @@ * Response from setting session env vars. * * `shell_export_block` uses `` placeholders, NOT the actual - * secret — it is meant for the user to copy into their shell profile. + * secret - it is meant for the user to copy into their shell profile. */ export type SetKanbanSessionEnvResponse = { /** diff --git a/bindings/ValidateKanbanCredentialsResponse.ts b/bindings/ValidateKanbanCredentialsResponse.ts index 163f0d05..2e5af8f3 100644 --- a/bindings/ValidateKanbanCredentialsResponse.ts +++ b/bindings/ValidateKanbanCredentialsResponse.ts @@ -6,7 +6,7 @@ import type { LinearValidationDetailsDto } from "./LinearValidationDetailsDto"; /** * Response from validating kanban credentials. * - * `valid: false` is returned for auth failures — never a 4xx/5xx HTTP - * status — so clients can display `error` inline without exception handling. + * `valid: false` is returned for auth failures - never a 4xx/5xx HTTP + * status - so clients can display `error` inline without exception handling. */ export type ValidateKanbanCredentialsResponse = { valid: boolean, error?: string | null, jira?: JiraValidationDetailsDto | null, linear?: LinearValidationDetailsDto | null, github?: GithubValidationDetailsDto | null, }; diff --git a/bindings/VsCodeLaunchOptions.ts b/bindings/VsCodeLaunchOptions.ts index f042a779..39d27161 100644 --- a/bindings/VsCodeLaunchOptions.ts +++ b/bindings/VsCodeLaunchOptions.ts @@ -10,7 +10,7 @@ export type VsCodeLaunchOptions = { */ delegator: string | null, /** - * Model to use (sonnet, opus, haiku) — fallback when no delegator + * Model to use (sonnet, opus, haiku) - fallback when no delegator */ model: VsCodeModelOption, /** diff --git a/bindings/WorkflowFormatDto.ts b/bindings/WorkflowFormatDto.ts index b642e196..7d86a6eb 100644 --- a/bindings/WorkflowFormatDto.ts +++ b/bindings/WorkflowFormatDto.ts @@ -4,13 +4,13 @@ import type { SupportStatus } from "./SupportStatus"; /** * One workflow export format operator can emit, for `GET /api/v1/workflow-formats`. * - * A projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry — + * A projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry - * the single source of truth for the format's [`SupportStatus`] and docs. Lets * the UIs render a format picker without hardcoding the list. */ export type WorkflowFormatDto = { /** - * Stable slug (e.g. "claude", "agnt") — the value the `format` query param takes. + * Stable slug (e.g. "claude", "agnt") - the value the `format` query param takes. */ slug: string, /** diff --git a/bindings/WriteGithubConfigBody.ts b/bindings/WriteGithubConfigBody.ts index db645fed..07bf7737 100644 --- a/bindings/WriteGithubConfigBody.ts +++ b/bindings/WriteGithubConfigBody.ts @@ -12,7 +12,7 @@ owner: string, /** * Env var name where the project-scoped token is set * (default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN` - * — see Token Disambiguation in the kanban github docs. + * - see Token Disambiguation in the kanban github docs. */ api_key_env: string, /** diff --git a/bindings/WriteKanbanConfigRequest.ts b/bindings/WriteKanbanConfigRequest.ts index 8852a472..4b95dd55 100644 --- a/bindings/WriteKanbanConfigRequest.ts +++ b/bindings/WriteKanbanConfigRequest.ts @@ -8,7 +8,7 @@ import type { WriteOpenspecConfigBody } from "./WriteOpenspecConfigBody"; /** * Request to write or upsert a kanban config section. * - * This endpoint does NOT take the secret — only the env var NAME + * This endpoint does NOT take the secret - only the env var NAME * (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. */ export type WriteKanbanConfigRequest = { provider: KanbanProviderKind, jira?: WriteJiraConfigBody | null, linear?: WriteLinearConfigBody | null, github?: WriteGithubConfigBody | null, openspec?: WriteOpenspecConfigBody | null, }; diff --git a/bindings/XOperator.ts b/bindings/XOperator.ts index 1088ce53..46b26356 100644 --- a/bindings/XOperator.ts +++ b/bindings/XOperator.ts @@ -3,9 +3,8 @@ import type { DelegatorLaunchConfig } from "./DelegatorLaunchConfig"; import type { GitExecutionConfig } from "./GitExecutionConfig"; /** - * The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator - * carries that have no shared-core equivalent. AGNT ignores this bag; Operator - * round-trips it losslessly. + * The Operator-namespaced half of an [`AgentProfile`] - the fields a Delegator + * carries that have no shared-core equivalent. */ export type XOperator = { /** diff --git a/bun.lock b/bun.lock index fe87e775..e93384e5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "operator-docs", "devDependencies": { + "oxlint": "1.81.0", + "oxlint-tsgolint": "7.0.2001", "typedoc": "^0.27.0", "typescript": "^5.0.0", }, @@ -13,6 +15,56 @@ "packages": { "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@1.27.2", "", { "dependencies": { "@shikijs/engine-oniguruma": "^1.27.2", "@shikijs/types": "^1.27.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-GeWyHz8ao2gBiUW4OJnQDxXQnFgZQwwQk05t/CVVgNBN7/rK8XZ7xY6YhLVv9tH3VppWWmr9DCl3MwemB/i+Og=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2001", "", { "os": "linux", "cpu": "arm64" }, "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2001", "", { "os": "linux", "cpu": "x64" }, "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2001", "", { "os": "win32", "cpu": "arm64" }, "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.81.0", "", { "os": "android", "cpu": "arm" }, "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.81.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.81.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.81.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.81.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.81.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.81.0", "", { "os": "linux", "cpu": "arm" }, "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.81.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.81.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.81.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.81.0", "", { "os": "linux", "cpu": "none" }, "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.81.0", "", { "os": "linux", "cpu": "none" }, "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.81.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.81.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.81.0", "", { "os": "linux", "cpu": "x64" }, "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.81.0", "", { "os": "none", "cpu": "arm64" }, "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.81.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.81.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.81.0", "", { "os": "win32", "cpu": "x64" }, "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], "@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], @@ -41,6 +93,10 @@ "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "oxlint": ["oxlint@1.81.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.81.0", "@oxlint/binding-android-arm64": "1.81.0", "@oxlint/binding-darwin-arm64": "1.81.0", "@oxlint/binding-darwin-x64": "1.81.0", "@oxlint/binding-freebsd-x64": "1.81.0", "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", "@oxlint/binding-linux-arm-musleabihf": "1.81.0", "@oxlint/binding-linux-arm64-gnu": "1.81.0", "@oxlint/binding-linux-arm64-musl": "1.81.0", "@oxlint/binding-linux-ppc64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-gnu": "1.81.0", "@oxlint/binding-linux-riscv64-musl": "1.81.0", "@oxlint/binding-linux-s390x-gnu": "1.81.0", "@oxlint/binding-linux-x64-gnu": "1.81.0", "@oxlint/binding-linux-x64-musl": "1.81.0", "@oxlint/binding-openharmony-arm64": "1.81.0", "@oxlint/binding-win32-arm64-msvc": "1.81.0", "@oxlint/binding-win32-ia32-msvc": "1.81.0", "@oxlint/binding-win32-x64-msvc": "1.81.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "typedoc": ["typedoc@0.27.9", "", { "dependencies": { "@gerrit0/mini-shiki": "^1.24.0", "lunr": "^2.3.9", "markdown-it": "^14.1.0", "minimatch": "^9.0.5", "yaml": "^2.6.1" }, "peerDependencies": { "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x" }, "bin": { "typedoc": "bin/typedoc" } }, "sha512-/z585740YHURLl9DN2jCWe6OW7zKYm6VoQ93H0sxZ1cwHQEQrUn5BJrEnkWhfzUdyO+BLGjnKUZ9iz9hKloFDw=="], diff --git a/coder-module/README.md b/coder-module/README.md index 48f5becc..b3acd2f3 100644 --- a/coder-module/README.md +++ b/coder-module/README.md @@ -12,22 +12,24 @@ Run [Operator](https://github.com/untra/operator) as a background REST API serve The module downloads the operator binary and the `opr8r` client from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. +> This module runs Operator **inside** a workspace. To run Operator elsewhere (eg. a Kubernetes deployment) and have it *spawn* Coder workspaces as agent targets, you do not need this module at all - configure a `[[targets]]` entry with `kind = "coder"` instead. See the [Coder platform guide](https://operator.untra.io/getting-started/platforms/coder/). + ## Usage ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id } ``` +Pin `version` to a published module release for reproducible builds. `install_version` is separate - it selects the Operator release the module downloads, and defaults to the version this module shipped with. + ### Custom configuration ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id port = 7008 max_parallel_agents = 4 @@ -37,10 +39,11 @@ module "operator" { ### Full TOML override +`config_toml` is written verbatim - quotes, `$` and backticks all survive, because the value is base64-encoded on the way into the startup script. + ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id config_toml = <<-EOT [rest_api] @@ -49,22 +52,74 @@ module "operator" { [agents] max_parallel = 4 - health_check_interval = 30 [sessions] wrapper = "tmux" - - [[delegators]] - name = "default" - tool = "claude-code" - model = "sonnet" EOT } ``` +Setting `config_toml` replaces the generated config entirely, including the `[[targets]]` block described below - declare the target yourself if you need both. + +### Spawning child agent workspaces + +Set `agent_template` and the generated config gains a `[[targets]]` entry, so tickets launched from this workspace create **sibling** workspaces from that template rather than running agents locally. + +```tf +module "operator" { + source = "registry.coder.com/untra/operator/coder" + agent_id = coder_agent.main.id + agent_template = "operator-agent" + workdir = "/home/coder/project" +} +``` + +This mode has prerequisites the basic mode does not - see below. + +## Variables + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_id` | `string` | *(required)* | The ID of a Coder agent | +| `port` | `number` | `7008` | Port for the Operator REST API server | +| `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | +| `slug` | `string` | `"operator"` | Application slug | +| `install_version` | `string` | *(module version)* | Operator GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binaries into | +| `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | +| `config_toml` | `string` | `""` | Raw TOML written verbatim instead of the generated config | +| `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | +| `session_wrapper` | `string` | `"tmux"` | Session wrapper (`tmux`, `cmux`, or `zellij`) | +| `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | +| `order` | `number` | `null` | Position of the app in the dashboard (lower = first) | +| `group` | `string` | `null` | Group that this app belongs to | +| `offline` | `bool` | `false` | Skip downloading; requires a pre-installed binary at `install_prefix` | +| `use_cached` | `bool` | `false` | Reuse a cached binary if present, otherwise download | + +### Child-workspace spawning + +All optional. `agent_template` is the switch - leave it empty and no `[[targets]]` entry is written and the rest are ignored. Keys left unset are omitted from the config so Operator's own defaults apply. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_template` | `string` | `""` | Coder template child agent workspaces are created from. Empty disables the coder target. | +| `coder_token_env` | `string` | `"CODER_SESSION_TOKEN"` | Name of the env var holding the Coder **user session token** used to spawn workspaces | +| `callback_url` | `string` | `""` | Control-plane-reachable `OPERATOR_API_URL` override, so detached multi-step survives SSH tunnel loss. Empty keeps the reverse-tunnel default. | +| `name_prefix` | `string` | `""` | Workspace name prefix for deterministic per-ticket naming | +| `workdir` | `string` | `""` | Project root inside spawned workspaces | +| `stop_on_complete` | `bool` | `null` | Stop a spawned workspace when its ticket completes (never deletes) | +| `create_timeout_secs` | `number` | `null` | Bound on workspace create plus agent-ready wait, in seconds | + ## Prerequisites -The workspace image must include `tmux` (or your chosen `session_wrapper`) for operator to spawn agent sessions. Most Coder workspace images include tmux by default. +The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. + +Setting `agent_template` adds two more: + +- **`ssh` in the workspace image** (`openssh-client`). Operator launches child-workspace agents over real `ssh`. The `coder` CLI is used too, but Operator fetches it from your deployment if it is not already on `PATH`. +- **A Coder user session token** in the variable named by `coder_token_env`. This is **not** the ambient `CODER_AGENT_TOKEN` (see below) and the module does not provision it - supply it yourself, e.g. through a template `env` block backed by a Coder parameter or secret. + +> **Blast radius:** a Coder user session token can create, delete, and SSH into every workspace its user owns. Scope the account accordingly. ## Coder Workspace Context @@ -72,6 +127,7 @@ Coder automatically injects environment variables into every workspace that oper - `CODER_WORKSPACE_NAME` - workspace identifier - `CODER_WORKSPACE_OWNER` - workspace owner username -- `CODER_AGENT_TOKEN` - agent authentication token +- `CODER_URL` - deployment URL, which is what the coder target reads by default +- `CODER_AGENT_TOKEN` - **agent** authentication token, scoped to this one workspace -No operator configuration is needed to access these - they are ambient in the workspace environment. +No operator configuration is needed to access these. Note that `CODER_AGENT_TOKEN` is not a substitute for the user session token child-workspace spawning needs - it cannot create workspaces. Operator also strips the session-token variable from every agent's environment before launching it, on every target kind. diff --git a/coder-module/main.test.ts b/coder-module/main.test.ts index 3e7a1e93..ae3594e2 100644 --- a/coder-module/main.test.ts +++ b/coder-module/main.test.ts @@ -98,6 +98,45 @@ describe("operator", async () => { }); const script = findResourceInstance(state, "coder_script").script; - expect(script).toContain(customConfig); + expect(script).toContain(Buffer.from(customConfig).toString("base64")); + }); + + // The value reaches run.sh through a shell assignment, so an unencoded + // hand-off silently ate every quote and expanded every $VAR. + it("survives quotes and dollar signs in config_toml", async () => { + const customConfig = '[sessions]\nwrapper = "tmux"\nhome = "$HOME"\n'; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + config_toml: customConfig, + }); + + const script = findResourceInstance(state, "coder_script").script; + const encoded = Buffer.from(customConfig).toString("base64"); + expect(script).toContain(encoded); + expect(Buffer.from(encoded, "base64").toString()).toBe(customConfig); + }); + + it("writes the optional coder target keys only when set", async () => { + const bare = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_template: "operator-agent", + }); + const bareScript = findResourceInstance(bare, "coder_script").script; + expect(bareScript).toContain('name_prefix=""'); + expect(bareScript).toContain('stop_on_complete=""'); + + const full = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + agent_template: "operator-agent", + name_prefix: "op", + workdir: "/home/coder/proj", + stop_on_complete: "false", + create_timeout_secs: "600", + }); + const fullScript = findResourceInstance(full, "coder_script").script; + expect(fullScript).toContain('name_prefix="op"'); + expect(fullScript).toContain('workdir="/home/coder/proj"'); + expect(fullScript).toContain('stop_on_complete="false"'); + expect(fullScript).toContain('create_timeout_secs="600"'); }); }); diff --git a/coder-module/main.tf b/coder-module/main.tf index c922c2c6..5e56daf7 100644 --- a/coder-module/main.tf +++ b/coder-module/main.tf @@ -38,7 +38,7 @@ variable "slug" { variable "install_version" { type = string description = "The version of operator to install (must match a GitHub release tag)." - default = "0.2.7" + default = "0.2.8" } variable "install_prefix" { @@ -59,6 +59,31 @@ variable "config_toml" { default = "" } +# Optional coder-target knobs. Empty/null means "leave it out of the generated config" +variable "name_prefix" { + type = string + description = "Workspace name prefix for deterministic per-ticket naming. Empty uses Operator's default." + default = "" +} + +variable "workdir" { + type = string + description = "Project root inside spawned agent workspaces. Empty uses Operator's default." + default = "" +} + +variable "stop_on_complete" { + type = bool + description = "Stop a spawned workspace when its ticket completes (never deletes). Null uses Operator's default." + default = null +} + +variable "create_timeout_secs" { + type = number + description = "Bound on workspace create plus agent-ready wait, in seconds. Null uses Operator's default." + default = null +} + variable "max_parallel_agents" { type = number description = "Maximum number of parallel agents operator can run." @@ -76,8 +101,9 @@ variable "session_wrapper" { } variable "share" { - type = string - default = "owner" + type = string + description = "Dashboard sharing level for the Operator app." + default = "owner" validation { condition = contains(["owner", "authenticated", "public"], var.share) error_message = "share must be one of: owner, authenticated, public." @@ -134,18 +160,22 @@ resource "coder_script" "operator" { display_name = "Operator" icon = "/icon/terminal.svg" script = templatefile("${path.module}/run.sh", { - VERSION = var.install_version, - PORT = var.port, - INSTALL_PREFIX = var.install_prefix, - LOG_PATH = var.log_path, - CONFIG_TOML = var.config_toml, - MAX_PARALLEL = var.max_parallel_agents, - SESSION_WRAPPER = var.session_wrapper, - OFFLINE = var.offline, - USE_CACHED = var.use_cached, - AGENT_TEMPLATE = var.agent_template, - CODER_TOKEN_ENV = var.coder_token_env, - CALLBACK_URL = var.callback_url, + VERSION = var.install_version, + PORT = var.port, + INSTALL_PREFIX = var.install_prefix, + LOG_PATH = var.log_path, + CONFIG_TOML_B64 = base64encode(var.config_toml), + MAX_PARALLEL = var.max_parallel_agents, + SESSION_WRAPPER = var.session_wrapper, + OFFLINE = var.offline, + USE_CACHED = var.use_cached, + AGENT_TEMPLATE = var.agent_template, + CODER_TOKEN_ENV = var.coder_token_env, + CALLBACK_URL = var.callback_url, + NAME_PREFIX = var.name_prefix, + WORKDIR = var.workdir, + STOP_ON_COMPLETE = var.stop_on_complete == null ? "" : tostring(var.stop_on_complete), + CREATE_TIMEOUT_SECS = var.create_timeout_secs == null ? "" : tostring(var.create_timeout_secs), }) run_on_start = true diff --git a/coder-module/run.sh b/coder-module/run.sh index c82aac12..da34feaa 100755 --- a/coder-module/run.sh +++ b/coder-module/run.sh @@ -88,15 +88,32 @@ fi mkdir -p .tickets/operator .tickets/queue -# Bind template values to shell variables so conditionals below are real runtime checks -config_toml="${CONFIG_TOML}" +CONFIG_FILE=.tickets/operator/config.toml + +# Bind template values to shell variables so conditionals below are real runtime checks. +# CONFIG_TOML arrives base64-encoded: templatefile() escapes only the interpolation +# marker, so a raw value's quotes and dollar signs are mangled before reaching the file. +config_toml_b64="${CONFIG_TOML_B64}" agent_template="${AGENT_TEMPLATE}" callback_url="${CALLBACK_URL}" +name_prefix="${NAME_PREFIX}" +workdir="${WORKDIR}" +stop_on_complete="${STOP_ON_COMPLETE}" +create_timeout_secs="${CREATE_TIMEOUT_SECS}" + +append_kv() { + [ -n "$2" ] || return 0 + if [ "$3" = quoted ]; then + echo "$1 = \"$2\"" >> "$CONFIG_FILE" + else + echo "$1 = $2" >> "$CONFIG_FILE" + fi +} -if [ -n "$config_toml" ]; then - echo "$config_toml" > .tickets/operator/config.toml +if [ -n "$config_toml_b64" ]; then + printf '%s' "$config_toml_b64" | base64 -d > "$CONFIG_FILE" else - cat > .tickets/operator/config.toml < "$CONFIG_FILE" <> .tickets/operator/config.toml <> "$CONFIG_FILE" <> .tickets/operator/config.toml - fi + append_kv callback_url "$callback_url" quoted + append_kv name_prefix "$name_prefix" quoted + append_kv workdir "$workdir" quoted + append_kv stop_on_complete "$stop_on_complete" bare + append_kv create_timeout_secs "$create_timeout_secs" bare fi fi diff --git a/collections/README.md b/collections/README.md index 74c6f070..0b80f593 100644 --- a/collections/README.md +++ b/collections/README.md @@ -23,9 +23,7 @@ curated embedded set lives in `src/collections/`. - `id` equal to the directory name - `tier: "community"` with **`author`, `url`, and `license`** (SPDX id) - required for community submissions - - `issue_types`: 1–32 entries; keys match `^[A-Z][A-Z0-9_]{1,15}$` - (hyphens are reserved for the `{KEY}-{number}` ticket-id separator); - paths are bare filenames next to the manifest + - `issue_types`: 1-32 entries; keys match `^[A-Z][A-Z0-9_]{1,15}$` (hyphens are reserved for the `{KEY}-{number}` ticket-id separator); paths are bare filenames next to the manifest - optional `workflow_hints` (loop shape, memory surfaces, review gates, stop conditions) and `kanban_defaults.suggested_type_mappings` (descriptive only - they inform users and onboarding, not execution) diff --git a/crates/relay/Cargo.toml b/crates/relay/Cargo.toml index c3ccf89d..34c08672 100644 --- a/crates/relay/Cargo.toml +++ b/crates/relay/Cargo.toml @@ -18,3 +18,59 @@ tracing = "0.1" [dev-dependencies] tempfile = "3" + +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +all = { level = "warn", priority = -2 } +pedantic = { level = "warn", priority = -1 } +cognitive_complexity = "warn" +redundant_clone = "deny" +clone_on_copy = "deny" +unnecessary_to_owned = "deny" +borrowed_box = "deny" +explicit_auto_deref = "deny" +borrow_deref_ref = "deny" +deref_addrof = "deny" +needless_borrow = "deny" +clone_on_ref_ptr = "warn" +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +return_self_not_must_use = "allow" +struct_excessive_bools = "allow" +too_many_lines = "allow" +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +wildcard_imports = "allow" +unused_self = "allow" +trivially_copy_pass_by_ref = "allow" +needless_pass_by_value = "allow" +similar_names = "allow" +struct_field_names = "allow" +format_push_string = "allow" +unnecessary_wraps = "allow" +unused_async = "allow" +doc_link_with_quotes = "allow" +cast_possible_wrap = "allow" +match_same_arms = "allow" +assigning_clones = "allow" +manual_let_else = "allow" +items_after_statements = "allow" +ref_option = "allow" +fn_params_excessive_bools = "allow" +implicit_hasher = "allow" +map_unwrap_or = "allow" +needless_for_each = "allow" +needless_continue = "allow" +match_wildcard_for_single_variants = "allow" +redundant_else = "allow" +needless_raw_string_hashes = "allow" +doc_markdown = "allow" +uninlined_format_args = "allow" +single_match_else = "allow" +nonminimal_bool = "allow" diff --git a/crates/relay/src/channel_session.rs b/crates/relay/src/channel_session.rs index 64cbb70b..a21dea19 100644 --- a/crates/relay/src/channel_session.rs +++ b/crates/relay/src/channel_session.rs @@ -105,8 +105,8 @@ impl ChannelSession { // Background reader task: route all subsequent ServerMsg { - let req_map = req_map.clone(); - let bcast_map = bcast_map.clone(); + let req_map = Arc::clone(&req_map); + let bcast_map = Arc::clone(&bcast_map); tokio::spawn(async move { let mut line = String::new(); loop { @@ -173,7 +173,7 @@ impl ChannelSession { { ServerMsg::Peers { peers, .. } => Ok(peers), ServerMsg::Err { code, message, .. } => { - Err(anyhow::anyhow!("list_peers error: {code:?} — {message:?}")) + Err(anyhow::anyhow!("list_peers error: {code:?} - {message:?}")) } other => Err(anyhow::anyhow!("unexpected list_peers response: {other:?}")), } @@ -235,7 +235,7 @@ impl ChannelSession { { ServerMsg::Ack { .. } => Ok(()), ServerMsg::Err { code, message, .. } => { - Err(anyhow::anyhow!("rename error: {code:?} — {message:?}")) + Err(anyhow::anyhow!("rename error: {code:?} - {message:?}")) } other => Err(anyhow::anyhow!("unexpected rename response: {other:?}")), } @@ -289,7 +289,7 @@ async fn route_msg( let _ = tx.send(count); } } - // Ack/Peers/Err without correlation ID — ignore + // Ack/Peers/Err without correlation ID - ignore _ => {} } } diff --git a/crates/relay/src/client.rs b/crates/relay/src/client.rs index 2e7afd18..02ea7b5a 100644 --- a/crates/relay/src/client.rs +++ b/crates/relay/src/client.rs @@ -1,6 +1,6 @@ //! Thin relay client for connecting to the hub from opr8r or the relay-channel binary. //! -//! Handles connection, registration, and rename. Does not manage reconnection — +//! Handles connection, registration, and rename. Does not manage reconnection - //! that is the caller's responsibility for long-lived use cases. use std::path::{Path, PathBuf}; diff --git a/crates/relay/src/hub.rs b/crates/relay/src/hub.rs index 3a5a058f..615fa2f0 100644 --- a/crates/relay/src/hub.rs +++ b/crates/relay/src/hub.rs @@ -1,4 +1,4 @@ -//! Relay hub — tokio actor that owns the peer registry and routes ask/reply/broadcast messages. +//! Relay hub - tokio actor that owns the peer registry and routes ask/reply/broadcast messages. //! //! The hub runs embedded in operator's async runtime (lifetime = operator lifetime). //! No idle-shutdown timer: the hub exits only when operator exits. @@ -61,7 +61,7 @@ impl RelayHub { )); } _ => { - // Stale socket — remove it + // Stale socket - remove it let _ = std::fs::remove_file(&socket_path); } } @@ -373,7 +373,7 @@ impl HubState { if let Some(entry) = self.id_to_entry.get_mut(&conn_id) { entry.name = new_name.clone(); } - // Update pending asks — must happen before ack (matches TS ordering) + // Update pending asks - must happen before ack (matches TS ordering) self.update_name_on_rename(&old_name, &new_name); self.send_to_id(conn_id, ServerMsg::Ack { req_id }); } @@ -542,7 +542,7 @@ impl HubState { let cmd_tx_clone = cmd_tx.clone(); let timeout_task = tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(timeout_ms)).await; - // Broadcast timeouts don't send errors — replies just stop arriving + // Broadcast timeouts don't send errors - replies just stop arriving let _ = cmd_tx_clone .send(HubCommand::TimeoutExpired { ask_id: ask_id_clone, @@ -1248,7 +1248,7 @@ mod tests { .await; let _ = caller.recv().await; // ack - // bob replies — should reach carol (formerly alice) + // bob replies - should reach carol (formerly alice) target .send(&ClientMsg::Reply { ask_id: "a6".into(), diff --git a/docs/architecture/operator-opr8r.md b/docs/architecture/operator-opr8r.md index 6febdbb7..d28bd4b7 100644 --- a/docs/architecture/operator-opr8r.md +++ b/docs/architecture/operator-opr8r.md @@ -16,7 +16,7 @@ Operator ships as **two executables built from one repository**: `operator`, a l | **Runs where** | The operator's own terminal/host | Inside each agent's session (tmux/cmux/Zellij pane, VS Code terminal) | | **Lifetime** | For the duration of the workspace | For the duration of a single ticket step | | **Role in the relationship** | Server: owns ticket/queue state, exposes a REST API, hosts the relay hub | Client: wraps an LLM tool invocation, reports back over HTTP, optionally speaks MCP | -| **Binary size** | Full application (~tens of MB) | Optimized for size (~3–5 MB): stripped, LTO, single codegen unit, `panic = abort` | +| **Binary size** | Full application (~tens of MB) | Optimized for size (~3-5 MB): stripped, LTO, single codegen unit, `panic = abort` | `opr8r` is deliberately minimal so it can be signed and distributed as an independent artifact alongside `operator` releases and the VS Code extension, without needing the full application dependency tree. diff --git a/docs/cli/index.md b/docs/cli/index.md index 0d54b879..0b46a4af 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -40,7 +40,7 @@ Launch agent for next available ticket | `--delegator` | Use a named delegator from config (mutually exclusive with --llm-tool/--model/--model-server) | | `--llm-tool` | LLM tool override (e.g., claude, codex, gemini, or configured tool) | | `--model` | Model override (e.g., opus, gpt-4o, qwen2.5-coder) | -| `--model-server` | Named model server reference (e.g., ollama-local) — overrides the delegator's default. Pairs with --llm-tool/--model for ad-hoc ollama-backed launches. v1 accepts the flag and validates the name; env-var injection on spawn ships in v2 | +| `--model-server` | Named model server reference (e.g., ollama-local) - overrides the delegator's default. Pairs with --llm-tool/--model for ad-hoc ollama-backed launches. v1 accepts the flag and validates the name; env-var injection on spawn ships in v2 | ### `agents` diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 6707c0b2..3831c541 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -279,6 +279,16 @@ cors_origins = [] branch_format = "{type}/{ticket_id}" use_worktrees = false +[git.gitea] +enabled = false +token_env = "GITEA_TOKEN" +wip_prefix = "WIP: " + +[git.forgejo] +enabled = false +token_env = "FORGEJO_TOKEN" +wip_prefix = "WIP: " + [git.github] enabled = false token_env = "" diff --git a/docs/delegators/index.md b/docs/delegators/index.md index 87edb173..2f388d55 100644 --- a/docs/delegators/index.md +++ b/docs/delegators/index.md @@ -138,11 +138,11 @@ variables, and the token variable is stripped from every agent's spawn environment on all target kinds. **Blast radius:** a Coder session token can create, delete, and SSH into every workspace its user owns - scope accordingly. -Known limitation: prompt files are written on the operator side, so a coder -target currently requires the workspace to reach them (e.g. Operator itself -running inside a Coder workspace via the -[coder module](/getting-started/platforms/coder/)); `callback_url` keeps -multi-step chains reporting when the SSH tunnel drops. +No shared filesystem is required: the prompt and command payload are written on the operator side and pushed over SSH into the workspace before the session starts, so Operator can drive Coder from anywhere it can reach the deployment - a [Kubernetes deployment](/getting-started/platforms/kubernetes/#coder-targets), a server, or a laptop. +`callback_url` keeps multi-step chains reporting when the SSH tunnel drops. + +The `coder` CLI is resolved from `PATH`, then a cache in the state directory, and is otherwise downloaded from the deployment itself - so nothing has to be baked into an image and the CLI cannot drift from the server. `ssh` does have to be present. Keep `url_env` and `token_env` at their default names unless you have a reason not to: the SSH `ProxyCommand` runs the CLI as a subprocess, and +it reads `CODER_URL` / `CODER_SESSION_TOKEN` from the environment it inherits. Remote constraints for ssh and coder targets: worktrees and relay MCP injection are forced off, and the zellij session wrapper is unsupported. See diff --git a/docs/getting-started/git/gitea.md b/docs/getting-started/git/gitea.md index 15c53068..9913f6ea 100644 --- a/docs/getting-started/git/gitea.md +++ b/docs/getting-started/git/gitea.md @@ -13,7 +13,7 @@ provider = "gitea" [git.gitea] enabled = true -host = "https://gitea.kube.untra.casa" +host = "https://gitea.kube.your.site" token_env = "GITEA_TOKEN" wip_prefix = "WIP: " ``` @@ -30,7 +30,7 @@ name = "Operator agent {ticket_id}" email = "agent-{ticket_id}@example.org" [delegators.git.credentials] -repository_url = "https://gitea.kube.untra.casa/team/project.git" +repository_url = "https://gitea.kube.your.site/team/project.git" username = "operator-agent" token_env = "PROJECT_AGENT_TOKEN" diff --git a/docs/getting-started/integrations/agnt.md b/docs/getting-started/integrations/agnt.md index 56ca9b13..a1c4369d 100644 --- a/docs/getting-started/integrations/agnt.md +++ b/docs/getting-started/integrations/agnt.md @@ -88,8 +88,7 @@ plugin via AGNT's MCP settings: This surfaces Operator's ~18 MCP tools in AGNT immediately. The plugin's advantages over the raw bridge are first-class canvas nodes with typed -parameters and marketplace discoverability — but the bridge is zero-build and -the same `operator mcp` server works with any MCP-capable platform. +parameters and marketplace discoverability - but the bridge is zero-build and the same `operator mcp` server works with any MCP-capable platform. ## Trust & permissions diff --git a/docs/getting-started/integrations/index.md b/docs/getting-started/integrations/index.md index 1d1b47e7..721b5b55 100644 --- a/docs/getting-started/integrations/index.md +++ b/docs/getting-started/integrations/index.md @@ -4,7 +4,7 @@ description: "Connect Operator! to agent OS and automation platforms like AGNT.g layout: doc --- -Operator is not just a standalone TUI — it exposes its ticket orchestration over +Operator is not just a standalone TUI - it exposes its ticket orchestration over a **REST API** and a **stdio MCP server**, so external *automation platforms* and *agent operating systems* can drive it, and Operator can hand work out to them. @@ -25,8 +25,8 @@ Operator connects to an automation platform in two complementary directions: ## The portable substrate -Because the "platform → Operator" direction rides on **REST + MCP** — both -portable, widely supported substrates — exposing them once connects Operator to +Because the "platform → Operator" direction rides on **REST + MCP** - both +portable, widely supported substrates - exposing them once connects Operator to the *whole category*, not just one tool. Platforms in this space include [AGNT.gg](https://agnt.gg), n8n, Activepieces, Windmill, Dify, Flowise, Langflow, OpenAI AgentKit, and Zapier/Make. @@ -41,7 +41,7 @@ Operator already ships: ## Supported integrations -- **[AGNT.gg](/getting-started/integrations/agnt/)** — export Operator workflows +- **[AGNT.gg](/getting-started/integrations/agnt/)** - export Operator workflows as AGNT graphs, and drive Operator from AGNT workflows via the `operator-plugin`. > Write/launch tools mutate your repositories. Only connect platforms you trust, diff --git a/docs/getting-started/kanban/index.md b/docs/getting-started/kanban/index.md index 8609e64f..4395b87e 100644 --- a/docs/getting-started/kanban/index.md +++ b/docs/getting-started/kanban/index.md @@ -39,7 +39,7 @@ through the same three directories: **Queue.** New tickets land in `.tickets/queue/` and are ordered by their issue type's position in the active collection, then FIFO by timestamp within the same -type. The ordering is a property of the collection, not a hard-coded table — see +type. The ordering is a property of the collection, not a hard-coded table - see [Workflows](/workflows/). **Assignment.** When an agent slot frees up, Operator selects the next ticket, @@ -57,7 +57,7 @@ Operator bounds concurrent work so agents do not collide: - **Max agents** = min(configured_max, cpu_cores - reserved_cores) - **Autonomous agents** can run in parallel across different projects -- **Paired agents** run one at a time — they need your attention +- **Paired agents** run one at a time - they need your attention - **Same project** is sequential, to avoid conflicting edits Whether an issue type is autonomous or paired is declared by its `mode`. See @@ -66,8 +66,8 @@ practice. ## Column Mapping (todo / doing / done) -Operator is strict about its three internal states — **todo**, **doing**, -**done** — because they represent the work actually inflight at operator's +Operator is strict about its three internal states - **todo**, **doing**, +**done** - because they represent the work actually inflight at operator's level. External boards have flexible columns, so each synced project declares a `status_mapping` linking the two: diff --git a/docs/getting-started/kanban/jira.md b/docs/getting-started/kanban/jira.md index d5d3f335..26b7bc2e 100644 --- a/docs/getting-started/kanban/jira.md +++ b/docs/getting-started/kanban/jira.md @@ -95,7 +95,7 @@ done = "Done" # Column pushed when a ticket completes ### Column Mapping (todo / doing / done) -Operator is strict about its three internal states — todo, doing, done — while +Operator is strict about its three internal states - todo, doing, done - while Jira boards have arbitrary columns. `status_mapping` declares which Jira status corresponds to each operator state: @@ -107,7 +107,7 @@ corresponds to each operator state: Discover the board's real column names via `POST /api/v1/kanban/statuses` (onboarding) or -`GET /api/v1/kanban/jira/PROJ/statuses` (configured project) — the VS Code +`GET /api/v1/kanban/jira/PROJ/statuses` (configured project) - the VS Code config panel uses these to populate the mapping dropdowns. > **Migrating from `sync_statuses`:** the old diff --git a/docs/getting-started/kanban/openspec.md b/docs/getting-started/kanban/openspec.md index 7c996868..8685fb76 100644 --- a/docs/getting-started/kanban/openspec.md +++ b/docs/getting-started/kanban/openspec.md @@ -8,7 +8,7 @@ layout: doc Operator can import work from [**OpenSpec**](https://github.com/Fission-AI/OpenSpec), the spec-driven development (SDD) framework for AI coding assistants. OpenSpec keeps proposed changes as plain-markdown bundles in your repository; Operator turns their task checklists into queued tickets. -> **Experimental.** This provider is pull-only and file-based. Operator never edits your OpenSpec files — checking off completed tasks in `tasks.md` remains yours (or your agent's) to do. +> **Experimental.** This provider is pull-only and file-based. Operator never edits your OpenSpec files - checking off completed tasks in `tasks.md` remains yours (or your agent's) to do. ## How the mapping works @@ -21,7 +21,7 @@ OpenSpec stores each proposed change at `openspec/changes//` with a ` | Checklist items under the group | The ticket's task list (embedded in the body) | | All items checked | Group counts as `done` and is skipped on import | -Each imported ticket carries `external_provider: openspec` and `external_id: #` in its frontmatter, so re-running an import skips everything already in your queue — imports are idempotent. +Each imported ticket carries `external_provider: openspec` and `external_id: #` in its frontmatter, so re-running an import skips everything already in your queue - imports are idempotent. The ticket body includes the change id, the proposal's **Why** section, the group's checklist verbatim, and a pointer to the change directory so agents read the full spec (proposal, design, deltas) before starting. @@ -39,10 +39,10 @@ project = "yourproject" # operator project for imported tick | Setting | Default | Description | |---------|---------|-------------| | `enabled` | `false` | Whether this OpenSpec root is active | -| `root_path` | — | Directory containing the OpenSpec `changes/` tree | +| `root_path` | - | Directory containing the OpenSpec `changes/` tree | | `project` | change id | Operator project stamped on imported tickets | -No credentials are needed — OpenSpec is local markdown. +No credentials are needed - OpenSpec is local markdown. ## Importing @@ -62,6 +62,6 @@ Fully-checked task groups are skipped; unchecked or partially-checked groups bec ## Limitations - **Pull-only.** Ticket completion is not written back to `tasks.md` checkboxes, and Operator cannot create OpenSpec changes. -- **Group granularity.** One ticket per `## N.` task group — individual checklist items are not split into their own tickets. +- **Group granularity.** One ticket per `## N.` task group - individual checklist items are not split into their own tickets. - **No dependency ordering.** Tickets are queued FIFO in group order; Operator's same-project sequencing keeps them from running concurrently, but there is no hard blocking between groups. - `design.md` and spec deltas are referenced by path, not ingested. diff --git a/docs/getting-started/model-servers/anthropic.md b/docs/getting-started/model-servers/anthropic.md index f72383e3..767069fc 100644 --- a/docs/getting-started/model-servers/anthropic.md +++ b/docs/getting-started/model-servers/anthropic.md @@ -4,7 +4,7 @@ description: "Connect Anthropic as a first-party model provider and list its mod layout: doc --- -[**Anthropic**](https://www.anthropic.com/) is a first-party model provider — it +[**Anthropic**](https://www.anthropic.com/) is a first-party model provider - it produces the Claude family of models and serves them from its own API. It is the zero-config default for the `claude` llm tool, and a first-class [model provider](./) in its own right: once connected, operator lists its @@ -16,7 +16,7 @@ available models live so delegators can pick one. ## Connect -Operator references your key by env-var name — it never stores the secret. Set +Operator references your key by env-var name - it never stores the secret. Set the standard Anthropic key and operator can probe the provider: ```bash @@ -30,7 +30,7 @@ VS Code section) Anthropic then shows ● connected with its live model list. ## Listing models Operator probes `https://api.anthropic.com/v1/models` and stays agnostic to which -models exist — it lists whatever the API returns rather than hardcoding names: +models exist - it lists whatever the API returns rather than hardcoding names: ```bash GET /api/v1/model-servers/kinds/anthropic-api/models # { reachable, models[], error? } diff --git a/docs/getting-started/model-servers/google.md b/docs/getting-started/model-servers/google.md index 7dad00f8..6bb2adfe 100644 --- a/docs/getting-started/model-servers/google.md +++ b/docs/getting-started/model-servers/google.md @@ -4,7 +4,7 @@ description: "Connect Google (Gemini) as a first-party model provider and list i layout: doc --- -[**Google**](https://ai.google.dev/) is a first-party model provider — it +[**Google**](https://ai.google.dev/) is a first-party model provider - it produces the Gemini family and serves them from its own API. It is the zero-config default for the `gemini` llm tool, and a first-class [model provider](./): once connected, operator lists its available models live @@ -16,7 +16,7 @@ for delegators. ## Connect -Operator references your key by env-var name — it never stores the secret: +Operator references your key by env-var name - it never stores the secret: ```bash export GEMINI_API_KEY="..." diff --git a/docs/getting-started/model-servers/index.md b/docs/getting-started/model-servers/index.md index 460cb687..c6c2b84d 100644 --- a/docs/getting-started/model-servers/index.md +++ b/docs/getting-started/model-servers/index.md @@ -6,25 +6,21 @@ layout: doc A **model server** is a named host that serves models via an inference API. It's orthogonal to the LLM tool that runs your coding agent: -- **LLM tools** (claude, codex, gemini) are the agentic CLIs that drive the coding session — they use tools, edit files, resume sessions. -- **Model servers** are where the model weights live — Anthropic's API, OpenAI's API, Google's API, or a many-model provider like [OpenRouter](/getting-started/model-servers/openrouter/), a local [Ollama](/getting-started/model-servers/ollama/) server, lmstudio, or vllm. +- **LLM tools** (claude, codex, gemini) are the agentic CLIs that drive the coding session - they use tools, edit files, resume sessions. +- **Model servers** are where the model weights live - Anthropic's API, OpenAI's API, Google's API, or a many-model provider like [OpenRouter](/getting-started/model-servers/openrouter/), a local [Ollama](/getting-started/model-servers/ollama/) server, lmstudio, or vllm. A delegator pairs an LLM tool with a model (and, optionally, a model server). ## Two families -Every kind is a **model provider**. They split into two classes — the grouping -every surface (README badges, the status tree, the REST `/kinds` catalog) derives -from `ModelServerKind::provider_class()`: - -- **First-party** — a single vendor's own API: [Anthropic](/getting-started/model-servers/anthropic/) +- **First-party** - a single vendor's own API: [Anthropic](/getting-started/model-servers/anthropic/) (`anthropic-api`), [OpenAI](/getting-started/model-servers/openai/) (`openai-api`), [Google](/getting-started/model-servers/google/) (`google-api`). These double as the zero-config defaults for the - claude/codex/gemini tools, so you rarely declare them — but they're first-class: + claude/codex/gemini tools, so you rarely declare them - but they're first-class: operator lists each one's live models from its `/models` endpoint when the corresponding key env is set. -- **Gateways** — a host or aggregator that fronts *many* models behind one +- **Gateways** - a host or aggregator that fronts *many* models behind one endpoint: [OpenRouter](/getting-started/model-servers/openrouter/) (`openrouter`), a local [Ollama](/getting-started/model-servers/ollama/) server (`ollama`), or any `openai-compat` / `lmstudio` server. Declare one to @@ -121,7 +117,7 @@ operator launch \ Each kind knows how to enumerate the models its endpoint serves (ollama `/api/tags`, OpenAI-protocol `/v1/models`, Anthropic `/v1/models`, Gemini `/v1beta/models`). The -same probe doubles as a reachability check — there is no separate "test connection". +same probe doubles as a reachability check - there is no separate "test connection". - **REST**: `GET /api/v1/model-servers/{name}/models` returns `{ reachable, models[], error? }`. - **VS Code**: expand a server in the status tree to see its live model list (or an @@ -156,7 +152,7 @@ The API key is injected **by reference**, not by value: if `api_key_env = "MY_KE the script exports `OPENAI_API_KEY="${MY_KEY}"`, which the shell resolves from the inherited environment at run time. The secret is never written into the on-disk command script. Any `extra_env` entries are exported verbatim and take precedence. -Implicit builtins with no `base_url` inject nothing — the vendor-default path is unchanged. +Implicit builtins with no `base_url` inject nothing - the vendor-default path is unchanged. **Still deferred:** diff --git a/docs/getting-started/model-servers/ollama.md b/docs/getting-started/model-servers/ollama.md index f1ce06a2..ea0abcdc 100644 --- a/docs/getting-started/model-servers/ollama.md +++ b/docs/getting-started/model-servers/ollama.md @@ -6,7 +6,7 @@ layout: doc [**Ollama**](https://ollama.com/) runs open models (Llama, Qwen, Mistral, …) locally and serves them over an OpenAI-compatible API. Declare it as a -[model server](./) to drive agents against models on your own machine — no cloud +[model server](./) to drive agents against models on your own machine - no cloud key required. ## Prerequisites @@ -14,7 +14,7 @@ key required. - Ollama installed and running: [ollama.com/download](https://ollama.com/download), then `ollama serve` (default `http://localhost:11434`) - At least one model pulled, e.g. `ollama pull qwen2.5-coder` -- An OpenAI-protocol LLM tool — **codex** works directly; claude/gemini need a +- An OpenAI-protocol LLM tool - **codex** works directly; claude/gemini need a bridge (see [Protocol compatibility](./#protocol-compatibility)) ## Configuration diff --git a/docs/getting-started/model-servers/openai.md b/docs/getting-started/model-servers/openai.md index 537c9a95..8bdff5d8 100644 --- a/docs/getting-started/model-servers/openai.md +++ b/docs/getting-started/model-servers/openai.md @@ -4,7 +4,7 @@ description: "Connect OpenAI as a first-party model provider and list its models layout: doc --- -[**OpenAI**](https://openai.com/) is a first-party model provider — it produces +[**OpenAI**](https://openai.com/) is a first-party model provider - it produces the GPT family and serves them from its own API. It is the zero-config default for the `codex` llm tool, and a first-class [model provider](./): once connected, operator lists its available models live for delegators to pick from. @@ -15,7 +15,7 @@ operator lists its available models live for delegators to pick from. ## Connect -Operator references your key by env-var name — it never stores the secret: +Operator references your key by env-var name - it never stores the secret: ```bash export OPENAI_API_KEY="sk-..." @@ -47,5 +47,5 @@ model = "gpt-4o" Declare an `openai-api` server with an explicit `base_url` to point Codex at a proxy; that base URL is then injected at spawn (`OPENAI_BASE_URL`). The probe -default (`https://api.openai.com`) is **probe-only** — it never changes the +default (`https://api.openai.com`) is **probe-only** - it never changes the launch path on its own. diff --git a/docs/getting-started/model-servers/openrouter.md b/docs/getting-started/model-servers/openrouter.md index 4c0c3514..bbf5fbb1 100644 --- a/docs/getting-started/model-servers/openrouter.md +++ b/docs/getting-started/model-servers/openrouter.md @@ -13,12 +13,12 @@ OpenAI-compatible endpoint and one API key. Declare it once as a - An OpenRouter account and an API key from [openrouter.ai/keys](https://openrouter.ai/keys) -- An OpenAI-protocol LLM tool — **codex** works directly; claude/gemini need a +- An OpenAI-protocol LLM tool - **codex** works directly; claude/gemini need a bridge (see [Protocol compatibility](./#protocol-compatibility)) ## Configuration -Export your key (kept out of config — Operator references it by name): +Export your key (kept out of config - Operator references it by name): ```bash export OPENROUTER_API_KEY="sk-or-..." @@ -69,6 +69,6 @@ Operator exports: | `OPENAI_BASE_URL` | `https://openrouter.ai/api/v1` | | `OPENAI_API_KEY` | `${OPENROUTER_API_KEY}` (by reference) | -The key is injected **by reference**, never by value — the secret is never +The key is injected **by reference**, never by value - the secret is never written into the on-disk command script. See the [Model Providers overview](./#how-env-injection-works) for the full mechanism. diff --git a/docs/getting-started/platform-support.md b/docs/getting-started/platform-support.md index dbc27d66..c07a6a9f 100644 --- a/docs/getting-started/platform-support.md +++ b/docs/getting-started/platform-support.md @@ -70,7 +70,6 @@ Operator also ships as a container image and a Helm chart. Both are Linux-only |--------------|--------|-------| | Docker image `untra/operator` | ✅ Supported | Multi-arch. See [Docker](/getting-started/platforms/docker/) | | Helm chart `oci://ghcr.io/untra/charts/operator` | ⚠️ Alpha | Single-replica StatefulSet, ReadWriteOnce persistence. See [Kubernetes](/getting-started/platforms/kubernetes/) | -| Example Helmfile | ⚠️ Alpha | `examples/helmfile.yaml` in the repository | | Feature | Status | Reason | Workaround | |---------|--------|--------|------------| diff --git a/docs/getting-started/platforms/coder.md b/docs/getting-started/platforms/coder.md index 6b1d2923..0b539fdd 100644 --- a/docs/getting-started/platforms/coder.md +++ b/docs/getting-started/platforms/coder.md @@ -1,31 +1,42 @@ --- title: "Coder" -description: "Run Operator as a background service in Coder workspaces via Terraform module." +description: "Run Operator inside a Coder workspace, or point Operator at Coder to spawn per-ticket agent workspaces." layout: doc --- -Supported +Alpha -Run [Operator](https://operator.untra.io) as a background REST API server inside your [Coder](https://coder.com) workspace. The module downloads the operator binary from GitHub releases, generates configuration, starts the API server, and exposes the dashboard through the Coder workspace UI with automatic healthchecks. +[Operator](https://operator.untra.io) and [Coder](https://coder.com) fit together in two directions. They are independent - pick the one that matches where Operator runs. + +| | Operator runs | Agents run | Set up with | +|---|---|---|---| +| **[Inside a workspace](#operator-inside-a-coder-workspace)** | in a Coder workspace | in that same workspace | the Terraform module | +| **[Targeting Coder](#operator-targeting-coder)** | anywhere (Kubernetes, a server, your laptop) | in per-ticket Coder workspaces | a `[[targets]]` entry | + +The two can be combined: Operator inside a workspace can also spawn *sibling* workspaces. See [child agent workspaces](#child-agent-workspaces). + +## Operator inside a Coder workspace + +A Terraform module runs Operator as a background REST API server in the workspace, and exposes its dashboard as a Coder app with healthchecks. **Registry:** [`registry.coder.com/untra/operator/coder`](https://registry.coder.com/modules/operator) -## Usage +Templates and modules do different jobs here: a **template** is the whole workspace blueprint (cloud, compute, storage), while a **module** adds one feature inside it. Operator is a module - you drop it into a template you already have. + +### Usage ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id } ``` -### Custom configuration +Pin `version` to a published module release for reproducible builds. `install_version` is a separate knob that selects which Operator release the module downloads. ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id port = 7008 max_parallel_agents = 4 @@ -35,10 +46,11 @@ module "operator" { ### Full TOML override +`config_toml` is written verbatim and replaces the generated config entirely. + ```tf module "operator" { source = "registry.coder.com/untra/operator/coder" - version = "1.0.0" agent_id = coder_agent.main.id config_toml = <<-EOT [rest_api] @@ -47,20 +59,14 @@ module "operator" { [agents] max_parallel = 4 - health_check_interval = 30 [sessions] wrapper = "tmux" - - [[delegators]] - name = "default" - tool = "claude-code" - model = "sonnet" EOT } ``` -## Variables +### Variables | Variable | Type | Default | Description | |----------|------|---------|-------------| @@ -68,8 +74,8 @@ module "operator" { | `port` | `number` | `7008` | The port for the operator REST API server | | `display_name` | `string` | `"Operator"` | Display name in the Coder dashboard | | `slug` | `string` | `"operator"` | Application slug | -| `install_version` | `string` | `"{{ site.version }}"` | GitHub release tag to install | -| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binary into | +| `install_version` | `string` | `"{{ site.version }}"` | Operator GitHub release tag to install | +| `install_prefix` | `string` | `"/tmp/operator"` | Directory to install the binaries into | | `log_path` | `string` | `"/tmp/operator.log"` | Path to write log output | | `config_toml` | `string` | `""` | Raw TOML config (written verbatim instead of auto-generated config) | | `max_parallel_agents` | `number` | `2` | Maximum number of parallel agents | @@ -77,24 +83,43 @@ module "operator" { | `share` | `string` | `"owner"` | Dashboard sharing level (`owner`, `authenticated`, or `public`) | | `order` | `number` | `null` | Position of the app in the Coder dashboard (lower = first) | | `group` | `string` | `null` | Group that this app belongs to | -| `offline` | `bool` | `false` | Skip downloading; requires pre-installed binary at `install_prefix` | +| `offline` | `bool` | `false` | Skip downloading; requires a pre-installed binary at `install_prefix` | | `use_cached` | `bool` | `false` | Use cached binary if present, otherwise download | -## Prerequisites +### Child agent workspaces + +Set `agent_template` and the generated config gains a `[[targets]]` entry with `kind = "coder"`, so tickets launched from this workspace create sibling workspaces from that template instead of running agents locally. These variables are ignored unless `agent_template` is set, and any left unset are omitted from the config so Operator's own defaults apply. + +| Variable | Type | Default | Description | +|----------|------|---------|-------------| +| `agent_template` | `string` | `""` | Template child agent workspaces are created from. Empty disables the coder target. | +| `coder_token_env` | `string` | `"CODER_SESSION_TOKEN"` | Name of the env var holding the Coder **user session token** | +| `callback_url` | `string` | `""` | Control-plane-reachable `OPERATOR_API_URL` override; empty keeps the reverse-tunnel default | +| `name_prefix` | `string` | `""` | Workspace name prefix for deterministic per-ticket naming | +| `workdir` | `string` | `""` | Project root inside spawned workspaces | +| `stop_on_complete` | `bool` | `null` | Stop a spawned workspace when its ticket completes (never deletes) | +| `create_timeout_secs` | `number` | `null` | Bound on workspace create plus agent-ready wait, in seconds | + +This mode needs a **user session token**, which is not the ambient `CODER_AGENT_TOKEN` - that one is scoped to a single workspace and cannot create others. The module does not provision it; supply it through your template. A user session token can create, delete, and SSH into every workspace its user owns, so scope the account accordingly. + +### Prerequisites The workspace image must include `tmux` (or your chosen `session_wrapper`) for Operator to spawn agent sessions. Most Coder workspace images include tmux by default. -## Coder Workspace Context +For child agent workspaces, the image also needs `ssh` (`openssh-client`), since agents are launched over real SSH. + +### Coder workspace context Coder automatically injects environment variables into every workspace that Operator can reference in ticket templates and agent prompts: -- `CODER_WORKSPACE_NAME` — workspace identifier -- `CODER_WORKSPACE_OWNER` — workspace owner username -- `CODER_AGENT_TOKEN` — agent authentication token +- `CODER_WORKSPACE_NAME` - workspace identifier +- `CODER_WORKSPACE_OWNER` - workspace owner username +- `CODER_URL` - deployment URL, which the coder target reads by default +- `CODER_AGENT_TOKEN` - agent authentication token, scoped to this workspace -No Operator configuration is needed to access these — they are ambient in the workspace environment. +No Operator configuration is needed to access these - they are ambient in the workspace environment. -## How It Works +### How it works 1. The module runs a startup script that detects the workspace architecture (`linux-x86_64` or `linux-arm64`) 2. Downloads the Operator binary from GitHub releases (or uses a cached/pre-installed binary), then the `opr8r` client binary that agent sessions call to report step completion for multi-step workflows. @@ -102,20 +127,81 @@ No Operator configuration is needed to access these — they are ambient in the 4. Starts `operator api` as a background process 5. Registers the Operator dashboard as a Coder app with healthchecks polling `/api/v1/health` every 5 seconds +## Operator targeting Coder + +Here Operator runs outside Coder - most often as the [Kubernetes deployment](/getting-started/platforms/kubernetes/) - and provisions a Coder workspace per ticket. The Terraform module is not involved. + +Declare a target. `template` is an allowlist: agents can only ever land on the template you name here. + +```toml +[[targets]] +name = "cloud" +kind = "coder" +template = "operator-agent" +``` + +Then reference it from a delegator, or set it as the default target. Full field reference: [execution targets](/delegators/#execution-targets). + +### Credentials + +Operator reads two environment variables, resolved **by name** so the values never enter the config file or the state store: + +| Variable | Default name | Holds | +|----------|--------------|-------| +| `url_env` | `CODER_URL` | Your deployment URL, e.g. `https://coder.example.com` | +| `token_env` | `CODER_SESSION_TOKEN` | A Coder user session token | + +A session token can create, delete, and SSH into every workspace its user owns, so give Operator its own service account rather than a human's credentials. Operator strips the token variable from every agent's spawn environment, on every target kind - including `local` agents, which would otherwise read it straight out of `env`. + +Keep both variables at their default names unless you have a reason not to. The SSH `ProxyCommand` runs the `coder` CLI as a subprocess, and the CLI reads these canonical names from the inherited environment. + +### The `coder` CLI + +Operator does not bundle the CLI. It resolves one in this order: + +1. `coder` on `PATH` +2. A previously downloaded copy in the state directory, at `.tickets/operator/bin/coder` +3. Otherwise it downloads `{CODER_URL}/bin/coder-linux-{amd64,arm64}` - your deployment serves a CLI matching its own version - and caches it at (2) + +So a container needs no CLI baked in, and the CLI can never drift from the server it talks to. It does need `ssh` and outbound network access to the deployment. The official image ships `openssh-client`; if you supply your own, include it. + +### Workspace lifecycle + +- **Naming is deterministic:** `{name_prefix}-{project}-{ticket_id}`, sanitized and capped at Coder's 32-character limit. Relaunching a ticket reuses its workspace. +- **Create or start:** absent workspaces are created from `template`; existing ones on that template are started. +- **Collisions are refused:** a workspace of the same name on a *different* template stops the launch rather than being reused, so Operator can never adopt a workspace a human made. +- **Never deleted:** completed workspaces are stopped (when `stop_on_complete` is set). Reclamation stays with your Coder autostop and autodelete policy. + +Operator writes its own per-workspace SSH config fragment under `.tickets/operator/ssh/` rather than running `coder config-ssh`, which would rewrite `~/.ssh/config`. The fragment proxies through `coder ssh --stdio` and skips host-key checking, matching what `coder config-ssh` writes for its own hosts: the Coder tailnet is the authentication boundary, and per-ticket workspaces are too short-lived for trust-on-first-use to be meaningful. + +### Constraints + +For `coder` targets, as for `ssh` targets, git worktrees and relay MCP injection are forced off, and the `zellij` session wrapper is unsupported. + ## Troubleshooting -### Binary download fails +### Binary download fails (module) 1. Check that the `install_version` matches a valid [GitHub release tag](https://github.com/untra/operator/releases) 2. Verify the workspace has internet access (or use `offline = true` with a pre-installed binary) 3. Check logs at the configured `log_path` (default: `/tmp/operator.log`) -### Healthcheck timeout +### Healthcheck timeout (module) 1. Verify the port is not already in use: `ss -tlnp | grep 7008` 2. Check operator logs: `cat /tmp/operator.log` 3. Ensure the session wrapper (tmux by default) is installed in the workspace image -### Port conflicts +### Port conflicts (module) Change the `port` variable to an unused port. Remember to update any other services or extensions that connect to the Operator API. + +### A coder target fails to launch + +Operator fails fast and names what is missing. In order: + +1. **A missing environment variable** - the error names it. Confirm `CODER_URL` and the session token are present in Operator's own environment, not just the agent's. +2. **The CLI download fails** - the error names the URL it tried. Usually egress: from a container, check reachability directly, e.g. `curl -sSf $CODER_URL/api/v2/buildinfo`. In Kubernetes this is commonly Coder's *own* ingress NetworkPolicy declining to admit Operator's namespace, which is a fix on the Coder side. +3. **`coder create` fails** - the message is Coder's own, verbatim. Template permissions and workspace quotas surface here. +4. **The workspace never becomes reachable over SSH** within `create_timeout_secs` - the template's agent is not starting, or `ssh` is missing from Operator's environment. +5. **A refused name collision** - a workspace of that name already exists on another template. Rename or remove it. diff --git a/docs/getting-started/platforms/docker.md b/docs/getting-started/platforms/docker.md index 8bf5a017..d7acfa2b 100644 --- a/docs/getting-started/platforms/docker.md +++ b/docs/getting-started/platforms/docker.md @@ -8,7 +8,7 @@ layout: doc Run [Operator](https://operator.untra.io) from an official multi-arch container image. The image bundles the Operator binary (with the embedded web dashboard and REST API) and the `opr8r` client on a slim Debian base, plus the `git` and `tmux` substrate Operator needs to launch agents. Mount your projects root into the container and Operator treats it as the workspace. -**Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) — `linux/amd64` and `linux/arm64`. +**Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) - `linux/amd64` and `linux/arm64`. ## Usage @@ -34,7 +34,7 @@ docker run --rm -v $(pwd):/op:rw \ **`OPERATOR_REST_API__HOST=0.0.0.0` is required to publish the port at all.** -Operator binds `127.0.0.1` by default, which inside a container means the *container's* loopback — unreachable from the host no matter how you publish it. +Operator binds `127.0.0.1` by default, which inside a container means the *container's* loopback - unreachable from the host no matter how you publish it. Setting the bind address to `0.0.0.0` makes it reachable from the container network; `-p 127.0.0.1:7008:7008` then restricts which host interface it appears on. Both halves are needed, and they do different jobs. Outside a container the default is unchanged: Operator binds loopback, and you do not need to set this. @@ -75,7 +75,7 @@ docker run --rm -v $(pwd):/op:rw -it untra/operator:{{ site.version }} **Not included: the LLM CLI and its auth.** Operator launches agents via an LLM tool (`claude`, `codex`, or `gemini`) that you supply. Two ways to provide it: -1. **Derived image** — extend the official image with your tool of choice: +1. **Derived image** - extend the official image with your tool of choice: ```dockerfile FROM untra/operator @@ -88,7 +88,7 @@ docker run --rm -v $(pwd):/op:rw -it untra/operator:{{ site.version }} USER 10001 ``` -2. **Mount + env vars** — mount an already-installed, authenticated CLI from the host +2. **Mount + env vars** - mount an already-installed, authenticated CLI from the host and pass credentials. The container runs as uid/gid 10001 with `$HOME=/home/operator`: ```bash @@ -102,7 +102,7 @@ docker run --rm -v $(pwd):/op:rw -it untra/operator:{{ site.version }} Operator reads `.tickets/operator/config.toml` relative to its working directory. Because the image uses `WORKDIR /op` and you mount your projects root at `/op`, an existing config -is picked up automatically — no flags required. Run from a directory without one and +is picked up automatically - no flags required. Run from a directory without one and Operator uses its built-in defaults. A global override at `~/.config/operator/config.toml` (i.e. @@ -111,8 +111,8 @@ A global override at `~/.config/operator/config.toml` (i.e. ## Prerequisites - Docker (with `buildx` for multi-arch hosts, which is the default on modern Docker). -- The host directory you mount at `/op` should be your **projects root** — the directory - containing your code repositories and `.tickets/` — so Operator can start work in the +- The host directory you mount at `/op` should be your **projects root** - the directory + containing your code repositories and `.tickets/` - so Operator can start work in the right place. - Use `-it` for the interactive TUI; omit it for one-shot subcommands and `api`. diff --git a/docs/getting-started/platforms/kubernetes.md b/docs/getting-started/platforms/kubernetes.md index 89397a9e..f5341ef9 100644 --- a/docs/getting-started/platforms/kubernetes.md +++ b/docs/getting-started/platforms/kubernetes.md @@ -9,18 +9,18 @@ layout: doc Run [Operator](https://operator.untra.io) in a cluster from the official Helm chart. The chart deploys a single-replica StatefulSet with a persistent workspace volume, a ClusterIP Service. -**Chart:** `oci://ghcr.io/untra/charts/operator` — **Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) +**Chart:** `oci://ghcr.io/untra/charts/operator` - **Image:** [`untra/operator`](https://hub.docker.com/r/untra/operator) ## What the chart does not contain -Stated up front, because it is the first thing worth knowing about running an agent orchestrator in your cluster: +Stated up front, because it is the first thing worth knowing about running an agent orchestrator in the cluster: - **No Docker socket** is mounted. - **No Kubernetes controller.** Operator does not watch, create, or reconcile cluster resources. - **No Role, RoleBinding, or ClusterRole** is created. - The ServiceAccount sets `automountServiceAccountToken: false`, so the pod has no Kubernetes API credential at all. -Operator in your cluster is an application with a volume and a port. It cannot reach the Kubernetes API, because it has no token and no client to use one with. +Operator in a kubernetes cluster is an application with a volume and a port. It cannot reach the Kubernetes API, because it has no token and no client to use one with. ## Install @@ -30,7 +30,7 @@ helm install operator oci://ghcr.io/untra/charts/operator \ --set publicUrl=https://operator.example.com ``` -The chart's `appVersion` is the image tag. It is pinned to an exact release — the chart never deploys `latest`. +The chart's `appVersion` is the image tag. It is pinned to an exact release - the chart never deploys `latest`. ### Bootstrap the admin account @@ -66,8 +66,7 @@ kubectl -n operator delete secret operator-bootstrap ## DNS and TLS -The chart does not manage certificates. Create the TLS Secret yourself, or let -cert-manager create it, then point the Ingress at it. +The chart does not manage certificates. Create the TLS Secret independently, or let cert-manager create it, then point the Ingress at it. With cert-manager: @@ -114,8 +113,9 @@ persistence: storageClass: fast-ssd ``` -The volume is mounted at `/op` and holds the workspace, repositories, -`.tickets/`, and the authentication database. It is the only durable state. +The volume is mounted at `/op` and holds the workspace, repositories, `.tickets/`, and the authentication database. It is the only durable state - `$HOME` and `/tmp` are emptyDir mounts and are discarded on every restart. + +Two things land here that are easy to overlook, both under `.tickets/operator/`: `ssh/` holds the per-workspace SSH config fragments for [Coder targets](#coder-targets), and `bin/` caches the `coder` CLI when Operator downloads one. Keeping them on the volume is why a pod restart does not re-download the CLI. **Horizontal scaling is not supported.** Operator is a single-writer process over a ReadWriteOnce volume with a local queue and a local SQLite database. @@ -125,7 +125,7 @@ volume, and the chart does not offer the option. ## NetworkPolicy Optional and disabled by default. Enabling it is how you bound Operator's -egress — the code-level destination validation described in +egress - the code-level destination validation described in [Security](/security/#server-side-request-forgery) is one control, and this is the other. @@ -149,17 +149,16 @@ networkPolicy: - 192.168.0.0/16 ``` -Operator needs egress to your model provider, kanban provider, and Git host. It -does not need egress to the rest of your cluster. +Operator needs egress to the model provider, kanban provider, and Git host. It does not need egress to the rest of the cluster - unless you use [Coder targets](#coder-targets), which need to reach the Coder deployment. + +Note default: with `enabled: true` and an empty `egress.to`, the rendered policy permits DNS. An empty list is deny-all, not allow-all. ## Custom agent images -The base image ships `git`, `tmux`, and `ca-certificates`, but **no agent CLI** -— no `claude`, `codex`, or `gemini`, and no credentials for them. Supply your -own image: +The base image ships `git`, `tmux`, `openssh-client`, `curl`, and `ca-certificates`, but **no agent CLI** - no `claude`, `codex`, or `gemini`, and no credentials for them. ```dockerfile -FROM untra/operator:0.2.6 +FROM untra/operator:0.2.7 USER root RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ && npm install -g @anthropic-ai/claude-code \ @@ -170,7 +169,7 @@ USER 10001 ```yaml image: repository: registry.example.com/operator-claude - tag: "0.2.6" + tag: "0.2.7" ``` Provide the agent's credentials as environment variables from a Secret: @@ -182,9 +181,49 @@ extraEnvFrom: ``` Note that an agent process runs as the same user as Operator and can read -these. That is inherent to the current execution model — see +these. That is inherent to the current execution model - see [the trust boundary discussion](/security/#the-agent-process-is-inside-the-trust-boundary). +## Coder targets + +Operator can run agents in per-ticket [Coder](/getting-started/platforms/coder/#operator-targeting-coder) +workspaces instead of in its own pod. From a Kubernetes deployment that needs three things. + +**1. Credentials, by name.** Operator reads the deployment URL and a user session token from environment variables. Put them in a Secret and reference it - the chart has no dedicated values for this: + +```yaml +extraEnvFrom: + - secretRef: + name: operator-coder +``` + +```bash +kubectl -n operator create secret generic operator-coder --from-literal=CODER_URL=https://coder.example.com --from-literal=CODER_SESSION_TOKEN= +``` + +Give Operator its own Coder service account. A session token can create, delete, and SSH into every workspace its user owns. + +**2. Egress to Coder.** If `networkPolicy.enabled` is true, add the Coder namespace explicitly: + +```yaml +networkPolicy: + enabled: true + egress: + allowDNS: true + to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: coder +``` + +Coder's own ingress NetworkPolicy has to admit Operator's namespace too. + +```bash +kubectl -n operator exec operator-0 -- curl -sSf https://coder.example.com/api/v2/buildinfo +``` + +**3. Nothing else.** The image already ships `openssh-client`, and Operator downloads the `coder` CLI from the deployment on first use, caching it on the persistent volume at `.tickets/operator/bin/coder`. No custom image, no initContainer, and no relaxing of `readOnlyRootFilesystem` - the cache and the SSH fragments both live under `/op`. + ## Security context Applied by default; you should not need to change any of it: @@ -220,10 +259,9 @@ The authentication database migrates forward automatically on start. Back up the persistent volume. It holds everything: workspace, tickets, state, and `auth.sqlite3`. -Treat the backup as sensitive — it contains the authentication database, which holds the token signing key. +Treat the backup as sensitive - it contains the authentication database, which holds the token signing key. -To restore, pre-create the PersistentVolumeClaim the StatefulSet expects, backed -by your snapshot, before installing the chart. A StatefulSet adopts an existing claim whose name matches its `volumeClaimTemplate`, which is `workspace--0`: +To restore, pre-create the PersistentVolumeClaim the StatefulSet expects, backed by the snapshot, before installing the chart. A StatefulSet adopts an existing claim whose name matches its `volumeClaimTemplate`, which is `workspace--0`: ```yaml apiVersion: v1 @@ -272,7 +310,7 @@ The chart uses **HTTP probes** against `/livez` (liveness) and `/readyz` (readiness). Both are public and carry no workspace metadata. `/livez` answers as soon as the server is serving; `/readyz` additionally checks that the authentication database on the persistent volume is reachable, returning `503` -with `auth store unavailable` when it is not — so a pod stuck `NotReady` with a +with `auth store unavailable` when it is not - so a pod stuck `NotReady` with a healthy `/livez` points at the volume, not the process. Do not repoint either probe at `/api/v1/health`: that endpoint reports workspace @@ -285,8 +323,7 @@ even on a perfectly healthy pod. ### Agents fail to launch -The base image intentionally omits the agent CLI. Confirm your derived image -provides an authenticated `claude`, `codex`, or `gemini` on `PATH`: +The base image intentionally omits the agent CLI. Confirm the derived image provides an authenticated `claude`, `codex`, or `gemini` on `PATH`: ```bash kubectl -n operator exec statefulset/operator -- sh -c 'command -v claude' diff --git a/docs/getting-started/sessions/cmux.md b/docs/getting-started/sessions/cmux.md index ba407d85..56c66b75 100644 --- a/docs/getting-started/sessions/cmux.md +++ b/docs/getting-started/sessions/cmux.md @@ -51,7 +51,7 @@ The `placement` setting controls how Operator creates new agent sessions: | Policy | Behavior | |--------|----------| -| `auto` | **0–1 open windows**: creates a new workspace in the active window. **>1 open windows**: creates a new window for the ticket. | +| `auto` | **0-1 open windows**: creates a new workspace in the active window. **>1 open windows**: creates a new window for the ticket. | | `workspace` | Always creates a new workspace in the active window | | `window` | Always creates a new window for each ticket | diff --git a/docs/getting-started/sessions/remote-hosts/index.md b/docs/getting-started/sessions/remote-hosts/index.md index 9d3dbad8..6163049f 100644 --- a/docs/getting-started/sessions/remote-hosts/index.md +++ b/docs/getting-started/sessions/remote-hosts/index.md @@ -49,17 +49,17 @@ Operator preflights all of this (reachability, tmux, tool, workdir) before creat ## Disconnects and reconnecting If the SSH link drops (laptop sleep, network change), the local pane dies and -the agent shows as dead — but the **remote tmux session and agent survive**. +the agent shows as dead - but the **remote tmux session and agent survive**. Relaunch the ticket from the TUI: the wrapper regenerates and `tmux new-session -A` reattaches the surviving remote session with scrollback intact. ## limitations -- **No git worktrees** for remote agents — the agent works directly in +- **No git worktrees** for remote agents - the agent works directly in `workdir`, regardless of `use_worktrees`. - **No hook signals or artifact detection** (both read the local filesystem); liveness relies on pane presence and screen content, the same posture cmux agents have. - **No relay MCP injection** (the relay hub is a local Unix socket). -- **No docker mode** and **no zellij wrapper** with a remote host — both are rejected at resolution time. +- **No docker mode** and **no zellij wrapper** with a remote host - both are rejected at resolution time. - **One remote agent per host at a time** is the safe posture: concurrent agents to the same host would collide on the reverse-tunnel port, and the second launch fails loudly. - Ticket files live on the local machine; remote agents signal progress through `opr8r` callbacks rather than moving ticket files. diff --git a/docs/getting-started/tickets/index.md b/docs/getting-started/tickets/index.md index bb1940da..bf36227d 100644 --- a/docs/getting-started/tickets/index.md +++ b/docs/getting-started/tickets/index.md @@ -4,7 +4,7 @@ description: "Create and manage tickets with markdown format, naming conventions layout: doc --- -Tickets are the unit of work in Operator!. Each one describes a task for an agent to complete, and carries an **issue type** that decides *how* the work is done — see [Workflows](/workflows/) for the process behind the ticket. +Tickets are the unit of work in Operator!. Each one describes a task for an agent to complete, and carries an **issue type** that decides *how* the work is done - see [Workflows](/workflows/) for the process behind the ticket. ## Ticket Format @@ -14,7 +14,7 @@ Tickets are markdown files with a specific naming convention: {TYPE}-{ID}-{project}-{description}.md ``` -`{TYPE}` is the issue type key (`FEAT`, `FIX`, `PRD`, …), which is why keys never contain hyphens — the hyphen separates the key from the ticket number. +`{TYPE}` is the issue type key (`FEAT`, `FIX`, `PRD`, …), which is why keys never contain hyphens - the hyphen separates the key from the ticket number. ### Examples diff --git a/docs/getting-started/workflows/agnt.md b/docs/getting-started/workflows/agnt.md index bc9e54b9..e831b068 100644 --- a/docs/getting-started/workflows/agnt.md +++ b/docs/getting-started/workflows/agnt.md @@ -4,8 +4,7 @@ description: "Export an Operator ticket + issue type into an AGNT.gg workflow gr layout: doc --- -Renders a `ticket + issue type` into an [AGNT.gg](https://agnt.gg) **workflow -graph** — a `{ name, description, nodes, edges }` JSON document AGNT can import +Renders a `ticket + issue type` into an [AGNT.gg](https://agnt.gg) **workflow graph** - a `{ name, description, nodes, edges }` JSON document AGNT can import and run. ```bash @@ -21,23 +20,22 @@ curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?for ## Output shape Each node carries `{ id, type, text, x, y, parameters }` (AGNT's runnable node -shape — `text` is the canvas label, `x`/`y` are coordinates, and `parameters` is +shape - `text` is the canvas label, `x`/`y` are coordinates, and `parameters` is the bag AGNT resolves into the node's tool). Each issue-type step becomes one node: -- **`operator-run-step`** — the generic node, carrying `{ ticket, step, prompt, +- **`operator-run-step`** - the generic node, carrying `{ ticket, step, prompt, model, … }` in its `parameters`; it calls Operator's launch endpoint. (The - `prompt` is an inert annotation — Operator owns the prompt internally; the tool + `prompt` is an inert annotation - Operator owns the prompt internally; the tool reads only `ticket`/`model`.) -- **`agnt-agent`** — AGNT's native agent-chat node, emitted when a delegator is +- **`agnt-agent`** - AGNT's native agent-chat node, emitted when a delegator is an AGNT-hosted remote agent (`remote_agent.platform == "agnt"`), carrying `agentId` (the agent's UUID) and `message` (the prompt) so AGNT runs the step itself instead of calling back into Operator. The `next_step` chain becomes `edges`, each `{ id, start: { id }, end: { id } }`. Fan-out shapes (MultiModel / Matrixed / Pipeline) flatten to a single node, and -human review gates, RAG, and MCP requirements are recorded in a `gap` field — -lossy conversions are annotated, not dropped silently. +human review gates, RAG, and MCP requirements are recorded in a `gap` field. This is one half of the broader [AGNT integration](https://operator.untra.io/getting-started/integrations/agnt/); diff --git a/docs/getting-started/workflows/claude.md b/docs/getting-started/workflows/claude.md index 0105f9e1..573bf1c1 100644 --- a/docs/getting-started/workflows/claude.md +++ b/docs/getting-started/workflows/claude.md @@ -5,7 +5,7 @@ layout: doc --- The default export target. Renders a `ticket + issue type` into a **Claude Code -dynamic workflow** — a `.js` module the +dynamic workflow** - a `.js` module the [`@untra/naiveworkflow-compiler`](https://operator.untra.io/getting-started/workflows/) walks to drive Claude Code agents. @@ -24,7 +24,7 @@ curl -X POST "http://localhost:7008/api/v1/tickets/FEAT-1234/workflow-export?for The emitted module is deterministic (no wallclock, `Date.now`, or `Math.random`). It begins with an `export const meta = { name, description, -phases }` block, followed by **top-level statements** (one per step) — not a +phases }` block, followed by **top-level statements** (one per step) - not a wrapped `export default async function`, because that is the form the compiler expects. diff --git a/docs/getting-started/workflows/index.md b/docs/getting-started/workflows/index.md index deafbb03..38fb9178 100644 --- a/docs/getting-started/workflows/index.md +++ b/docs/getting-started/workflows/index.md @@ -5,14 +5,14 @@ layout: doc --- Operator is a kanban-shaped orchestrator: each **ticket** carries the work, and -its **issue type** carries an **Operator workflow** — an ordered graph of steps +its **issue type** carries an **Operator workflow** - an ordered graph of steps (tasks, classifiers, delegators, fan-outs, pipelines, human review gates). That JSON-defined workflow is the *native* format, and it is what [collections](/workflows/) share. A **workflow export** renders a `ticket + issue type` pair into a concrete orchestration format some *other* tool or model can execute. Exports are -derived from the native workflow — never the other way round. +derived from the native workflow - never the other way round. This is **export-only and lossy-by-design**: Operator emits the format; it does not parse one back. Shapes a target can't represent natively (human review diff --git a/docs/maturity/index.md b/docs/maturity/index.md index 1cb4ee53..47c2b262 100644 --- a/docs/maturity/index.md +++ b/docs/maturity/index.md @@ -45,7 +45,7 @@ Operator integrates with many providers and tools across several **verticals**. | Bitbucket | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | | Azure DevOps | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | | Forgejo | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | -| Gitea | ![Proto](https://img.shields.io/badge/Proto-6B7280) | - | +| Gitea | ![Alpha](https://img.shields.io/badge/Alpha-6495ED) | [Gitea](https://operator.untra.io/getting-started/git/gitea/) | ## Session diff --git a/docs/relay/index.md b/docs/relay/index.md index 385a5b7e..3706de2a 100644 --- a/docs/relay/index.md +++ b/docs/relay/index.md @@ -4,7 +4,7 @@ description: "Multi-agent peer-to-peer communication hub embedded in Operator." layout: doc --- -Operator! embeds a relay hub that lets agents launched for different tickets discover and message each other in real time. When a delegator sets `operator_relay = true` in its `launch_config`, Operator injects the `relay` MCP server into Claude Code launches for that delegator — provided the relay hub socket is available. Injection does **not** happen automatically for all launches; the global default is `relay.auto_inject_mcp = false`. +Operator! embeds a relay hub that lets agents launched for different tickets discover and message each other in real time. When a delegator sets `operator_relay = true` in its `launch_config`, Operator injects the `relay` MCP server into Claude Code launches for that delegator - provided the relay hub socket is available. Injection does **not** happen automatically for all launches; the global default is `relay.auto_inject_mcp = false`. The MCP server runs as `opr8r relay` (a subcommand of the signed `opr8r` binary) so no additional executable needs to be signed or distributed. Codex and other tools receive the env vars but require manual MCP configuration. @@ -12,9 +12,9 @@ The MCP server runs as `opr8r relay` (a subcommand of the signed `opr8r` binary) Operator ships two complementary executables for agent orchestration: -### opr8r — step wrapper and API client +### opr8r - step wrapper and API client -`opr8r` wraps LLM tool invocations (Claude Code, Codex, Gemini CLI) inside multi-step ticket workflows. It runs as the **parent process** of the LLM tool, intercepts its exit code, and reports step completion to the Operator REST API. The API then decides what happens next — another step, a review gate, or workflow completion. +`opr8r` wraps LLM tool invocations (Claude Code, Codex, Gemini CLI) inside multi-step ticket workflows. It runs as the **parent process** of the LLM tool, intercepts its exit code, and reports step completion to the Operator REST API. The API then decides what happens next - another step, a review gate, or workflow completion. ``` opr8r --ticket-id FEAT-042 --step build -- claude --prompt "implement the feature" @@ -26,7 +26,7 @@ opr8r --ticket-id FEAT-042 --step build -- claude --prompt "implement the featur See the [opr8r CLI reference](/cli/) for full flag documentation. -### relay — MCP client for the relay hub +### relay - MCP client for the relay hub `relay` is the MCP stdio server that Operator ships so agents can communicate with each other. It runs as a **child process** of the LLM tool (spawned by the MCP host), connects to the relay hub over a Unix socket, and exposes five relay tools via the MCP protocol: @@ -54,7 +54,7 @@ operator process relay_reply(ask_id, "yes, pushed to feat/auth") ``` -The hub runs for the lifetime of the Operator process. Unlike the standalone `claude-relay` tool, there is no idle-shutdown timer — the hub stays up as long as Operator is running. +The hub runs for the lifetime of the Operator process. Unlike the standalone `claude-relay` tool, there is no idle-shutdown timer - the hub stays up as long as Operator is running. ## Hub socket @@ -62,11 +62,11 @@ The hub binds to a Unix domain socket. The path is resolved in this priority ord | Priority | Source | Default | |----------|--------|---------| -| 1 | `$RELAY_HUB_SOCKET` | — | -| 2 | `$CLAUDE_PLUGIN_DATA/hub.sock` | — | +| 1 | `$RELAY_HUB_SOCKET` | - | +| 2 | `$CLAUDE_PLUGIN_DATA/hub.sock` | - | | 3 | fallback | `~/.claude-relay/hub.sock` | -Operator exports `RELAY_HUB_SOCKET` automatically at startup, so every child process it spawns can find the hub. For Claude Code, Operator also writes a per-session `relay-mcp.json` and passes `--mcp-config ` at launch time, so `relay` starts automatically alongside the agent — no manual setup needed. For other tools, the socket env var is exported but MCP wiring requires manual configuration. +Operator exports `RELAY_HUB_SOCKET` automatically at startup, so every child process it spawns can find the hub. For Claude Code, Operator also writes a per-session `relay-mcp.json` and passes `--mcp-config ` at launch time, so `relay` starts automatically alongside the agent - no manual setup needed. For other tools, the socket env var is exported but MCP wiring requires manual configuration. ## Agent naming @@ -107,4 +107,4 @@ The protocol is byte-compatible with TypeScript claude-relay. Existing TS channe - [Claude agent setup](/getting-started/agents/claude/) - [Codex agent setup](/getting-started/agents/codex/) -- [Delegators](/delegators/) — named tool + model pairings that launch agents +- [Delegators](/delegators/) - named tool + model pairings that launch agents diff --git a/docs/schemas/config.json b/docs/schemas/config.json index e3528cbc..3cd9e402 100644 --- a/docs/schemas/config.json +++ b/docs/schemas/config.json @@ -97,12 +97,25 @@ "enabled": true, "host": "127.0.0.1", "port": 7008, - "cors_origins": [] + "cors_origins": [], + "public_url": null } }, "git": { "$ref": "#/$defs/GitConfig", "default": { + "gitea": { + "enabled": false, + "token_env": "GITEA_TOKEN", + "host": null, + "wip_prefix": "WIP: " + }, + "forgejo": { + "enabled": false, + "token_env": "FORGEJO_TOKEN", + "host": null, + "wip_prefix": "WIP: " + }, "provider": null, "github": { "enabled": false, @@ -1118,12 +1131,20 @@ "default": 7008 }, "cors_origins": { - "description": "CORS allowed origins (empty = allow all)", + "description": "CORS allowed origins. Empty means **same-origin only**", "type": "array", "items": { "type": "string" }, "default": [] + }, + "public_url": { + "description": "Externally reachable base URL (e.g. `https://operator.example.com`).\n\nOAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header,\nwhich a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy.", + "type": [ + "string", + "null" + ], + "default": null } } }, @@ -1131,6 +1152,35 @@ "description": "Git provider configuration for PR/MR operations", "type": "object", "properties": { + "identity": { + "description": "Default commit identity for delegated work.", + "anyOf": [ + { + "$ref": "#/$defs/GitIdentityConfig" + }, + { + "type": "null" + } + ] + }, + "gitea": { + "$ref": "#/$defs/GiteaConfig", + "default": { + "enabled": false, + "token_env": "GITEA_TOKEN", + "host": null, + "wip_prefix": "WIP: " + } + }, + "forgejo": { + "$ref": "#/$defs/ForgejoConfig", + "default": { + "enabled": false, + "token_env": "FORGEJO_TOKEN", + "host": null, + "wip_prefix": "WIP: " + } + }, "provider": { "description": "Active provider (auto-detected from remote URL if not specified)", "anyOf": [ @@ -1172,6 +1222,72 @@ } } }, + "GitIdentityConfig": { + "description": "Commit identity template for delegated work.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "GiteaConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "token_env": { + "type": "string", + "default": "GITEA_TOKEN" + }, + "host": { + "description": "HTTPS host or base URL; defaults to gitea.com.", + "type": [ + "string", + "null" + ], + "default": null + }, + "wip_prefix": { + "type": "string", + "default": "WIP: " + } + } + }, + "ForgejoConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "token_env": { + "type": "string", + "default": "FORGEJO_TOKEN" + }, + "host": { + "description": "HTTPS host or base URL; defaults to codeberg.org.", + "type": [ + "string", + "null" + ], + "default": null + }, + "wip_prefix": { + "type": "string", + "default": "WIP: " + } + } + }, "GitProviderConfig": { "description": "Git provider selection", "oneOf": [ @@ -1484,6 +1600,17 @@ "description": "Agent delegator configuration for autonomous ticket launching\n\nA delegator is a named {tool, model} pairing with optional launch configuration\nthat can be used to launch agents for tickets.", "type": "object", "properties": { + "git": { + "description": "Optional Git identity, HTTPS credential reference, and runtime settings.", + "anyOf": [ + { + "$ref": "#/$defs/GitExecutionConfig" + }, + { + "type": "null" + } + ] + }, "name": { "description": "Unique name for this delegator (e.g., \"claude-opus-auto\")", "type": "string" @@ -1559,6 +1686,76 @@ "model" ] }, + "GitExecutionConfig": { + "description": "Git settings owned by a named delegator.", + "type": "object", + "properties": { + "identity": { + "anyOf": [ + { + "$ref": "#/$defs/GitIdentityConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "credentials": { + "anyOf": [ + { + "$ref": "#/$defs/GitCredentialConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/$defs/GitConfigEntry" + }, + "default": [] + } + } + }, + "GitCredentialConfig": { + "description": "Supplied HTTPS credential, bound to a repository; contains no secret value.", + "type": "object", + "properties": { + "repository_url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "token_env": { + "type": "string" + } + }, + "required": [ + "repository_url", + "username", + "token_env" + ] + }, + "GitConfigEntry": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + }, "DelegatorLaunchConfig": { "description": "Launch configuration for a delegator\n\nControls how the delegator launches agents. Optional fields use tri-state\nsemantics: `None` = inherit from global config, `Some(true/false)` = override.", "type": "object", @@ -1855,7 +2052,7 @@ "default": "op" }, "workdir": { - "description": "Project root inside the workspace (None = workspace $HOME)", + "description": "Project root inside the workspace (None = /home/coder/{project})", "type": [ "string", "null" @@ -1881,7 +2078,7 @@ ] }, "parameters": { - "description": "Passthrough `-p` template parameters for `coder create`", + "description": "Passthrough `--parameter` template parameters for `coder create`", "type": "object", "additionalProperties": { "type": "string" diff --git a/docs/schemas/config.md b/docs/schemas/config.md index 9c9d7bd8..32d94bd7 100644 --- a/docs/schemas/config.md +++ b/docs/schemas/config.md @@ -363,9 +363,10 @@ REST API server configuration | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether the REST API is enabled | -| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server — which reports the project directory name — is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | +| `host` | `string` | No | Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server - which reports the project directory name - is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. | | `port` | `integer` | No | Port for the REST API server | -| `cors_origins` | `array` | No | CORS allowed origins (empty = allow all) | +| `cors_origins` | `array` | No | CORS allowed origins. Empty means **same-origin only** | +| `public_url` | `string` \| `null` | No | Externally reachable base URL (e.g. `https://operator.example.com`). OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. | ### GitConfig @@ -373,12 +374,42 @@ Git provider configuration for PR/MR operations | Property | Type | Required | Description | | --- | --- | --- | --- | +| `identity` | object | No | Default commit identity for delegated work. | +| `gitea` | → `GiteaConfig` | No | | +| `forgejo` | → `ForgejoConfig` | No | | | `provider` | object | No | Active provider (auto-detected from remote URL if not specified) | | `github` | → `GitHubConfig` | No | GitHub-specific configuration | | `gitlab` | → `GitLabConfig` | No | GitLab-specific configuration | | `branch_format` | `string` | No | Branch naming format (e.g., "{type}/{ticket_id}-{slug}") | | `use_worktrees` | `boolean` | No | Whether to use git worktrees for per-ticket isolation (default: false) When false, tickets work directly in the project directory with branches | +### GitIdentityConfig + +Commit identity template for delegated work. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | Yes | | +| `email` | `string` | Yes | | + +### GiteaConfig + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | No | | +| `token_env` | `string` | No | | +| `host` | `string` \| `null` | No | HTTPS host or base URL; defaults to gitea.com. | +| `wip_prefix` | `string` | No | | + +### ForgejoConfig + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | No | | +| `token_env` | `string` | No | | +| `host` | `string` \| `null` | No | HTTPS host or base URL; defaults to codeberg.org. | +| `wip_prefix` | `string` | No | | + ### GitProviderConfig Git provider selection @@ -424,7 +455,7 @@ Providers are keyed by domain/workspace: | --- | --- | --- | --- | | `jira` | `object` | No | Jira Cloud instances keyed by domain (e.g., "foobar.atlassian.net") | | `linear` | `object` | No | Linear instances keyed by workspace slug | -| `github` | `object` | No | GitHub Projects v2 instances keyed by owner login (user or org) NOTE: This is the *kanban* GitHub integration (Projects v2), distinct from `GitHubConfig` which is the *git provider* used for PRs and branches. The two use different env vars and different scopes — see `docs/getting-started/kanban/github.md` for the full disambiguation. | +| `github` | `object` | No | GitHub Projects v2 instances keyed by owner login (user or org) NOTE: This is the *kanban* GitHub integration (Projects v2), distinct from `GitHubConfig` which is the *git provider* used for PRs and branches. The two use different env vars and different scopes - see `docs/getting-started/kanban/github.md` for the full disambiguation. | | `openspec` | `object` | No | `OpenSpec` roots keyed by a free-form instance name (e.g., a repo alias). Experimental, pull-only: each active change under `/changes/` acts as a kanban "project" whose issues are the tasks.md task groups. | ### JiraConfig @@ -488,7 +519,7 @@ GitHub Projects v2 (kanban) provider configuration The owner login (user or org) is specified as the `HashMap` key in `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node -IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly +IDs (e.g., `PVT_kwDOABcdefg`) - opaque, stable identifiers used directly by every GitHub Projects v2 mutation without needing a lookup. **Distinct from `GitHubConfig`** (the git provider used for PR/branch @@ -500,7 +531,7 @@ require different OAuth scopes (`project` vs `repo`). See | Property | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | `boolean` | No | Whether this provider is enabled | -| `api_key_env` | `string` | No | Environment variable name containing the GitHub token (default: `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or `read:project`) scope, NOT just `repo` — see the disambiguation guide in the kanban github docs. | +| `api_key_env` | `string` | No | Environment variable name containing the GitHub token (default: `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or `read:project`) scope, NOT just `repo` - see the disambiguation guide in the kanban github docs. | | `projects` | `object` | No | Per-project sync configuration. Keys are `GraphQL` project node IDs. | ### OpenspecConfig @@ -508,7 +539,7 @@ require different OAuth scopes (`project` vs `repo`). See `OpenSpec` provider configuration (experimental, pull-only) The instance name is the `HashMap` key in `KanbanConfig.openspec`. There -are no credentials — the provider reads local markdown under `root_path`. +are no credentials - the provider reads local markdown under `root_path`. | Property | Type | Required | Description | | --- | --- | --- | --- | @@ -535,6 +566,7 @@ that can be used to launch agents for tickets. | Property | Type | Required | Description | | --- | --- | --- | --- | +| `git` | object | No | Optional Git identity, HTTPS credential reference, and runtime settings. | | `name` | `string` | Yes | Unique name for this delegator (e.g., "claude-opus-auto") | | `llm_tool` | `string` | Yes | LLM tool name (must match a detected tool, e.g., "claude", "codex") | | `model` | `string` | Yes | Model alias (e.g., "opus", "sonnet", "gpt-4o") | @@ -542,11 +574,38 @@ that can be used to launch agents for tickets. | `model_properties` | `object` | No | Arbitrary model properties (e.g., `reasoning_effort`, sandbox) | | `launch_config` | object | No | Optional launch configuration | | `model_server` | `string` \| `null` | No | Name of a declared `ModelServer` (from `Config.model_servers`). `None` means use the `llm_tool`'s implicit vendor default (claude → anthropic-api, codex → openai-api, gemini → google-api). | -| `remote_agent` | object | No | Declarative reference to a remote, named agent on another platform (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). Export-only: Operator has no runtime client for those platforms, so a delegator carrying this CANNOT be launched locally — resolution errors out (see `delegator_resolution`). It is stored, listed, serialized into an `AgentProfile`, and — for `platform == "agnt"` — surfaced in the `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the `id` must be the agent's UUID, not its display name). `None` = ordinary, locally launchable delegator. | +| `remote_agent` | object | No | Declarative reference to a remote, named agent on another platform (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). Export-only: Operator has no runtime client for those platforms, so a delegator carrying this CANNOT be launched locally - resolution errors out (see `delegator_resolution`). It is stored, listed, serialized into an `AgentProfile`, and - for `platform == "agnt"` - surfaced in the `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the `id` must be the agent's UUID, not its display name). `None` = ordinary, locally launchable delegator. | | `x_agnt` | object | No | Opaque AGNT-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip so re-export is lossless (e.g. `memory`, `assignedWorkflows`, `creditLimit`). Operator never interprets this. | | `x_openai` | object | No | Opaque OpenAI-namespaced extension fields, preserved verbatim across an `AgentProfile` round-trip (e.g. `instructions`, `tools`, `tool_resources`, `metadata`, thread refs). Mirror of [`Self::x_agnt`]; never interpreted. | | `unmapped_core` | object | No | Opaque carry for `AgentProfile` shared-core fields Operator cannot model first-class (`system_prompt` / `skills` / `mcp_servers` / `tools`) so an import→export round-trip is lossless. Distinct from `x_agnt`: these are shared-core fields, not AGNT-specific, so folding them into `x_agnt` would corrupt that namespace. Operator never interprets this. | +### GitExecutionConfig + +Git settings owned by a named delegator. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `identity` | object | No | | +| `credentials` | object | No | | +| `settings` | `array` | No | | + +### GitCredentialConfig + +Supplied HTTPS credential, bound to a repository; contains no secret value. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `repository_url` | `string` | Yes | | +| `username` | `string` | Yes | | +| `token_env` | `string` | Yes | | + +### GitConfigEntry + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `key` | `string` | Yes | | +| `value` | `string` | Yes | | + ### DelegatorLaunchConfig Launch configuration for a delegator @@ -572,7 +631,7 @@ semantics: `None` = inherit from global config, `Some(true/false)` = override. A declarative reference to a remote, named agent hosted by another platform. -`platform` is the hosting service (`"agnt"`, `"openai"`) — deliberately +`platform` is the hosting service (`"agnt"`, `"openai"`) - deliberately distinct from the core `provider`/`llm_tool` (the model or coding CLI). These agents are API/memory-native and live on the remote side; Operator has no runtime client for them, so a delegator carrying one is **export-only** and @@ -636,20 +695,20 @@ A named execution target agents can be launched on. ### CoderConfig Coder workspace target: lifecycle + alias provisioning around the shared -SSH remote-launch path. There is no `enabled` field — presence in +SSH remote-launch path. There is no `enabled` field - presence in `[[targets]]` is the enablement. | Property | Type | Required | Description | | --- | --- | --- | --- | -| `template` | `string` | Yes | Coder template child workspaces are created from (an allowlist — never per-ticket input) | +| `template` | `string` | Yes | Coder template child workspaces are created from (an allowlist - never per-ticket input) | | `url_env` | `string` | No | Env var NAME holding the Coder deployment URL | | `token_env` | `string` | No | Env var NAME holding the Coder session token. The variable is stripped from every agent's spawn environment on all target kinds. | | `name_prefix` | `string` | No | Workspace name prefix for deterministic per-ticket naming | -| `workdir` | `string` \| `null` | No | Project root inside the workspace (None = workspace $HOME) | +| `workdir` | `string` \| `null` | No | Project root inside the workspace (None = /home/coder/{project}) | | `stop_on_complete` | `boolean` | No | Stop the workspace when the ticket completes (never delete) | | `create_timeout_secs` | `integer` | No | Bound on workspace create + agent-ready wait | | `callback_url` | `string` \| `null` | No | Control-plane-reachable `OPERATOR_API_URL` override for detached multi-step (empty/None = reverse tunnel default) | -| `parameters` | `object` | No | Passthrough `-p` template parameters for `coder create` | +| `parameters` | `object` | No | Passthrough `--parameter` template parameters for `coder create` | ### SshTarget diff --git a/docs/schemas/issuetype.md b/docs/schemas/issuetype.md index 1e636358..260c4e37 100644 --- a/docs/schemas/issuetype.md +++ b/docs/schemas/issuetype.md @@ -386,7 +386,7 @@ Configuration for matrixed work output steps (N x M delegators x prompts) | Property | Type | Required | Description | | --- | --- | --- | --- | | `delegators` | `array` | Yes | Named delegator references (N), minimum 2 | -| `prompt_variations` | `array` | Yes | Prompt variations (M) — Handlebars templates, minimum 2 | +| `prompt_variations` | `array` | Yes | Prompt variations (M) - Handlebars templates, minimum 2 | | `output_format` | → `MatrixedOutputFormat` | Yes | How to organize/present the N x M output | | `aggregation_prompt` | `string` \| `null` | No | Optional aggregation prompt (receives the full matrix of results) | @@ -399,7 +399,7 @@ Output format for matrixed steps Configuration for pipeline steps: iterate a list of items through ordered stages with no barrier (each item flows through all stages independently). -The step graph stays linear — a pipeline step still has exactly one +The step graph stays linear - a pipeline step still has exactly one `next_step`. The fan-out (N items x M stages) lives entirely inside this one step; iteration is an intra-step concern, never a step-to-step edge. @@ -416,7 +416,7 @@ the compiled graph) vs runtime (an identifier → symbolic width). ### Definition: PipelineStage -A single stage in a pipeline — deliberately flat (not a recursive +A single stage in a pipeline - deliberately flat (not a recursive `StepSchema`): "prompt + optional agent/model/schema" only. It has no `next_step`/`review_type`/`on_reject`, so a stage cannot reopen the step-graph linearity question. diff --git a/docs/schemas/metadata.md b/docs/schemas/metadata.md index 0f4e3579..a303680a 100644 --- a/docs/schemas/metadata.md +++ b/docs/schemas/metadata.md @@ -25,7 +25,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc | --- | --- | --- | --- | | `id` | `string` | Yes | Kanban ticket ID (e.g., FEAT-1234). Also used for tmux session name derivation. Key grammar: uppercase start, then uppercase letters, digits, or underscores (hyphen is reserved as the key/number separator). | | `status` | `string` | Yes | Operator workflow status | -| `collection` | `string` | No | Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search. | +| `collection` | `string` | No | Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets - resolution falls back to the active collection, then a deterministic search. | | `step` | `string` | No | Current workflow step name (e.g., plan, build, code, test, deploy) | | `priority` | `string` | No | Ticket priority level | | `project` | `string` | No | Target project name (subdirectory in projects root) | @@ -54,7 +54,7 @@ Schema for operator-tracked ticket metadata in YAML frontmatter. This schema doc ### collection -- **Description**: Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets — resolution falls back to the active collection, then a deterministic search. +- **Description**: Issuetype collection the ticket's type resolves within. Stamped at creation (active collection) or kanban sync (the project sync's collection). Absent on legacy tickets - resolution falls back to the active collection, then a deterministic search. - **Type**: `string` - **Pattern**: `^[a-z0-9_]{3,64}$` - **Examples**: `dev_kanban`, `ralph_loop`, `custom` diff --git a/docs/schemas/openapi.json b/docs/schemas/openapi.json index 1396ab1c..4bb11d88 100644 --- a/docs/schemas/openapi.json +++ b/docs/schemas/openapi.json @@ -5967,6 +5967,17 @@ ], "description": "Optional display name for UI" }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { @@ -6021,6 +6032,17 @@ ], "description": "Optional display name" }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { @@ -6273,7 +6295,6 @@ "review_type": { "type": "string", "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" - "description": "Type of review required: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" } } }, @@ -6581,6 +6602,17 @@ ], "description": "Optional display name" }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { @@ -6674,10 +6706,6 @@ "health_ok": { "type": "boolean" }, - "health_ok": { - "type": "boolean", - "description": "Whether the tool passed its health check at detection on startup" - }, "min_version": { "type": [ "string", @@ -7026,6 +7054,89 @@ } } }, + "GitConfigEntry": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "GitCredentialConfig": { + "type": "object", + "description": "Supplied HTTPS credential, bound to a repository; contains no secret value.", + "required": [ + "repository_url", + "username", + "token_env" + ], + "properties": { + "repository_url": { + "type": "string" + }, + "token_env": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "GitExecutionConfig": { + "type": "object", + "description": "Git settings owned by a named delegator.", + "properties": { + "credentials": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitCredentialConfig" + } + ] + }, + "identity": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitIdentityConfig" + } + ] + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GitConfigEntry" + } + } + } + }, + "GitIdentityConfig": { + "type": "object", + "description": "Commit identity template for delegated work.", + "required": [ + "name", + "email" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "GithubCredentials": { "type": "object", "description": "Ephemeral GitHub Projects credentials supplied by a client during onboarding.\n\nThe token must have `project` (or `read:project`) scope. A repo-only token\n(the kind used for `GITHUB_TOKEN` and operator's git provider) will be\nrejected at validation time with a friendly \"lacks `project` scope\" error.", @@ -8499,7 +8610,49 @@ }, "review_type": { "type": "string", - "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\"" + "description": "Review type: \"none\", \"plan\", \"visual\", \"pr\", \"proof\"" + } + } + }, + "OAuthErrorCode": { + "type": "string", + "description": "OAuth error codes Operator emits.", + "enum": [ + "authorization_pending", + "slow_down", + "expired_token", + "access_denied", + "invalid_grant", + "invalid_request", + "invalid_client", + "invalid_scope", + "unsupported_grant_type" + ] + }, + "OAuthErrorResponse": { + "type": "object", + "description": "Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can\ninterpret it — notably `authorization_pending` and `slow_down`, which a\ndevice-flow client polls against.", + "required": [ + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/OAuthErrorCode", + "description": "Machine-readable error code." + }, + "error_description": { + "type": [ + "string", + "null" + ], + "description": "Human-readable explanation." + }, + "error_uri": { + "type": [ + "string", + "null" + ], + "description": "Documentation link." } } }, @@ -10694,6 +10847,17 @@ ], "description": "Optional display name for UI." }, + "git": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/GitExecutionConfig", + "description": "Optional Git identity, HTTPS credential reference, and runtime settings." + } + ] + }, "launch_config": { "oneOf": [ { diff --git a/docs/schemas/state.json b/docs/schemas/state.json index 802dc8ac..4cb4f75a 100644 --- a/docs/schemas/state.json +++ b/docs/schemas/state.json @@ -56,6 +56,18 @@ "AgentState": { "type": "object", "properties": { + "git_context": { + "description": "Non-secret Git configuration captured at launch.", + "anyOf": [ + { + "$ref": "#/$defs/GitExecutionConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "id": { "type": "string" }, @@ -294,6 +306,92 @@ "paired" ] }, + "GitExecutionConfig": { + "description": "Git settings owned by a named delegator.", + "type": "object", + "properties": { + "identity": { + "anyOf": [ + { + "$ref": "#/$defs/GitIdentityConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "credentials": { + "anyOf": [ + { + "$ref": "#/$defs/GitCredentialConfig" + }, + { + "type": "null" + } + ], + "default": null + }, + "settings": { + "type": "array", + "items": { + "$ref": "#/$defs/GitConfigEntry" + }, + "default": [] + } + } + }, + "GitIdentityConfig": { + "description": "Commit identity template for delegated work.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "name", + "email" + ] + }, + "GitCredentialConfig": { + "description": "Supplied HTTPS credential, bound to a repository; contains no secret value.", + "type": "object", + "properties": { + "repository_url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "token_env": { + "type": "string" + } + }, + "required": [ + "repository_url", + "username", + "token_env" + ] + }, + "GitConfigEntry": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + }, "StepLaunchContext": { "description": "Launch context fixed at launch time, persisted with the agent record, and\nread back by `complete_step` to build subsequent step commands.\n\nThe persisted context is the baseline for a ticket's whole chain; per-step\n`agent` overrides from the step schema apply on top for that step only.", "type": "object", diff --git a/docs/security/authentication.md b/docs/security/authentication.md index 4857c0a0..92166e53 100644 --- a/docs/security/authentication.md +++ b/docs/security/authentication.md @@ -155,6 +155,8 @@ This is not an authentication bypass. The credential is a real one, checked the Run `operator auth reset-admin-password` only **locally**, against the database file. It sets a new admin password and revokes every session, refresh-token family, issued token record, and access key. +The dashboard's password-change form also revokes all credentials. + In Kubernetes that means `kubectl exec`, which is itself an audited, RBAC-gated action. ## Audit records diff --git a/docs/security/index.md b/docs/security/index.md index 7d56fd22..a243c017 100644 --- a/docs/security/index.md +++ b/docs/security/index.md @@ -8,7 +8,7 @@ Operator launches AI coding agents against your source code, holds credentials f This page is the threat model for that surface: what Operator trusts, what it does not, and what remains your responsibility to control. -For the authentication mechanism itself — accounts, tokens, scopes, and recovery — see [Authentication](/security/authentication/). +For the authentication mechanism itself - accounts, tokens, scopes, and recovery - see [Authentication](/security/authentication/). ## Trust boundaries @@ -59,7 +59,7 @@ Two deliberate choices in that table: authority. Model servers, delegators, and execution targets have focused endpoints with their own response types. -**Health and status are not public.** They report the workspace directory name and a directory identifier. That is workspace identity, and it is exactly the sort of detail a public probe should not disclose — hence the separate, metadata-free `/livez` and `/readyz` endpoints for Kubernetes. +**Health and status are not public.** They report the workspace directory name and a directory identifier. That is workspace identity, and it is exactly the sort of detail a public probe should not disclose - hence the separate, metadata-free `/livez` and `/readyz` endpoints for Kubernetes. ### The dashboard bundle is public @@ -75,8 +75,8 @@ shell; every data request returns `401`, and the client redirects to the login screen. **Residual risk:** the set of route names and the structure of the UI are -public. No workspace data, configuration, or credentials are in the bundle — -all of it arrives over authenticated API calls — but the shape of the +public. No workspace data, configuration, or credentials are in the bundle - +all of it arrives over authenticated API calls - but the shape of the application is discoverable. This is accepted deliberately; the alternative is a separately served login document, which is a larger change for a small reduction in disclosure. @@ -112,17 +112,17 @@ controls: Untreated, the model-server probe is the sharpest of these: an authenticated caller sets a base URL, triggers a probe, and Operator makes the request *with a -provider API key attached*. Redirects compound it — a permitted host can +provider API key attached*. Redirects compound it - a permitted host can redirect to a forbidden one. Four controls apply together, and none is sufficient alone: -1. **Authentication and scopes** — probing requires `execute`; changing a +1. **Authentication and scopes** - probing requires `execute`; changing a model-server URL requires `admin`. An anonymous caller cannot reach either. -2. **Destination validation** — loopback, link-local, multicast, and cloud-metadata addresses are rejected unless explicitly allowed, and schemes and CIDR ranges are validated against configuration. -3. **Redirect re-validation** — every redirect hop is re-checked against the +2. **Destination validation** - loopback, link-local, multicast, and cloud-metadata addresses are rejected unless explicitly allowed, and schemes and CIDR ranges are validated against configuration. +3. **Redirect re-validation** - every redirect hop is re-checked against the same policy, not just the initial URL. -4. **NetworkPolicy** — in Kubernetes, egress is restricted at the network +4. **NetworkPolicy** - in Kubernetes, egress is restricted at the network layer, so a validation bug does not become cluster-internal access. Control 2 is code, control 4 is cluster configuration, and **you must configure control 4 yourself**; the chart ships the template but leaves it disabled by default. @@ -138,7 +138,7 @@ The chart is deliberately minimal about what it can touch: Operator running in your cluster cannot enumerate, create, or delete cluster resources, because it has neither the credential nor the tooling to try. -Ingress is disabled by default. Enabling it publishes an authenticated service, which is the intended posture — but it is your TLS certificate, your DNS name, and your decision. +Ingress is disabled by default. Enabling it publishes an authenticated service, which is the intended posture - but it is your TLS certificate, your DNS name, and your decision. ### Secrets are not encrypted by default @@ -165,11 +165,11 @@ See the [Kubernetes guide](/getting-started/platforms/kubernetes/) for the mecha The persistent volume also holds the workspace, the ticket queue, and `state.json`. A backup that captures the volume captures all of it, including -the authentication database — so the volume snapshot inherits the same +the authentication database - so the volume snapshot inherits the same sensitivity. -Password recovery is **local only**: `operator auth reset-admin-password` operates directly on the database. -It is never exposed as an HTTP route, so there is no network-reachable password-reset path to attack. +Forgotten-password recovery is **local only**: `operator auth reset-admin-password` operates directly on the database. +The HTTP reset route requires the current username and password; it cannot recover a forgotten credential. ## Residual risks diff --git a/docs/startup/index.md b/docs/startup/index.md index 500ae05d..170855aa 100644 --- a/docs/startup/index.md +++ b/docs/startup/index.md @@ -77,11 +77,11 @@ Worktrees allow multiple agents to work on different tickets simultaneously with Operator has a single human account, `admin`. -This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists. +This terminal and the CLI need no password: a loopback process authenticates with an owner-only token file in the state directory. A browser cannot read that file, so the web dashboard stays locked until an admin password exists. -Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page. +Leave both fields blank to skip. You can set one later with `operator auth bootstrap` or from the /setup page. -The password must be at least 12 characters. This step is hidden when an admin account already exists. +The password must be at least 12 characters. This step is hidden when an admin account already exists. **Navigation**: Tab to switch fields, Enter to continue (blank to skip), Esc to go back @@ -170,9 +170,9 @@ Select a preset collection of issue types: *Browse and select hosted collections (only shown if Browse chosen)* -Pick one or more curated collections published at operator.untra.io. +Pick one or more curated collections published at operator.untra.io. -The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. +The list is fetched from the collections manifest; if it cannot be reached, the collections bundled with Operator are offered instead. Each collection brings its own issue types and workflow steps. Selections are additive - choose as many as apply. diff --git a/docs/workflows/index.md b/docs/workflows/index.md index 321b236f..a40f8eaf 100644 --- a/docs/workflows/index.md +++ b/docs/workflows/index.md @@ -10,7 +10,7 @@ section: workflows An **Operator workflow** is a process defined once in JSON: an ordered graph of typed steps, review gates, and retry edges that an LLM agent can follow. It is -the native format — Operator runs it directly, and every +the native format - Operator runs it directly, and every [export format](/getting-started/workflows/) (Claude, AGNT) is derived from it. Three terms, three different things: @@ -18,7 +18,7 @@ Three terms, three different things: | Term | What it is | |------|-----------| | **Operator workflow** | The step graph itself. Lives in an issue type's `steps`. | -| **Issue type** | One kind of work — `FEAT`, `PRD`, `ELVSTAGE`. Carries identity, input fields, and exactly one Operator workflow. | +| **Issue type** | One kind of work - `FEAT`, `PRD`, `ELVSTAGE`. Carries identity, input fields, and exactly one Operator workflow. | | **Collection** | A named, versioned bundle of issue types: a complete, shareable way of working. This page lists them. | Collections are deliberately separate from your **kanban issue types**. Jira, @@ -26,7 +26,7 @@ Linear, and GitHub Projects types describe how *your* team labels work; a collection describes how the *agents* do it. Map one onto the other once, and the workflow travels between projects, teams, and providers unchanged. -Every collection below is installable from Operator directly — they are published from this site as a [machine-readable index](/collections/index.json) that operator instances read on startup. +Every collection below is installable from Operator directly - they are published from this site as a [machine-readable index](/collections/index.json) that operator instances read on startup. @@ -260,7 +260,7 @@ Every collection below is installable from Operator directly — they are publis ## Contribute a collection -There is no single best way to run agents — the right loop depends on the work. +There is no single best way to run agents - the right loop depends on the work. That is exactly why these are shareable: a workflow that works for you is worth publishing, and one that does not fit is worth forking. @@ -269,13 +269,13 @@ Official collections live in the [operator repository](https://github.com/untra/ 1. Create `collections/community//`, where `` matches `^[a-z0-9_]{3,64}$`. 2. Add a `collection.json` conforming to [the collection schema](/collections/schema.json), with `tier: "community"` plus `author`, `url`, and `license`. -3. Add one `.json` per issue type — see [the issue type schema](/schemas/issuetype/) — +3. Add one `.json` per issue type - see [the issue type schema](/schemas/issuetype/) - and an optional `.md` ticket template. 4. Add an `icon.svg` following the [Simple Icons](https://github.com/simple-icons/simple-icons) shape: a 24×24 viewBox, a single ``, and no `fill` or `stroke` so it inherits the page's color. -5. Leave checksums out — they are computed at publish time. +5. Leave checksums out - they are computed at publish time. 6. Run the CI gate locally, then open a pull request: ```bash diff --git a/opr8r/Cargo.toml b/opr8r/Cargo.toml index b18d022d..8181538a 100644 --- a/opr8r/Cargo.toml +++ b/opr8r/Cargo.toml @@ -23,3 +23,59 @@ strip = true lto = true codegen-units = 1 panic = "abort" + +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +all = { level = "warn", priority = -2 } +pedantic = { level = "warn", priority = -1 } +cognitive_complexity = "warn" +redundant_clone = "deny" +clone_on_copy = "deny" +unnecessary_to_owned = "deny" +borrowed_box = "deny" +explicit_auto_deref = "deny" +borrow_deref_ref = "deny" +deref_addrof = "deny" +needless_borrow = "deny" +clone_on_ref_ptr = "warn" +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +return_self_not_must_use = "allow" +struct_excessive_bools = "allow" +too_many_lines = "allow" +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +wildcard_imports = "allow" +unused_self = "allow" +trivially_copy_pass_by_ref = "allow" +needless_pass_by_value = "allow" +similar_names = "allow" +struct_field_names = "allow" +format_push_string = "allow" +unnecessary_wraps = "allow" +unused_async = "allow" +doc_link_with_quotes = "allow" +cast_possible_wrap = "allow" +match_same_arms = "allow" +assigning_clones = "allow" +manual_let_else = "allow" +items_after_statements = "allow" +ref_option = "allow" +fn_params_excessive_bools = "allow" +implicit_hasher = "allow" +map_unwrap_or = "allow" +needless_for_each = "allow" +needless_continue = "allow" +match_wildcard_for_single_variants = "allow" +redundant_else = "allow" +needless_raw_string_hashes = "allow" +doc_markdown = "allow" +uninlined_format_args = "allow" +single_match_else = "allow" +nonminimal_bool = "allow" diff --git a/opr8r/src/operator_relay.rs b/opr8r/src/operator_relay.rs index 4f18f588..0e630c23 100644 --- a/opr8r/src/operator_relay.rs +++ b/opr8r/src/operator_relay.rs @@ -94,10 +94,10 @@ pub async fn run() -> ExitCode { if std::env::var("RELAY_AGENT_NAME").is_err() { use operator_relay::session_name::{ClaudeSessionNameSource, SessionNameSource}; let src = ClaudeSessionNameSource::for_current_process(); - let s = session.clone(); + let s = Arc::clone(&session); if let Err(e) = src .watch(move |new_name| { - let s = s.clone(); + let s = Arc::clone(&s); tokio::spawn(async move { let _ = s.rename(new_name).await; }); diff --git a/package.json b/package.json index 13b74eb4..e129483d 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,15 @@ "private": true, "description": "Documentation generation for Operator TypeScript types", "scripts": { - "docs:typescript": "typedoc" + "docs:typescript": "typedoc", + "lint": "oxlint --type-aware", + "lint:ui": "oxlint --type-aware ui/src", + "lint:webcomponents": "oxlint --type-aware webcomponents/src", + "lint:vscode": "oxlint --type-aware vscode-extension/src vscode-extension/test vscode-extension/webview-ui" }, "devDependencies": { + "oxlint": "1.81.0", + "oxlint-tsgolint": "7.0.2001", "typedoc": "^0.27.0", "typescript": "^5.0.0" } diff --git a/scripts/ci/check-coder-module.sh b/scripts/ci/check-coder-module.sh index 2364ec15..b50971ac 100755 --- a/scripts/ci/check-coder-module.sh +++ b/scripts/ci/check-coder-module.sh @@ -27,7 +27,7 @@ VARS=' PORT = 7008, INSTALL_PREFIX = "/tmp/operator", LOG_PATH = "/tmp/operator.log", - CONFIG_TOML = "", + CONFIG_TOML_B64 = "", MAX_PARALLEL = 2, SESSION_WRAPPER = "tmux", OFFLINE = false, @@ -35,6 +35,32 @@ VARS=' AGENT_TEMPLATE = "operator-agent", CODER_TOKEN_ENV = "CODER_SESSION_TOKEN", CALLBACK_URL = "", + NAME_PREFIX = "", + WORKDIR = "", + STOP_ON_COMPLETE = "", + CREATE_TIMEOUT_SECS = "", +' + +# Second pass: every optional branch populated, and a config_toml carrying the +# quotes and `$` that the base64 hand-off exists to protect. Rendering only the +# empty case is how a value-mangling bug stays invisible to bash -n. +VARS_POPULATED=' + VERSION = "0.0.0", + PORT = 7008, + INSTALL_PREFIX = "/tmp/operator", + LOG_PATH = "/tmp/operator.log", + CONFIG_TOML_B64 = base64encode("[sessions]\nwrapper = \"tmux\"\nhome = \"$HOME\"\n"), + MAX_PARALLEL = 2, + SESSION_WRAPPER = "tmux", + OFFLINE = false, + USE_CACHED = false, + AGENT_TEMPLATE = "operator-agent", + CODER_TOKEN_ENV = "CODER_SESSION_TOKEN", + CALLBACK_URL = "https://operator.example.com", + NAME_PREFIX = "op", + WORKDIR = "/home/coder/proj", + STOP_ON_COMPLETE = "true", + CREATE_TIMEOUT_SECS = "600", ' extract_keys() { @@ -54,18 +80,23 @@ fi RENDER="$(mktemp -d)" trap 'rm -rf "$RENDER"' EXIT -cat > "$RENDER/main.tf" < "$dir/main.tf" </dev/null + "$TF" -chdir="$dir" apply -auto-approve -input=false >/dev/null + "$TF" -chdir="$dir" output -raw s > "$dir/rendered.sh" + bash -n "$dir/rendered.sh" + shellcheck -S error "$dir/rendered.sh" + echo "coder-module rendered startup script OK ($label)" +} -"$TF" -chdir="$RENDER" init -input=false >/dev/null -"$TF" -chdir="$RENDER" apply -auto-approve -input=false >/dev/null -"$TF" -chdir="$RENDER" output -raw s > "$RENDER/rendered.sh" - -bash -n "$RENDER/rendered.sh" -shellcheck -S error "$RENDER/rendered.sh" -echo "coder-module rendered startup script OK" +render_and_check defaults "$VARS" +render_and_check populated "$VARS_POPULATED" diff --git a/shared/types.ts b/shared/types.ts index 51c3cb33..aa1ef078 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -425,9 +425,16 @@ host: string, */ port: number, /** - * CORS allowed origins (empty = allow all) + * CORS allowed origins. Empty means **same-origin only** */ -cors_origins: Array, }; +cors_origins: Array, +/** + * Externally reachable base URL (e.g. `https://operator.example.com`). + * + * OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, + * which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + */ +public_url: string | null, }; export type LlmToolsConfig = { /** @@ -557,6 +564,10 @@ global: Array, project: Array, }; export type Delegator = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name for this delegator (e.g., "claude-opus-auto") */ @@ -726,6 +737,10 @@ x_agnt?: JsonValue | null, x_openai?: JsonValue | null, }; export type XOperator = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Optional display name for UI. */ @@ -825,7 +840,11 @@ project_collection_prefs: { [key in string]: string }, */ multi_agent_groups: Array, }; -export type AgentState = { id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, +export type AgentState = { +/** + * Non-secret Git configuration captured at launch. + */ +git_context: GitExecutionConfig | null, id: string, ticket_id: string, ticket_type: string, project: string, status: string, started_at: string, last_activity: string, last_message: string | null, paired: boolean, /** * The terminal session name for this agent (for recovery) */ @@ -1281,6 +1300,10 @@ skills: Array, total: number, }; export type DelegatorResponse = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name */ @@ -1326,6 +1349,10 @@ delegators: Array, total: number, }; export type CreateDelegatorRequest = { +/** + * Optional Git identity, HTTPS credential reference, and runtime settings. + */ +git?: GitExecutionConfig | null, /** * Unique name for the delegator */ diff --git a/skills/step.md b/skills/step.md index 8ff227d0..4869070f 100644 --- a/skills/step.md +++ b/skills/step.md @@ -18,7 +18,7 @@ Create or update the file `.operator/step-complete.json` with the following cont } ``` -Then **stop and wait** — the operator will detect completion and provide the next step's prompt. +Then **stop and wait** - the operator will detect completion and provide the next step's prompt. ## Important diff --git a/src/acp/agent.rs b/src/acp/agent.rs index 0420a876..ea7cd45b 100644 --- a/src/acp/agent.rs +++ b/src/acp/agent.rs @@ -23,22 +23,19 @@ use crate::config::{Config, Delegator}; /// Build the `InitializeResponse` operator advertises. /// -/// Echoes the client's protocol version (the ACP convention — the agent -/// accepts the protocol version requested unless it cannot satisfy it), -/// advertises default agent capabilities, and attaches `agentInfo` so -/// editors can identify operator in their UI. +/// Echoes the client's protocol version, advertises default agent capabilities, and attaches `agentInfo` +/// so that editors can identify operator in their UI. pub fn build_initialize_response(request: &InitializeRequest) -> InitializeResponse { InitializeResponse::new(request.protocol_version) .agent_capabilities(AgentCapabilities::default()) .agent_info(Implementation::new("operator", env!("CARGO_PKG_VERSION")).title("Operator")) } -/// Run operator as an ACP agent over stdin/stdout until the client -/// disconnects. +/// Run operator as an ACP agent over stdin/stdout until the client disconnects. /// /// Returns the protocol's `Result` so the binary entrypoint can surface /// transport errors. Logs go to stderr via `tracing`; stdout is reserved -/// for line-delimited JSON-RPC (see `src/logging.rs` — global subscriber +/// for line-delimited JSON-RPC (see `src/logging.rs` - global subscriber /// writes to stderr). pub async fn run_stdio(config: Config) -> agent_client_protocol::Result<()> { let registry = Arc::new(SessionRegistry::new()); diff --git a/src/acp/client_configs.rs b/src/acp/client_configs.rs index 53a82164..8418f9f1 100644 --- a/src/acp/client_configs.rs +++ b/src/acp/client_configs.rs @@ -21,7 +21,7 @@ fn exe_string() -> String { current_exe().to_string_lossy().into_owned() } -/// Zed `~/.config/zed/settings.json` — `agent_servers` block. +/// Zed `~/.config/zed/settings.json` - `agent_servers` block. pub fn zed_snippet() -> Value { json!({ "agent_servers": { @@ -44,7 +44,7 @@ pub fn jetbrains_snippet() -> Value { }) } -/// Emacs `agent-shell` — elisp form to add to your init file. +/// Emacs `agent-shell` - elisp form to add to your init file. pub fn emacs_snippet() -> String { format!( "(add-to-list 'agent-shell-acp-agents\n '(:name \"operator\" :command \"{}\" :args (\"acp\")))", diff --git a/src/acp/server.rs b/src/acp/server.rs index f5f991ff..8a645844 100644 --- a/src/acp/server.rs +++ b/src/acp/server.rs @@ -1,7 +1,7 @@ //! ACP agent status/count handle for the dashboard. //! //! Unlike [`crate::rest::server::RestApiServer`], this is **not** a listener -//! lifecycle — editor-spawned `operator acp` runs in a separate stdio +//! lifecycle - editor-spawned `operator acp` runs in a separate stdio //! subprocess that the TUI never hosts. [`AcpAgentServer`] just records //! whether ACP is advertised in the dashboard and how many sessions are //! currently active (always `0` in v1, since out-of-process ACP runs don't @@ -18,7 +18,7 @@ use crate::config::Config; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum AcpAgentStatus { - /// `[acp].stdio_advertised = false` — operator is intentionally not + /// `[acp].stdio_advertised = false` - operator is intentionally not /// advertising itself as an ACP agent. Disabled, /// Advertised. No active sessions are visible to the TUI (the editor diff --git a/src/acp/session.rs b/src/acp/session.rs index a353b6d6..a5456320 100644 --- a/src/acp/session.rs +++ b/src/acp/session.rs @@ -1,4 +1,4 @@ -//! ACP session registry — maps `SessionId` to operator tickets. +//! ACP session registry - maps `SessionId` to operator tickets. //! //! When an editor calls `session/new`, [`SessionRegistry::create_or_attach`] //! either attaches to an existing in-progress ACP ticket (if exactly one @@ -175,7 +175,7 @@ fn write_new_acp_ticket(in_progress: &Path, session_id: &SessionId, cwd: &Path) Ok(path) } -/// First 8 hex chars of the session UUID — short enough for a filename, long +/// First 8 hex chars of the session UUID - short enough for a filename, long /// enough that random collisions inside one in-progress dir are negligible. fn session_short(session_id: &SessionId) -> String { session_id diff --git a/src/agents/activity.rs b/src/agents/activity.rs index 376a28c4..4ab65d08 100644 --- a/src/agents/activity.rs +++ b/src/agents/activity.rs @@ -276,7 +276,7 @@ impl ActivityDetector for CmuxActivityDetector { } fn configure(&self, _session_id: &str, _config: &ActivityConfig) -> Result<(), SessionError> { - // cmux doesn't have a monitor-silence equivalent — no-op + // cmux doesn't have a monitor-silence equivalent: no-op Ok(()) } @@ -377,7 +377,7 @@ impl ActivityDetector for ZellijActivityDetector { } fn configure(&self, _session_id: &str, _config: &ActivityConfig) -> Result<(), SessionError> { - // Zellij doesn't have a monitor-silence equivalent — no-op + // Zellij doesn't have a monitor-silence equivalent: no-op Ok(()) } @@ -565,20 +565,22 @@ mod tests { .unwrap(); client.set_screen_content(&ws_id, "Initial content\n> "); - let detector = - CmuxActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = CmuxActivityDetector::new( + Arc::::clone(&client), + create_idle_detector_with_patterns(), + ); detector.register_workspace("session-1", &ws_id); - // First call — stores hash, returns false + // First call: stores hash, returns false assert!(!detector.has_resumed("session-1").unwrap()); // Change content client.set_screen_content(&ws_id, "New output\nDoing things...\n"); - // Second call — content changed, returns true + // Second call: content changed, returns true assert!(detector.has_resumed("session-1").unwrap()); - // Third call — no change since last, returns false + // Third call: no change since last, returns false assert!(!detector.has_resumed("session-1").unwrap()); } @@ -637,20 +639,22 @@ mod tests { client.create_tab("agent-tab", "/tmp").unwrap(); client.set_screen_content("agent-tab", "Initial content\n> "); - let detector = - ZellijActivityDetector::new(client.clone(), create_idle_detector_with_patterns()); + let detector = ZellijActivityDetector::new( + Arc::::clone(&client), + create_idle_detector_with_patterns(), + ); detector.register_tab("session-1", "agent-tab"); - // First call — stores hash, returns false + // First call: stores hash, returns false assert!(!detector.has_resumed("session-1").unwrap()); // Change content client.set_screen_content("agent-tab", "New output\nDoing things...\n"); - // Second call — content changed, returns true + // Second call: content changed, returns true assert!(detector.has_resumed("session-1").unwrap()); - // Third call — no change since last, returns false + // Third call: no change since last, returns false assert!(!detector.has_resumed("session-1").unwrap()); } diff --git a/src/agents/agent_switcher.rs b/src/agents/agent_switcher.rs index 99ba484f..e7488fe5 100644 --- a/src/agents/agent_switcher.rs +++ b/src/agents/agent_switcher.rs @@ -442,7 +442,7 @@ mod tests { // Set content to show shell prompt (agent exited immediately) mock.set_session_content("op-test", "user@host ~/project $ "); - let switcher = AgentSwitcher::new(mock.clone()); + let switcher = AgentSwitcher::new(Arc::::clone(&mock)); let _result = switcher.switch_agent("op-test", "claude", "gemini --model pro"); // Should succeed (agent readiness poll will fail but that's ok for unit test) @@ -460,7 +460,7 @@ mod tests { mock.add_session("op-test", "/tmp/project"); mock.set_session_content("op-test", "user@host ~/project $ "); - let switcher = AgentSwitcher::new(mock.clone()); + let switcher = AgentSwitcher::new(Arc::::clone(&mock)); let _ = switcher.switch_agent("op-test", "gemini", "claude --model opus"); let keys = mock.get_session_keys_sent("op-test").unwrap(); @@ -476,7 +476,7 @@ mod tests { mock.add_session("op-test", "/tmp/project"); mock.set_session_content("op-test", "user@host ~/project $ "); - let switcher = AgentSwitcher::new(mock.clone()); + let switcher = AgentSwitcher::new(Arc::::clone(&mock)); let _ = switcher.switch_agent("op-test", "codex", "claude --model opus"); let keys = mock.get_session_keys_sent("op-test").unwrap(); diff --git a/src/agents/cmux.rs b/src/agents/cmux.rs index 00c74c0a..28964957 100644 --- a/src/agents/cmux.rs +++ b/src/agents/cmux.rs @@ -42,7 +42,7 @@ pub enum CmuxError { NotInCmux, #[error( - "cmux {found} is not supported; operator requires cmux >= {minimum} — please update cmux" + "cmux {found} is not supported; operator requires cmux >= {minimum} - please update cmux" )] UnsupportedVersion { found: String, minimum: String }, @@ -190,7 +190,7 @@ pub trait CmuxClient: Send + Sync { } // ============================================================================ -// SystemCmuxClient — real CLI calls +// SystemCmuxClient - real CLI calls // ============================================================================ /// Real implementation using the cmux binary @@ -335,7 +335,7 @@ impl CmuxClient for SystemCmuxClient { } // ============================================================================ -// MockCmuxClient — in-memory state for testing +// MockCmuxClient - in-memory state for testing // ============================================================================ /// Mock workspace for testing @@ -672,7 +672,7 @@ impl CmuxClient for MockCmuxClient { } // ============================================================================ -// CmuxWrapper — SessionWrapper implementation for cmux +// CmuxWrapper - SessionWrapper implementation for cmux // ============================================================================ /// Wrapper around `CmuxClient` that implements `SessionWrapper` trait @@ -1244,7 +1244,7 @@ mod tests { require_in_cmux: true, placement: CmuxPlacementPolicy::Auto, }; - let wrapper = CmuxWrapper::new(client_arc.clone(), &config); + let wrapper = CmuxWrapper::new(Arc::::clone(&client_arc), &config); wrapper .create_session("op-TASK-006", "/tmp/project") @@ -1318,7 +1318,7 @@ mod tests { require_in_cmux: true, placement: CmuxPlacementPolicy::Auto, }; - let wrapper = CmuxWrapper::new(client_arc.clone(), &config); + let wrapper = CmuxWrapper::new(Arc::::clone(&client_arc), &config); wrapper .create_session("op-TASK-009", "/tmp/project") diff --git a/src/agents/delegator_resolution.rs b/src/agents/delegator_resolution.rs index 228f6eae..fdc3040c 100644 --- a/src/agents/delegator_resolution.rs +++ b/src/agents/delegator_resolution.rs @@ -126,7 +126,7 @@ fn adhoc_model_server_env( Ok(crate::api::providers::model_server::env_for_server(&server)) } -/// Resolve the execution target for a launch — the one pure decision point. +/// Resolve the execution target for a launch. /// /// Precedence: /// 1. `target` name set → look up (explicit `[[targets]]`, builtin @@ -163,7 +163,7 @@ pub fn resolve_target( if lc.docker == Some(true) { HOST_WINS.call_once(|| { tracing::warn!( - "launch_config sets both `docker` and `host` (deprecated); host wins — \ + "launch_config sets both `docker` and `host` (deprecated); host wins - \ migrate to `target`" ); }); @@ -331,7 +331,7 @@ pub fn resolve_launch_options( apply_delegator_launch_config(&mut options, &delegator.launch_config, config)?; return Ok(options); } - // Step agent name doesn't match any delegator — fall through + // Step agent name doesn't match any delegator - fall through } // 3. Issuetype-level agent @@ -386,7 +386,7 @@ pub fn resolve_launch_options( return Ok(options); } - // 5. No explicit selection — resolve default delegator + // 5. No explicit selection - resolve default delegator if let Some(delegator) = resolve_default_delegator(config) { options.provider = Some(delegator_to_provider(config, delegator)?); options.delegator_name = Some(delegator.name.clone()); @@ -394,7 +394,7 @@ pub fn resolve_launch_options( return Ok(options); } - // 6. No delegators at all — fall back to default tool/model or first detected + // 6. No delegators at all - fall back to default tool/model or first detected let tool = config .llm_tools .default_tool @@ -788,7 +788,7 @@ mod tests { #[test] fn resolve_remote_only_is_platform_agnostic() { - // The guard fires for any platform, not just AGNT — an OpenAI Assistant + // The guard fires for any platform, not just AGNT - an OpenAI Assistant // delegator is equally export-only. Proves the generalization. let mut config = Config::default(); let mut d = make_delegator("openai-reviewer", "openai", "gpt-4o"); @@ -817,7 +817,7 @@ mod tests { #[test] fn resolve_remote_only_step_agent_errors() { // The guard sits in the single resolution choke point, so the step-agent - // path errors too — proving the export-only contract holds on every path. + // path errors too - proving the export-only contract holds on every path. let mut config = Config::default(); let mut d = make_delegator("agnt-researcher", "anthropic", "claude-3-5-sonnet"); d.remote_agent = Some(crate::config::RemoteAgentRef { diff --git a/src/agents/launcher/cmux_session.rs b/src/agents/launcher/cmux_session.rs index 151f9555..dcef1e92 100644 --- a/src/agents/launcher/cmux_session.rs +++ b/src/agents/launcher/cmux_session.rs @@ -1,6 +1,6 @@ //! cmux session creation and management for agent launches //! -//! Parallel to `tmux_session.rs` — provides cmux-specific launch functions +//! Parallel to `tmux_session.rs` - provides cmux-specific launch functions //! that create workspaces/windows and send commands via `CmuxClient`. use std::sync::Arc; @@ -25,7 +25,7 @@ use super::prompt::{ use super::step_command; use super::SESSION_PREFIX; -/// Result of launching in cmux — includes refs needed for state tracking +/// Result of launching in cmux - includes refs needed for state tracking #[derive(Debug, Clone)] pub struct CmuxLaunchResult { pub session_name: String, diff --git a/src/agents/launcher/coder.rs b/src/agents/launcher/coder.rs index 48d2b271..ed60ee57 100644 --- a/src/agents/launcher/coder.rs +++ b/src/agents/launcher/coder.rs @@ -1,28 +1,37 @@ //! Coder workspace target: lifecycle + SSH alias provisioning. //! //! A coder target's execution shape is an SSH target with a -//! dynamically-provisioned alias — the launch itself reuses `remote.rs` +//! dynamically-provisioned alias - the launch itself reuses `remote.rs` //! unchanged. This module owns only what is Coder-specific: deterministic //! workspace naming, create/start lifecycle (never delete), the SSH config //! fragment (`ProxyCommand coder ssh --stdio`), and the git checkout on the //! workspace. Identity is a plain user session token resolved from the //! environment **by name** and never written to disk. +//! +//! The `coder` CLI is preferred from `PATH` and otherwise fetched from the +//! deployment itself, so its version can never drift from the server it talks +//! to and nothing has to be baked into the Operator image. -use std::path::PathBuf; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; use crate::config::{CoderConfig, Config, RemoteHost}; -use super::prompt::shell_escape; +use super::prompt::{shell_escape, shell_escape_if_needed}; /// Coder caps workspace names at 32 characters. const MAX_WORKSPACE_NAME: usize = 32; /// Over budget: keep this much of the readable key, then `-` + 6 hex of hash. const TRUNCATED_KEY_LEN: usize = 25; +/// Binary name looked up on `PATH` and used for the download cache. +const CODER_CLI_BIN: &str = "coder"; +/// Bound on fetching the CLI from the deployment. +const CODER_CLI_DOWNLOAD_TIMEOUT_SECS: u64 = 120; -/// Resolved Coder credentials — env values read at launch time, held only in +/// Resolved Coder credentials - env values read at launch time, held only in /// memory and injected into `coder` child processes under the CLI's standard /// variable names. #[derive(Debug)] @@ -93,24 +102,39 @@ pub fn workspace_alias(workspace: &str) -> String { format!("op-coder-{workspace}") } -/// SSH config fragment content: `coder ssh --stdio` as a `ProxyCommand`, so -/// real `ssh` — with the full flag set (`-t`, `-R`) — works over the Coder -/// tailnet. Operator writes its own fragment rather than running +/// SSH config fragment content: `coder ssh --stdio` as a `ProxyCommand`, so real `ssh` works over the Coder +/// Operator writes its own fragment rather than running /// `coder config-ssh`, which rewrites the user's `~/.ssh/config`. -pub fn ssh_fragment(workspace: &str) -> String { +/// +/// The `ProxyCommand` carries the *resolved* binary path: `ssh` spawns it +/// itself, so a bare `coder` would resolve against ssh's PATH and miss a +/// CLI that was downloaded to the state directory. +/// +/// Host-key checking is off, matching what `coder config-ssh` writes for its +/// own hosts. The tailnet reached through the `ProxyCommand` is the +/// authentication boundary; the workspace host key adds nothing on top of it, +/// and per-ticket workspaces are ephemeral enough that trust-on-first-use +/// would only accumulate dead `known_hosts` entries. +pub fn ssh_fragment(coder_bin: &Path, workspace: &str) -> String { format!( - "Host {alias}\n ProxyCommand coder ssh --stdio {workspace}\n User coder\n", + "Host {alias}\n ProxyCommand {bin} ssh --stdio {workspace}\n User coder\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n LogLevel ERROR\n", alias = workspace_alias(workspace), + bin = shell_escape_if_needed(&coder_bin.to_string_lossy()), ) } /// Write the per-workspace fragment under `.tickets/operator/ssh/` and return -/// its path. Idempotent — keyed by workspace name. -pub(crate) fn write_ssh_fragment(config: &Config, workspace: &str) -> Result { +/// its path. Idempotent - keyed by workspace name. +pub(crate) fn write_ssh_fragment( + config: &Config, + coder_bin: &Path, + workspace: &str, +) -> Result { let ssh_dir = config.tickets_path().join("operator/ssh"); std::fs::create_dir_all(&ssh_dir).context("Failed to create ssh fragment directory")?; let path = ssh_dir.join(format!("{workspace}.config")); - std::fs::write(&path, ssh_fragment(workspace)).context("Failed to write ssh fragment")?; + std::fs::write(&path, ssh_fragment(coder_bin, workspace)) + .context("Failed to write ssh fragment")?; Ok(path) } @@ -122,14 +146,14 @@ pub(crate) struct WorkspaceInfo { } /// What provisioning must do for a workspace, decided from `coder list` -/// output. Pure — directly unit-testable. +/// output. Pure - directly unit-testable. #[derive(Debug, PartialEq)] pub(crate) enum WorkspaceAction { /// Exists on our template: `coder start` (no-op if running) Start, /// Absent: `coder create --template -y` Create, - /// Exists on a DIFFERENT template: refuse — guards against colliding + /// Exists on a DIFFERENT template: refuse - guards against colliding /// with a human's workspace of the same name. Refuse { existing_template: String }, } @@ -150,7 +174,7 @@ pub(crate) fn decide_workspace_action( /// Git checkout script run on the workspace over ssh: reuse a matching /// checkout (fetch + ticket branch), otherwise clone then branch. Branch /// naming stays in Rust (the caller passes the `git.branch_format`-derived -/// name) — never duplicated into a Coder template. +/// name) - never duplicated into a Coder template. pub(crate) fn checkout_script(workdir: &str, remote_url: &str, branch: &str) -> String { let dir = shell_escape(workdir); let url = shell_escape(remote_url); @@ -161,11 +185,11 @@ pub(crate) fn checkout_script(workdir: &str, remote_url: &str, branch: &str) -> } /// Run a `coder` CLI invocation with the session injected under the CLI's -/// standard env names. Errors surface Coder's stderr verbatim — quota and +/// standard env names. Errors surface Coder's stderr verbatim - quota and /// permission failures are the control plane's message, not ours to /// reinterpret. -fn run_coder(session: &CoderSession, args: &[&str]) -> Result { - let output = Command::new("coder") +fn run_coder(coder_bin: &Path, session: &CoderSession, args: &[&str]) -> Result { + let output = Command::new(coder_bin) .args(args) .env("CODER_URL", &session.url) .env("CODER_SESSION_TOKEN", &session.token) @@ -182,8 +206,13 @@ fn run_coder(session: &CoderSession, args: &[&str]) -> Result { } /// Look up a workspace by exact name via `coder list --output json`. -fn find_workspace(session: &CoderSession, name: &str) -> Result> { +fn find_workspace( + coder_bin: &Path, + session: &CoderSession, + name: &str, +) -> Result> { let out = run_coder( + coder_bin, session, &[ "list", @@ -197,8 +226,114 @@ fn find_workspace(session: &CoderSession, name: &str) -> Result PathBuf { + config.state_path().join("bin").join(CODER_CLI_BIN) +} + +fn is_executable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +/// Locate an already-present CLI: `PATH` first, then the download cache. +/// `None` means one has to be fetched. Takes `PATH` as an argument rather than +/// reading the environment so it stays pure and test-parallel-safe. +fn resolve_coder_cli(path_var: Option<&OsStr>, cache: &Path) -> Option { + let on_path = path_var.and_then(|paths| { + std::env::split_paths(paths) + .map(|dir| dir.join(CODER_CLI_BIN)) + .find(|candidate| is_executable(candidate)) + }); + on_path.or_else(|| is_executable(cache).then(|| cache.to_path_buf())) +} + +/// A Coder deployment serves a CLI matching its own version at `/bin/`. +fn coder_download_url(base: &str, arch: &str) -> Result { + let suffix = match arch { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => anyhow::bail!( + "No Coder CLI download exists for architecture '{other}'; install `coder` on PATH" + ), + }; + Ok(format!( + "{}/bin/coder-linux-{suffix}", + base.trim_end_matches('/') + )) +} + +/// Fetch the CLI from the deployment into `dest`. Downloads to a sibling +/// temp file and renames, so a killed process can never leave a truncated +/// binary that later looks like a valid cache hit. +fn download_coder_cli(base_url: &str, dest: &Path) -> Result { + let url = coder_download_url(base_url, std::env::consts::ARCH)?; + let dir = dest + .parent() + .context("Coder CLI cache path has no parent directory")?; + std::fs::create_dir_all(dir).with_context(|| { + format!( + "Failed to create the Coder CLI cache directory at {}", + dir.display() + ) + })?; + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs( + CODER_CLI_DOWNLOAD_TIMEOUT_SECS, + )) + .build() + .context("Failed to build the HTTP client for the Coder CLI download")?; + let bytes = client + .get(&url) + .send() + .and_then(reqwest::blocking::Response::error_for_status) + .and_then(reqwest::blocking::Response::bytes) + .with_context(|| { + format!( + "Failed to fetch the Coder CLI from {url}. Operator does not bundle it -- either \ + the deployment is unreachable from here (check egress rules) or you can install \ + `coder` on PATH yourself" + ) + })?; + + let staged = dir.join(format!("{CODER_CLI_BIN}.download")); + std::fs::write(&staged, &bytes) + .with_context(|| format!("Failed to write the Coder CLI to {}", staged.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)) + .context("Failed to mark the downloaded Coder CLI executable")?; + } + std::fs::rename(&staged, dest) + .with_context(|| format!("Failed to install the Coder CLI at {}", dest.display()))?; + tracing::info!(url = %url, path = %dest.display(), "Downloaded the Coder CLI"); + Ok(dest.to_path_buf()) +} + +/// Resolve the `coder` CLI, preferring one already on `PATH` and otherwise +/// using (or populating) the cache under the state directory. +fn ensure_coder_cli(config: &Config, session: &CoderSession) -> Result { + let cache = coder_cli_cache_path(config); + match resolve_coder_cli(std::env::var_os("PATH").as_deref(), &cache) { + Some(found) => Ok(found), + None => download_coder_cli(&session.url, &cache), + } +} + /// Provision the workspace for a ticket and return the `RemoteHost` the -/// shared remote launch tail consumes. Blocking — workspace creation is +/// shared remote launch tail consumes. Blocking - workspace creation is /// bounded by `create_timeout_secs`. pub(crate) fn provision_workspace( config: &Config, @@ -217,17 +352,14 @@ pub(crate) fn provision_workspace( )?; } } - // Fail fast before any lifecycle action: credentials, then CLI presence. + // Fail fast before any lifecycle action: credentials, then the CLI (whose + // download source is the deployment the credentials just named). let session = resolve_session(coder)?; - if !cli_available() { - anyhow::bail!( - "Coder target requires the `coder` CLI on PATH; install it from your deployment" - ); - } + let coder_bin = ensure_coder_cli(config, &session)?; let workspace = workspace_name(&coder.name_prefix, project, ticket_id); match decide_workspace_action( - find_workspace(&session, &workspace)?.as_ref(), + find_workspace(&coder_bin, &session, &workspace)?.as_ref(), &coder.template, ) { WorkspaceAction::Refuse { existing_template } => anyhow::bail!( @@ -236,7 +368,7 @@ pub(crate) fn provision_workspace( coder.template ), WorkspaceAction::Start => { - run_coder(&session, &["start", &workspace, "--no-wait"]).map(|_| ())?; + run_coder(&coder_bin, &session, &["start", &workspace, "--no-wait"]).map(|_| ())?; } WorkspaceAction::Create => { let mut args: Vec = vec![ @@ -253,18 +385,18 @@ pub(crate) fn provision_workspace( args.push(format!("{k}={v}")); } let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); - run_coder(&session, &arg_refs).map(|_| ())?; + run_coder(&coder_bin, &session, &arg_refs).map(|_| ())?; } } - let fragment = write_ssh_fragment(config, &workspace)?; + let fragment = write_ssh_fragment(config, &coder_bin, &workspace)?; let alias = workspace_alias(&workspace); let workdir = coder .workdir .clone() .unwrap_or_else(|| format!("/home/coder/{project}")); - wait_for_ssh(&fragment, &alias, coder.create_timeout_secs)?; + wait_for_ssh(&session, &fragment, &alias, coder.create_timeout_secs)?; // Ensure the checkout before the agent lands in the workdir. if let (Some(url), Some(branch)) = (remote_url, branch) { @@ -284,7 +416,7 @@ pub(crate) fn provision_workspace( let path = super::prompt::shell_escape(&runtime.path.to_string_lossy()); script = format!(". {path}/env.sh\ntrap 'rm -rf -- {path}' EXIT\n{script}"); } - run_ssh(&fragment, &alias, &script) + run_ssh(&session, &fragment, &alias, &script) .with_context(|| format!("Failed to prepare checkout on workspace '{workspace}'"))?; } Ok(RemoteHost { @@ -296,28 +428,38 @@ pub(crate) fn provision_workspace( }) } -/// Stop the workspace (never delete — reclamation is the Coder admin's +/// Stop the workspace (never delete - reclamation is the Coder admin's /// autostop/autodelete policy). Best-effort by design. -pub fn stop_workspace(coder: &CoderConfig, workspace: &str) -> Result<()> { +pub fn stop_workspace(config: &Config, coder: &CoderConfig, workspace: &str) -> Result<()> { let session = resolve_session(coder)?; - run_coder(&session, &["stop", workspace, "--yes"]).map(|_| ()) + let coder_bin = ensure_coder_cli(config, &session)?; + run_coder(&coder_bin, &session, &["stop", workspace, "--yes"]).map(|_| ()) } -fn cli_available() -> bool { - Command::new("which") - .arg("coder") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) +/// `ssh` spawns the fragment's `ProxyCommand` itself, and that `coder` +/// subprocess reads the CLI's own canonical variable names. Inject them here +/// so a target configured with custom `url_env` / `token_env` names still +/// authenticates -- in-process only, never written to the fragment on disk. +fn coder_ssh_command(session: &CoderSession, fragment: &Path) -> Command { + let mut command = Command::new("ssh"); + command + .args(["-F".as_ref(), fragment.as_os_str()]) + .env("CODER_URL", &session.url) + .env("CODER_SESSION_TOKEN", &session.token); + command } /// Poll ssh connectivity through the provisioned alias until the workspace /// agent answers, bounded by `timeout_secs`. -fn wait_for_ssh(fragment: &std::path::Path, alias: &str, timeout_secs: u64) -> Result<()> { +fn wait_for_ssh( + session: &CoderSession, + fragment: &Path, + alias: &str, + timeout_secs: u64, +) -> Result<()> { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); loop { - let ok = Command::new("ssh") - .args(["-F".as_ref(), fragment.as_os_str()]) + let ok = coder_ssh_command(session, fragment) .args(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]) .arg(alias) .arg("true") @@ -337,9 +479,8 @@ fn wait_for_ssh(fragment: &std::path::Path, alias: &str, timeout_secs: u64) -> R } } -fn run_ssh(fragment: &std::path::Path, alias: &str, script: &str) -> Result<()> { - let status = Command::new("ssh") - .args(["-F".as_ref(), fragment.as_os_str()]) +fn run_ssh(session: &CoderSession, fragment: &Path, alias: &str, script: &str) -> Result<()> { + let status = coder_ssh_command(session, fragment) .args(["-o", "BatchMode=yes"]) .arg(alias) .arg(script) @@ -390,7 +531,7 @@ pub fn stop_on_complete_for_agent(config: &Config, agent: &crate::state::AgentSt if !coder.stop_on_complete { return; } - match stop_workspace(&coder, &workspace) { + match stop_workspace(config, &coder, &workspace) { Ok(()) => tracing::info!(workspace = %workspace, "Stopped coder workspace on completion"), Err(e) => tracing::warn!(workspace = %workspace, error = %e, "Failed to stop workspace"), } @@ -430,14 +571,145 @@ mod tests { assert_ne!(name, other); } + /// Create an executable stub at `path`, parent dirs included. + fn touch_executable(path: &std::path::Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "#!/bin/sh\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + #[test] fn test_ssh_fragment_shape() { - let frag = ssh_fragment("op-proj-feat-1"); + let frag = ssh_fragment( + std::path::Path::new("/usr/local/bin/coder"), + "op-proj-feat-1", + ); assert!(frag.contains("Host op-coder-op-proj-feat-1")); - assert!(frag.contains("ProxyCommand coder ssh --stdio op-proj-feat-1")); + assert!( + frag.contains("ProxyCommand /usr/local/bin/coder ssh --stdio op-proj-feat-1"), + "ProxyCommand must carry the resolved absolute path, not a bare `coder`: {frag}" + ); assert!(frag.contains("User coder")); } + #[test] + fn test_ssh_fragment_disables_host_key_checking() { + let frag = ssh_fragment(std::path::Path::new("/usr/local/bin/coder"), "ws-1"); + // HOME is an emptyDir in the Helm deployment, so there is no + // known_hosts and BatchMode ssh would fail verification every launch. + assert!(frag.contains("StrictHostKeyChecking no")); + assert!(frag.contains("UserKnownHostsFile /dev/null")); + assert!(frag.contains("LogLevel ERROR")); + } + + #[test] + fn test_ssh_fragment_quotes_a_path_needing_it() { + let frag = ssh_fragment(std::path::Path::new("/opt/my coder/coder"), "ws-1"); + assert!( + frag.contains("ProxyCommand '/opt/my coder/coder' ssh --stdio ws-1"), + "a path with a space must survive sh parsing: {frag}" + ); + } + + #[test] + fn test_coder_download_url_for_arch() { + assert_eq!( + coder_download_url("https://coder.example.com", "x86_64").unwrap(), + "https://coder.example.com/bin/coder-linux-amd64" + ); + assert_eq!( + coder_download_url("https://coder.example.com", "aarch64").unwrap(), + "https://coder.example.com/bin/coder-linux-arm64" + ); + } + + #[test] + fn test_coder_download_url_trims_trailing_slash() { + assert_eq!( + coder_download_url("https://coder.example.com/", "x86_64").unwrap(), + "https://coder.example.com/bin/coder-linux-amd64" + ); + } + + #[test] + fn test_coder_download_url_unsupported_arch_names_it() { + let err = coder_download_url("https://c.example.com", "riscv64") + .unwrap_err() + .to_string(); + assert!(err.contains("riscv64"), "error must name the arch: {err}"); + } + + #[test] + fn test_resolve_coder_cli_prefers_path_over_cache() { + let temp = tempfile::tempdir().unwrap(); + let bin_dir = temp.path().join("path-bin"); + let on_path = bin_dir.join("coder"); + touch_executable(&on_path); + let cache = temp.path().join("state/bin/coder"); + touch_executable(&cache); + + let path_var = std::env::join_paths([bin_dir.as_path()]).unwrap(); + assert_eq!( + resolve_coder_cli(Some(path_var.as_os_str()), &cache), + Some(on_path), + "a PATH entry must win over the downloaded cache" + ); + } + + #[test] + fn test_resolve_coder_cli_falls_back_to_cached_binary() { + let temp = tempfile::tempdir().unwrap(); + let empty_dir = temp.path().join("empty"); + std::fs::create_dir_all(&empty_dir).unwrap(); + let cache = temp.path().join("state/bin/coder"); + touch_executable(&cache); + + let path_var = std::env::join_paths([empty_dir.as_path()]).unwrap(); + assert_eq!( + resolve_coder_cli(Some(path_var.as_os_str()), &cache), + Some(cache) + ); + } + + #[test] + fn test_resolve_coder_cli_none_when_absent_everywhere() { + let temp = tempfile::tempdir().unwrap(); + let empty_dir = temp.path().join("empty"); + std::fs::create_dir_all(&empty_dir).unwrap(); + let path_var = std::env::join_paths([empty_dir.as_path()]).unwrap(); + assert_eq!( + resolve_coder_cli( + Some(path_var.as_os_str()), + &temp.path().join("state/bin/coder") + ), + None, + "absent everywhere must signal a download, not a bogus path" + ); + } + + #[test] + fn test_coder_cli_cache_path_lives_under_state() { + let temp = tempfile::tempdir().unwrap(); + let config = Config { + paths: crate::config::PathsConfig { + tickets: temp.path().to_string_lossy().to_string(), + projects: temp.path().to_string_lossy().to_string(), + state: temp.path().join("s").to_string_lossy().to_string(), + worktrees: temp.path().join("w").to_string_lossy().to_string(), + }, + ..Default::default() + }; + // The state dir is the PVC in the Helm deployment; HOME is an emptyDir. + assert_eq!( + coder_cli_cache_path(&config), + temp.path().join("s/bin/coder") + ); + } + #[test] fn test_decide_workspace_action_template_mismatch_refuses() { let existing = WorkspaceInfo { @@ -512,11 +784,12 @@ mod tests { }, ..Default::default() }; - let p1 = write_ssh_fragment(&config, "ws-1").unwrap(); - let p2 = write_ssh_fragment(&config, "ws-1").unwrap(); + let bin = std::path::Path::new("/usr/local/bin/coder"); + let p1 = write_ssh_fragment(&config, bin, "ws-1").unwrap(); + let p2 = write_ssh_fragment(&config, bin, "ws-1").unwrap(); assert_eq!(p1, p2); assert!(std::fs::read_to_string(&p1) .unwrap() - .contains("ProxyCommand coder ssh --stdio ws-1")); + .contains("ProxyCommand /usr/local/bin/coder ssh --stdio ws-1")); } } diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs index 07006cfe..ba83f64e 100644 --- a/src/agents/launcher/llm_command.rs +++ b/src/agents/launcher/llm_command.rs @@ -70,7 +70,7 @@ fn build_llm_command_impl( // A recorded pass is trusted; anything else is re-verified here rather than // failing on a value the launching process never checked (only the TUI runs - // startup detection — `launch`, `api`, `mcp` and `acp` do not). + // startup detection - `launch`, `api`, `mcp` and `acp` do not). if !tool.health_ok && !verify_health(tool_name) { anyhow::bail!( "LLM tool '{tool_name}' is not launchable: its health check failed. Check the binary is installed and on PATH, or fix its detection.health_command." @@ -127,7 +127,7 @@ pub fn apply_yolo_flags(config: &Config, cmd: &str, tool_name: &str) -> String { /// Wrap an inner LLM command for a resolved execution target. /// /// `Local` is the identity; `Docker` wraps in `docker run`. `Ssh` and `Coder` -/// are NOT command wraps — they dispatch through the remote session launch +/// are NOT command wraps - they dispatch through the remote session launch /// path before the command pipeline, so reaching them here is a bug. pub fn wrap_for_target( config: &Config, @@ -186,7 +186,7 @@ fn is_containerized() -> bool { /// /// `docker_config` is the resolved target's payload (not necessarily the /// global `launch.docker`). Values are shell-escaped where they can contain -/// hostile characters; `extra_args` are passed verbatim — users may embed +/// hostile characters; `extra_args` are passed verbatim - users may embed /// their own quoting. pub fn build_docker_command( config: &Config, @@ -235,7 +235,7 @@ pub fn build_docker_command( } // Route the containerised opr8r's completion POST to the host-side REST - // API. On Linux the gateway alias must be mapped explicitly — omitting it + // API. On Linux the gateway alias must be mapped explicitly - omitting it // produces a silent stall, not an error. Injected before operator_env so // an explicit caller value (e.g. a callback URL) wins (last -e wins). #[cfg(target_os = "linux")] @@ -270,7 +270,7 @@ pub fn build_docker_command( // Add the image docker_args.push(docker_config.image.clone()); - // Add the inner command. Quoted as one argument to the container's shell — + // Add the inner command. Quoted as one argument to the container's shell - // unquoted, every flag after the binary name would bind to $0/$1 and be // silently dropped. docker_args.push("sh".to_string()); @@ -610,7 +610,7 @@ fn relay_mcp_config_flag_with_command( write_mcp_server_config(session_dir, "relay", relay_entry) } -/// Locate the opr8r binary itself — alongside the running operator binary +/// Locate the opr8r binary itself - alongside the running operator binary /// first (primary: signed distribution), then on PATH. Used by the step /// wrapper. Distinct from `locate_relay_command`, which also accepts the /// legacy standalone relay binary. @@ -657,7 +657,7 @@ fn locate_relay_command() -> Option<(PathBuf, Vec)> { } } } - // 2. OPERATOR_RELAY env var (user override — treated as opr8r path) + // 2. OPERATOR_RELAY env var (user override - treated as opr8r path) if let Ok(path) = std::env::var("OPERATOR_RELAY") { let p = PathBuf::from(&path); if p.exists() { @@ -1045,7 +1045,7 @@ mod tests { let cmd = result.unwrap(); assert!( cmd.contains("sh -c 'claude --model sonnet'"), - "inner command must be quoted as ONE sh -c argument — unquoted, every \ + "inner command must be quoted as ONE sh -c argument - unquoted, every \ flag after the binary binds to $0/$1 and is silently dropped, got: {cmd}" ); } diff --git a/src/agents/launcher/mod.rs b/src/agents/launcher/mod.rs index 922c72fd..e355552e 100644 --- a/src/agents/launcher/mod.rs +++ b/src/agents/launcher/mod.rs @@ -294,7 +294,7 @@ impl Launcher { config: &Config, project_path: impl AsRef, ) -> Option { - if let Some(configured) = config.git.provider.clone() { + if let Some(configured) = config.git.provider { return Some(configured.into()); } let hosts = crate::types::pr::ProviderHosts::from_config(&config.git).ok()?; @@ -405,7 +405,7 @@ impl Launcher { working_dir_str: &str, options: &mut LaunchOptions, ) -> Result<()> { - if let crate::config::TargetKind::Coder(coder_cfg) = options.target.kind.clone() { + if let crate::config::TargetKind::Coder(coder_cfg) = &options.target.kind { let remote_url = crate::git::GitCli::get_remote_url(std::path::Path::new(working_dir_str)) .await @@ -413,7 +413,7 @@ impl Launcher { let branch = ticket.branch_name(); let host = coder::provision_workspace( &self.config, - &coder_cfg, + coder_cfg, &ticket.project, &ticket.id, remote_url.as_deref(), @@ -425,7 +425,8 @@ impl Launcher { )? .as_ref(), )?; - options.api_url_override = coder_cfg.callback_url.clone().filter(|u| !u.is_empty()); + let callback_url = coder_cfg.callback_url.clone().filter(|u| !u.is_empty()); + options.api_url_override = callback_url; options.provisioned_host = Some(host); } Ok(()) @@ -848,7 +849,7 @@ impl Launcher { ) -> Result { if pending.is_empty() { anyhow::bail!( - "multi-agent step '{}' produced zero sub-agents — check config", + "multi-agent step '{}' produced zero sub-agents - check config", step.name ); } diff --git a/src/agents/launcher/options.rs b/src/agents/launcher/options.rs index e29f35f0..b19b677c 100644 --- a/src/agents/launcher/options.rs +++ b/src/agents/launcher/options.rs @@ -68,7 +68,7 @@ pub struct ParsedLaunchMode { } /// Parse a persisted `launch_mode` string. Legacy values ("default", "yolo", -/// "docker", "docker-yolo") and unknown strings all parse — old state files +/// "docker", "docker-yolo") and unknown strings all parse - old state files /// predate the coder/ssh vocabulary. pub fn parse_launch_mode(s: &str) -> ParsedLaunchMode { let (base, yolo) = match s.strip_suffix("-yolo") { @@ -109,7 +109,7 @@ impl LaunchOptions { .or_else(|| self.target.as_remote_host()) } - /// Get the launch mode string for state tracking — the single derivation + /// Get the launch mode string for state tracking - the single derivation /// point for the persisted vocabulary: /// `default|yolo|docker[-yolo]|coder[-yolo]|ssh[-yolo]`. pub fn launch_mode_string(&self) -> String { diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs index f369d5a8..42abddce 100644 --- a/src/agents/launcher/prompt.rs +++ b/src/agents/launcher/prompt.rs @@ -232,7 +232,7 @@ pub fn write_command_file( .unwrap_or_default(); // Strip Coder session tokens from the agent's environment on every target - // kind — Operator needs them to drive the control plane; no agent CLI does. + // kind - Operator needs them to drive the control plane; no agent CLI does. let strip_block = { let names = crate::config::coder_token_envs(config); if names.is_empty() { @@ -292,7 +292,7 @@ pub fn write_command_file( /// /// Keys are sorted for deterministic output. Values are shell-escaped, *except* /// a pure shell-variable reference like `${OLLAMA_API_KEY}` is emitted unquoted -/// so the shell expands it at run time — this lets an API key be passed by +/// so the shell expands it at run time - this lets an API key be passed by /// reference (inherited from operator's env) without writing the secret value /// into the on-disk command script. fn render_env_exports(env: &std::collections::HashMap) -> String { @@ -354,7 +354,7 @@ mod tests { #[test] fn test_command_file_strips_coder_token_env_on_local_target() { // The token variable is stripped from EVERY agent spawn environment, - // including Local launches — the likeliest exposure is a local agent + // including Local launches - the likeliest exposure is a local agent // inside the operator's own Coder workspace. let temp = tempfile::tempdir().unwrap(); let mut config = Config::default(); @@ -674,7 +674,7 @@ mod tests { let mut provider_env = std::collections::HashMap::new(); provider_env.insert("OPENAI_BASE_URL".to_string(), "http://gpu:8000".to_string()); - // API key passed by reference — must NOT be written as a literal secret. + // API key passed by reference - must NOT be written as a literal secret. provider_env.insert("OPENAI_API_KEY".to_string(), "${MY_SECRET_KEY}".to_string()); let result = write_command_file( diff --git a/src/agents/launcher/remote.rs b/src/agents/launcher/remote.rs index 451013ef..6d361e25 100644 --- a/src/agents/launcher/remote.rs +++ b/src/agents/launcher/remote.rs @@ -142,8 +142,8 @@ pub(crate) fn remote_payload_path(host: &RemoteHost, session_uuid: &str) -> Stri /// Build the agent CLI command executed on the remote host. /// -/// Uses the *loaded* tool config template — builtin or user-provided (see -/// `crate::llm::tool_config`) — with the bare tool name, resolved via the +/// Uses the *loaded* tool config template - builtin or user-provided (see +/// `crate::llm::tool_config`) - with the bare tool name, resolved via the /// remote PATH, rather than the locally detected binary path, which would be /// wrong on the remote machine. `{{config_flags}}` is dropped: permission /// translation, MCP config, and statusline all write local files @@ -163,7 +163,7 @@ pub(crate) fn build_remote_llm_command( .map(|p| p.display().to_string()) .unwrap_or_else(|| "~/.config/operator/tools".to_string()); anyhow::anyhow!( - "LLM tool '{tool_name}' has no tool config; builtins are claude/codex/gemini — \ + "LLM tool '{tool_name}' has no tool config; builtins are claude/codex/gemini - \ add a JSON under {tools_dir} to support others" ) })?; diff --git a/src/agents/launcher/step_command.rs b/src/agents/launcher/step_command.rs index beb23aba..4a7ac78c 100644 --- a/src/agents/launcher/step_command.rs +++ b/src/agents/launcher/step_command.rs @@ -2,7 +2,7 @@ //! //! One builder produces the `opr8r --ticket-id … --step … -- ` wrapper //! for both the first step (launcher) and subsequent steps (`complete_step` -//! route). The returned command is always the INNER command — target wrapping +//! route). The returned command is always the INNER command - target wrapping //! (docker, remote) is applied once, to the outermost launch, by the launcher; //! `exec()` transitions happen inside the already-wrapped environment. @@ -55,7 +55,7 @@ pub struct StepLaunchContext { } /// A step command plus the session UUID minted for it. The caller persists the -/// UUID (ticket `session_ids`, agent state) — building is side-effect-free +/// UUID (ticket `session_ids`, agent state) - building is side-effect-free /// with respect to ticket and state files. #[derive(Debug)] pub struct BuiltStepCommand { @@ -479,7 +479,7 @@ mod tests { } /// A non-builtin issue type installed into the workspace registry must - /// wrap too — otherwise a collection issuetype's chain never engages. + /// wrap too - otherwise a collection issuetype's chain never engages. #[test] fn test_chain_step_wraps_registry_only_issuetype() { const GAST_JSON: &str = r#"{ diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs index 5e831a3a..a25fcd6b 100644 --- a/src/agents/launcher/tests.rs +++ b/src/agents/launcher/tests.rs @@ -399,7 +399,8 @@ async fn test_launch_creates_session_with_correct_working_dir() { ) .unwrap(); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = + Launcher::with_tmux_client(&config, Arc::::clone(&mock)).unwrap(); let result = launcher.launch(&ticket).await; // The launch should succeed @@ -435,7 +436,8 @@ async fn test_launch_command_includes_cd_to_project() { ) .unwrap(); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = + Launcher::with_tmux_client(&config, Arc::::clone(&mock)).unwrap(); let result = launcher.launch(&ticket).await; assert!(result.is_ok(), "Launch failed: {:?}", result.err()); @@ -481,7 +483,8 @@ async fn test_launch_global_ticket_uses_root() { ) .unwrap(); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = + Launcher::with_tmux_client(&config, Arc::::clone(&mock)).unwrap(); let result = launcher.launch(&ticket).await; assert!(result.is_ok(), "Launch failed: {:?}", result.err()); @@ -571,7 +574,7 @@ fn test_launch_in_tmux_existing_session_returns_error() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -608,7 +611,7 @@ fn test_launch_in_tmux_sends_cd_command() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -654,7 +657,7 @@ fn test_launch_in_tmux_remote_host_sends_wrapper_and_skips_relay() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -688,7 +691,7 @@ fn test_launch_in_tmux_remote_host_sends_wrapper_and_skips_relay() { let keys_sent = mock.get_session_keys_sent(&session_name).unwrap(); // Exactly one command typed into the pane: the remote wrapper. No relay - // export line — the relay unix socket is meaningless on a remote host. + // export line - the relay unix socket is meaningless on a remote host. assert_eq!(keys_sent.len(), 1, "got: {keys_sent:?}"); let sent_cmd = keys_sent[0].trim_end_matches(" [Enter]"); assert!( @@ -723,7 +726,7 @@ fn test_launch_in_tmux_sends_llm_command() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -766,7 +769,7 @@ fn test_launch_in_tmux_yolo_mode_applies_flags() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -808,7 +811,7 @@ fn test_launch_in_tmux_yolo_mode_disabled_no_flags() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -850,7 +853,7 @@ fn test_launch_in_tmux_docker_mode_wraps() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config_with_docker(&temp_dir, "my-claude:latest"); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -888,7 +891,7 @@ fn test_launch_in_tmux_docker_mode_wraps() { } /// FEAT ticket positioned on "plan", the first step of the embedded FEAT -/// schema — satisfies `step_command::chain_step` so the launch wraps in opr8r. +/// schema - satisfies `step_command::chain_step` so the launch wraps in opr8r. fn make_chain_ticket(project: &str) -> Ticket { Ticket { ticket_type: "FEAT".to_string(), @@ -902,7 +905,7 @@ fn test_launch_in_tmux_wraps_command_in_opr8r() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_chain_ticket("test-project"); let project_path = temp_dir .path() @@ -952,7 +955,7 @@ fn test_launch_in_tmux_docker_wrap_is_outermost() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config_with_docker(&temp_dir, "my-claude:latest"); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_chain_ticket("test-project"); let project_path = temp_dir .path() @@ -995,7 +998,7 @@ fn test_launch_in_tmux_docker_wrap_is_outermost() { // The token immediately preceding "--ticket-id=" must be the bare literal // "opr8r" (per resolve_opr8r_invocation(true)), not an absolute host path - // like "/Users/x/operator/opr8r" — `contains("opr8r --ticket-id=")` alone + // like "/Users/x/operator/opr8r" - `contains("opr8r --ticket-id=")` alone // would match either, since a path only has safe characters and isn't // quoted by escape_if_needed. let before_ticket_id = script_content @@ -1021,7 +1024,7 @@ fn test_launched_session_uuid_returns_backend_minted_uuid() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_chain_ticket("test-project"); // The backend records the uuid on the in-progress ticket file. @@ -1072,7 +1075,7 @@ fn test_launch_in_tmux_no_wrap_for_unknown_ticket_type() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = Ticket { ticket_type: "UNKNOWNTYPE".to_string(), ..make_test_ticket("test-project") @@ -1113,7 +1116,7 @@ fn test_launch_in_tmux_both_modes() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config_with_docker(&temp_dir, "my-claude:latest"); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1175,7 +1178,7 @@ fn test_launch_in_tmux_uses_provider_from_options() { health_ok: true, }); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1338,7 +1341,7 @@ fn test_relaunch_remote_resume_reuses_session_and_adds_resume_flag() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1408,7 +1411,7 @@ fn test_relaunch_inherits_yolo_mode() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1454,7 +1457,7 @@ fn test_relaunch_inherits_docker_mode() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config_with_docker(&temp_dir, "my-claude:latest"); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1500,7 +1503,7 @@ fn test_relaunch_existing_session_errors() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1538,7 +1541,7 @@ fn test_relaunch_with_resume_adds_flag() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1600,7 +1603,7 @@ fn test_relaunch_missing_prompt_fresh_start() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1687,7 +1690,7 @@ fn test_launch_correct_project_directory_from_ticket() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1759,7 +1762,7 @@ fn test_launch_provider_from_delegator_determines_tool() { }); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1809,7 +1812,7 @@ fn test_launch_yolo_flags_per_tool() { let temp_dir = TempDir::new().unwrap(); let config = make_test_config(&temp_dir); let mock = Arc::new(MockTmuxClient::new()); - let tmux: Arc = mock.clone(); + let tmux: Arc = Arc::::clone(&mock); let ticket = make_test_ticket("test-project"); let project_path = temp_dir .path() @@ -1882,7 +1885,8 @@ async fn test_launch_pending_sub_agents_launches_all_when_slots_allow() { add_delegators(&mut config, &["claude-opus", "gemini-pro"]); let mock = Arc::new(MockTmuxClient::new()); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = + Launcher::with_tmux_client(&config, Arc::::clone(&mock)).unwrap(); let ticket = make_test_ticket("test-project"); // Seed a group with 2 pending sub-agents @@ -1958,7 +1962,8 @@ async fn test_launch_pending_sub_agents_respects_slot_budget() { add_delegators(&mut config, &["claude-opus", "gemini-pro"]); let mock = Arc::new(MockTmuxClient::new()); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = + Launcher::with_tmux_client(&config, Arc::::clone(&mock)).unwrap(); let ticket = make_test_ticket("test-project"); let group_id = { @@ -2020,7 +2025,8 @@ async fn test_launch_pending_sub_agents_errors_on_unknown_delegator() { // Intentionally do NOT add any delegators. let mock = Arc::new(MockTmuxClient::new()); - let launcher = Launcher::with_tmux_client(&config, mock.clone()).unwrap(); + let launcher = + Launcher::with_tmux_client(&config, Arc::::clone(&mock)).unwrap(); let ticket = make_test_ticket("test-project"); let group_id = { diff --git a/src/agents/launcher/zellij_session.rs b/src/agents/launcher/zellij_session.rs index ed771529..6669d458 100644 --- a/src/agents/launcher/zellij_session.rs +++ b/src/agents/launcher/zellij_session.rs @@ -1,6 +1,6 @@ //! Zellij session creation and management for agent launches //! -//! Parallel to `cmux_session.rs` — provides zellij-specific launch functions +//! Parallel to `cmux_session.rs` - provides zellij-specific launch functions //! that create tabs and send commands via `ZellijClient`. use std::sync::Arc; @@ -23,7 +23,7 @@ use super::prompt::{ write_prompt_file, OperatorEnvVars, }; use super::step_command; -/// Result of launching in zellij — includes tab name for state tracking +/// Result of launching in zellij - includes tab name for state tracking #[derive(Debug, Clone)] pub struct ZellijLaunchResult { pub session_name: String, diff --git a/src/agents/monitor.rs b/src/agents/monitor.rs index be4599d0..29ce9319 100644 --- a/src/agents/monitor.rs +++ b/src/agents/monitor.rs @@ -325,7 +325,7 @@ impl SessionMonitor { } } - // 3. Fallback: Silence flag check (tmux only — cmux/zellij don't have silence monitoring) + // 3. Fallback: Silence flag check (tmux only - cmux/zellij don't have silence monitoring) if !detected_awaiting && !is_cmux && !is_zellij { if let Ok(is_silent) = self.tmux.check_silence_flag(&session_name) { if is_silent { @@ -828,7 +828,8 @@ mod tests { mock.add_session("op-STALE-1", "/tmp"); mock.add_session("op-STALE-2", "/tmp"); - let monitor = SessionMonitor::with_tmux_client(&config, mock.clone()); + let monitor = + SessionMonitor::with_tmux_client(&config, Arc::::clone(&mock)); // Verify sessions exist assert!(mock.session_exists("op-STALE-1").unwrap()); @@ -1057,7 +1058,7 @@ mod tests { // Write hook signal to trigger idle detection let signal_path = write_hook_signal(&agent_id); - // Empty worktree — no artifact files + // Empty worktree - no artifact files let worktree = TempDir::new().unwrap(); let mut artifact_context: HashMap)> = HashMap::new(); @@ -1106,7 +1107,7 @@ mod tests { let mock = Arc::new(MockTmuxClient::new()); mock.add_session("op-FEAT-ART-3", "/tmp"); mock.set_session_content("op-FEAT-ART-3", "Actively working..."); - // No hook signal, no silence flag — agent is NOT idle + // No hook signal, no silence flag - agent is NOT idle // Artifacts exist but agent isn't idle let worktree = TempDir::new().unwrap(); @@ -1126,7 +1127,7 @@ mod tests { let mut monitor = SessionMonitor::with_tmux_client(&config, mock); let result = monitor.check_health(&artifact_context).unwrap(); - // Not idle — artifacts should not be checked + // Not idle - artifacts should not be checked assert!( result.awaiting_input.is_empty(), "Session should NOT be in awaiting_input" diff --git a/src/agents/sync.rs b/src/agents/sync.rs index 237d073f..5cb8fe7d 100644 --- a/src/agents/sync.rs +++ b/src/agents/sync.rs @@ -32,14 +32,14 @@ use crate::templates::schema::ReviewType; fn proof_result_message(result: &ProofResult, proof_ref: &str) -> String { if result.timed_out { format!( - "Proof FAILED (timeout, exit {}) — awaiting review ({proof_ref})", + "Proof FAILED (timeout, exit {}) - awaiting review ({proof_ref})", result.exit_code ) } else if result.passed { - format!("Proof passed — awaiting review ({proof_ref})") + format!("Proof passed - awaiting review ({proof_ref})") } else { format!( - "Proof FAILED (exit {}) — awaiting review ({proof_ref})", + "Proof FAILED (exit {}) - awaiting review ({proof_ref})", result.exit_code ) } @@ -476,7 +476,7 @@ impl TicketSessionSync { error = %e, "Proof runner error" ); - "Proof runner error — awaiting review".to_string() + "Proof runner error - awaiting review".to_string() } }; match State::load(&config) { @@ -1206,7 +1206,7 @@ mod tests { let mut health = HealthCheckResult::default(); health.awaiting_input.push("op-FEAT-123".to_string()); - // artifact_ready is empty — agent is idle but no artifacts found + // artifact_ready is empty - agent is idle but no artifacts found let ticket = Ticket { filename: "test.md".to_string(), diff --git a/src/agents/terminal_wrapper.rs b/src/agents/terminal_wrapper.rs index 5a81767f..25ea43d4 100644 --- a/src/agents/terminal_wrapper.rs +++ b/src/agents/terminal_wrapper.rs @@ -129,7 +129,7 @@ pub trait SessionWrapper: Send + Sync { } /// Get topology info for a session (hierarchy refs, placement info) - /// Default returns `NotSupported` — only wrappers with rich hierarchy implement this. + /// Default returns `NotSupported` - only wrappers with rich hierarchy implement this. fn session_topology(&self, _session: &str) -> Result { Err(SessionError::NotSupported( "topology not available for this wrapper".into(), diff --git a/src/agents/vscode_types.rs b/src/agents/vscode_types.rs index b8eabb1f..bd2d4716 100644 --- a/src/agents/vscode_types.rs +++ b/src/agents/vscode_types.rs @@ -196,7 +196,7 @@ pub struct VsCodeLaunchOptions { /// Named delegator to use (takes precedence over model) #[serde(default)] pub delegator: Option, - /// Model to use (sonnet, opus, haiku) — fallback when no delegator + /// Model to use (sonnet, opus, haiku) - fallback when no delegator pub model: VsCodeModelOption, /// YOLO mode - auto-accept all prompts pub yolo_mode: bool, diff --git a/src/agents/zellij.rs b/src/agents/zellij.rs index 855225ef..18f028f2 100644 --- a/src/agents/zellij.rs +++ b/src/agents/zellij.rs @@ -67,10 +67,6 @@ pub trait ZellijClient: Send + Sync { fn close_tab(&self, tab_name: &str) -> Result<(), ZellijError>; } -// ============================================================================ -// SystemZellijClient — real CLI calls -// ============================================================================ - /// Real implementation using the zellij binary. /// /// Operates via `zellij action` commands inside the current Zellij session. @@ -170,10 +166,6 @@ impl ZellijClient for SystemZellijClient { } } -// ============================================================================ -// MockZellijClient — in-memory state for testing -// ============================================================================ - /// Mock tab for testing #[derive(Debug, Clone)] struct MockTab { @@ -351,15 +343,10 @@ impl ZellijClient for MockZellijClient { } } -// ============================================================================ -// ZellijWrapper — SessionWrapper implementation for Zellij -// ============================================================================ - /// Wrapper around `ZellijClient` that implements `SessionWrapper` trait. /// /// This provides the `SessionWrapper` interface for Zellij-based session -/// management. Each "session" maps to a Zellij tab within the current -/// Zellij instance. +/// management. Each "session" maps to a Zellij tab within the current Zellij instance. pub struct ZellijWrapper { client: Arc, /// Map of session name -> tab name @@ -714,7 +701,7 @@ mod tests { #[tokio::test] async fn test_zellij_wrapper_send_command() { let client = Arc::new(MockZellijClient::new()); - let wrapper = ZellijWrapper::new(client.clone()); + let wrapper = ZellijWrapper::new(Arc::::clone(&client)); wrapper .create_session("op-TASK-001", "/tmp/project") @@ -782,7 +769,7 @@ mod tests { #[tokio::test] async fn test_zellij_wrapper_capture_content() { let client = Arc::new(MockZellijClient::new()); - let wrapper = ZellijWrapper::new(client.clone()); + let wrapper = ZellijWrapper::new(Arc::::clone(&client)); wrapper .create_session("op-TASK-001", "/tmp/project") diff --git a/src/api/kanban_sync.rs b/src/api/kanban_sync.rs index 1165865c..e0b43ffe 100644 --- a/src/api/kanban_sync.rs +++ b/src/api/kanban_sync.rs @@ -1,4 +1,4 @@ -//! Bidirectional kanban sync — pushes operator ticket state changes upstream. +//! Bidirectional kanban sync - pushes operator ticket state changes upstream. use std::sync::Arc; @@ -59,7 +59,7 @@ impl KanbanBidirectionalSync { } /// Called when a ticket is returned to the queue (doing → todo). Pushes the - /// mapped "todo" status to the provider — no-op unless `status_mapping.todo` + /// mapped "todo" status to the provider - no-op unless `status_mapping.todo` /// is explicitly configured (there is no safe universal default column). pub async fn on_ticket_requeued(&self, ticket: &Ticket) { if let Some((provider, sync_cfg)) = self.resolve(ticket) { diff --git a/src/api/pr_service.rs b/src/api/pr_service.rs index 507f7f37..a1087e78 100644 --- a/src/api/pr_service.rs +++ b/src/api/pr_service.rs @@ -283,9 +283,8 @@ type Resolver = /// /// `provider_name()`, `check_available()`, and `get_authenticated_user()` /// take no `RepoInfo`, so there's no per-call provider to route on. They -/// fall back to GitHub (the pre-router default) — `provider_name()` reports -/// `"auto"` so callers can tell it's the router rather than a concrete -/// provider. +/// fall back to GitHub (the pre-router default) - `provider_name()` reports +/// `"auto"` so callers can tell it's the router rather than a concrete provider. pub struct PrServiceRouter { default_provider: GitProvider, resolve: Resolver, @@ -298,7 +297,7 @@ impl PrServiceRouter { ) -> Self { let git = config.git.clone(); Self { - default_provider: git.provider.clone().map(Into::into).unwrap_or_default(), + default_provider: git.provider.map(Into::into).unwrap_or_default(), resolve: Box::new(move |provider| { let service = pr_service_for(provider, &git)?; let auth = match provider { @@ -775,7 +774,7 @@ mod tests { fn router_with_mocks(calls: Arc) -> PrServiceRouter { let github: Arc = Arc::new(MockPrService { provider: "github", - calls: calls.clone(), + calls: Arc::clone(&calls), }); let gitlab: Arc = Arc::new(MockPrService { provider: "gitlab", @@ -783,8 +782,8 @@ mod tests { }); PrServiceRouter::with_resolver(move |provider| match provider { - GitProvider::GitHub => Ok(github.clone()), - GitProvider::GitLab => Ok(gitlab.clone()), + GitProvider::GitHub => Ok(Arc::clone(&github)), + GitProvider::GitLab => Ok(Arc::clone(&gitlab)), other => Err(UnsupportedProviderError { provider: other }), }) } @@ -792,7 +791,7 @@ mod tests { #[tokio::test] async fn test_router_dispatches_to_github_mock() { let calls = Arc::new(AtomicUsize::new(0)); - let router = router_with_mocks(calls.clone()); + let router = router_with_mocks(Arc::clone(&calls)); let repo = RepoInfo::new(GitProvider::GitHub, "owner", "repo"); let pr = router.get_pr(&repo, 1).await.unwrap(); @@ -804,7 +803,7 @@ mod tests { #[tokio::test] async fn test_router_dispatches_to_gitlab_mock() { let calls = Arc::new(AtomicUsize::new(0)); - let router = router_with_mocks(calls.clone()); + let router = router_with_mocks(Arc::clone(&calls)); let repo = RepoInfo::new(GitProvider::GitLab, "owner", "repo"); let pr = router.get_pr(&repo, 2).await.unwrap(); diff --git a/src/api/providers/kanban/github_projects.rs b/src/api/providers/kanban/github_projects.rs index da9df95c..1423c06c 100644 --- a/src/api/providers/kanban/github_projects.rs +++ b/src/api/providers/kanban/github_projects.rs @@ -128,7 +128,7 @@ impl GithubProjectsProvider { /// Create from environment. /// /// Reads **only** `OPERATOR_GITHUB_TOKEN`. Does **not** fall back to - /// `GITHUB_TOKEN` even if it exists — that env var belongs to operator's + /// `GITHUB_TOKEN` even if it exists - that env var belongs to operator's /// git provider (PR/branch workflows) and almost certainly lacks the /// `project` scope, which would surface confusing 403s deeper in the /// stack. See module-level Token Disambiguation note. @@ -234,7 +234,7 @@ impl GithubProjectsProvider { // with the friendly disambiguation hint so users see it via the // generic provider_error_message helper. Preserve the raw error so // legitimate bugs (field-level permission failures, feature-gated - // fields, etc.) are still debuggable — the hint alone was masking + // fields, etc.) are still debuggable - the hint alone was masking // real root causes. let lower = combined.to_lowercase(); if lower.contains("project") @@ -396,7 +396,7 @@ impl GithubProjectsProvider { } } - // Scope verification — header scrape (classic PATs). + // Scope verification - header scrape (classic PATs). let scopes_header = self.fetch_oauth_scopes().await; if let Some(scopes) = &scopes_header { @@ -573,7 +573,7 @@ impl GithubProjectsProvider { } } // `GraphQL` errors here usually mean the schema doesn't expose - // `issueTypes` (older orgs) — treat that as "no types available" + // `issueTypes` (older orgs) - treat that as "no types available" // and let the caller fall back to labels. Err(ApiError::HttpError { message, .. }) if message.contains("issueTypes") => { warn!("issueTypes field not available, falling back to labels"); @@ -943,7 +943,7 @@ impl KanbanProvider for GithubProjectsProvider { } async fn list_projects(&self) -> Result, ApiError> { - // Reuse the validate_detailed query — it's the canonical projects discovery. + // Reuse the validate_detailed query - it's the canonical projects discovery. let details = self.validate_detailed().await?; Ok(details .projects @@ -1399,7 +1399,7 @@ impl KanbanProvider for GithubProjectsProvider { "optionId": option_id, }); - // Discard the response — we only care that it didn't error. + // Discard the response - we only care that it didn't error. let _: serde_json::Value = self.graphql(mutation, Some(variables)).await?; // Return a minimal updated ExternalIssue. Re-fetching the full item @@ -1772,12 +1772,12 @@ impl KanbanProvider for GithubProjectsProvider { let comment_body = if summary_text.is_empty() { format!( - "🤖 **opr8r activity** — step: `{}` | delegator: `{}` | {}", + "🤖 **opr8r activity** - step: `{}` | delegator: `{}` | {}", entry.step, entry.delegator, timestamp ) } else { format!( - "🤖 **opr8r activity** — step: `{}` | delegator: `{}` | {}\n\n> {}", + "🤖 **opr8r activity** - step: `{}` | delegator: `{}` | {}\n\n> {}", entry.step, entry.delegator, timestamp, summary_text ) }; @@ -1812,7 +1812,7 @@ impl GithubProjectsProvider { project_id: &str, after: Option<&str>, ) -> Result { - // NOTE: assignees.nodes must NOT request `email` — GitHub gates the + // NOTE: assignees.nodes must NOT request `email` - GitHub gates the // `User.email` field behind `user:email` or `read:user` scope, which // is orthogonal to the `project` scope this provider requires and // would break any token scoped to projects-only. `RawAssignee.email` @@ -2005,7 +2005,7 @@ mod tests { let result = GithubProjectsProvider::from_env(); assert!( result.is_err(), - "from_env must not fall back to GITHUB_TOKEN — see Token Disambiguation rule 1" + "from_env must not fall back to GITHUB_TOKEN - see Token Disambiguation rule 1" ); env::remove_var("GITHUB_TOKEN"); } diff --git a/src/api/providers/kanban/jira.rs b/src/api/providers/kanban/jira.rs index 44eed41a..f3c94d45 100644 --- a/src/api/providers/kanban/jira.rs +++ b/src/api/providers/kanban/jira.rs @@ -16,7 +16,7 @@ const PROVIDER_NAME: &str = "jira"; /// Detailed validation result for Jira onboarding. /// -/// Richer than `KanbanProvider::test_connection` — includes the authenticated +/// Richer than `KanbanProvider::test_connection` - includes the authenticated /// user's `accountId` (used as `sync_user_id` in config) and display name. #[derive(Debug, Clone)] pub struct JiraValidationDetails { @@ -812,7 +812,7 @@ impl KanbanProvider for JiraProvider { // Format the comment text let timestamp = entry.completed_at.format("%Y-%m-%d %H:%M UTC").to_string(); let mut text = format!( - "🤖 opr8r — step: {} | delegator: {} | {}", + "🤖 opr8r - step: {} | delegator: {} | {}", entry.step, entry.delegator, timestamp ); if let Some(ref summary) = entry.summary { diff --git a/src/api/providers/kanban/linear.rs b/src/api/providers/kanban/linear.rs index a10a93c8..a0ca2d8b 100644 --- a/src/api/providers/kanban/linear.rs +++ b/src/api/providers/kanban/linear.rs @@ -23,7 +23,7 @@ pub struct LinearTeamInfo { /// Detailed validation result for Linear onboarding. /// -/// Richer than `KanbanProvider::test_connection` — includes viewer, org, and +/// Richer than `KanbanProvider::test_connection` - includes viewer, org, and /// the full list of teams available to the API key in a single round-trip. #[derive(Debug, Clone)] pub struct LinearValidationDetails { @@ -1137,7 +1137,7 @@ impl KanbanProvider for LinearProvider { let timestamp = entry.completed_at.format("%Y-%m-%d %H:%M UTC"); let mut body = format!( - "**opr8r activity** — step: `{}` | delegator: `{}` | {}", + "**opr8r activity** - step: `{}` | delegator: `{}` | {}", entry.step, entry.delegator, timestamp ); if let Some(ref summary) = entry.summary { diff --git a/src/api/providers/kanban/mod.rs b/src/api/providers/kanban/mod.rs index ccd5841e..ead7f5e4 100644 --- a/src/api/providers/kanban/mod.rs +++ b/src/api/providers/kanban/mod.rs @@ -227,7 +227,7 @@ pub trait KanbanProvider: Send + Sync { /// Append an agent activity entry to the upstream issue. /// - /// Implementations append (not replace) a structured log entry — as a comment + /// Implementations append (not replace) a structured log entry - as a comment /// on Jira/Linear, or as a body update on GitHub draft issues. /// Default: no-op (returns `Ok(())`). async fn append_activity_log( @@ -301,7 +301,7 @@ impl KanbanProviderType { } } - /// Lowercase wire slug — the stable identifier used in config keys, the + /// Lowercase wire slug - the stable identifier used in config keys, the /// `ConfigureKanbanProvider` action, and the REST catalog. pub fn slug(&self) -> &'static str { match self { @@ -331,7 +331,7 @@ impl KanbanProviderType { /// The provider's credential/token page. Opened by the TUI "Configure" /// action and surfaced as the clickable link on the web `/#/kanban` rows - /// (there is no in-browser onboarding wizard — this opens the token page). + /// (there is no in-browser onboarding wizard - this opens the token page). pub fn setup_url(&self) -> &'static str { match self { KanbanProviderType::Jira => { @@ -339,7 +339,7 @@ impl KanbanProviderType { } KanbanProviderType::Linear => "https://linear.app/settings/api", KanbanProviderType::Github => "https://github.com/settings/personal-access-tokens", - // No token page exists — OpenSpec is local files; link the docs. + // No token page exists - OpenSpec is local files; link the docs. KanbanProviderType::Openspec => { "https://operator.untra.io/getting-started/kanban/openspec/" } @@ -420,13 +420,13 @@ impl DetectedKanbanProvider { } KanbanProviderType::Github => { // GitHub Projects just needs the token. Note: only - // OPERATOR_GITHUB_TOKEN counts here — see Token + // OPERATOR_GITHUB_TOKEN counts here - see Token // Disambiguation rule 5 in github_projects.rs. self.env_vars_found .iter() .any(|v| v.contains("TOKEN") || v.contains("API_KEY")) } - // OpenSpec needs no env vars — configuration is a local path. + // OpenSpec needs no env vars - configuration is a local path. KanbanProviderType::Openspec => true, } } @@ -699,7 +699,7 @@ pub fn get_provider(name: &str) -> Option> { "github" => GithubProjectsProvider::from_env() .ok() .map(|p| Box::new(p) as Box), - // openspec cannot be built from env — use get_provider_from_config + // openspec cannot be built from env - use get_provider_from_config _ => None, } } diff --git a/src/api/providers/kanban/onboarding.rs b/src/api/providers/kanban/onboarding.rs index b6904cac..c7d293af 100644 --- a/src/api/providers/kanban/onboarding.rs +++ b/src/api/providers/kanban/onboarding.rs @@ -60,7 +60,7 @@ pub struct DiscoveredProject { pub provider_native_id: Option, } -/// Sibling trait for onboarding flows — does NOT replace `KanbanProvider`. +/// Sibling trait for onboarding flows - does NOT replace `KanbanProvider`. /// /// Provides a uniform interface across Jira, Linear, and GitHub Projects /// for credential validation and project discovery during onboarding. diff --git a/src/api/providers/kanban/openspec.rs b/src/api/providers/kanban/openspec.rs index dc2a29ab..2372d831 100644 --- a/src/api/providers/kanban/openspec.rs +++ b/src/api/providers/kanban/openspec.rs @@ -1,4 +1,4 @@ -//! `OpenSpec` (spec-driven development) kanban provider — alpha. +//! `OpenSpec` (spec-driven development) kanban provider - alpha. //! //! Reads local `OpenSpec` change bundles (`openspec/changes//{proposal,tasks}.md`) //! and exposes each change as a kanban "project" whose issues are the `## N.` @@ -276,7 +276,7 @@ impl OpenspecProvider { let mut description = String::new(); if let Some(title) = &proposal.title { - description.push_str(&format!("OpenSpec change **{change_id}** — {title}\n\n")); + description.push_str(&format!("OpenSpec change **{change_id}** - {title}\n\n")); } else { description.push_str(&format!("OpenSpec change **{change_id}**\n\n")); } diff --git a/src/api/providers/model_server/mod.rs b/src/api/providers/model_server/mod.rs index ae6c711f..a055c319 100644 --- a/src/api/providers/model_server/mod.rs +++ b/src/api/providers/model_server/mod.rs @@ -5,7 +5,7 @@ //! A *model server* is a named inference endpoint a delegator can target //! (anthropic-api / openai-api / google-api builtins, plus user-declared //! ollama / openai-compat / lmstudio hosts). The [`ModelServerKind`] enum is the -//! single source of truth for the closed set of supported protocols — every +//! single source of truth for the closed set of supported protocols - every //! surface (TUI status section, web `/#/model-servers` projection, the REST //! catalog endpoint, and the VS Code status tree) derives its list from //! [`ModelServerKind::ALL`] so the options can't drift apart. @@ -35,7 +35,7 @@ use crate::config::ModelServer; /// (takes precedence over the derived vars). /// /// Pure (no environment reads), so the secret never transits this function. -/// Returns an empty map for implicit builtins with no `base_url` — preserving the +/// Returns an empty map for implicit builtins with no `base_url` - preserving the /// vendor-default path exactly as before. pub fn env_for_server(server: &ModelServer) -> HashMap { let mut env = HashMap::new(); @@ -66,7 +66,7 @@ pub fn env_for_server(server: &ModelServer) -> HashMap { /// badges, docs nav, the REST `/kinds` catalog, the web Model Providers view, /// and the VS Code section). /// -/// Distinct from [`ModelServerKind::is_builtin`] — `is_builtin` governs +/// Distinct from [`ModelServerKind::is_builtin`] - `is_builtin` governs /// delete-protection / the zero-config implicit default, whereas /// `provider_class` is about *first-party vendor* vs *gateway/host*. They happen /// to partition the same way today, but they answer different questions, so both @@ -118,7 +118,7 @@ pub enum ModelServerKind { impl ModelServerKind { /// The canonical list of supported model-server kinds, in display order. /// - /// Single source of truth — every surface derives its catalog from here. + /// Single source of truth - every surface derives its catalog from here. pub const ALL: [ModelServerKind; 7] = [ ModelServerKind::AnthropicApi, ModelServerKind::OpenAiApi, @@ -129,7 +129,7 @@ impl ModelServerKind { ModelServerKind::LmStudio, ]; - /// Which sub-class of the Model Provider vertical this kind belongs to — + /// Which sub-class of the Model Provider vertical this kind belongs to - /// drives the grouping across every surface. pub fn provider_class(&self) -> ModelProviderClass { match self { @@ -143,7 +143,7 @@ impl ModelServerKind { } } - /// Stable wire slug — matches the `kind` string stored on + /// Stable wire slug - matches the `kind` string stored on /// [`crate::config::ModelServer`] and used in config, the REST catalog, and /// the `ConfigureModelServer` action. pub fn slug(&self) -> &'static str { @@ -236,7 +236,7 @@ impl ModelServerKind { /// /// One basename feeds every surface: docs map it to /// `/assets/icons/{b}.svg`, VS Code to the `operator-{b}` `ThemeIcon`, and the - /// web UI to `/icons/{b}.svg` — so the brand set can't drift between them. + /// web UI to `/icons/{b}.svg` - so the brand set can't drift between them. /// `openai-api` deliberately stays on a codicon (no first-party logo asset). pub fn brand_icon(&self) -> Option<&'static str> { match self { @@ -252,7 +252,7 @@ impl ModelServerKind { /// Path appended to a server's `base_url` to list the models it serves. /// - /// The protocol determines the shape of the response — see + /// The protocol determines the shape of the response - see /// [`probe::probe_models`] for parsing. Endpoints reflect each vendor's /// documented model-list route. pub fn models_endpoint(&self) -> &'static str { @@ -309,7 +309,7 @@ impl ModelServerKind { /// be declared with an explicit `base_url` before it can be probed /// (`openai-compat` / `lmstudio` are bring-your-own-endpoint). /// - /// Probe-only: this is **never** injected into the agent spawn environment — + /// Probe-only: this is **never** injected into the agent spawn environment - /// see [`env_for_server`], which only exports a `base_url` a server sets /// explicitly, preserving the vendor-default / OAuth launch path for the /// implicit builtins. @@ -329,7 +329,7 @@ impl ModelServerKind { /// ollama server) or the provider must declare its own. /// /// Distinct from [`api_key_env_var`](Self::api_key_env_var), which is the - /// canonical var the *agent CLI* reads at spawn — this is the var the + /// canonical var the *agent CLI* reads at spawn - this is the var the /// *operator probe* reads from its own environment to list models. pub fn default_api_key_env(&self) -> Option<&'static str> { match self { @@ -521,7 +521,7 @@ mod tests { #[test] fn test_probe_defaults_do_not_leak_into_spawn_env() { // The provider has a probe-only default_base_url, but a builtin server - // declares no base_url — so the spawn env must stay empty. This keeps the + // declares no base_url - so the spawn env must stay empty. This keeps the // vendor-default / OAuth launch path intact; defaults are probe-only. for kind in [ ModelServerKind::AnthropicApi, @@ -543,7 +543,7 @@ mod tests { let mut s = server("openai-compat", Some("http://gpu:8000")); s.api_key_env = Some("MY_SECRET_KEY".into()); let env = env_for_server(&s); - // Mapped to the canonical var by reference — the secret value is never read. + // Mapped to the canonical var by reference - the secret value is never read. assert_eq!( env.get("OPENAI_API_KEY").map(String::as_str), Some("${MY_SECRET_KEY}") diff --git a/src/api/providers/model_server/probe.rs b/src/api/providers/model_server/probe.rs index 5975db74..c028df32 100644 --- a/src/api/providers/model_server/probe.rs +++ b/src/api/providers/model_server/probe.rs @@ -2,7 +2,7 @@ //! //! [`probe_models`] hits a server's [`ModelServerKind::models_endpoint`] and //! returns the models it serves. The same request doubles as a reachability -//! health check — a successful probe means the endpoint is up and (where +//! health check - a successful probe means the endpoint is up and (where //! relevant) the API key is accepted, so there is no separate "test connection". //! //! Parsing is split out from the HTTP call ([`parse_models`]) so the per-protocol @@ -16,7 +16,7 @@ use serde_json::Value; use super::ModelServerKind; use crate::config::ModelServer; -/// A single model offered by a server. Minimal by design — id is the wire name +/// A single model offered by a server. Minimal by design - id is the wire name /// passed to `--model`; `display_name` is shown in UIs when the server provides one. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelInfo { @@ -174,19 +174,19 @@ pub fn parse_models(kind: ModelServerKind, body: &str) -> Result, /// Whether a single raw model entry is an LLM text/chat model suitable for an /// agent model dropdown. /// -/// Listing endpoints return more than chat models — `OpenAI` and Google mix in +/// Listing endpoints return more than chat models - `OpenAI` and Google mix in /// embeddings, audio (TTS/Whisper), image, and moderation models that must never /// surface in a model picker. Each provider exposes a different capability signal /// (or none), so the rule is per-kind: /// -/// - Google: authoritative — keep iff `supportedGenerationMethods` advertises +/// - Google: authoritative - keep iff `supportedGenerationMethods` advertises /// `generateContent`. Absent field ⇒ keep (tolerant of API drift / fixtures). /// - `OpenRouter`: keep iff the architecture's output modalities include `text`. /// Absent ⇒ keep. /// - `OpenAI`: no capability field, so classify by id family (deny embeddings / /// audio / image / moderation; allow the gpt / o-series / chatgpt families; /// deny anything unrecognized so unknown non-text families stay out). -/// - Anthropic / ollama / openai-compat / lmstudio: pass-through — Anthropic +/// - Anthropic / ollama / openai-compat / lmstudio: pass-through - Anthropic /// lists only chat models, and BYO/local hosts serve whatever the user runs. fn is_text_model(kind: ModelServerKind, raw: &Value, id: &str) -> bool { match kind { @@ -263,7 +263,7 @@ fn parse_ollama(value: &Value) -> Vec { /// /// Shared by `OpenAI` / Anthropic / openai-compat / lmstudio, so it takes the /// `kind` to apply the per-protocol text-model filter (only `OpenAI` filters; the -/// others pass through — see [`is_text_model`]). +/// others pass through - see [`is_text_model`]). fn parse_openai_like(kind: ModelServerKind, value: &Value) -> Vec { value .get("data") @@ -463,7 +463,7 @@ mod tests { #[test] fn test_parse_compat_and_lmstudio_passthrough() { - // BYO/local hosts are not filtered — an "embedding"-looking id is kept, + // BYO/local hosts are not filtered - an "embedding"-looking id is kept, // guarding against the OpenAI deny-list bleeding into compat kinds. let body = r#"{"data":[{"id":"nomic-embed-text","object":"model"}, {"id":"qwen2.5-coder","object":"model"}]}"#; diff --git a/src/app/agents.rs b/src/app/agents.rs index f618b57c..7f8356be 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -244,7 +244,7 @@ impl App { // Resolve launch options via the delegator chain let options = match crate::agents::delegator_resolution::resolve_launch_options( &self.config, - None, // no explicit delegator — let the chain resolve + None, // no explicit delegator - let the chain resolve None, // no explicit provider None, // no explicit model None, // no explicit model_server @@ -493,7 +493,7 @@ impl App { } /// Focus the cmux window containing the selected agent's workspace. - /// This is a cmux power-user action — other wrappers show a status message. + /// This is a cmux power-user action - other wrappers show a status message. pub(super) fn focus_agent_window(&mut self) -> Result<()> { let agent = self.dashboard.selected_agent().cloned(); let Some(agent) = agent else { @@ -502,7 +502,7 @@ impl App { if agent.session_wrapper.as_deref() != Some("cmux") { self.dashboard - .set_status("F: cmux window focus — not a cmux agent"); + .set_status("F: cmux window focus - not a cmux agent"); return Ok(()); } diff --git a/src/app/git_onboarding.rs b/src/app/git_onboarding.rs index 0a536049..90bbe5d4 100644 --- a/src/app/git_onboarding.rs +++ b/src/app/git_onboarding.rs @@ -13,19 +13,19 @@ use crate::config::{Config, GitProviderConfig}; /// The resolved onboarding step for a provider. #[derive(Debug)] pub enum OnboardingStep { - /// CLI not installed — open install page. + /// CLI not installed - open install page. InstallCli { install_url: String, provider_display: String, }, - /// CLI installed but no token — show PAT dialog. + /// CLI installed but no token - show PAT dialog. CollectToken { pat_url: String, provider: String, provider_display: String, placeholder: String, }, - /// CLI installed and authenticated — token ready to use. + /// CLI installed and authenticated - token ready to use. AutoConfigured { username: String, token: String, diff --git a/src/app/kanban_onboarding.rs b/src/app/kanban_onboarding.rs index f95ecefe..de978e77 100644 --- a/src/app/kanban_onboarding.rs +++ b/src/app/kanban_onboarding.rs @@ -54,7 +54,7 @@ impl App { | KanbanOnboardingAction::PickedProvider(_) | KanbanOnboardingAction::Cancelled | KanbanOnboardingAction::Done => { - // Pure UI transitions — no async work needed. + // Pure UI transitions - no async work needed. } KanbanOnboardingAction::SubmitJiraCreds { domain, @@ -213,11 +213,11 @@ impl App { } } KanbanOnboardingAction::CopyExportBlock => { - // No-op on the Rust side — the dialog displays the block; + // No-op on the Rust side - the dialog displays the block; // the user can manually copy from the terminal. Future // enhancement: integrate with arboard for system clipboard. self.sync_status_message = Some( - "Export block displayed in dialog — copy manually from the terminal" + "Export block displayed in dialog - copy manually from the terminal" .to_string(), ); } @@ -275,7 +275,7 @@ impl App { }; let env_resp = kanban_onboarding::set_session_env(env_req); - // Sync issue types (best effort — non-fatal) + // Sync issue types (best effort - non-fatal) self.try_sync_kanban_issue_types("jira", &project_key).await; self.kanban_onboarding_dialog.set_success( @@ -335,7 +335,7 @@ impl App { } /// Best-effort issue type sync after onboarding completes. - /// Non-fatal — onboarding succeeds even if the sync fails. + /// Non-fatal - onboarding succeeds even if the sync fails. async fn try_sync_kanban_issue_types(&mut self, provider: &str, project_key: &str) { use crate::api::providers::kanban::get_provider_from_config; use crate::config::Config; diff --git a/src/app/mod.rs b/src/app/mod.rs index b7634756..be2caa1f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -435,7 +435,7 @@ impl App { // Update dashboard with server statuses and exit confirmation mode self.dashboard .update_rest_api_status(self.rest_api_server.status()); - // MCP session count — try_lock so we never block the UI tick; + // MCP session count - try_lock so we never block the UI tick; // a contended lock falls back to the previous frame's count. let mcp_sessions = self .rest_api_server diff --git a/src/app/status_actions.rs b/src/app/status_actions.rs index 3c87c63a..0ad9aedc 100644 --- a/src/app/status_actions.rs +++ b/src/app/status_actions.rs @@ -16,7 +16,7 @@ pub(super) enum WebUiOutcome { StatusOnly(String), } -/// Pure decision logic for "user pressed `w` / clicked Open Web UI". +/// Decision logic for "user pressed `w` / clicked Open Web UI". /// /// Kept free of `&self` so it can be unit-tested without spinning up an `App`. /// Callers resolve the inputs from runtime state and act on the returned @@ -28,17 +28,17 @@ pub(super) fn decide_open_web_ui( ) -> WebUiOutcome { if !api_running { return WebUiOutcome::StatusOnly( - "API not running — press Enter on the Operator API row to start it.".into(), + "API not running - press Enter on the Operator API row to start it.".into(), ); } match state { EmbeddedUiState::Ready => WebUiOutcome::Open(url.to_string()), EmbeddedUiState::Placeholder => WebUiOutcome::StatusOnly( - "Web UI placeholder detected — run `cd ui && bun run build` and rebuild operator." + "Web UI placeholder detected - run `cd ui && bun run build` and rebuild operator." .into(), ), EmbeddedUiState::Missing => WebUiOutcome::StatusOnly( - "Binary built without `embed-ui` feature — rebuild with `cargo build` (default) or `--features embed-ui`." + "Binary built without `embed-ui` feature - rebuild with `cargo build` (default) or `--features embed-ui`." .into(), ), } @@ -143,7 +143,7 @@ impl App { .set_status(&format!("Failed to open {provider} setup: {e}")); } else { self.dashboard.set_status(&format!( - "Opened {provider} API key page — add credentials to config.toml" + "Opened {provider} API key page - add credentials to config.toml" )); } } @@ -159,7 +159,7 @@ impl App { .set_status(&format!("Failed to open {kind} setup: {e}")); } else { self.dashboard.set_status(&format!( - "Opened {kind} setup page — add a [[model_servers]] entry to your config" + "Opened {kind} setup page - add a [[model_servers]] entry to your config" )); } } @@ -226,7 +226,7 @@ impl App { StatusAction::ResetConfig => { // TODO: implement double-confirm dialog (type working dir name to confirm) self.dashboard - .set_status("Config reset requires confirmation — not yet implemented"); + .set_status("Config reset requires confirmation - not yet implemented"); } StatusAction::ReloadConfig => match crate::config::Config::load(None) { Ok(new_config) => { @@ -244,9 +244,9 @@ impl App { self.config.mcp.http_enabled = !self.config.mcp.http_enabled; self.dashboard.update_config(&self.config); self.dashboard.set_status(if self.config.mcp.http_enabled { - "MCP HTTP enabled — restart the API to mount routes" + "MCP HTTP enabled - restart the API to mount routes" } else { - "MCP HTTP disabled — restart the API to unmount routes" + "MCP HTTP disabled - restart the API to unmount routes" }); } StatusAction::WriteAndOpenMcpClientConfig { client } => { @@ -480,7 +480,7 @@ mod tests { #[test] fn test_decide_open_web_ui_api_stopped_takes_precedence_over_missing() { // Even if the UI is missing, the user's first problem to solve is - // starting the API — surface that message, not the embed-ui one. + // starting the API - surface that message, not the embed-ui one. let outcome = decide_open_web_ui(false, URL, EmbeddedUiState::Missing); match outcome { WebUiOutcome::StatusOnly(msg) => { diff --git a/src/app/tests.rs b/src/app/tests.rs index 558c3c1b..16608405 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -586,7 +586,7 @@ mod review_signals { #[test] fn test_review_rejection_blocked_for_running_state() { - // Mirrors approval tests but for rejection path — same guard logic applies + // Mirrors approval tests but for rejection path - same guard logic applies let review_state: Option<&str> = Some("running"); let can_reject = matches!( diff --git a/src/app/tickets.rs b/src/app/tickets.rs index 8fc494bf..ae21ae0e 100644 --- a/src/app/tickets.rs +++ b/src/app/tickets.rs @@ -484,8 +484,7 @@ mod admin_password_tests { #[test] fn test_invalid_password_surfaces_as_an_error() { - // The wizard validates first, so this only happens if that check is - // bypassed — it must still not create a weak account silently. + // The wizard validates first, so this only happens if that check is bypassed let store = AuthStore::in_memory().unwrap(); assert!(persist_admin_password(&store, Some("short")).is_err()); } diff --git a/src/auth/callback.rs b/src/auth/callback.rs index 1437f0c3..3bf1f150 100644 --- a/src/auth/callback.rs +++ b/src/auth/callback.rs @@ -19,7 +19,7 @@ use crate::config::Config; /// Lifetime of a callback token. /// /// Deliberately far longer than the 15-minute access-token TTL. A step may legitimately run for hours, and a credential that expired mid-run would -/// strand an agent holding completed work it cannot report — turning a security control into a reliability bug. The token is bounded by its claims instead of by the clock. +/// strand an agent holding completed work it cannot report - turning a security control into a reliability bug. The token is bounded by its claims instead of by the clock. const CALLBACK_TTL: Duration = Duration::hours(24); /// Mint a callback token for one ticket, step, and agent session. diff --git a/src/auth/egress.rs b/src/auth/egress.rs index 1a0c76ec..f9183c2d 100644 --- a/src/auth/egress.rs +++ b/src/auth/egress.rs @@ -7,7 +7,7 @@ //! credentials; pointed at an internal address, it is a port scanner with a //! bearer token. //! -//! Authentication and scopes are the first control — probing needs `execute`, +//! Authentication and scopes are the first control - probing needs `execute`, //! changing a URL needs `admin`. This module is the second: even an authorized //! caller cannot aim Operator at the loopback interface, link-local space, or //! the cloud metadata endpoint. @@ -33,7 +33,7 @@ const ALLOWED_SCHEMES: &[&str] = &["http", "https"]; pub struct EgressPolicy { /// Permit loopback destinations. /// - /// On by default because Operator's normal local workflow talks to `localhost` model servers — Ollama, LM Studio, an OpenAI-compatible proxy. + /// On by default because Operator's normal local workflow talks to `localhost` model servers - Ollama, LM Studio, an OpenAI-compatible proxy. /// It is turned **off** in a published deployment, where loopback means the container's own interfaces rather than the user's laptop. pub allow_loopback: bool, /// Permit RFC 1918 / unique-local addresses, for a self-hosted provider on the same network. @@ -133,7 +133,7 @@ pub fn check_addr(ip: IpAddr, policy: &EgressPolicy) -> Result<()> { /// A hostname is *not* resolved here. DNS resolution followed by a separate /// connection is a time-of-check/time-of-use gap (DNS rebinding), so the /// authoritative check is [`check_addr`] applied to the address actually -/// connected to — see [`validated_client`]. +/// connected to - see [`validated_client`]. pub fn check_url(url: &Url, policy: &EgressPolicy) -> Result<()> { if !ALLOWED_SCHEMES.contains(&url.scheme()) { return Err(anyhow!( @@ -144,7 +144,7 @@ pub fn check_url(url: &Url, policy: &EgressPolicy) -> Result<()> { // Match on the parsed host rather than the string. `host_str()` renders an // IPv6 literal in its bracketed form (`[::1]`), which does not parse as an - // `IpAddr` — so string-parsing silently treated every IPv6 literal as a hostname and skipped the address checks entirely. + // `IpAddr` - so string-parsing silently treated every IPv6 literal as a hostname and skipped the address checks entirely. match url.host() { Some(url::Host::Ipv4(v4)) => check_addr(IpAddr::V4(v4), policy), Some(url::Host::Ipv6(v6)) => check_addr(IpAddr::V6(v6), policy), diff --git a/src/auth/local.rs b/src/auth/local.rs index 91756b82..455f5b7f 100644 --- a/src/auth/local.rs +++ b/src/auth/local.rs @@ -1,7 +1,7 @@ //! Local auto-unlock for loopback processes. //! -//! A local `operator` run — the TUI, the CLI, and the `opr8r` client talking to -//! `127.0.0.1` — needs no login. This is **not** an authentication bypass: the +//! A local `operator` run - the TUI, the CLI, and the `opr8r` client talking to +//! `127.0.0.1` - needs no login. This is **not** an authentication bypass: the //! credential is real and is checked like any other. It is issued //! automatically to a caller who has already proven, by reading a file only its //! owner can read, that they are the user who started the process. @@ -9,8 +9,8 @@ //! The proof is file ownership rather than peer-credential inspection //! (`SO_PEERCRED` / `LOCAL_PEERCRED`). Those are Unix-socket mechanisms and //! Operator listens on TCP, where they do not apply; mode `0600` establishes -//! the same boundary — only the owning uid (and root, which can bypass any -//! check anyway) can read the token — and works identically on Windows, where +//! the same boundary - only the owning uid (and root, which can bypass any +//! check anyway) can read the token - and works identically on Windows, where //! the file inherits the user profile's ACL. //! //! Two conditions must both hold before a token is written: @@ -35,7 +35,7 @@ pub const LOCAL_TOKEN_FILENAME: &str = "local-token"; /// /// The mode is set **as the file is created**, not afterwards. A /// write-then-chmod sequence leaves a window in which the token is -/// world-readable, and — as the test suite found — it also fails outright if +/// world-readable, and - as the test suite found - it also fails outright if /// anything removes the file in between. #[cfg(unix)] fn write_owner_only(path: &Path, contents: &str) -> std::io::Result<()> { @@ -48,7 +48,7 @@ fn write_owner_only(path: &Path, contents: &str) -> std::io::Result<()> { // Two subtleties this avoids. `mode()` applies only when a file is // *created*, so writing straight to an existing token file would keep its // old permissions. And `create_new` on a fixed path fails when two - // processes start at once, which is normal here — the TUI's embedded + // processes start at once, which is normal here - the TUI's embedded // server and a separate `operator api` share a state directory. Rename is // atomic and indifferent to an existing target, so both succeed and the // last writer wins. diff --git a/src/auth/mod.rs b/src/auth/mod.rs index dc392402..98e022e7 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -2,7 +2,7 @@ //! //! Operator's HTTP surface is authenticated always: there is no configuration //! flag that turns this off. What varies is how the first credential is -//! obtained — a loopback process gets one issued automatically (see +//! obtained - a loopback process gets one issued automatically (see //! [`local`]), while any other bind must bootstrap an admin password. //! //! This module lives in the library, not the binary, because `src/rest` does diff --git a/src/auth/password.rs b/src/auth/password.rs index f7c0340b..33f12931 100644 --- a/src/auth/password.rs +++ b/src/auth/password.rs @@ -49,7 +49,7 @@ pub fn hash_password(password: &str) -> Result { /// Verify a password against a stored PHC hash. /// /// Returns `Ok(false)` for a wrong password and `Err` only when the stored hash -/// is unreadable — the caller must not treat a corrupt hash as a failed login, +/// is unreadable - the caller must not treat a corrupt hash as a failed login, /// because that would silently lock the account instead of surfacing the fault. pub fn verify_password(password: &str, phc: &str) -> Result { let parsed = diff --git a/src/auth/schema.rs b/src/auth/schema.rs index b668128d..0ed1cd65 100644 --- a/src/auth/schema.rs +++ b/src/auth/schema.rs @@ -3,7 +3,7 @@ //! There is no migration framework in this repo, so this is the smallest thing //! that works: an ordered list of migrations applied inside one transaction and //! tracked by `SQLite`'s own `user_version` pragma. Appending is the only legal -//! edit — editing a shipped migration would leave already-migrated databases +//! edit - editing a shipped migration would leave already-migrated databases //! silently inconsistent with new ones. use anyhow::{Context, Result}; @@ -11,7 +11,7 @@ use rusqlite::{Connection, TransactionBehavior}; /// Ordered schema migrations. **Append only.** const MIGRATIONS: &[&str] = &[ - // v1 — initial schema. + // v1 - initial schema. r#" -- The single admin account. `id` is pinned to 1 by CHECK, so a second -- INSERT fails on the primary key rather than creating a second admin. @@ -138,7 +138,7 @@ const MIGRATIONS: &[&str] = &[ /// Apply any migrations the database has not seen. /// -/// Two Operator processes can open the same workspace at once — the TUI runs an +/// Two Operator processes can open the same workspace at once - the TUI runs an /// embedded API server while `operator api` may already be running, and the /// test suite opens many at once. So the version check and the migration must /// be one atomic step. diff --git a/src/auth/scope.rs b/src/auth/scope.rs index 0f44d5a3..0ff76578 100644 --- a/src/auth/scope.rs +++ b/src/auth/scope.rs @@ -79,6 +79,8 @@ pub static ROUTE_RULES: &[RouteRule] = &[ public("GET", "/api/v1/auth/bootstrap"), public("POST", "/api/v1/auth/bootstrap"), public("POST", "/api/v1/auth/login"), + public("POST", "/api/v1/auth/forgot-password"), + public("POST", "/api/v1/auth/reset-password"), public("POST", "/api/v1/auth/device/code"), public("POST", "/api/v1/auth/token"), // --- Auth: authenticated session management ----------------------------- @@ -282,6 +284,8 @@ mod tests { ("GET", "/api/v1/auth/bootstrap"), ("POST", "/api/v1/auth/bootstrap"), ("POST", "/api/v1/auth/login"), + ("POST", "/api/v1/auth/forgot-password"), + ("POST", "/api/v1/auth/reset-password"), ("POST", "/api/v1/auth/device/code"), ("POST", "/api/v1/auth/token"), ] @@ -290,7 +294,7 @@ mod tests { assert_eq!( public, expected, - "the public route allowlist changed — this is a security boundary, \ + "the public route allowlist changed - this is a security boundary, \ not a routing detail. Update the threat model and docs/security/ too." ); } diff --git a/src/auth/secret.rs b/src/auth/secret.rs index c1263296..45125238 100644 --- a/src/auth/secret.rs +++ b/src/auth/secret.rs @@ -3,7 +3,7 @@ //! Two distinct jobs live here, and conflating them is a classic mistake: //! //! * **Passwords** are low-entropy and human-chosen, so they need a slow, -//! salted KDF (Argon2id — see [`super::password`]). +//! salted KDF (Argon2id - see [`super::password`]). //! * **Opaque tokens** (session cookies, refresh tokens, device codes, access //! keys) are 256-bit random values *we* generate. They need only a fast //! pre-image-resistant hash; Argon2 on a lookup path would add latency for @@ -17,7 +17,7 @@ use rand::TryRngCore; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; -/// Bytes of entropy in a generated credential. 256 bits — well beyond any +/// Bytes of entropy in a generated credential. 256 bits - well beyond any /// offline search, and the reason a fast hash suffices for storage. const SECRET_BYTES: usize = 32; diff --git a/src/auth/store.rs b/src/auth/store.rs index d08e9351..be8652f5 100644 --- a/src/auth/store.rs +++ b/src/auth/store.rs @@ -33,9 +33,9 @@ pub const ADMIN_SUBJECT: &str = "admin"; /// Browser session lifetime. const SESSION_TTL: Duration = Duration::hours(12); -/// Refresh token idle lifetime — using a token resets this. +/// Refresh token idle lifetime - using a token resets this. const REFRESH_IDLE_TTL: Duration = Duration::days(30); -/// Refresh token absolute lifetime — fixed at issuance, never extended. +/// Refresh token absolute lifetime - fixed at issuance, never extended. const REFRESH_ABSOLUTE_TTL: Duration = Duration::days(90); /// Device code lifetime in seconds. pub const DEVICE_CODE_TTL_SECS: u64 = 15 * 60; @@ -55,7 +55,7 @@ pub enum RefreshOutcome { scopes: Vec, }, /// The token was valid once but has already been redeemed. The family is - /// now revoked — see [`AuthStore::redeem_refresh_token`]. + /// now revoked - see [`AuthStore::redeem_refresh_token`]. Reused, /// No such token, or it is expired or revoked. Invalid, @@ -83,7 +83,7 @@ pub enum DevicePollOutcome { client_id: String, scopes: Vec, }, - /// Not approved yet — keep polling. + /// Not approved yet - keep polling. Pending, /// Polled faster than the advertised interval. SlowDown, @@ -255,22 +255,86 @@ impl AuthStore { /// Verify a password against the stored admin hash. pub fn verify_admin_password(&self, password: &str) -> Result { - let phc: Option = self.with_conn(|conn| { + self.verify_credentials(ADMIN_SUBJECT, password) + } + + /// Verify both parts of a human credential without treating the single + /// current username as an implicit client-side constant. + pub fn verify_credentials(&self, username: &str, password: &str) -> Result { + let account: Option<(String, String)> = self.with_conn(|conn| { Ok(conn .query_row( - "SELECT password_hash FROM admin_account WHERE id = 1", + "SELECT subject, password_hash FROM admin_account WHERE id = 1", [], - |r| r.get(0), + |r| Ok((r.get(0)?, r.get(1)?)), ) .optional()?) })?; - match phc { - Some(phc) => verify_password(password, &phc), + match account { + Some((subject, phc)) => { + let password_matches = verify_password(password, &phc)?; + Ok(crate::auth::local::matches(&subject, username) && password_matches) + } None => Ok(false), } } + /// Change a password and revoke existing credentials as one serialized + /// operation. Concurrent reset attempts cannot both authenticate against + /// the old password and race to choose the final value. + pub fn reset_password( + &self, + username: &str, + current_password: &str, + new_password: &str, + ) -> Result { + validate_password(new_password)?; + + self.with_conn(|conn| { + let account: Option<(String, String)> = conn + .query_row( + "SELECT subject, password_hash FROM admin_account WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + let Some((subject, current_phc)) = account else { + return Ok(false); + }; + let password_matches = verify_password(current_password, ¤t_phc)?; + if !crate::auth::local::matches(&subject, username) || !password_matches { + return Ok(false); + } + + let new_phc = hash_password(new_password)?; + let now = Utc::now().to_rfc3339(); + let tx = conn.transaction()?; + tx.execute( + "UPDATE admin_account SET password_hash = ?1, awaiting_reset = 0, updated_at = ?2 WHERE id = 1", + rusqlite::params![new_phc, now], + )?; + tx.execute( + "UPDATE session SET revoked_at = ?1 WHERE revoked_at IS NULL", + [&now], + )?; + tx.execute( + "UPDATE refresh_family SET revoked_at = ?1, revoked_reason = ?2 WHERE revoked_at IS NULL", + rusqlite::params![now, "password reset"], + )?; + tx.execute( + "UPDATE access_key SET revoked_at = ?1 WHERE revoked_at IS NULL", + [&now], + )?; + tx.execute( + "UPDATE device_authorization SET denied_at = ?1 WHERE approved_at IS NULL AND denied_at IS NULL", + [&now], + )?; + tx.commit()?; + Ok(true) + }) + } + /// Revoke every credential: sessions, refresh families, access keys, and /// pending device authorizations. Used by password reset and re-bootstrap. pub fn revoke_all_credentials(&self, reason: &str) -> Result<()> { @@ -528,7 +592,7 @@ impl AuthStore { /// Redeem a refresh token, rotating it. /// /// Presenting an **already-consumed** token means two parties hold the same - /// credential — the legitimate client and a thief — and there is no way to + /// credential - the legitimate client and a thief - and there is no way to /// tell which is calling. The whole family is revoked rather than guessing: /// a forced re-authentication is a far better outcome than silently serving /// an attacker. @@ -1136,6 +1200,51 @@ mod tests { assert!(!s.verify_admin_password("some other long password").unwrap()); } + #[test] + fn test_credentials_require_the_stored_username_and_password() { + let s = store(); + s.create_admin(GOOD_PASSWORD, false).unwrap(); + + assert!(s.verify_credentials(ADMIN_SUBJECT, GOOD_PASSWORD).unwrap()); + assert!(!s.verify_credentials("not-admin", GOOD_PASSWORD).unwrap()); + assert!(!s + .verify_credentials(ADMIN_SUBJECT, "some other long password") + .unwrap()); + } + + #[test] + fn test_password_reset_requires_both_credentials() { + let s = store(); + let replacement = "a replacement password"; + s.create_admin(GOOD_PASSWORD, false).unwrap(); + + assert!(!s + .reset_password("not-admin", GOOD_PASSWORD, replacement) + .unwrap()); + assert!(!s.verify_credentials(ADMIN_SUBJECT, replacement).unwrap()); + assert!(s + .reset_password(ADMIN_SUBJECT, GOOD_PASSWORD, replacement) + .unwrap()); + assert!(!s.verify_credentials(ADMIN_SUBJECT, GOOD_PASSWORD).unwrap()); + assert!(s.verify_credentials(ADMIN_SUBJECT, replacement).unwrap()); + } + + #[test] + fn test_password_reset_revokes_existing_credentials() { + let s = store(); + s.create_admin(GOOD_PASSWORD, false).unwrap(); + let (session, _, _) = s.create_session().unwrap(); + let (_, access_key) = s.create_access_key("test", &[Scope::Read], 1).unwrap(); + assert!(s.authenticate_session(&session).unwrap().is_some()); + assert!(s.redeem_access_key(&access_key).unwrap().is_some()); + + s.reset_password(ADMIN_SUBJECT, GOOD_PASSWORD, "a replacement password") + .unwrap(); + + assert!(s.authenticate_session(&session).unwrap().is_none()); + assert!(s.redeem_access_key(&access_key).unwrap().is_none()); + } + #[test] fn test_concurrent_bootstrap_yields_exactly_one_winner() { let s = store(); diff --git a/src/auth/tokens.rs b/src/auth/tokens.rs index baf7b406..785861ed 100644 --- a/src/auth/tokens.rs +++ b/src/auth/tokens.rs @@ -1,6 +1,6 @@ //! Access-token minting and verification (`EdDSA` / Ed25519). //! -//! Access tokens are deliberately **not revocable individually** — checking a +//! Access tokens are deliberately **not revocable individually** - checking a //! revocation list on every request would put a database read in the hot path. //! Their blast radius is bounded by a short expiry instead, which is why //! [`ACCESS_TOKEN_TTL`] is 15 minutes and why anything longer-lived (sessions, @@ -36,7 +36,7 @@ pub struct Claims { pub iss: String, /// Audience. pub aud: String, - /// Subject — the account the token acts as. + /// Subject - the account the token acts as. pub sub: String, /// Space-separated scopes, per OAuth convention. pub scope: String, @@ -196,7 +196,7 @@ pub fn api_claims(subject: &str, scopes: &[Scope], now: DateTime, jti: Stri /// The lifetime is the step's, not [`ACCESS_TOKEN_TTL`]: a step may legitimately /// run for hours, and a callback that expired mid-run would strand the agent /// with completed work it cannot report. What bounds this token is not time but -/// its claims — it carries only `execute`, is pinned to one ticket, step, and +/// its claims - it carries only `execute`, is pinned to one ticket, step, and /// session, and is issued for the `opr8r-callback` audience, so it is useless /// against any other route. pub fn callback_claims( diff --git a/src/collections/manifest.rs b/src/collections/manifest.rs index ec9a1e80..c89859f3 100644 --- a/src/collections/manifest.rs +++ b/src/collections/manifest.rs @@ -19,7 +19,7 @@ pub const SCHEMA_VERSION: u32 = 1; /// Provenance tier of a collection: who authored and maintains it. /// -/// Orthogonal to distribution — curated community-authored collections may +/// Orthogonal to distribution - curated community-authored collections may /// ship embedded in the binary, while community submissions under /// `collections/community/` are hosted-only. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/collections/validate.rs b/src/collections/validate.rs index aa2bde2a..d24996a7 100644 --- a/src/collections/validate.rs +++ b/src/collections/validate.rs @@ -125,7 +125,7 @@ pub fn validate_manifest(manifest: &CollectionManifest, dir_name: &str) -> Resul Ok(()) } -/// File references must be bare filenames next to the manifest — no +/// File references must be bare filenames next to the manifest - no /// separators or traversal, matching the flat hosted/embedded layout. fn validate_path(path: &str) -> Result<()> { if path.is_empty() diff --git a/src/config.rs b/src/config.rs index c88d6469..908f96e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -286,9 +286,7 @@ pub struct RestApiConfig { /// Whether the REST API is enabled #[serde(default = "default_rest_enabled")] pub enabled: bool, - /// Address the REST API binds to. Defaults to `127.0.0.1` (local only) so - /// the server — which reports the project directory name — is not reachable - /// from other hosts. Set to `0.0.0.0` to expose it on all interfaces. + /// Address the REST API binds to. Defaults to `127.0.0.1` (local only) so the server is not reachable from other hosts. Set to `0.0.0.0` to expose it on all interfaces. #[serde(default = "default_rest_host")] pub host: String, /// Port for the REST API server @@ -297,10 +295,7 @@ pub struct RestApiConfig { /// CORS allowed origins. Empty means **same-origin only** #[serde(default)] pub cors_origins: Vec, - /// Externally reachable base URL (e.g. `https://operator.example.com`). - /// - /// OAuth and MCP descriptor URLs are generated from this rather than from the request's `Host` header, - /// which a caller controls. Defaults to request host, which is correct for a loopback bind and wrong behind a reverse proxy. + /// Externally reachable base URL (e.g. `https://operator.example.com`). Defaults to request host. #[serde(default)] pub public_url: Option, } @@ -625,8 +620,6 @@ impl Default for ApiConfig { } } -// ─── Version Check Configuration ──────────────────────────────────────────── - /// Version check configuration for automatic update notifications #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -666,8 +659,6 @@ impl Default for VersionCheckConfig { } } -// ─── Relay Configuration ───────────────────────────────────────────────────── - /// Relay MCP injection configuration #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] @@ -957,8 +948,6 @@ impl Default for Config { mod tests { use super::*; - // --- Default value function tests (private functions — must stay inline) --- - #[test] fn test_default_generation_timeout_is_300() { assert_eq!(default_generation_timeout(), 300); diff --git a/src/config/agent_profile.rs b/src/config/agent_profile.rs index 4bf59a6d..0e4a809f 100644 --- a/src/config/agent_profile.rs +++ b/src/config/agent_profile.rs @@ -7,12 +7,12 @@ //! defines a namespaced interchange format both sides can serialize to and from //! *losslessly*: a shared core, an Operator-namespaced bag (`x_operator`), and an //! AGNT-namespaced bag (`x_agnt`). Each side reads the core and its own bag, and -//! preserves the other side's bag verbatim — the same lossy-but-honest discipline +//! preserves the other side's bag verbatim - the same lossy-but-honest discipline //! as the `OPERATOR-GAP` markers in [`crate::workflow_gen`]. //! //! This is the schema half of the remote-agent bridge. There is deliberately //! **no** runtime client for any remote platform: a profile carrying -//! [`AgentProfile::remote_agent`] is a *declarative* reference — surfaced in the +//! [`AgentProfile::remote_agent`] is a *declarative* reference - surfaced in the //! `--format agnt` export when its platform is AGNT, but never executed by //! Operator (see the launch guard in `delegator_resolution`). @@ -36,8 +36,7 @@ pub struct AgentProfile { pub provider: String, /// Model alias or id (maps to [`Delegator::model`]). pub model: String, - /// System prompt. Operator has no first-class system prompt, so this is - /// preserved opaquely across import (see [`Delegator::unmapped_core`]). + /// System prompt. This is preserved opaquely across import (see [`Delegator::unmapped_core`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, /// Named skills. Preserved opaquely across import. @@ -49,29 +48,22 @@ pub struct AgentProfile { /// Tool names. Preserved opaquely across import. #[serde(default)] pub tools: Vec, - /// Declarative reference to a remote, named agent (AGNT, `OpenAI`, ...). - /// `None` = a locally launchable agent, not bound to a remote platform. + /// Declarative reference to a remote, named agent. `None` = a locally launchable agent, not bound to a remote target. #[serde(default, skip_serializing_if = "Option::is_none")] pub remote_agent: Option, - /// Operator-owned extension fields (typed). `None` when the agent carries no - /// Operator-specific configuration. + /// Operator-owned extension fields (typed). `None` when the agent carries no Operator-specific configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub x_operator: Option, - /// AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, - /// `creditLimit`, ...). Operator never interprets this — pure pass-through. + /// AGNT-owned extension fields, opaque (`memory`, `assignedWorkflows`, `creditLimit`, ...). #[serde(default, skip_serializing_if = "Option::is_none")] pub x_agnt: Option, - /// OpenAI-owned extension fields, opaque (`instructions`, `tools`, - /// `tool_resources`, `metadata`, thread refs, ...). Mirror of `x_agnt` for a - /// second platform — never interpreted. This field is the whole per-tool cost - /// of adding `OpenAI`: a passthrough bag, no mapping logic. + /// OpenAI-owned extension fields, opaque (`instructions`, `tools`, `tool_resources`, `metadata`, thread refs, ...). #[serde(default, skip_serializing_if = "Option::is_none")] pub x_openai: Option, } -/// The Operator-namespaced half of an [`AgentProfile`] — the fields a Delegator -/// carries that have no shared-core equivalent. AGNT ignores this bag; Operator -/// round-trips it losslessly. +/// The Operator-namespaced half of an [`AgentProfile`] - the fields a Delegator +/// carries that have no shared-core equivalent. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, TS, utoipa::ToSchema)] #[ts(export)] pub struct XOperator { @@ -337,7 +329,7 @@ mod tests { #[test] fn openai_profile_roundtrips_with_x_openai() { - // The structural twin of the x_agnt test, for a second platform — proving + // The structural twin of the x_agnt test, for a second platform - proving // the per-tool cost is exactly one opaque bag + the generic remote ref. let p = AgentProfile { name: "openai-reviewer".to_string(), diff --git a/src/config/git_config.rs b/src/config/git_config.rs index 1e4efb03..630dd7cc 100644 --- a/src/config/git_config.rs +++ b/src/config/git_config.rs @@ -55,7 +55,7 @@ impl Default for GitConfig { } /// Git provider selection -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[ts(export)] pub enum GitProviderConfig { diff --git a/src/config/kanban.rs b/src/config/kanban.rs index 950e1b29..e39ea8f4 100644 --- a/src/config/kanban.rs +++ b/src/config/kanban.rs @@ -23,7 +23,7 @@ pub struct KanbanConfig { /// /// NOTE: This is the *kanban* GitHub integration (Projects v2), distinct /// from `GitHubConfig` which is the *git provider* used for PRs and - /// branches. The two use different env vars and different scopes — see + /// branches. The two use different env vars and different scopes - see /// `docs/getting-started/kanban/github.md` for the full disambiguation. #[serde(default)] pub github: std::collections::HashMap, @@ -104,7 +104,7 @@ impl Default for LinearConfig { /// /// The owner login (user or org) is specified as the `HashMap` key in /// `KanbanConfig.github`. Project keys inside `projects` are `GraphQL` node -/// IDs (e.g., `PVT_kwDOABcdefg`) — opaque, stable identifiers used directly +/// IDs (e.g., `PVT_kwDOABcdefg`) - opaque, stable identifiers used directly /// by every GitHub Projects v2 mutation without needing a lookup. /// /// **Distinct from `GitHubConfig`** (the git provider used for PR/branch @@ -120,7 +120,7 @@ pub struct GithubProjectsConfig { pub enabled: bool, /// Environment variable name containing the GitHub token (default: /// `OPERATOR_GITHUB_TOKEN`). The token must have `project` (or - /// `read:project`) scope, NOT just `repo` — see the disambiguation + /// `read:project`) scope, NOT just `repo` - see the disambiguation /// guide in the kanban github docs. #[serde(default = "default_github_projects_api_key_env")] pub api_key_env: String, @@ -146,7 +146,7 @@ impl Default for GithubProjectsConfig { /// `OpenSpec` provider configuration (experimental, pull-only) /// /// The instance name is the `HashMap` key in `KanbanConfig.openspec`. There -/// are no credentials — the provider reads local markdown under `root_path`. +/// are no credentials - the provider reads local markdown under `root_path`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS, Default)] #[ts(export)] pub struct OpenspecConfig { @@ -383,7 +383,7 @@ pub struct ProjectSyncConfig { impl ProjectSyncConfig { /// Statuses to pull from the external board: the mapped `todo` column - /// (queued work) plus `doing` (resume in-flight). Empty when unmapped — + /// (queued work) plus `doing` (resume in-flight). Empty when unmapped - /// providers then fall back to their default status filter. pub fn pull_statuses(&self) -> Vec { [&self.status_mapping.todo, &self.status_mapping.doing] diff --git a/src/config/llm_tools.rs b/src/config/llm_tools.rs index 00f78426..4e2eca72 100644 --- a/src/config/llm_tools.rs +++ b/src/config/llm_tools.rs @@ -130,7 +130,7 @@ pub struct SkillDirectoriesOverride { /// A declarative reference to a remote, named agent hosted by another platform. /// -/// `platform` is the hosting service (`"agnt"`, `"openai"`) — deliberately +/// `platform` is the hosting service (`"agnt"`, `"openai"`) - deliberately /// distinct from the core `provider`/`llm_tool` (the model or coding CLI). These /// agents are API/memory-native and live on the remote side; Operator has no /// runtime client for them, so a delegator carrying one is **export-only** and @@ -178,9 +178,9 @@ pub struct Delegator { /// (e.g. an AGNT agent or an `OpenAI` Assistant; see [`crate::config::AgentProfile`]). /// /// Export-only: Operator has no runtime client for those platforms, so a - /// delegator carrying this CANNOT be launched locally — resolution errors out + /// delegator carrying this CANNOT be launched locally - resolution errors out /// (see `delegator_resolution`). It is stored, listed, serialized into an - /// `AgentProfile`, and — for `platform == "agnt"` — surfaced in the + /// `AgentProfile`, and - for `platform == "agnt"` - surfaced in the /// `--format agnt` workflow export as a native AGNT `agnt-agent` node, whose /// `agentId` is this reference's `id` (AGNT identifies agents by UUID, so the /// `id` must be the agent's UUID, not its display name). `None` = ordinary, diff --git a/src/config/sessions.rs b/src/config/sessions.rs index e67b9996..7cb73906 100644 --- a/src/config/sessions.rs +++ b/src/config/sessions.rs @@ -51,7 +51,7 @@ impl SessionWrapperType { /// tmux sets `TMUX`, cmux sets `CMUX_WORKSPACE_ID`, zellij sets `ZELLIJ`, and /// VS Code's integrated terminal sets `TERM_PROGRAM=vscode`. These are the /// same env names checked by the wrapper detection in `status_panel` and - /// `agents::{cmux,zellij}` — reuse, don't invent new ones. + /// `agents::{cmux,zellij}` - reuse, don't invent new ones. pub fn is_active_context(&self) -> bool { match self { SessionWrapperType::Tmux => std::env::var("TMUX").is_ok(), diff --git a/src/config/targets.rs b/src/config/targets.rs index 281d1060..721113d7 100644 --- a/src/config/targets.rs +++ b/src/config/targets.rs @@ -1,4 +1,4 @@ -//! Named execution targets — where a launched agent process runs. +//! Named execution targets - where a launched agent process runs. //! //! `[[targets]]` entries collapse the legacy trio of environment knobs //! (`launch.docker` + `DelegatorLaunchConfig.docker`, `[[hosts]]` + @@ -118,12 +118,12 @@ pub struct SshTarget { } /// Coder workspace target: lifecycle + alias provisioning around the shared -/// SSH remote-launch path. There is no `enabled` field — presence in +/// SSH remote-launch path. There is no `enabled` field - presence in /// `[[targets]]` is the enablement. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[ts(export)] pub struct CoderConfig { - /// Coder template child workspaces are created from (an allowlist — + /// Coder template child workspaces are created from (an allowlist - /// never per-ticket input) pub template: String, /// Env var NAME holding the Coder deployment URL @@ -136,7 +136,7 @@ pub struct CoderConfig { /// Workspace name prefix for deterministic per-ticket naming #[serde(default = "default_coder_name_prefix")] pub name_prefix: String, - /// Project root inside the workspace (None = workspace $HOME) + /// Project root inside the workspace (None = /home/coder/{project}) #[serde(default, skip_serializing_if = "Option::is_none")] pub workdir: Option, /// Stop the workspace when the ticket completes (never delete) @@ -149,7 +149,7 @@ pub struct CoderConfig { /// multi-step (empty/None = reverse tunnel default) #[serde(default, skip_serializing_if = "Option::is_none")] pub callback_url: Option, - /// Passthrough `-p` template parameters for `coder create` + /// Passthrough `--parameter` template parameters for `coder create` #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] pub parameters: std::collections::HashMap, } @@ -200,7 +200,7 @@ pub fn validate_targets(config: &super::Config) -> anyhow::Result<()> { if d.enabled { tracing::warn!( target = %target.name, - "`enabled` is ignored inside a [[targets]] entry — presence is enablement" + "`enabled` is ignored inside a [[targets]] entry - presence is enablement" ); } } @@ -229,7 +229,7 @@ pub fn validate_targets(config: &super::Config) -> anyhow::Result<()> { } else if lc.docker == Some(true) && lc.host.is_some() { tracing::warn!( delegator = %delegator.name, - "launch_config sets both `docker` and `host` (deprecated); host wins — \ + "launch_config sets both `docker` and `host` (deprecated); host wins - \ migrate to `target`" ); } @@ -253,7 +253,7 @@ pub fn launchable_target_names(config: &super::Config) -> Vec { /// Env-var NAMES holding Coder session tokens across all configured coder /// targets. These are stripped from every agent's spawn environment on ALL -/// target kinds — an agent launched with a Local target inside the operator's +/// target kinds - an agent launched with a Local target inside the operator's /// own Coder workspace would otherwise read the token straight out of `env`. pub fn coder_token_envs(config: &super::Config) -> Vec { let mut names: Vec = config @@ -350,6 +350,38 @@ template = "operator-agent" } } + /// The coder-module's `run.sh` writes exactly this block. Parsing it here + /// keeps the Terraform module and `CoderConfig` from drifting apart. + #[test] + fn test_target_def_coder_toml_matches_coder_module_output() { + let toml_src = r#" +name = "coder-agents" +kind = "coder" +template = "operator-agent" +token_env = "CODER_SESSION_TOKEN" +callback_url = "https://op.example.com" +name_prefix = "op" +workdir = "/home/coder/proj" +stop_on_complete = true +create_timeout_secs = 600 +"#; + let def: TargetDef = toml::from_str(toml_src).unwrap(); + assert_eq!(def.name, "coder-agents"); + match &def.kind { + TargetKind::Coder(c) => { + assert_eq!(c.template, "operator-agent"); + assert_eq!(c.name_prefix, "op"); + assert_eq!(c.workdir.as_deref(), Some("/home/coder/proj")); + assert_eq!(c.callback_url.as_deref(), Some("https://op.example.com")); + assert_eq!(c.create_timeout_secs, 600); + assert!(c.stop_on_complete); + // Not emitted by the module: the workspace gets CODER_URL ambiently. + assert_eq!(c.url_env, "CODER_URL"); + } + other => panic!("expected coder kind, got {other:?}"), + } + } + #[test] fn test_target_def_local_toml() { let def: TargetDef = toml::from_str("name = \"here\"\nkind = \"local\"\n").unwrap(); diff --git a/src/docs_gen/collections_manifest.rs b/src/docs_gen/collections_manifest.rs index f17ef3af..a1a8d1d3 100644 --- a/src/docs_gen/collections_manifest.rs +++ b/src/docs_gen/collections_manifest.rs @@ -25,8 +25,8 @@ //! are deliberately excluded: they are presentational and never executed, and a //! malformed one must not be able to fail an install. //! -//! No workflow previews are emitted. The graph renders from `.json` — the -//! native Operator workflow that is already published and already checksummed — +//! No workflow previews are emitted. The graph renders from `.json` - the +//! native Operator workflow that is already published and already checksummed - //! so there is nothing per-workflow to pre-generate. use std::path::{Path, PathBuf}; diff --git a/src/docs_gen/collections_pages.rs b/src/docs_gen/collections_pages.rs index 0859fdf8..5a76289b 100644 --- a/src/docs_gen/collections_pages.rs +++ b/src/docs_gen/collections_pages.rs @@ -29,7 +29,7 @@ use crate::docs_gen::collections_search::{build_catalog, CatalogEntry}; const VOCABULARY: &str = r" An **Operator workflow** is a process defined once in JSON: an ordered graph of typed steps, review gates, and retry edges that an LLM agent can follow. It is -the native format — Operator runs it directly, and every +the native format - Operator runs it directly, and every [export format](/getting-started/workflows/) (Claude, AGNT) is derived from it. Three terms, three different things: @@ -37,7 +37,7 @@ Three terms, three different things: | Term | What it is | |------|-----------| | **Operator workflow** | The step graph itself. Lives in an issue type's `steps`. | -| **Issue type** | One kind of work — `FEAT`, `PRD`, `ELVSTAGE`. Carries identity, input fields, and exactly one Operator workflow. | +| **Issue type** | One kind of work - `FEAT`, `PRD`, `ELVSTAGE`. Carries identity, input fields, and exactly one Operator workflow. | | **Collection** | A named, versioned bundle of issue types: a complete, shareable way of working. This page lists them. | Collections are deliberately separate from your **kanban issue types**. Jira, @@ -50,7 +50,7 @@ the workflow travels between projects, teams, and providers unchanged. const CONTRIBUTING: &str = r#" ## Contribute a collection -There is no single best way to run agents — the right loop depends on the work. +There is no single best way to run agents - the right loop depends on the work. That is exactly why these are shareable: a workflow that works for you is worth publishing, and one that does not fit is worth forking. @@ -59,13 +59,13 @@ Official collections live in the [operator repository](https://github.com/untra/ 1. Create `collections/community//`, where `` matches `^[a-z0-9_]{3,64}$`. 2. Add a `collection.json` conforming to [the collection schema](/collections/schema.json), with `tier: "community"` plus `author`, `url`, and `license`. -3. Add one `.json` per issue type — see [the issue type schema](/schemas/issuetype/) — +3. Add one `.json` per issue type - see [the issue type schema](/schemas/issuetype/) - and an optional `.md` ticket template. 4. Add an `icon.svg` following the [Simple Icons](https://github.com/simple-icons/simple-icons) shape: a 24×24 viewBox, a single ``, and no `fill` or `stroke` so it inherits the page's color. -5. Leave checksums out — they are computed at publish time. +5. Leave checksums out - they are computed at publish time. 6. Run the CI gate locally, then open a pull request: ```bash @@ -163,7 +163,7 @@ fn card(entry: &CatalogEntry) -> String { badge = tier_badge(&entry.tier), count = issue_type_count(entry.issue_type_count), author = escape(entry.author.as_deref().unwrap_or("Operator!")), - updated = escape(entry.updated.as_deref().unwrap_or("—")), + updated = escape(entry.updated.as_deref().unwrap_or("-")), ) } @@ -186,11 +186,11 @@ fn table_row(entry: &CatalogEntry) -> String { name = escape(&entry.name), description = escape(&entry.description), count = entry.issue_type_count, - loop_kind = escape(entry.loop_kind.as_deref().unwrap_or("—")), + loop_kind = escape(entry.loop_kind.as_deref().unwrap_or("-")), author = escape(entry.author.as_deref().unwrap_or("Operator!")), badge = tier_badge(&entry.tier), - created = escape(entry.created.as_deref().unwrap_or("—")), - updated = escape(entry.updated.as_deref().unwrap_or("—")), + created = escape(entry.created.as_deref().unwrap_or("-")), + updated = escape(entry.updated.as_deref().unwrap_or("-")), ) } @@ -203,7 +203,7 @@ fn hub_page(entries: &[CatalogEntry]) -> String { out.push_str(VOCABULARY); out.push_str( - "\nEvery collection below is installable from Operator directly — they are \ + "\nEvery collection below is installable from Operator directly - they are \ published from this site as a [machine-readable index](/collections/index.json) \ that operator instances read on startup.\n\n", ); @@ -367,7 +367,7 @@ mod tests { let entries = catalog(); let page = hub_page(&entries); for entry in &entries { - // Once as a card, once as a table row — both filterable. + // Once as a card, once as a table row - both filterable. assert_eq!( page.matches(&format!("data-search=\"{}\"", escape(&entry.search_text))) .count(), @@ -430,7 +430,7 @@ mod tests { fn test_icons_are_inlined_so_they_tint_with_the_theme() { let page = hub_page(&catalog()); // An would load the SVG as its own document, where currentColor - // cannot resolve — the icon would stay black and vanish in dark mode. + // cannot resolve - the icon would stay black and vanish in dark mode. assert!( !page.contains(".json` files via //! `TemplateSchema`, so the step counts here are the real ones. @@ -212,7 +212,7 @@ pub fn icon_svg_for(id: &str) -> Option { } /// Build the full catalog: embedded collections in `EMBEDDED_COLLECTIONS` -/// order, then community collections sorted by id — matching `index.json`. +/// order, then community collections sorted by id - matching `index.json`. pub fn build_catalog() -> Result { let mut collections = Vec::new(); diff --git a/src/docs_gen/integrations.rs b/src/docs_gen/integrations.rs index 94e4ae04..41170391 100644 --- a/src/docs_gen/integrations.rs +++ b/src/docs_gen/integrations.rs @@ -1,7 +1,7 @@ //! Feature-maturity documentation generator. //! //! Emits `docs/maturity/index.md` from the vertical catalog -//! ([`crate::integrations::catalog::all_integrations`]) — a human-facing +//! ([`crate::integrations::catalog::all_integrations`]) - a human-facing //! companion to the machine-checked `tests/vertical_parity.rs`. Because it is //! derived from the same source of truth as the REST `/api/v1/integrations` //! endpoint and the README badges, the page can never drift from reality. @@ -49,7 +49,7 @@ impl DocGenerator for MaturityDocGenerator { ## Support levels\n\n", ); - // Legend — one colored badge + blurb per level, most→least mature. + // Legend - one colored badge + blurb per level, most→least mature. for status in [ SupportStatus::Ga, SupportStatus::Beta, @@ -57,7 +57,7 @@ impl DocGenerator for MaturityDocGenerator { SupportStatus::Proto, ] { content.push_str(&format!( - "- {badge} — {blurb}\n", + "- {badge} - {blurb}\n", badge = status_badge(status), blurb = status.blurb(), )); @@ -75,7 +75,7 @@ impl DocGenerator for MaturityDocGenerator { for e in rows { let docs = match e.docs_url() { Some(url) => format!("[{}]({})", e.label, url), - None => "—".to_string(), + None => "-".to_string(), }; content.push_str(&format!( "| {label} | {badge} | {docs} |\n", diff --git a/src/docs_gen/llm_tools.rs b/src/docs_gen/llm_tools.rs index ced253fa..43a4ce0f 100644 --- a/src/docs_gen/llm_tools.rs +++ b/src/docs_gen/llm_tools.rs @@ -42,7 +42,7 @@ Operator supports multiple LLM CLI tools through a plugin-like configuration sys ## Adding a New Tool To add support for a new LLM CLI tool, drop a JSON configuration file into your -user tool-config directory — no rebuild required: +user tool-config directory - no rebuild required: - Linux: `~/.config/operator/tools/.json` - macOS: `~/Library/Application Support/operator/tools/.json` @@ -67,13 +67,13 @@ user tool-config directory — no rebuild required: } ``` -Configs are loaded fresh on every startup. A user config whose `tool_name` matches a builtin (claude, gemini, codex) **fully replaces** that builtin — it +Configs are loaded fresh on every startup. A user config whose `tool_name` matches a builtin (claude, gemini, codex) **fully replaces** that builtin - it is not merged field-by-field. Malformed files are skipped with a logged warning. Runtime-loaded tools work everywhere the builtins do, including remote (SSH) launches, where the tool's presence on the remote host is verified by a `command -v` preflight. > **Security note:** `command_template` is arbitrary shell executed at launch. -> Operator only ever loads tool configs from the user-global config directory — -> never from repository-local paths — so a cloned repo cannot inject a tool +> Operator only ever loads tool configs from the user-global config directory - +> never from repository-local paths - so a cloned repo cannot inject a tool > config. New *builtin* tools (shipped with Operator) are instead added as embedded JSONs @@ -93,19 +93,19 @@ optional `detection` object overrides this: | Field | Values | Description | |-------|--------|-------------| -| `mode` | `which` (default), `always` | `always` skips the PATH lookup and uses `tool_name` verbatim as the invocation path — for tools not installed locally (e.g. run over SSH) | +| `mode` | `which` (default), `always` | `always` skips the PATH lookup and uses `tool_name` verbatim as the invocation path - for tools not installed locally (e.g. run over SSH) | | `health_command` | any command | Health check run at every startup; failure marks the tool unhealthy (`health_ok: false`) | Health is **earned, never assumed**, and re-verified on every startup: | Mode | No `health_command` | With `health_command` | |------|---------------------|-----------------------| -| `which` | Healthy — the PATH lookup proves the binary is present | Healthy if still on PATH **and** the command passes | -| `always` | **Unhealthy** — nothing is locally verifiable | Healthy if the command passes | +| `which` | Healthy - the PATH lookup proves the binary is present | Healthy if still on PATH **and** the command passes | +| `always` | **Unhealthy** - nothing is locally verifiable | Healthy if the command passes | An unhealthy tool stays listed in the detected tools (so you can see it and why), but launching a local agent with it fails until it is healthy again. Remote (SSH) -launches are unaffected — they are gated by their own `command -v` preflight on +launches are unaffected - they are gated by their own `command -v` preflight on the remote host. An `always`-mode tool should therefore define a `health_command` that proves reachability, e.g. `ssh gpu-vm command -v agy`. @@ -227,7 +227,7 @@ On every startup, Operator: Already-detected tools keep their cached `path`/`version` across restarts (no version re-probing); config-sourced fields like the command template and model aliases are re-derived from the loaded configs each startup. Health is never -carried over from a previous run — presence and the `health_command` are +carried over from a previous run - presence and the `health_command` are re-checked every startup, so an uninstalled binary or a newly failing health command demotes the tool on the next launch of Operator. diff --git a/src/docs_gen/llms.rs b/src/docs_gen/llms.rs index 9c9485f9..e78f1560 100644 --- a/src/docs_gen/llms.rs +++ b/src/docs_gen/llms.rs @@ -150,7 +150,7 @@ const SECTIONS: &[Section] = &[ Section { heading: "Optional", // `docs/architecture/index.md` is `published: false`, so Jekyll never - // builds it — listing it here produced a live link to a 404. Re-add it + // builds it - listing it here produced a live link to a 404. Re-add it // when the page is published. links: &[], extra: &[( @@ -181,7 +181,7 @@ impl DocGenerator for LlmsTxtDocGenerator { let docs_root = Path::new("docs"); let mut out = String::new(); - // Auto-gen marker (HTML comment — valid markdown, ignored by llms.txt + // Auto-gen marker (HTML comment - valid markdown, ignored by llms.txt // parsers, and not YAML front matter so Jekyll copies the file as-is). out.push_str("\n"); out.push_str("\n\n"); @@ -311,7 +311,7 @@ mod tests { let content = std::fs::read_to_string(path).expect("page reads"); assert!( !content.contains("published: false"), - "llms.txt lists '{}', but {} is `published: false` — Jekyll will not \ + "llms.txt lists '{}', but {} is `published: false` - Jekyll will not \ build it and the link will 404.", link.slug, path.display() @@ -349,7 +349,7 @@ mod tests { fn test_generate_is_spec_shaped() { let out = LlmsTxtDocGenerator.generate().unwrap(); - // No YAML front matter — must be served verbatim, not wrapped in a layout. + // No YAML front matter - must be served verbatim, not wrapped in a layout. assert!(!out.starts_with("---")); assert!(!out.contains("layout:")); diff --git a/src/editors.rs b/src/editors.rs index c526cb56..6fb6a80b 100644 --- a/src/editors.rs +++ b/src/editors.rs @@ -142,8 +142,6 @@ mod tests { fn test_partial_env_override() { with_clean_env(|| { std::env::set_var("EDITOR", "nano"); - // VISUAL not set — should get vscode default - let config = EditorConfig::detect(SessionWrapperType::Vscode); assert_eq!(config.editor, "nano"); assert_eq!(config.visual, "code --wait"); diff --git a/src/git/worktree.rs b/src/git/worktree.rs index 42fe5939..b0a083fc 100644 --- a/src/git/worktree.rs +++ b/src/git/worktree.rs @@ -21,10 +21,11 @@ static WORKTREE_CREATION_LOCKS: std::sync::LazyLock Arc> { let mut locks = WORKTREE_CREATION_LOCKS.lock().await; - locks - .entry(path.to_path_buf()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() + Arc::clone( + locks + .entry(path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) } /// Information about a created worktree diff --git a/src/integrations/catalog.rs b/src/integrations/catalog.rs index bc6394f9..a8c5efc2 100644 --- a/src/integrations/catalog.rs +++ b/src/integrations/catalog.rs @@ -1,4 +1,4 @@ -//! The vertical integration catalog — single source of truth for every +//! The vertical integration catalog - single source of truth for every //! advertised integration and its [`SupportStatus`]. //! //! Operator advertises integrations across several **verticals** (kanban @@ -12,7 +12,7 @@ //! - the `tests/vertical_parity.rs` soup-to-nuts alignment test, which also //! cross-checks that every provider-enum variant (`KanbanProviderType::ALL`, //! `ModelServerKind::ALL`, `GitProvider::ALL`, `SessionWrapperType::ALL`) has a -//! catalog entry — so a new variant can't ship without docs/badges/UI. +//! catalog entry - so a new variant can't ship without docs/badges/UI. //! //! Adding a new vertical entry here, plus its docs page (and README badge for //! `Alpha`+), is all that is required to keep the surfaces aligned. @@ -66,7 +66,7 @@ impl Vertical { } } - /// Human label — matches the bold category in the README badge list. + /// Human label - matches the bold category in the README badge list. pub fn label(&self) -> &'static str { match self { Vertical::Kanban => "Kanban Provider", @@ -83,7 +83,7 @@ impl Vertical { } /// Docs section directory (site-root-relative) that hosts this vertical's - /// entry pages — the sidebar nav item URL and the section `index.md`. + /// entry pages - the sidebar nav item URL and the section `index.md`. /// `Session` and `Editor` deliberately share one section. pub fn docs_section(&self) -> &'static str { match self { diff --git a/src/integrations/support_status.rs b/src/integrations/support_status.rs index a557e8a9..dbdfe1f5 100644 --- a/src/integrations/support_status.rs +++ b/src/integrations/support_status.rs @@ -2,7 +2,7 @@ //! //! [`SupportStatus`] is the single, low-level designation attached to every //! entry in the vertical catalog ([`crate::integrations::catalog`]). It is the -//! canonical DTO for "how supported is X" — every surface (the REST +//! canonical DTO for "how supported is X" - every surface (the REST //! `/api/v1/integrations` endpoint, the generated TypeScript bindings, the //! JSON-Schema, and the generated `docs/maturity/` page) derives its notion of //! maturity from here, so the four surfaces can't drift. @@ -34,7 +34,7 @@ use utoipa::ToSchema; #[serde(rename_all = "lowercase")] #[ts(export)] pub enum SupportStatus { - /// Experimental — wired in code with no guarantees. Not publicly advertised + /// Experimental - wired in code with no guarantees. Not publicly advertised /// (no README badge); docs optional. Proto, /// Usable, but expect breaking change. Advertised with caveats. @@ -90,7 +90,7 @@ impl SupportStatus { pub fn blurb(&self) -> &'static str { match self { SupportStatus::Proto => { - "Experimental — present in code with no guarantees. Not advertised yet." + "Experimental - present in code with no guarantees. Not advertised yet." } SupportStatus::Alpha => "Usable, but expect breaking changes. Advertised with caveats.", SupportStatus::Beta => "Stable-ish and hardening toward general availability.", diff --git a/src/lib.rs b/src/lib.rs index 285bbf40..5cdbd8b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,7 +39,7 @@ pub mod integrations; // MCP server bridge pub mod mcp; -// ACP agent bridge (Agent Client Protocol — editor-hosted sessions over stdio) +// ACP agent bridge (Agent Client Protocol - editor-hosted sessions over stdio) pub mod acp; // Re-export env_vars for potential external use diff --git a/src/llm/detection.rs b/src/llm/detection.rs index 8ea0bb49..91700e16 100644 --- a/src/llm/detection.rs +++ b/src/llm/detection.rs @@ -17,7 +17,7 @@ pub fn detect_all_tools() -> LlmToolsConfig { } /// Rebuild detection state from the currently loaded tool configs, preserving -/// user prefs. Cached entries keep their probed fields (`path`, `version` — no +/// user prefs. Cached entries keep their probed fields (`path`, `version` - no /// process spawns); config-sourced fields are re-derived so config edits and /// runtime-loaded tools take effect every startup. Tools whose config no longer /// exists are dropped; new configs are probed fresh. @@ -64,8 +64,8 @@ fn refresh_with_configs(existing: &LlmToolsConfig, configs: &[ToolConfig]) -> Ll } /// Re-derive config-sourced fields on a cached tool, keeping its probed -/// `path`/`version` (no version re-spawn). Health is always recomputed — a -/// cached `health_ok` is never trusted — so an uninstalled binary or a newly +/// `path`/`version` (no version re-spawn). Health is always recomputed - a +/// cached `health_ok` is never trusted - so an uninstalled binary or a newly /// failing health command demotes the tool on the next startup. Also repairs /// partial entries written by external detectors (e.g. the VS Code extension /// caches only name/path/version). diff --git a/src/llm/tool_config.rs b/src/llm/tool_config.rs index febed0b2..def1b1c6 100644 --- a/src/llm/tool_config.rs +++ b/src/llm/tool_config.rs @@ -1,7 +1,7 @@ //! Tool configuration loading and templating //! -//! This module loads LLM CLI tool configurations — embedded builtin JSONs plus -//! user JSONs from `/operator/tools/` — and provides template-based +//! This module loads LLM CLI tool configurations - embedded builtin JSONs plus +//! user JSONs from `/operator/tools/` - and provides template-based //! command building. User configs are only ever read from the user-global //! config dir, never from repo-local paths (see [`load_user_tool_configs`]). @@ -212,7 +212,7 @@ fn load_builtin_tool_configs() -> Vec { /// duplicate `tool_name`s resolve deterministically (last wins). Malformed or /// unreadable files are skipped with a warning. /// -/// Only the user-global config dir is ever scanned — never repo-local paths: +/// Only the user-global config dir is ever scanned - never repo-local paths: /// `command_template` is arbitrary shell executed at launch, so loading tool /// configs from a checked-out repository would be a supply-chain hazard. fn load_user_tool_configs(dir: &std::path::Path) -> Vec { diff --git a/src/main.rs b/src/main.rs index a1afe519..1b3258a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -172,7 +172,7 @@ enum Commands { #[arg(long)] model: Option, - /// Named model server reference (e.g., ollama-local) — overrides the delegator's default. + /// Named model server reference (e.g., ollama-local) - overrides the delegator's default. /// Pairs with --llm-tool/--model for ad-hoc ollama-backed launches. v1 accepts the flag /// and validates the name; env-var injection on spawn ships in v2. #[arg(long = "model-server")] @@ -381,7 +381,7 @@ async fn main() -> Result<()> { let logging_handle = logging::init_logging(&config, is_tui_mode, cli.debug)?; // Inject the status-section provider into the REST layer. The section logic - // lives in `ui` (which `rest` can't depend on — see rest::dto::sections), so + // lives in `ui` (which `rest` can't depend on - see rest::dto::sections), so // the binary registers it here, before any server starts. Covers all serving // paths (TUI app, `operator rest`, embedded UI) since they share one process. rest::dto::register_section_provider(std::sync::Arc::new(|config, registry, live| { diff --git a/src/mcp/resources.rs b/src/mcp/resources.rs index d40af138..64e9bae3 100644 --- a/src/mcp/resources.rs +++ b/src/mcp/resources.rs @@ -1,4 +1,4 @@ -//! MCP resources — exposes tickets as URI-addressable resources. +//! MCP resources - exposes tickets as URI-addressable resources. //! //! Each ticket is reachable at `operator://tickets/{status}/{id}` where status //! is one of `queue`, `in-progress`, `completed`. Resource reads return the diff --git a/src/mcp/stdio.rs b/src/mcp/stdio.rs index 6cdeb8a0..04fa0cd7 100644 --- a/src/mcp/stdio.rs +++ b/src/mcp/stdio.rs @@ -1,4 +1,4 @@ -//! Stdio transport for MCP — line-delimited JSON-RPC over stdin/stdout. +//! Stdio transport for MCP - line-delimited JSON-RPC over stdin/stdout. //! //! Each line on stdin is one JSON-RPC request. Each response is one JSON //! object written to stdout terminated by `\n`. Logs and diagnostics go to diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 8309782c..ddb21acc 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -101,7 +101,7 @@ pub fn all_tool_definitions() -> Vec { }, McpToolDefinition { name: "operator_list_tickets".to_string(), - description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links — not body content.".to_string(), + description: "List tickets in the operator queue. Filter by status: queue, in-progress, completed. Returns id, project, type, summary, priority, branch, and external links - not body content.".to_string(), input_schema: json!({ "type": "object", "properties": { diff --git a/src/mcp/transport.rs b/src/mcp/transport.rs index e1abcbab..2e49c0a4 100644 --- a/src/mcp/transport.rs +++ b/src/mcp/transport.rs @@ -6,6 +6,7 @@ //! sends responses back through the SSE stream use std::convert::Infallible; +use std::sync::Arc; use std::time::Duration; use super::Host; @@ -31,7 +32,7 @@ pub struct MessageQuery { session_id: String, } -/// SSE endpoint — opens an event stream and sends the message endpoint URL +/// SSE endpoint - opens an event stream and sends the message endpoint URL /// /// The client connects here first, receives the message endpoint URL, /// then sends JSON-RPC requests to that endpoint. @@ -68,7 +69,7 @@ pub async fn sse_handler( let message_url = format!("{base}/api/v1/mcp/message?sessionId={session_id}"); let session_id_cleanup = session_id.clone(); - let sessions_cleanup = state.mcp_sessions.clone(); + let sessions_cleanup = Arc::clone(&state.mcp_sessions); // Build SSE stream: first event is the endpoint URL, then relay messages let endpoint_event = tokio_stream::once(Ok::<_, Infallible>( @@ -94,7 +95,7 @@ pub async fn sse_handler( ) } -/// Message endpoint — receives JSON-RPC requests and sends responses via SSE +/// Message endpoint - receives JSON-RPC requests and sends responses via SSE #[utoipa::path( post, path = "/api/v1/mcp/message", diff --git a/src/notifications/service.rs b/src/notifications/service.rs index 39778f15..c43c7041 100644 --- a/src/notifications/service.rs +++ b/src/notifications/service.rs @@ -93,7 +93,7 @@ impl NotificationService { for integration in &self.integrations { if integration.is_enabled() && integration.handles_event(&event) { - let integration = integration.clone(); + let integration = Arc::clone(integration); let event = event.clone(); // Fire-and-forget - spawn task and don't await @@ -126,7 +126,7 @@ impl NotificationService { && integration.handles_event(&event) && integration.name() == "os" { - let integration = integration.clone(); + let integration = Arc::clone(integration); let event = event.clone(); // Try to get current runtime handle @@ -310,13 +310,13 @@ mod tests { name: "all".into(), enabled: true, events: vec![], // All events - send_count: count1.clone(), + send_count: Arc::clone(&count1), }), Arc::new(MockIntegration { name: "completed-only".into(), enabled: true, events: vec!["agent.completed".into()], - send_count: count2.clone(), + send_count: Arc::clone(&count2), }), ], enabled: true, @@ -348,7 +348,7 @@ mod tests { name: "disabled".into(), enabled: false, events: vec![], - send_count: count.clone(), + send_count: Arc::clone(&count), })], enabled: true, }; @@ -376,7 +376,7 @@ mod tests { name: "test".into(), enabled: true, events: vec![], - send_count: count.clone(), + send_count: Arc::clone(&count), })], enabled: false, // Globally disabled }; diff --git a/src/rest/directory.rs b/src/rest/directory.rs index 91bb90de..e407fa36 100644 --- a/src/rest/directory.rs +++ b/src/rest/directory.rs @@ -4,8 +4,8 @@ //! belongs to the *same* project before adopting it as "connected". We expose: //! //! - `directory_name`: the top-level directory name (basename of the working -//! root). This is intentionally human-readable — operator's purpose is to -//! report on the projects/repos under that directory — and is the value the +//! root). This is intentionally human-readable - operator's purpose is to +//! report on the projects/repos under that directory - and is the value the //! code-projects API also surfaces. //! - `directory_id`: a non-reversible fingerprint (first 12 hex chars of //! `SHA-256(canonical absolute path)`). Used *only* for exact same-directory diff --git a/src/rest/dto/agents.rs b/src/rest/dto/agents.rs index d46d9e02..430c823f 100644 --- a/src/rest/dto/agents.rs +++ b/src/rest/dto/agents.rs @@ -230,13 +230,13 @@ pub struct LaunchTicketRequest { /// Named delegator to use (takes precedence over provider/model) #[serde(default)] pub delegator: Option, - /// LLM provider to use (e.g., "claude") — legacy fallback when no delegator + /// LLM provider to use (e.g., "claude") - legacy fallback when no delegator #[serde(default)] pub provider: Option, - /// Model to use (e.g., "sonnet", "opus") — legacy fallback when no delegator + /// Model to use (e.g., "sonnet", "opus") - legacy fallback when no delegator #[serde(default)] pub model: Option, - /// Ad-hoc model server to target (e.g. "ollama-local") — legacy fallback when + /// Ad-hoc model server to target (e.g. "ollama-local") - legacy fallback when /// no delegator. Injects the server's base URL / API key env at spawn. #[serde(default)] pub model_server: Option, diff --git a/src/rest/dto/auth.rs b/src/rest/dto/auth.rs index 2d85f1d4..3206d274 100644 --- a/src/rest/dto/auth.rs +++ b/src/rest/dto/auth.rs @@ -4,9 +4,9 @@ //! Two rules hold across every type in this module, and the tests at the //! bottom enforce both: //! -//! 1. **A secret crosses the wire at most once, outbound.** Bootstrap and -//! login accept a password inbound; access-key and token creation return a -//! secret exactly once at creation. No other type carries one. +//! 1. **A secret crosses the wire only where authentication requires it.** +//! Bootstrap, login, and password reset accept passwords inbound; +//! access-key and token creation return a secret exactly once at creation. //! 2. **No summary type ever carries a hash.** Metadata DTOs describe a //! credential (created, expires, last used, revoked) so it can be managed //! without ever exposing the material used to authenticate with it. @@ -147,8 +147,10 @@ pub struct BootstrapSubmitRequest { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct BootstrapSubmitResponse { - /// The state after submission — `Complete` on success. + /// The state after submission - `Complete` on success. pub state: BootstrapState, + /// The account name created by bootstrap. + pub username: String, } // ============================================================================= @@ -159,13 +161,50 @@ pub struct BootstrapSubmitResponse { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct LoginRequest { + /// The account name. Required even while Operator supports one human account. + #[schema(min_length = 1, max_length = 128)] + pub username: String, /// The admin password. Never persisted in plaintext or logged. #[schema(write_only, format = Password, min_length = 12, max_length = 1024)] pub password: String, } +/// Request recovery instructions without revealing whether an account exists. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ForgotPasswordRequest { + #[schema(min_length = 1, max_length = 128)] + pub username: String, +} + +/// Generic recovery guidance for a self-hosted Operator deployment. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ForgotPasswordResponse { + pub message: String, +} + +/// Change the account password after proving knowledge of the current one. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ResetPasswordRequest { + #[schema(min_length = 1, max_length = 128)] + pub username: String, + #[schema(write_only, format = Password, min_length = 12, max_length = 1024)] + pub current_password: String, + #[schema(write_only, format = Password, min_length = 12, max_length = 1024)] + pub new_password: String, +} + +/// Result of changing the account password. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] +#[ts(export)] +pub struct ResetPasswordResponse { + pub changed: bool, +} + /// Successful login. The session itself rides in a `Set-Cookie` header, not in -/// this body — a body-borne session identifier would be readable by script. +/// this body - a body-borne session identifier would be readable by script. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct LoginResponse { @@ -190,7 +229,7 @@ pub struct LogoutResponse { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct CurrentSessionResponse { - /// Account name — always `admin`, the single human account. + /// Account name - always `admin`, the single human account. pub subject: String, /// Scopes this credential holds. pub scopes: Vec, @@ -341,7 +380,7 @@ pub struct TokenResponse { } /// Standardized OAuth error, shaped per RFC 6749 §5.2 so stock clients can -/// interpret it — notably `authorization_pending` and `slow_down`, which a +/// interpret it - notably `authorization_pending` and `slow_down`, which a /// device-flow client polls against. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -361,7 +400,7 @@ pub struct OAuthErrorResponse { #[ts(export)] #[serde(rename_all = "snake_case")] pub enum OAuthErrorCode { - /// The device code is valid but the human has not approved yet — keep polling. + /// The device code is valid but the human has not approved yet - keep polling. AuthorizationPending, /// Polling faster than `interval`; back off. SlowDown, @@ -395,14 +434,14 @@ pub struct CreateAccessKeyRequest { /// Scopes to grant. Only what the integration needs. #[schema(min_items = 1, max_items = 4)] pub scopes: Vec, - /// Days until the key expires. Expiry is mandatory — there is no + /// Days until the key expires. Expiry is mandatory - there is no /// non-expiring key. #[schema(minimum = 1, maximum = 365)] pub expires_in_days: u64, } /// A newly created access key. **The secret appears here and nowhere else, -/// ever** — only its hash is stored, so it cannot be shown again. +/// ever** - only its hash is stored, so it cannot be shown again. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct CreateAccessKeyResponse { @@ -459,7 +498,7 @@ pub struct RevokeAccessKeyResponse { // Session and device metadata // ============================================================================= -/// An active or expired browser session. Carries no session identifier — the +/// An active or expired browser session. Carries no session identifier - the /// cookie value is never readable back out, only the session's `id` for /// revocation. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] diff --git a/src/rest/dto/integrations.rs b/src/rest/dto/integrations.rs index ec6285c4..53011974 100644 --- a/src/rest/dto/integrations.rs +++ b/src/rest/dto/integrations.rs @@ -1,7 +1,7 @@ //! Vertical integration catalog DTO for `GET /api/v1/integrations`. //! -//! A thin projection of [`crate::integrations::catalog::all_integrations`] — -//! the single source of truth — exposing each advertised integration with its +//! A thin projection of [`crate::integrations::catalog::all_integrations`] - +//! the single source of truth - exposing each advertised integration with its //! [`SupportStatus`]. Consumed by the docs site and reserved for future //! entitlement control. diff --git a/src/rest/dto/kanban.rs b/src/rest/dto/kanban.rs index 05fae9ae..1bcc81de 100644 --- a/src/rest/dto/kanban.rs +++ b/src/rest/dto/kanban.rs @@ -109,7 +109,7 @@ pub enum KanbanProviderKind { /// Ephemeral Jira credentials supplied by a client during onboarding. /// /// These are never persisted to disk by the onboarding endpoints that take -/// this struct — the actual secret stays in the env var named in +/// this struct - the actual secret stays in the env var named in /// `api_key_env` once set via `/api/v1/kanban/session-env`. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -145,7 +145,7 @@ pub struct GithubCredentials { pub token: String, } -/// `OpenSpec` source location supplied during onboarding. Not a credential — +/// `OpenSpec` source location supplied during onboarding. Not a credential - /// `OpenSpec` reads local markdown; there is no secret to validate or store. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -203,7 +203,7 @@ pub struct LinearValidationDetailsDto { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct GithubProjectInfoDto { - /// `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) — used as the project key + /// `GraphQL` node ID (e.g., `PVT_kwDOABcdefg`) - used as the project key pub node_id: String, /// Project number (e.g., 42) within the owner pub number: i32, @@ -233,8 +233,8 @@ pub struct GithubValidationDetailsDto { /// Response from validating kanban credentials. /// -/// `valid: false` is returned for auth failures — never a 4xx/5xx HTTP -/// status — so clients can display `error` inline without exception handling. +/// `valid: false` is returned for auth failures - never a 4xx/5xx HTTP +/// status - so clients can display `error` inline without exception handling. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct ValidateKanbanCredentialsResponse { @@ -315,7 +315,7 @@ pub struct WriteGithubConfigBody { pub owner: String, /// Env var name where the project-scoped token is set /// (default: `OPERATOR_GITHUB_TOKEN`). MUST be distinct from `GITHUB_TOKEN` - /// — see Token Disambiguation in the kanban github docs. + /// - see Token Disambiguation in the kanban github docs. pub api_key_env: String, /// `GraphQL` project node ID (e.g., `PVT_kwDOABcdefg`) pub project_key: String, @@ -340,7 +340,7 @@ pub struct WriteOpenspecConfigBody { } /// Request to list workflow statuses/columns for a specific project using -/// ephemeral creds (onboarding wizard — before any config is persisted). +/// ephemeral creds (onboarding wizard - before any config is persisted). #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct ListKanbanStatusesRequest { @@ -367,7 +367,7 @@ pub struct ListKanbanStatusesResponse { /// Request to write or upsert a kanban config section. /// -/// This endpoint does NOT take the secret — only the env var NAME +/// This endpoint does NOT take the secret - only the env var NAME /// (`api_key_env`). The secret is set via `/api/v1/kanban/session-env`. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] @@ -394,7 +394,7 @@ pub struct WriteKanbanConfigResponse { pub section_header: String, } -/// Jira session env body — includes the actual secret to set in env. +/// Jira session env body - includes the actual secret to set in env. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct JiraSessionEnv { @@ -405,7 +405,7 @@ pub struct JiraSessionEnv { pub api_key_env: String, } -/// Linear session env body — includes the actual secret to set in env. +/// Linear session env body - includes the actual secret to set in env. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct LinearSessionEnv { @@ -414,7 +414,7 @@ pub struct LinearSessionEnv { pub api_key_env: String, } -/// GitHub Projects session env body — includes the actual secret to set in env. +/// GitHub Projects session env body - includes the actual secret to set in env. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct GithubSessionEnv { @@ -440,7 +440,7 @@ pub struct SetKanbanSessionEnvRequest { /// Response from setting session env vars. /// /// `shell_export_block` uses `` placeholders, NOT the actual -/// secret — it is meant for the user to copy into their shell profile. +/// secret - it is meant for the user to copy into their shell profile. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct SetKanbanSessionEnvResponse { @@ -578,7 +578,7 @@ mod tests { #[test] fn test_write_config_body_deserializes_without_status_mapping() { - // Older clients omit status_mapping — must default to None. + // Older clients omit status_mapping - must default to None. let json = r#"{ "domain": "acme.atlassian.net", "email": "a@b.com", "api_key_env": "OPERATOR_JIRA_API_KEY", @@ -601,7 +601,7 @@ mod tests { #[test] fn test_write_kanban_config_body_carries_env_name_not_secret() { - // The config-write path stores only the env-var NAME (`api_key_env`) — it + // The config-write path stores only the env-var NAME (`api_key_env`) - it // must never carry the raw secret. (Contrast with the *SessionEnv bodies // below, which deliberately DO carry the secret to set server env.) let body = WriteJiraConfigBody { diff --git a/src/rest/dto/sections.rs b/src/rest/dto/sections.rs index a694c27e..00f8819e 100644 --- a/src/rest/dto/sections.rs +++ b/src/rest/dto/sections.rs @@ -8,7 +8,7 @@ //! To keep that boundary while still running the real section logic, the binary //! injects a provider via [`register_section_provider`] at startup. In lib-only //! and test contexts no provider is registered, so the endpoint returns an empty -//! list — the section logic is exercised by the ui-side builder's own tests. +//! list - the section logic is exercised by the ui-side builder's own tests. use std::sync::{Arc, OnceLock}; @@ -81,7 +81,7 @@ pub struct SectionDto { /// The `/api/v1/sections` handler is, by definition, proof the API (and embedded /// Web UI) are up; it passes these runtime facts to the provider so the /// connections section reflects reality rather than the config defaults. Internal -/// provider input only — never serialized over the wire. +/// provider input only - never serialized over the wire. #[derive(Debug, Clone)] pub struct LiveConnectionStatus { /// Whether the REST API is currently serving. diff --git a/src/rest/dto/workflow.rs b/src/rest/dto/workflow.rs index f6d70c65..34138825 100644 --- a/src/rest/dto/workflow.rs +++ b/src/rest/dto/workflow.rs @@ -58,13 +58,13 @@ impl From for WorkflowPreviewResponse { /// One workflow export format operator can emit, for `GET /api/v1/workflow-formats`. /// -/// A projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry — +/// A projection of [`WorkflowFormat`] joined to its `Workflows` catalog entry - /// the single source of truth for the format's [`SupportStatus`] and docs. Lets /// the UIs render a format picker without hardcoding the list. #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, TS)] #[ts(export)] pub struct WorkflowFormatDto { - /// Stable slug (e.g. "claude", "agnt") — the value the `format` query param takes. + /// Stable slug (e.g. "claude", "agnt") - the value the `format` query param takes. pub slug: String, /// Display label (e.g. "Claude Workflow"). pub label: String, diff --git a/src/rest/middleware/auth.rs b/src/rest/middleware/auth.rs index f55ba15c..543a0bd5 100644 --- a/src/rest/middleware/auth.rs +++ b/src/rest/middleware/auth.rs @@ -1,8 +1,8 @@ //! The authorization layer. //! //! One `middleware::from_fn_with_state` layer decides every request. It runs -//! over the *composed* router — the documented API routes, the Swagger UI, and -//! the config-gated MCP transport routes — so no surface can be mounted outside +//! over the *composed* router - the documented API routes, the Swagger UI, and +//! the config-gated MCP transport routes - so no surface can be mounted outside //! its reach. //! //! The decision is: resolve a principal from the request's credentials, look up @@ -35,10 +35,8 @@ pub const CSRF_HEADER: &str = "x-operator-csrf"; /// Paths served to an unauthenticated browser so it can render the login, bootstrap, and device-approval screens. /// /// This is the whole SPA bundle, unavoidably: the dashboard uses fragment -/// routing, so `#/login` and `#/config` are indistinguishable to the server — -/// it sees one request for `/` either way. The bundle carries no workspace data -/// or credentials; everything it displays arrives over authenticated API calls. -/// See `docs/security/#the-dashboard-bundle-is-public`. +/// routing, so `#/login` and `#/config` are indistinguishable to the server. +/// The bundle carries no workspace data or credentials; everything it displays arrives over authenticated API calls. fn is_public_asset(path: &str) -> bool { !(path.starts_with("/api/") || path.starts_with("/swagger-ui") || path.starts_with("/api-docs")) } @@ -49,7 +47,7 @@ fn is_public_asset(path: &str) -> bool { /// `routes!` entry, so they never produce a `MatchedPath` and cannot be listed /// in `ROUTE_RULES`. They still need classifying: the spec enumerates every /// endpoint this server exposes, which is not something to hand out -/// anonymously — but an authenticated admin should be able to open it. +/// anonymously - but an authenticated admin should be able to open it. fn unmatched_access(path: &str) -> Option { if path.starts_with("/swagger-ui") || path.starts_with("/api-docs") { return Some(Access::Scoped(Scope::Read)); @@ -165,7 +163,7 @@ fn origin_authority(origin: &str) -> Option<&str> { /// Three cases count as acceptable, and the first is easy to forget: a browser /// sends `Origin` on a **same-origin** POST too. Checking only the configured /// CORS allowlist therefore blocked the dashboard's own mutations, since that -/// list is empty by default — and a curl test never catches it, because curl +/// list is empty by default - and a curl test never catches it, because curl /// sends no `Origin` at all. fn origin_is_acceptable(headers: &HeaderMap, allowed: &[String]) -> bool { let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { @@ -206,7 +204,7 @@ pub async fn authorize( // Prefer the route table. Fall back to path-based classification when the // table has no entry: `SwaggerUi` mounts its own wildcard route, so it does - // produce a `MatchedPath` — just not one that can appear in `ROUTE_RULES`. + // produce a `MatchedPath` - just not one that can appear in `ROUTE_RULES`. // The fallback still denies any unclassified `/api/` path. let access = matched .as_deref() @@ -479,7 +477,7 @@ mod tests { fn test_same_origin_mutation_is_accepted_with_no_configured_origins() { // Regression: the dashboard's own POSTs were rejected because browsers // send `Origin` on same-origin mutations too and the default - // `cors_origins` list is empty. curl never reproduced it — curl sends + // `cors_origins` list is empty. curl never reproduced it - curl sends // no Origin header, so the check passed there. for (origin, host) in [ ("http://127.0.0.1:7008", "127.0.0.1:7008"), diff --git a/src/rest/mod.rs b/src/rest/mod.rs index 2c5392b4..dd9ede1b 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -32,8 +32,8 @@ pub mod web_ui; /// Shim exposing the same `EmbeddedUiState` API when the SPA isn't compiled /// in. Callers can treat the two modules identically without `#[cfg]` blocks. /// -/// `Ready` and `Placeholder` are never constructed in this configuration — -/// `embedded_ui_state()` always returns `Missing` when `embed-ui` is off — +/// `Ready` and `Placeholder` are never constructed in this configuration - +/// `embedded_ui_state()` always returns `Missing` when `embed-ui` is off - /// but they must exist so call-site `match` arms remain exhaustive across /// both feature configurations. #[cfg(not(feature = "embed-ui"))] @@ -67,7 +67,7 @@ pub const DEFAULT_PORT: u16 = 7008; /// OpenAPI spec exactly like the rest. fn auth_router() -> OpenApiRouter { OpenApiRouter::new() - // Kubernetes probes — public, and deliberately metadata-free. + // Kubernetes probes - public, and deliberately metadata-free. .routes(routes!(routes::probes::livez)) .routes(routes!(routes::probes::readyz)) // Obtaining a credential. @@ -76,6 +76,8 @@ fn auth_router() -> OpenApiRouter { routes::auth::bootstrap_submit )) .routes(routes!(routes::auth::login)) + .routes(routes!(routes::auth::forgot_password)) + .routes(routes!(routes::auth::reset_password)) .routes(routes!(routes::auth::device_code)) .routes(routes!(routes::auth::token)) // Managing credentials (authenticated). @@ -95,7 +97,7 @@ fn auth_router() -> OpenApiRouter { /// Build the documented API surface as a `utoipa_axum::OpenApiRouter`. /// /// Every always-on route is mounted here via `routes!`, so mounting a route -/// *is* registering it in the OpenAPI spec — the router and the spec cannot +/// *is* registering it in the OpenAPI spec - the router and the spec cannot /// drift. Handlers sharing a path (different HTTP methods) are grouped in a /// single `routes!` call. Config-gated routes (MCP `sse`/`message`) are NOT /// documented and are added separately in [`build_router`]. @@ -194,7 +196,7 @@ fn documented_router() -> OpenApiRouter { )) .routes(routes!(routes::delegators::create_from_tool)) // AgentProfile interchange (import is a distinct static path; export is a - // static suffix on the `{name}` param path — neither collides with CRUD). + // static suffix on the `{name}` param path - neither collides with CRUD). .routes(routes!(routes::delegators::import_profile)) .routes(routes!(routes::delegators::export_profile)) .routes(routes!( @@ -221,7 +223,7 @@ fn documented_router() -> OpenApiRouter { routes::model_servers::update, routes::model_servers::delete )) - // MCP descriptor — always mounted so non-HTTP MCP clients can still + // MCP descriptor - always mounted so non-HTTP MCP clients can still // discover the stdio entrypoint. .routes(routes!(crate::mcp::descriptor::descriptor)) } @@ -232,7 +234,7 @@ fn documented_router() -> OpenApiRouter { /// Config-gated MCP transport routes remain in the contract so clients can /// discover their wire format even when a particular deployment disables them. /// -/// The `info.version` is stamped here from `CARGO_PKG_VERSION` — the compiled +/// The `info.version` is stamped here from `CARGO_PKG_VERSION` - the compiled /// release version that CI writes into `Cargo.toml`/`VERSION` on every release. /// This is the single source of version truth for *every* consumer (served /// swagger-ui, generated `docs/schemas/openapi.json`, and `ApiDoc::json/yaml`), @@ -251,7 +253,7 @@ pub fn openapi_spec() -> utoipa::openapi::OpenApi { /// browser refuses to send cookies to a wildcard origin, so the permissive /// version could not have supported an authenticated dashboard anyway. /// -/// An empty `cors_origins` means **same-origin only** — no `Access-Control-Allow-Origin` +/// An empty `cors_origins` means **same-origin only** - no `Access-Control-Allow-Origin` /// is emitted, the same-origin dashboard still works, and no other site can /// read a response. fn cors_layer(config: &crate::config::Config) -> CorsLayer { @@ -308,7 +310,7 @@ pub fn build_router(state: ApiState) -> Router { // Ordering is load-bearing: `Router::fallback` registered *after* `.layer` // is not wrapped by that layer. With the fallback added last, an unknown // `/api/...` path bypassed authorization entirely and was answered with the - // SPA shell instead of a 401 — which also meant a route mounted without a + // SPA shell instead of a 401 - which also meant a route mounted without a // `ROUTE_RULES` entry would silently serve HTML rather than fail closed. let router = router.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_spec())); diff --git a/src/rest/openapi.rs b/src/rest/openapi.rs index fd536208..862a3b2d 100644 --- a/src/rest/openapi.rs +++ b/src/rest/openapi.rs @@ -28,16 +28,17 @@ use crate::rest::dto::{ CreateTicketResponse, CsrfTokenResponse, CurrentSessionResponse, DefaultLlmResponse, DelegatorLaunchConfigDto, DelegatorResponse, DelegatorsResponse, DeviceApprovalRequest, DeviceApprovalResponse, DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceSummary, - ExternalIssueTypeSummary, FieldResponse, HealthResponse, IntegrationCatalogEntryDto, - IssueTypeResponse, IssueTypeSummary, KanbanBoardResponse, KanbanIssueTypeResponse, - KanbanProviderCatalogEntry, KanbanSyncResponse, KanbanTicketCard, LaunchTicketRequest, - LaunchTicketResponse, ListKanbanProjectsRequest, ListKanbanProjectsResponse, - ListKanbanStatusesRequest, ListKanbanStatusesResponse, LoginRequest, LoginResponse, - LogoutResponse, ModelEntry, ModelServerKindEntry, ModelServerModelsResponse, - ModelServerResponse, ModelServersResponse, NextStepInfo, OAuthErrorCode, OAuthErrorResponse, - OperatorOutput, PrincipalKind, ProjectSummary, QueueByType, QueueControlResponse, - QueueStatusResponse, RejectReviewRequest, ReviewResponse, RevokeAccessKeyResponse, Scope, - SectionDto, SectionRowDto, SessionListResponse, SessionSummary, SetDefaultLlmRequest, + ExternalIssueTypeSummary, FieldResponse, ForgotPasswordRequest, ForgotPasswordResponse, + HealthResponse, IntegrationCatalogEntryDto, IssueTypeResponse, IssueTypeSummary, + KanbanBoardResponse, KanbanIssueTypeResponse, KanbanProviderCatalogEntry, KanbanSyncResponse, + KanbanTicketCard, LaunchTicketRequest, LaunchTicketResponse, ListKanbanProjectsRequest, + ListKanbanProjectsResponse, ListKanbanStatusesRequest, ListKanbanStatusesResponse, + LoginRequest, LoginResponse, LogoutResponse, ModelEntry, ModelServerKindEntry, + ModelServerModelsResponse, ModelServerResponse, ModelServersResponse, NextStepInfo, + OAuthErrorCode, OAuthErrorResponse, OperatorOutput, PrincipalKind, ProjectSummary, QueueByType, + QueueControlResponse, QueueStatusResponse, RejectReviewRequest, ResetPasswordRequest, + ResetPasswordResponse, ReviewResponse, RevokeAccessKeyResponse, Scope, SectionDto, + SectionRowDto, SessionListResponse, SessionSummary, SetDefaultLlmRequest, SetKanbanSessionEnvRequest, SetKanbanSessionEnvResponse, SkillEntry, SkillsResponse, StatusResponse, StepCompleteRequest, StepCompleteResponse, StepResponse, SyncKanbanIssueTypesResponse, TicketDetailResponse, TokenRequest, TokenResponse, @@ -68,7 +69,7 @@ use crate::rest::error::ErrorResponse; ), // NOTE: `paths(...)` is intentionally omitted. Routes self-register in the // OpenAPI spec when mounted via `utoipa_axum::routes!` in - // `crate::rest::build_router` — mounting a route *is* documenting it, so the + // `crate::rest::build_router` - mounting a route *is* documenting it, so the // two can no longer drift. See `crate::rest::openapi_spec`. components( schemas( @@ -182,6 +183,10 @@ use crate::rest::error::ErrorResponse; LoginRequest, LoginResponse, LogoutResponse, + ForgotPasswordRequest, + ForgotPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, CurrentSessionResponse, CsrfTokenResponse, SessionSummary, @@ -498,7 +503,7 @@ impl ApiDoc { /// /// Sourced from the fully-mounted router via [`crate::rest::openapi_spec`] /// so every live route appears in the spec (the bare `ApiDoc` derive carries - /// only info/components/tags — paths self-register on mount). `openapi_spec` + /// only info/components/tags - paths self-register on mount). `openapi_spec` /// also stamps `info.version` from `CARGO_PKG_VERSION`, so it stays in sync /// with the release version and `/api/v1/health`. pub fn json() -> Result { @@ -533,7 +538,7 @@ mod tests { #[test] fn test_openapi_declares_both_security_schemes() { // The schemes are added by a `Modify` addon, which is easy to drop from - // the derive without noticing — the spec still builds, just without any + // the derive without noticing - the spec still builds, just without any // way for a client to learn how to authenticate. let spec = ApiDoc::json().expect("generate spec"); let parsed: serde_json::Value = serde_json::from_str(&spec).expect("spec is JSON"); @@ -710,6 +715,10 @@ mod tests { "BootstrapSubmitRequest", "LoginRequest", "LoginResponse", + "ForgotPasswordRequest", + "ForgotPasswordResponse", + "ResetPasswordRequest", + "ResetPasswordResponse", "CurrentSessionResponse", "DeviceAuthorizationResponse", "TokenRequest", diff --git a/src/rest/routes/agents.rs b/src/rest/routes/agents.rs index eb75618c..49d729a4 100644 --- a/src/rest/routes/agents.rs +++ b/src/rest/routes/agents.rs @@ -235,7 +235,7 @@ pub async fn reject_review( /// The web UI's launch panel calls this for **cmux** launches: cmux exposes no /// browser URL scheme, so the operator control plane (which runs inside cmux) /// shells out to `cmux focus-workspace` for the agent's saved workspace ref to -/// bring its pane to the foreground. Other wrappers are unsupported here — VS +/// bring its pane to the foreground. Other wrappers are unsupported here - VS /// Code focuses through its extension's URI handler, and tmux/zellij are /// display-only in the UI, so it never calls this for them. #[utoipa::path( diff --git a/src/rest/routes/auth.rs b/src/rest/routes/auth.rs index 55c5cb74..c658f4e7 100644 --- a/src/rest/routes/auth.rs +++ b/src/rest/routes/auth.rs @@ -19,9 +19,10 @@ use crate::rest::dto::auth::{ AccessKeyListResponse, BootstrapState, BootstrapStatusResponse, BootstrapSubmitRequest, BootstrapSubmitResponse, CreateAccessKeyRequest, CreateAccessKeyResponse, CsrfTokenResponse, CurrentSessionResponse, DeviceApprovalRequest, DeviceApprovalResponse, - DeviceAuthorizationRequest, DeviceAuthorizationResponse, LoginRequest, LoginResponse, - LogoutResponse, OAuthErrorCode, OAuthErrorResponse, RevokeAccessKeyResponse, Scope, - SessionListResponse, TokenRequest, TokenResponse, + DeviceAuthorizationRequest, DeviceAuthorizationResponse, ForgotPasswordRequest, + ForgotPasswordResponse, LoginRequest, LoginResponse, LogoutResponse, OAuthErrorCode, + OAuthErrorResponse, ResetPasswordRequest, ResetPasswordResponse, RevokeAccessKeyResponse, + Scope, SessionListResponse, TokenRequest, TokenResponse, }; use crate::rest::error::ApiError; use crate::rest::middleware::auth::{enforce_backoff, Authenticated, SESSION_COOKIE}; @@ -30,13 +31,14 @@ use crate::rest::state::ApiState; /// Rate-limit bucket names. const BUCKET_BOOTSTRAP: &str = "bootstrap"; const BUCKET_LOGIN: &str = "login"; +const BUCKET_PASSWORD_RESET: &str = "password_reset"; const BUCKET_DEVICE_CODE: &str = "device_code"; const BUCKET_TOKEN: &str = "token"; /// Env var naming a file holding the out-of-band bootstrap password. /// /// A file rather than a plain env var: an env var is visible in `/proc`, in -/// `docker inspect`, and to every child process Operator spawns — including the +/// `docker inspect`, and to every child process Operator spawns - including the /// agent processes, which is precisely the thing that must not read it. pub const BOOTSTRAP_PASSWORD_FILE_ENV: &str = "OPERATOR_BOOTSTRAP_PASSWORD_FILE"; @@ -84,6 +86,11 @@ fn valid_client_id(client_id: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) } +fn valid_username(username: &str) -> bool { + let length = username.chars().count(); + length > 0 && length <= crate::rest::dto::auth::MAX_IDENTIFIER_LENGTH +} + // ============================================================================= // Bootstrap // ============================================================================= @@ -183,6 +190,7 @@ pub async fn bootstrap_submit( Ok(Json(BootstrapSubmitResponse { state: BootstrapState::Complete, + username: ADMIN_SUBJECT.to_string(), })) } @@ -219,6 +227,7 @@ pub async fn bootstrap_submit( Ok(Json(BootstrapSubmitResponse { state: BootstrapState::Complete, + username: ADMIN_SUBJECT.to_string(), })) } } @@ -246,7 +255,7 @@ fn session_cookie(token: &str) -> String { request_body = LoginRequest, responses( (status = 200, description = "Logged in", body = LoginResponse), - (status = 401, description = "Bad password"), + (status = 401, description = "Bad username or password"), (status = 429, description = "Too many attempts"), ) )] @@ -258,20 +267,21 @@ pub async fn login( return Err(limited); } + if !valid_username(&req.username) + || req.password.chars().count() > crate::auth::password::MAX_PASSWORD_LENGTH + { + return Err(reject_login(&state, "invalid credentials").await); + } + + let username = req.username.clone(); let password = req.password.clone(); let s = store(&state); - let ok = blocking(move || s.verify_admin_password(&password)) + let ok = blocking(move || s.verify_credentials(&username, &password)) .await .map_err(IntoResponse::into_response)?; if !ok { - let s = store(&state); - let _ = blocking(move || { - s.record_failure(BUCKET_LOGIN)?; - s.audit("login", Some("bad password"), false) - }) - .await; - return Err(ApiError::Unauthorized("incorrect password".to_string()).into_response()); + return Err(reject_login(&state, "invalid credentials").await); } let s = store(&state); @@ -298,6 +308,93 @@ pub async fn login( .into_response()) } +async fn reject_login(state: &ApiState, detail: &'static str) -> Response { + let s = store(state); + let _ = blocking(move || { + s.record_failure(BUCKET_LOGIN)?; + s.audit("login", Some(detail), false) + }) + .await; + ApiError::Unauthorized("incorrect username or password".to_string()).into_response() +} + +/// Return recovery guidance without confirming whether the username exists. +#[utoipa::path( + operation_id = "auth_forgot_password", + post, + path = "/api/v1/auth/forgot-password", + tag = "Auth", + request_body = ForgotPasswordRequest, + responses((status = 200, description = "Recovery guidance", body = ForgotPasswordResponse)) +)] +pub async fn forgot_password( + Json(_req): Json, +) -> Json { + Json(ForgotPasswordResponse { + message: "If this account exists, an administrator can reset it locally with `operator auth reset-admin-password`.".to_string(), + }) +} + +/// Change the password using the current username and password. +#[utoipa::path( + operation_id = "auth_reset_password", + post, + path = "/api/v1/auth/reset-password", + tag = "Auth", + request_body = ResetPasswordRequest, + responses( + (status = 200, description = "Password changed", body = ResetPasswordResponse), + (status = 401, description = "Invalid current credentials"), + (status = 429, description = "Too many attempts"), + ) +)] +pub async fn reset_password( + State(state): State, + Json(req): Json, +) -> Result, Response> { + if let Some(limited) = enforce_backoff(&state, BUCKET_PASSWORD_RESET).await { + return Err(limited); + } + if !valid_username(&req.username) + || req.current_password.chars().count() > crate::auth::password::MAX_PASSWORD_LENGTH + { + return Err(reject_password_reset(&state).await); + } + crate::auth::password::validate_password(&req.new_password) + .map_err(|error| ApiError::ValidationError(error.to_string()).into_response())?; + + let s = store(&state); + let changed = + blocking(move || s.reset_password(&req.username, &req.current_password, &req.new_password)) + .await + .map_err(IntoResponse::into_response)?; + if !changed { + return Err(reject_password_reset(&state).await); + } + + let s = store(&state); + let _ = blocking(move || { + s.clear_rate_limit(BUCKET_PASSWORD_RESET)?; + s.audit( + "password reset", + Some("via HTTP with current credentials"), + true, + ) + }) + .await; + Ok(Json(ResetPasswordResponse { changed: true })) +} + +async fn reject_password_reset(state: &ApiState) -> Response { + let s = store(state); + let _ = blocking(move || { + s.record_failure(BUCKET_PASSWORD_RESET)?; + s.audit("password reset", Some("invalid credentials"), false) + }) + .await; + ApiError::Unauthorized("incorrect username or password".to_string()).into_response() +} + /// Log out #[utoipa::path( operation_id = "auth_logout", @@ -672,7 +769,7 @@ pub async fn token( )); }; // An access key is re-presented on each exchange, so it produces no - // refresh token — there is nothing to refresh. + // refresh token - there is nothing to refresh. (scopes, None) } }; diff --git a/src/rest/routes/delegators.rs b/src/rest/routes/delegators.rs index 1acd2d12..b809c056 100644 --- a/src/rest/routes/delegators.rs +++ b/src/rest/routes/delegators.rs @@ -570,8 +570,8 @@ mod tests { #[tokio::test] async fn import_profile_conflicts_on_existing_name() { - // The happy path calls Config::save() (a fixed global path), so — like the - // create() tests — we only exercise the pre-save conflict branch here. The + // The happy path calls Config::save() (a fixed global path), so - like the + // create() tests - we only exercise the pre-save conflict branch here. The // profile→delegator conversion (incl. x_agnt/shared-core preservation) is // covered by the unit tests in `config::agent_profile`. let mut config = Config::default(); diff --git a/src/rest/routes/integrations.rs b/src/rest/routes/integrations.rs index b5a89acd..edcc0ba8 100644 --- a/src/rest/routes/integrations.rs +++ b/src/rest/routes/integrations.rs @@ -1,7 +1,7 @@ //! Vertical integration catalog endpoint. //! -//! Serves [`crate::integrations::catalog`] — the single source of truth for -//! advertised integrations and their support status — to the docs site and any +//! Serves [`crate::integrations::catalog`] - the single source of truth for +//! advertised integrations and their support status - to the docs site and any //! future entitlement layer. Static (config-independent), so it needs no state. use axum::Json; diff --git a/src/rest/routes/issuetypes.rs b/src/rest/routes/issuetypes.rs index cc77540b..26d07e62 100644 --- a/src/rest/routes/issuetypes.rs +++ b/src/rest/routes/issuetypes.rs @@ -97,8 +97,8 @@ pub async fn get_one( /// /// Returns the issue type verbatim, in the same shape as the `.json` files /// in a hosted collection bundle (`/schemas/issuetype.json`). This is the -/// *native* Operator workflow — the ordered step graph every export format is -/// derived from — so the web UI and the docs site render identical graphs from +/// *native* Operator workflow - the ordered step graph every export format is +/// derived from - so the web UI and the docs site render identical graphs from /// identical bytes. Prefer [`get_one`] for display metadata; use this when you /// need the full step structure including step types, reject edges, and /// per-type fan-out configuration. @@ -162,7 +162,7 @@ pub async fn create( })?; // Resolve target collection (default: active) and check for a duplicate - // within it — the same key in another collection is fine. + // within it - the same key in another collection is fine. let target = { let registry = state.registry.read().await; let target = diff --git a/src/rest/routes/kanban_onboarding.rs b/src/rest/routes/kanban_onboarding.rs index 59276c7e..d37fcb20 100644 --- a/src/rest/routes/kanban_onboarding.rs +++ b/src/rest/routes/kanban_onboarding.rs @@ -1,6 +1,6 @@ //! Kanban onboarding REST endpoints. //! -//! Thin wrappers around `services::kanban_onboarding` — each handler +//! Thin wrappers around `services::kanban_onboarding` - each handler //! deserializes its DTO, delegates to the service, and serializes the //! response. Business logic lives in the service module. @@ -88,7 +88,7 @@ pub async fn list_statuses( /// PUT /`api/v1/kanban/config` /// /// Write or upsert a kanban provider+project section into `config.toml`. -/// Does NOT receive the actual secret — only the env var name (`api_key_env`). +/// Does NOT receive the actual secret - only the env var name (`api_key_env`). #[utoipa::path( put, path = "/api/v1/kanban/config", diff --git a/src/rest/routes/launch.rs b/src/rest/routes/launch.rs index 4b5baf31..e3fdcec1 100644 --- a/src/rest/routes/launch.rs +++ b/src/rest/routes/launch.rs @@ -61,7 +61,7 @@ fn handle_multi_agent_completion( .map(|o| serde_json::to_value(o).unwrap_or(serde_json::Value::Null)) .unwrap_or(serde_json::Value::Null); - // Persist the per-sub-agent file — the sync loop picks it up. + // Persist the per-sub-agent file - the sync loop picks it up. crate::steps::manager::StepManager::write_agent_step_output( ticket, step_name, @@ -84,7 +84,7 @@ fn handle_multi_agent_completion( Some("sub-agent complete".to_string()), ); - // Build a minimal response — the group aggregation/advancement happens + // Build a minimal response - the group aggregation/advancement happens // in the sync loop, not here. let (previous_summary, previous_recommendation, cumulative_files_modified, cumulative_errors) = request.output.as_ref().map_or((None, None, 0, 0), |o| { @@ -418,14 +418,14 @@ fn build_next_step_command( /// Advance the ticket file to the next step and persist the minted session id /// and agent step, so chain bookkeeping matches what will execute. Best-effort: -/// failures are logged, not fatal — opr8r already holds the command. +/// failures are logged, not fatal - opr8r already holds the command. /// /// opr8r retries the completion POST up to 3 times, and the artifact-sync /// loop (src/agents/sync.rs) can also advance the ticket independently, so a /// re-entrant call must not advance twice: re-read the ticket fresh and only /// call `advance_step()` when it is still sitting on `completed_step`. On a /// duplicate (already advanced), still record the session id for the next -/// step — that part is idempotent. +/// step - that part is idempotent. fn record_step_transition( state: &ApiState, ticket: &crate::queue::Ticket, @@ -525,7 +525,7 @@ fn set_proof_status_message( /// Run a Proof step's assertion synchronously after a successful command /// exit, recording pass/fail evidence under `.proof/{ticket}/{step}/` and /// annotating the agent's status message. The caller's status logic already -/// yields `awaiting_review` for this step either way — a human still +/// yields `awaiting_review` for this step either way - a human still /// confirms; this only attaches evidence. /// /// Worktree resolution mirrors `build_next_step_command`'s fallback: the @@ -542,7 +542,7 @@ async fn run_proof_review_hook( state, ticket, request.session_id.as_deref(), - "Proof review (no config) — awaiting review", + "Proof review (no config) - awaiting review", ); return; }; @@ -584,21 +584,21 @@ async fn run_proof_review_hook( let proof_ref = format!(".proof/{}/{}", ticket.id, step.name); if result.timed_out { format!( - "Proof FAILED (timeout, exit {}) — awaiting review ({proof_ref})", + "Proof FAILED (timeout, exit {}) - awaiting review ({proof_ref})", result.exit_code ) } else if result.passed { - format!("Proof passed — awaiting review ({proof_ref})") + format!("Proof passed - awaiting review ({proof_ref})") } else { format!( - "Proof FAILED (exit {}) — awaiting review ({proof_ref})", + "Proof FAILED (exit {}) - awaiting review ({proof_ref})", result.exit_code ) } } Err(e) => { tracing::warn!(ticket = %ticket.id, step = %step.name, error = %e, "Proof runner error"); - "Proof runner error — awaiting review".to_string() + "Proof runner error - awaiting review".to_string() } }; @@ -673,7 +673,7 @@ pub async fn complete_step( // Clone what the rest of the function needs from the registry, then drop // the read guard before any `.await`. The proof hook below runs an // assertion command synchronously (up to its configured timeout, default - // 120s) — holding `registry.read()` across that would stall every + // 120s) - holding `registry.read()` across that would stall every // `registry.write()` caller (issuetypes/collections/steps routes) for // the duration of each Proof-reviewed step completion. let current_step = current_step.clone(); @@ -692,7 +692,7 @@ pub async fn complete_step( // Proof review: run the assertion synchronously on a clean exit so its // evidence (result.json + status message) is ready before the response - // goes out. Status stays `awaiting_review` either way (below) — a human + // goes out. Status stays `awaiting_review` either way (below) - a human // still confirms. if request.exit_code == 0 && current_step.review_type == crate::templates::schema::ReviewType::Proof @@ -738,7 +738,7 @@ pub async fn complete_step( // Build next command if auto-proceeding: the same builder the launcher // uses for step one, fed by the launch context persisted with the agent. - // Never target-wrapped — exec() happens inside the already-wrapped + // Never target-wrapped - exec() happens inside the already-wrapped // environment (see step_command module docs). let next_command = if auto_proceed { match next_step_schema { @@ -1291,7 +1291,7 @@ mod tests { let api_state = make_state_with_temp(&temp_dir); let ticket = make_multi_agent_ticket(&temp_dir); - // Fresh state — no groups, no agents. + // Fresh state - no groups, no agents. let req = StepCompleteRequest { exit_code: 0, output_valid: true, diff --git a/src/rest/routes/model_servers.rs b/src/rest/routes/model_servers.rs index c436f7c8..f12bf481 100644 --- a/src/rest/routes/model_servers.rs +++ b/src/rest/routes/model_servers.rs @@ -257,7 +257,7 @@ pub async fn update( /// List the models a server offers, via a live probe of its inference endpoint. /// -/// The probe doubles as a reachability check — `reachable: false` with an `error` +/// The probe doubles as a reachability check - `reachable: false` with an `error` /// when the endpoint is unreachable or rejects the request. #[utoipa::path( operation_id = "model_servers_models", @@ -334,7 +334,7 @@ pub async fn kinds() -> Json> { /// List the models a *provider kind* offers, via a live probe. /// /// Resolves to the declared instance of that kind (if the user has one) else a -/// transient instance built from the kind's probe defaults — so the Model +/// transient instance built from the kind's probe defaults - so the Model /// Providers catalog can show connection state + live models for every supported /// provider without first declaring one. `reachable` doubles as "connected". #[utoipa::path( @@ -489,7 +489,7 @@ mod tests { #[tokio::test] async fn test_models_unreachable_endpoint_reports_error() { - // Probe a declared server pointing at a closed local port — deterministic + // Probe a declared server pointing at a closed local port - deterministic // and offline (connection refused), exercising the unreachable path // without any external network dependency. let mut config = Config::default(); @@ -538,7 +538,7 @@ mod tests { #[tokio::test] async fn test_kind_models_bring_your_own_endpoint_unreachable_from_defaults() { // `openai-compat` has no default base_url and no declared instance, so a - // kind-level probe has nowhere to connect — reachable:false, offline. + // kind-level probe has nowhere to connect - reachable:false, offline. let config = Config::default(); let state = ApiState::new(config, PathBuf::from("/tmp/test-ms-km-byo")); let resp = kind_models(State(state), Path("openai-compat".to_string())) diff --git a/src/rest/routes/probes.rs b/src/rest/routes/probes.rs index 008479b5..19a9bd8d 100644 --- a/src/rest/routes/probes.rs +++ b/src/rest/routes/probes.rs @@ -2,7 +2,7 @@ //! //! These exist as a separate, public pair precisely so `/api/v1/health` does //! not have to be. That endpoint reports the workspace directory name and a -//! directory identifier — workspace identity, which an unauthenticated probe +//! directory identifier - workspace identity, which an unauthenticated probe //! should not disclose. These two carry no metadata at all: the HTTP status is //! the entire signal. @@ -16,7 +16,7 @@ use crate::rest::state::ApiState; /// /// Answers only "is the process serving HTTP". It deliberately does not touch /// the database: a liveness failure restarts the pod, and restarting will not -/// fix a corrupt database — it would just crash-loop. +/// fix a corrupt database - it would just crash-loop. #[utoipa::path( operation_id = "livez", get, diff --git a/src/rest/routes/sections.rs b/src/rest/routes/sections.rs index 63af4849..46c482e0 100644 --- a/src/rest/routes/sections.rs +++ b/src/rest/routes/sections.rs @@ -1,4 +1,4 @@ -//! Status sections endpoint — the canonical section tree shared with the TUI +//! Status sections endpoint - the canonical section tree shared with the TUI //! and VS Code extension, rendered for the web UI's Status page. use axum::{extract::State, Json}; diff --git a/src/rest/routes/workflow.rs b/src/rest/routes/workflow.rs index 3d48d734..2aa1958e 100644 --- a/src/rest/routes/workflow.rs +++ b/src/rest/routes/workflow.rs @@ -106,7 +106,7 @@ pub async fn preview( /// List the workflow export formats operator can emit. /// /// Returns each [`WorkflowFormat`] with its label, file extension, support -/// status, and docs link — derived from `WorkflowFormat::ALL` joined to the +/// status, and docs link - derived from `WorkflowFormat::ALL` joined to the /// `Workflows` catalog vertical. Lets UIs render a format picker for the /// `format` query param accepted by export/preview. #[utoipa::path( diff --git a/src/rest/server.rs b/src/rest/server.rs index f725b23b..7e730e2d 100644 --- a/src/rest/server.rs +++ b/src/rest/server.rs @@ -82,7 +82,7 @@ pub enum RestApiStatus { } impl RestApiStatus { - /// Returns true if a usable server is available — whether this process owns + /// Returns true if a usable server is available - whether this process owns /// it (`Running`) or we adopted a compatible external one (`RunningExternal`). pub fn is_running(&self) -> bool { matches!( @@ -96,7 +96,7 @@ impl RestApiStatus { /// decide whether to adopt it as "connected" or report a clear conflict. #[derive(Debug, Clone, PartialEq)] pub enum ExternalApiProbe { - /// A same-version operator API serving the same project — safe to adopt. + /// A same-version operator API serving the same project - safe to adopt. AdoptableSameProject { port: u16, version: String }, /// An operator API of a different version is on the port. VersionMismatch { found: String }, @@ -108,7 +108,7 @@ pub enum ExternalApiProbe { Unreachable, } -/// Tolerant view of `/api/v1/health` — older or foreign servers may omit fields. +/// Tolerant view of `/api/v1/health` - older or foreign servers may omit fields. #[derive(Debug, Clone, serde::Deserialize)] struct ProbeHealth { #[serde(default)] @@ -128,7 +128,7 @@ fn classify_probe( health: &ProbeHealth, ) -> ExternalApiProbe { if health.version.is_empty() { - // Responded, but without a version — not an operator health endpoint. + // Responded, but without a version - not an operator health endpoint. ExternalApiProbe::NotOperator } else if health.version != local_version { ExternalApiProbe::VersionMismatch { @@ -268,10 +268,10 @@ impl RestApiServer { let router = build_router(state); let port = self.port; let host_ip = self.config.rest_api.host_ip(); - let status = self.status.clone(); + let status = Arc::clone(&self.status); let tickets_path = self.tickets_path.clone(); let state_path = self.config.state_path(); - let api_state_handle = self.api_state.clone(); + let api_state_handle = Arc::clone(&self.api_state); *status.lock().unwrap() = RestApiStatus::Starting; diff --git a/src/rest/web_ui.rs b/src/rest/web_ui.rs index ba32ad30..88743d27 100644 --- a/src/rest/web_ui.rs +++ b/src/rest/web_ui.rs @@ -21,7 +21,7 @@ pub const PLACEHOLDER_MARKER: &str = "operator:placeholder"; pub enum EmbeddedUiState { /// A real built SPA is embedded. Ready, - /// The build.rs placeholder is embedded — `ui/dist` wasn't built before + /// The build.rs placeholder is embedded - `ui/dist` wasn't built before /// the cargo build. Placeholder, /// No SPA assets at all (should be unreachable when this module compiles). @@ -103,7 +103,7 @@ mod tests { assert!( compressed_total < TEN_MB, - "Embedded UI assets: {compressed_total}B ({:.1}MB) gzipped — exceeds 10MB budget \ + "Embedded UI assets: {compressed_total}B ({:.1}MB) gzipped - exceeds 10MB budget \ (uncompressed: {uncompressed_total}B / {:.1}MB)", compressed_total as f64 / 1_048_576.0, uncompressed_total as f64 / 1_048_576.0, @@ -119,7 +119,7 @@ mod tests { assert!( total < FIFTEEN_MB, - "Embedded UI assets: {total}B ({:.1}MB) uncompressed — exceeds 15MB budget", + "Embedded UI assets: {total}B ({:.1}MB) uncompressed - exceeds 15MB budget", total as f64 / 1_048_576.0, ); } diff --git a/src/services/kanban_onboarding.rs b/src/services/kanban_onboarding.rs index b8c1c74e..1f2e8d79 100644 --- a/src/services/kanban_onboarding.rs +++ b/src/services/kanban_onboarding.rs @@ -317,7 +317,7 @@ pub async fn list_statuses( /// Write or upsert a kanban config section to `config.toml`. /// -/// `config_override_path` is optional — when `None`, falls back to +/// `config_override_path` is optional - when `None`, falls back to /// `Config::operator_config_path()` (which is what production uses). /// When `Some`, the config is loaded from and saved to that path instead /// (used by unit tests). @@ -326,7 +326,7 @@ pub fn write_config( req: WriteKanbanConfigRequest, config_override_path: Option<&PathBuf>, ) -> Result { - // Load existing config (from disk — not from in-memory ApiState, so that + // Load existing config (from disk - not from in-memory ApiState, so that // concurrent writes don't clobber each other). If load fails, start with // a default config. let mut config = match config_override_path { @@ -493,12 +493,12 @@ pub fn set_session_env(req: SetKanbanSessionEnvRequest) -> SetKanbanSessionEnvRe }; } } - // OpenSpec has no secrets — nothing to set; fall through to the + // OpenSpec has no secrets - nothing to set; fall through to the // empty envelope below. KanbanProviderKind::Openspec => {} } - // No body supplied for the selected provider — return empty envelope. + // No body supplied for the selected provider - return empty envelope. SetKanbanSessionEnvResponse { env_vars_set, shell_export_block: String::new(), @@ -507,7 +507,7 @@ pub fn set_session_env(req: SetKanbanSessionEnvRequest) -> SetKanbanSessionEnvRe /// Build a copy-paste-ready `export` block for Jira's env vars. /// -/// Uses placeholders — never embeds the actual token in the returned +/// Uses placeholders - never embeds the actual token in the returned /// string. pub fn build_shell_export_block_jira(api_key_env: &str) -> String { format!("export {api_key_env}=\"\"") @@ -515,7 +515,7 @@ pub fn build_shell_export_block_jira(api_key_env: &str) -> String { /// Build a copy-paste-ready `export` block for Linear's env var. /// -/// Uses placeholders — never embeds the actual token in the returned +/// Uses placeholders - never embeds the actual token in the returned /// string. pub fn build_shell_export_block_linear(api_key_env: &str) -> String { format!("export {api_key_env}=\"\"") @@ -523,7 +523,7 @@ pub fn build_shell_export_block_linear(api_key_env: &str) -> String { /// Build a copy-paste-ready `export` block for the GitHub Projects token. /// -/// Uses placeholders — never embeds the actual token in the returned string. +/// Uses placeholders - never embeds the actual token in the returned string. /// The placeholder text reminds the user this is the *projects* token, not /// the repo token used by `GITHUB_TOKEN` (Token Disambiguation rule 4). pub fn build_shell_export_block_github(api_key_env: &str) -> String { diff --git a/src/services/pr_monitor.rs b/src/services/pr_monitor.rs index abbd170a..a89f07b1 100644 --- a/src/services/pr_monitor.rs +++ b/src/services/pr_monitor.rs @@ -103,7 +103,7 @@ impl PrMonitorService { /// Get a clone of the tracked PRs map for external access pub fn tracked_prs(&self) -> Arc>> { - self.tracked_prs.clone() + Arc::clone(&self.tracked_prs) } /// Generate a key for a tracked PR diff --git a/src/startup/templates.rs b/src/startup/templates.rs index d66d3af3..098ca86c 100644 --- a/src/startup/templates.rs +++ b/src/startup/templates.rs @@ -126,7 +126,7 @@ fn write_collection_icon(dir: &Path, manifest: &CollectionManifest, icon_svg: Op /// Write a fetched (or synthesized) collection into its collection-scoped /// directory: `templates//collection.json` + `.json`/`.md`. /// -/// `files` entries are `(key, schema_json, optional template_md)` — the shape +/// `files` entries are `(key, schema_json, optional template_md)` - the shape /// hosted fetches produce. pub fn write_fetched_collection( templates_path: &Path, diff --git a/src/state.rs b/src/state.rs index d5254819..fea476a0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -824,8 +824,6 @@ impl State { .collect() } - // ─── LLM Stats Methods ──────────────────────────────────────────────────── - /// Complete an agent and record LLM usage statistics pub fn complete_agent_with_stats( &mut self, diff --git a/src/templates/schema.rs b/src/templates/schema.rs index ffec26a8..00bc5d44 100644 --- a/src/templates/schema.rs +++ b/src/templates/schema.rs @@ -567,7 +567,7 @@ pub enum SelectionStrategy { pub struct MatrixedConfig { /// Named delegator references (N), minimum 2 pub delegators: Vec, - /// Prompt variations (M) — Handlebars templates, minimum 2 + /// Prompt variations (M) - Handlebars templates, minimum 2 pub prompt_variations: Vec, /// How to organize/present the N x M output pub output_format: MatrixedOutputFormat, @@ -592,7 +592,7 @@ pub enum MatrixedOutputFormat { /// Configuration for pipeline steps: iterate a list of items through ordered /// stages with no barrier (each item flows through all stages independently). /// -/// The step graph stays linear — a pipeline step still has exactly one +/// The step graph stays linear - a pipeline step still has exactly one /// `next_step`. The fan-out (N items x M stages) lives entirely inside this one /// step; iteration is an intra-step concern, never a step-to-step edge. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)] @@ -604,7 +604,7 @@ pub struct PipelineConfig { pub stages: Vec, } -/// A single stage in a pipeline — deliberately flat (not a recursive +/// A single stage in a pipeline - deliberately flat (not a recursive /// `StepSchema`): "prompt + optional agent/model/schema" only. It has no /// `next_step`/`review_type`/`on_reject`, so a stage cannot reopen the /// step-graph linearity question. @@ -640,7 +640,7 @@ pub enum ItemSource { /// projects" mechanism. Projects, /// An array produced by a prior step. Emits that step's result identifier - /// (`r_`) — a runtime value, so the graph width is symbolic. + /// (`r_`) - a runtime value, so the graph width is symbolic. FromStep { /// Name of the prior step whose (array) output is iterated. step: String, @@ -656,9 +656,9 @@ pub enum ItemSource { /// The items to iterate. items: Vec, }, - /// A ticket field value split into a list. Resolution is deferred — there + /// A ticket field value split into a list. Resolution is deferred - there /// is no list `FieldType` and ticket field values are not captured at - /// export time yet — so this currently emits a symbolic placeholder. + /// export time yet - so this currently emits a symbolic placeholder. Field { /// Name of the ticket field to read. name: String, diff --git a/src/templates/step_type.rs b/src/templates/step_type.rs index c5421dae..fccf0c99 100644 --- a/src/templates/step_type.rs +++ b/src/templates/step_type.rs @@ -259,7 +259,7 @@ pub fn aggregate_multi_model( .collect(); // For now, voting is represented as a placeholder structure. - // Actual voting requires a Phase 2 agent round — the votes will be + // Actual voting requires a Phase 2 agent round - the votes will be // filled in by the sync loop after the voting phase completes. // Here we select the winner based on strategy from the raw outputs. let (winner_index, winner_delegator) = select_winner_by_strategy(outputs, config); diff --git a/src/types/pr.rs b/src/types/pr.rs index 81731d76..4aeb0eee 100644 --- a/src/types/pr.rs +++ b/src/types/pr.rs @@ -742,19 +742,19 @@ mod tests { #[test] fn configured_gitea_host_is_exact_and_supports_ssh_and_https() { let mut config = crate::config::GitConfig::default(); - config.gitea.host = Some("gitea.kube.untra.casa".into()); + config.gitea.host = Some("gitea.kube.untra.io".into()); let hosts = ProviderHosts::from_config(&config).unwrap(); for remote in [ - "https://GITEA.KUBE.UNTRA.CASA/team/repo.git", - "git@gitea.kube.untra.casa:team/repo.git", - "ssh://git@gitea.kube.untra.casa:2222/team/repo.git", + "https://GITEA.KUBE.UNTRA.io/team/repo.git", + "git@gitea.kube.untra.io:team/repo.git", + "ssh://git@gitea.kube.untra.io:2222/team/repo.git", ] { let repo = RepoInfo::from_remote_url_with_hosts(remote, &hosts).unwrap(); assert_eq!(repo.provider, GitProvider::Gitea); assert_eq!(repo.full_name(), "team/repo"); } assert!(GitProvider::from_remote_url_with_hosts( - "https://gitea.kube.untra.casa.evil/team/repo", + "https://gitea.kube.untra.io.evil/team/repo", &hosts ) .is_none()); diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index ebda48f3..4640faa4 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -91,10 +91,10 @@ impl Dashboard { /// Determine the best panel to focus on startup. /// /// Priority: - /// 1. Status panel — if any section needs attention (Yellow/Red), focus there + /// 1. Status panel - if any section needs attention (Yellow/Red), focus there /// and select the first section that needs attention - /// 2. In Progress — if there are active agents - /// 3. Queue — default fallback + /// 2. In Progress - if there are active agents + /// 3. Queue - default fallback pub fn compute_initial_focus(&mut self) { let snapshot = self.build_status_snapshot(); if self.status_panel.has_attention_needed(&snapshot) { @@ -320,7 +320,7 @@ impl Dashboard { self.focused == FocusedPanel::Completed, ); - // Status bar — show dynamic hints when status panel is focused + // Status bar - show dynamic hints when status panel is focused let row_hints = if self.focused == FocusedPanel::Status { let snapshot = self.build_status_snapshot(); self.status_panel.current_row_hints(&snapshot) diff --git a/src/ui/dialogs/confirm.rs b/src/ui/dialogs/confirm.rs index 60e814bf..f4018323 100644 --- a/src/ui/dialogs/confirm.rs +++ b/src/ui/dialogs/confirm.rs @@ -961,7 +961,7 @@ mod tests { #[test] fn test_focus_options_noop_when_no_options() { let mut dialog = ConfirmDialog::new(); - // No options configured — has_options() is false + // No options configured - has_options() is false dialog.focus_options(); assert!(matches!(dialog.focus, ConfirmDialogFocus::Buttons)); } diff --git a/src/ui/dialogs/kanban_onboarding.rs b/src/ui/dialogs/kanban_onboarding.rs index 8e2ec4bc..d1188881 100644 --- a/src/ui/dialogs/kanban_onboarding.rs +++ b/src/ui/dialogs/kanban_onboarding.rs @@ -4,8 +4,7 @@ //! project → write config + set session env + sync issue types → show //! shell export nudge. All async work (`validate_credentials` / //! `list_projects` / `write_config` / sync) is dispatched by the `App` -//! event loop calling `services::kanban_onboarding` directly — this -//! dialog is purely UI state + rendering + key handling. +//! event loop calling `services::kanban_onboarding` directly. use crossterm::event::KeyCode; use ratatui::{ @@ -28,7 +27,7 @@ pub enum KanbanOnboardingProvider { /// Multi-state wizard state machine. #[derive(Debug, Clone, PartialEq, Eq)] pub enum KanbanOnboardingState { - /// Initial state — pick Jira or Linear. + /// Initial state - pick Kanban Provider. PickProvider, /// Collecting Jira domain. JiraDomain, @@ -46,7 +45,7 @@ pub enum KanbanOnboardingState { Writing, /// Showing the shell export nudge after success. EnvExportNudge, - /// Inline error — user can press Enter to retry from the relevant input. + /// Inline error - user can press Enter to retry from the relevant input. Error, } @@ -56,19 +55,19 @@ pub enum KanbanOnboardingState { pub enum KanbanOnboardingAction { /// No state-machine transition; just a focus/cursor move. None, - /// User picked a provider — App should advance to the first input step. + /// User picked a provider - App should advance to the first input step. PickedProvider(KanbanOnboardingProvider), - /// User submitted full Jira credentials — App should call + /// User submitted full Jira credentials - App should call /// `services::kanban_onboarding::validate_credentials`. SubmitJiraCreds { domain: String, email: String, token: String, }, - /// User submitted Linear API key — App should call + /// User submitted Linear API key - App should call /// `services::kanban_onboarding::validate_credentials`. SubmitLinearCreds { api_key: String }, - /// User picked a project — App should call `write_config` + + /// User picked a project - App should call `write_config` + /// `set_session_env` + `sync_issue_types`. PickedProject { provider: KanbanOnboardingProvider, @@ -103,14 +102,14 @@ pub struct KanbanOnboardingDialog { /// Picker selection on the `PickProvider` step. provider_index: usize, - // Input buffers (separate per field — we don't share across steps) + // Input buffers (separate per field - we don't share across steps) domain_buf: String, email_buf: String, token_buf: String, api_key_buf: String, cursor_position: usize, - // Validation results — populated by App after validate_credentials + // Validation results - populated by App after validate_credentials pub jira_account_id: String, pub jira_display_name: String, pub linear_user_id: String, @@ -434,7 +433,7 @@ impl KanbanOnboardingDialog { KanbanOnboardingAction::None } KanbanOnboardingState::JiraToken => { - // Submit creds — App will dispatch validate + // Submit creds - App will dispatch validate self.state = KanbanOnboardingState::Validating; KanbanOnboardingAction::SubmitJiraCreds { domain: self.domain_buf.clone(), @@ -771,7 +770,7 @@ impl KanbanOnboardingDialog { .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ), - Span::raw(" — "), + Span::raw(" - "), Span::styled(p.name.clone(), Style::default().fg(Color::White)), ])) }) diff --git a/src/ui/in_progress_panel.rs b/src/ui/in_progress_panel.rs index c8bacdfe..e6faf799 100644 --- a/src/ui/in_progress_panel.rs +++ b/src/ui/in_progress_panel.rs @@ -68,7 +68,7 @@ impl InProgressPanel { Some("pending_visual") => ("\u{1f441}", Color::Magenta), // 👁 Visual review Some("pending_proof") => ( "\u{1f52c}", // 🔬 Proof review - // last_message is prefixed "Proof passed —" / "Proof FAILED (...) —" by sync/launch + // last_message is prefixed "Proof passed -" / "Proof FAILED (...) -" by sync/launch if a.last_message .as_deref() .is_some_and(|m| m.starts_with("Proof FAILED")) diff --git a/src/ui/masked_input.rs b/src/ui/masked_input.rs index 4eb2648c..1c7c5f24 100644 --- a/src/ui/masked_input.rs +++ b/src/ui/masked_input.rs @@ -5,8 +5,8 @@ //! rather than hand-roll a third `String` + cursor pair. //! //! The cursor is a **character** index, not a byte index. The original code -//! mixed the two — incrementing the cursor per character while indexing the -//! `String` by byte — so any multi-byte character panicked on the next edit. +//! mixed the two - incrementing the cursor per character while indexing the +//! `String` by byte - so any multi-byte character panicked on the next edit. //! Passwords are exactly where someone types an accented character or an emoji, //! and `validate_password` counts characters too //! (`crate::auth::password::validate_password`), so characters are the unit @@ -43,7 +43,7 @@ impl MaskedInput { self.value.is_empty() } - /// Length in characters — what the cursor and any length rule count in. + /// Length in characters - what the cursor and any length rule count in. pub fn char_count(&self) -> usize { self.value.chars().count() } @@ -280,7 +280,7 @@ mod tests { #[test] fn test_char_count_counts_characters_not_bytes() { - // A password rule counts characters, so the mask length must too — + // A password rule counts characters, so the mask length must too - // otherwise "éé" would render four bullets for two typed characters. let input = typed("éé🔐"); assert_eq!(input.char_count(), 3); diff --git a/src/ui/panels.rs b/src/ui/panels.rs index 56a60c7d..be8024ab 100644 --- a/src/ui/panels.rs +++ b/src/ui/panels.rs @@ -333,7 +333,7 @@ fn web_indicator(status: &RestApiStatus, embed_ui: bool) -> Span<'static> { format!(" {label} ●:{port}"), Style::default().fg(Color::Green), ), - // Adopted an external operator API — still usable, marked distinctly. + // Adopted an external operator API - still usable, marked distinctly. RestApiStatus::RunningExternal { port } => Span::styled( format!(" {label} ●:{port} ↗"), Style::default().fg(Color::Green), diff --git a/src/ui/sections/connections_section.rs b/src/ui/sections/connections_section.rs index 19a9723e..fcc93760 100644 --- a/src/ui/sections/connections_section.rs +++ b/src/ui/sections/connections_section.rs @@ -7,9 +7,8 @@ use crate::ui::status_panel::{ pub struct ConnectionsSection; impl ConnectionsSection { - /// Whether the operator advertises at least one agent protocol — MCP (HTTP - /// mounted or stdio) or ACP (stdio). Drives the section header health so it - /// reflects the connectivity rows the section actually shows, rather than a + /// Whether the operator advertises at least one agent protocol - MCP (HTTP mounted or stdio) or ACP (stdio). + /// Drives the section header health so it reflects the connectivity rows the section actually shows, rather than a /// session-wrapper check that isn't attached in the web context. fn any_protocol_exposed(&self, snapshot: &StatusSnapshot) -> bool { let mcp = matches!(snapshot.mcp_http_status, McpHttpStatus::Mounted { .. }) @@ -76,11 +75,9 @@ impl StatusSection for ConnectionsSection { actions: ActionSet::none(), health: SectionHealth::Gray, }, - // 1. Control wrapper — which session wrapper the operator control - // plane is running inside, and therefore how launched tickets are - // coordinated (VS Code terminal / cmux window / tmux / zellij tab). - // Mirrors the VS Code extension's "Session Wrapper" row. Informational - // only: does not drive the section header health. + // 1. Control wrapper - which session wrapper the operator control plane is running inside, and therefore how launched + // tickets are coordinated (VS Code terminal / cmux window / tmux / zellij tab). + // Mirrors the VS Code extension's "Session Wrapper" row. Informational only: does not drive the section header health. TreeRow { section_id: SectionId::Connections, id: "control-wrapper".into(), @@ -348,7 +345,7 @@ mod tests { #[test] fn test_connections_health_ignores_wrapper() { // A disconnected session-wrapper must NOT downgrade the section when the - // API and at least one protocol are up — health follows the shown rows. + // API and at least one protocol are up - health follows the shown rows. let section = ConnectionsSection; let mut snap = base_snapshot(); snap.wrapper_connection_status = WrapperConnectionStatus::Tmux { diff --git a/src/ui/sections/issuetype_section.rs b/src/ui/sections/issuetype_section.rs index f122ef92..72f5af0a 100644 --- a/src/ui/sections/issuetype_section.rs +++ b/src/ui/sections/issuetype_section.rs @@ -2,7 +2,7 @@ use crate::ui::status_panel::{ ActionSet, SectionHealth, SectionId, StatusIcon, StatusSection, StatusSnapshot, TreeRow, }; -/// Issue Types section — mirrors the VS Code extension's `IssueTypeSection`. +/// Issue Types section - mirrors the VS Code extension's `IssueTypeSection`. /// Visible once Kanban is configured; lists the active issue types. pub struct IssueTypeSection; diff --git a/src/ui/sections/llm_section.rs b/src/ui/sections/llm_section.rs index 9d64ac00..94397fcb 100644 --- a/src/ui/sections/llm_section.rs +++ b/src/ui/sections/llm_section.rs @@ -70,7 +70,7 @@ impl StatusSection for LlmSection { health: SectionHealth::Gray, }); - // Depth 2: model aliases — selecting sets as default + // Depth 2: model aliases - selecting sets as default for model in &tool.model_aliases { let is_default = snapshot.default_llm_tool.as_deref() == Some(&tool.name) && snapshot.default_llm_model.as_deref() == Some(model.as_str()); diff --git a/src/ui/sections/managed_projects_section.rs b/src/ui/sections/managed_projects_section.rs index 49475938..637645d0 100644 --- a/src/ui/sections/managed_projects_section.rs +++ b/src/ui/sections/managed_projects_section.rs @@ -2,7 +2,7 @@ use crate::ui::status_panel::{ ActionSet, SectionHealth, SectionId, StatusIcon, StatusSection, StatusSnapshot, TreeRow, }; -/// Managed Projects section — mirrors the VS Code extension's `ManagedProjectsSection`. +/// Managed Projects section - mirrors the VS Code extension's `ManagedProjectsSection`. /// Visible once Git is configured; lists the projects operator can assign work to. pub struct ManagedProjectsSection; diff --git a/src/ui/sections/modelserver_section.rs b/src/ui/sections/modelserver_section.rs index f35faf52..89bb90e8 100644 --- a/src/ui/sections/modelserver_section.rs +++ b/src/ui/sections/modelserver_section.rs @@ -279,7 +279,7 @@ mod tests { let rows = ModelServerSection.children(&snapshot); let add_rows: Vec<&TreeRow> = rows.iter().filter(|r| r.id.starts_with("add-")).collect(); - // ollama, openrouter, openai-compat, lmstudio — the addable kinds. + // ollama, openrouter, openai-compat, lmstudio - the addable kinds. assert_eq!(add_rows.len(), 4); assert!(add_rows .iter() diff --git a/src/ui/sections/workflows_section.rs b/src/ui/sections/workflows_section.rs index f329e56d..90bfafaf 100644 --- a/src/ui/sections/workflows_section.rs +++ b/src/ui/sections/workflows_section.rs @@ -1,7 +1,7 @@ //! The **Workflows** status section: the export formats a ticket + issuetype can //! be rendered into (Claude dynamic workflow `.js`, AGNT graph `.json`). //! -//! Info-only — formats are always available (no credentials), so the section is +//! Info-only - formats are always available (no credentials), so the section is //! `Gray`. Each row names a format, its support status + file extension, and //! links to its docs. The primary action opens the web UI's Workflows page where //! per-issuetype preview / per-ticket export run against the existing endpoints. diff --git a/src/ui/setup/mod.rs b/src/ui/setup/mod.rs index f139ea0e..6bb6b7a5 100644 --- a/src/ui/setup/mod.rs +++ b/src/ui/setup/mod.rs @@ -500,7 +500,7 @@ impl SetupScreen { /// Validate the password fields. /// - /// `Ok(None)` means the step was skipped — both fields empty. The step is + /// `Ok(None)` means the step was skipped - both fields empty. The step is /// optional, so an empty pair is a deliberate choice, not an error. /// `Err(message)` is shown inline and keeps the wizard on this step. fn validate_admin_password(&self) -> Result, String> { @@ -830,7 +830,7 @@ impl SetupScreen { return; } - match self.step.clone() { + match self.step { SetupStep::Welcome => self.render_welcome_step(frame), SetupStep::CollectionSource => self.render_collection_source_step(frame), SetupStep::HostedCollectionFetch => self.render_hosted_collection_step(frame), diff --git a/src/ui/setup/steps/admin_password.rs b/src/ui/setup/steps/admin_password.rs index aba13aa3..1caad1e8 100644 --- a/src/ui/setup/steps/admin_password.rs +++ b/src/ui/setup/steps/admin_password.rs @@ -1,6 +1,6 @@ //! Optional admin-password step. //! -//! Local use needs no password — the TUI, the CLI, and `opr8r` authenticate +//! Local use needs no password - the TUI, the CLI, and `opr8r` authenticate //! with the owner-only local token file. A browser cannot read that file, so //! this step exists solely to unlock the web dashboard, which is why it is //! skippable and why the copy says so. diff --git a/src/ui/setup/steps/hosted.rs b/src/ui/setup/steps/hosted.rs index 2ede3bc6..de7a28b8 100644 --- a/src/ui/setup/steps/hosted.rs +++ b/src/ui/setup/steps/hosted.rs @@ -122,7 +122,7 @@ impl SetupScreen { if author.is_empty() { String::new() } else { - format!(" — by {author}") + format!(" - by {author}") }, Style::default().fg(Color::DarkGray), ), @@ -145,7 +145,7 @@ impl SetupScreen { if let Some(hints) = &r.manifest.workflow_hints { let mut hint_spans = vec![Span::styled("Loop: ", Style::default().fg(Color::Cyan))]; hint_spans.push(Span::raw( - hints.loop_kind.clone().unwrap_or_else(|| "—".to_string()), + hints.loop_kind.clone().unwrap_or_else(|| "-".to_string()), )); if !hints.review_gates.is_empty() { hint_spans.push(Span::styled(" Gates: ", Style::default().fg(Color::Cyan))); diff --git a/src/ui/setup/tests.rs b/src/ui/setup/tests.rs index f1e4fb19..d05aa981 100644 --- a/src/ui/setup/tests.rs +++ b/src/ui/setup/tests.rs @@ -541,7 +541,7 @@ fn test_admin_password_mismatch_shows_error_and_stays() { #[test] fn test_admin_password_mismatch_is_reported_before_length() { - // A mismatched pair that is also too short should say "do not match" — + // A mismatched pair that is also too short should say "do not match" - // telling someone their password is too short when they simply mistyped the // confirmation sends them to fix the wrong thing. let mut screen = at_admin_password(); diff --git a/src/ui/setup/types.rs b/src/ui/setup/types.rs index ffe3ecba..b43cc1bd 100644 --- a/src/ui/setup/types.rs +++ b/src/ui/setup/types.rs @@ -325,7 +325,7 @@ impl WorktreeOption { } /// Steps in the setup process -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SetupStep { /// Welcome splash screen with discovered projects Welcome, @@ -370,7 +370,7 @@ pub enum PasswordField { } impl PasswordField { - /// The other field — Tab toggles between exactly two. + /// The other field - Tab toggles between exactly two. pub fn toggled(self) -> Self { match self { PasswordField::Password => PasswordField::Confirm, diff --git a/src/ui/status_panel.rs b/src/ui/status_panel.rs index 7d80d895..940514f2 100644 --- a/src/ui/status_panel.rs +++ b/src/ui/status_panel.rs @@ -52,7 +52,7 @@ pub enum SectionId { Workflows, } -/// Health state of a section — controls the header color. +/// Health state of a section - controls the header color. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] pub enum SectionHealth { @@ -105,7 +105,7 @@ impl SectionId { } } -/// Declarative section metadata — shared between TUI and `VSCode`. +/// Declarative section metadata - shared between TUI and `VSCode`. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export)] #[allow(dead_code)] @@ -189,7 +189,7 @@ pub struct TreeRow { pub icon: StatusIcon, /// Optional vendor-brand basename (e.g. "ollama") for surfaces that render /// logos (the web UI). The TUI ignores this and renders [`icon`](Self::icon) - /// as a semantic ANSI glyph — brand logos can't be drawn in a terminal. + /// as a semantic ANSI glyph - brand logos can't be drawn in a terminal. pub brand_icon: Option, pub is_header: bool, pub actions: ActionSet, @@ -328,20 +328,20 @@ pub enum McpHttpStatus { NotMounted, } -/// Which button was pressed — maps to ABXY gamepad layout. +/// Which button was pressed - maps to ABXY gamepad layout. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ActionButton { - /// A (Enter) — primary/affirm/activate + /// A (Enter) - primary/affirm/activate A, - /// B (Esc/Backspace) — go back, collapse parent + /// B (Esc/Backspace) - go back, collapse parent B, - /// X (Shift+Enter) — special/tertiary action + /// X (Shift+Enter) - special/tertiary action X, - /// Y (Ctrl+Enter) — contextual refresh/update + /// Y (Ctrl+Enter) - contextual refresh/update Y, } -/// Display metadata for an action — short title for TUI and title+tooltip for `VSCode`. +/// Display metadata for an action - short title for TUI and title+tooltip for `VSCode`. #[derive(Debug, Clone)] pub struct ActionMeta { /// Short label (max 6 chars) shown right-aligned on the selected row in TUI, @@ -355,15 +355,15 @@ pub struct ActionMeta { /// Four action slots mapped to ABXY gamepad buttons. #[derive(Debug, Clone)] pub struct ActionSet { - /// A (Enter) — primary/affirm/activate + /// A (Enter) - primary/affirm/activate pub primary: StatusAction, - /// B (Esc) — go back, collapse parent + /// B (Esc) - go back, collapse parent pub back: StatusAction, - /// X (Shift+Enter) — special/tertiary + /// X (Shift+Enter) - special/tertiary pub special: StatusAction, /// Display metadata for the special action (shown in TUI and `VSCode`). pub special_meta: Option, - /// Y (Ctrl+Enter) — contextual refresh + /// Y (Ctrl+Enter) - contextual refresh pub refresh: StatusAction, /// Display metadata for the refresh action. pub refresh_meta: Option, @@ -658,8 +658,8 @@ impl StatusSnapshot { /// Build a snapshot from config alone, with default (non-live) runtime fields. /// - /// Shared by the TUI dashboard — which overrides the runtime fields - /// (`api_status`, wrapper/mcp/acp liveness, editor env) with live state — + /// Shared by the TUI dashboard - which overrides the runtime fields + /// (`api_status`, wrapper/mcp/acp liveness, editor env) with live state - /// and the REST `/api/v1/sections` endpoint, which uses the config-derived /// result as-is. `issue_types` is passed in because the TUI and REST source /// it from different registries. Everything else here is derived purely from @@ -729,7 +729,7 @@ impl StatusSnapshot { }) .collect(); - // Model servers — user-declared plus implicit vendor builtins. + // Model servers - user-declared plus implicit vendor builtins. let mut model_servers: Vec = config .model_servers .iter() @@ -767,7 +767,7 @@ impl StatusSnapshot { _ => false, }; - // Managed projects — names from config, resolved against the projects base dir. + // Managed projects - names from config, resolved against the projects base dir. let projects_base = Path::new(&config.paths.projects); let managed_projects: Vec = config .projects @@ -812,7 +812,7 @@ impl StatusSnapshot { wrapper_type: config.sessions.wrapper.display_name().to_string(), operator_inside_wrapper: config.sessions.wrapper.is_active_context(), operator_version: env!("CARGO_PKG_VERSION").to_string(), - // Runtime field — callers with live state override this. + // Runtime field - callers with live state override this. api_status: RestApiStatus::Stopped, kanban_providers, llm_tools, @@ -876,7 +876,7 @@ pub trait StatusSection { /// Which section IDs must be Green before this section is visible. fn prerequisites(&self) -> &[SectionId]; - /// Current health state — determines header color. + /// Current health state - determines header color. fn health(&self, snapshot: &StatusSnapshot) -> SectionHealth; /// Summary description shown next to the section header. @@ -1044,7 +1044,7 @@ pub fn build_section_dtos(snapshot: &StatusSnapshot) -> Vec { gaps.push(format!( - "{GAP_MARKER}: rag step — the workflow sandbox has no filesystem; context sources must be gathered by the agent." + "{GAP_MARKER}: rag step - the workflow sandbox has no filesystem; context sources must be gathered by the agent." )); if let Some(cfg) = &step.rag_config { let srcs: Vec = cfg @@ -206,7 +206,7 @@ fn build_node( } StepTypeTag::Mcp => { gaps.push(format!( - "{GAP_MARKER}: mcp step — the workflow sandbox cannot guarantee MCP tool availability." + "{GAP_MARKER}: mcp step - the workflow sandbox cannot guarantee MCP tool availability." )); if let Some(cfg) = &step.mcp_config { let tools: Vec = cfg @@ -481,7 +481,7 @@ mod tests { /// `operator-run-step` plugin tool (`agnt-plugin/run-step.js`), which reads /// `params.ticket` (resolved from `node.parameters`). If the emitter stops /// writing the keys the tool requires, an exported graph would fail at - /// runtime in AGNT — this catches that. + /// runtime in AGNT - this catches that. #[test] fn agnt_nodes_carry_keys_the_run_step_tool_requires() { let v = export("FEAT"); @@ -692,7 +692,7 @@ mod tests { #[test] fn agnt_export_skips_agnt_node_for_non_agnt_remote_delegator() { // An OpenAI remote agent is export-only too, but it has no AGNT workflow - // analog — so the export must NOT emit an agnt-agent node for it. Proves + // analog - so the export must NOT emit an agnt-agent node for it. Proves // the export branch is keyed on platform, not just "is remote". let it = issuetype_with_step_agent("openai-reviewer"); let out = export_workflow_agnt( diff --git a/src/workflow_gen/command.rs b/src/workflow_gen/command.rs index df8c849d..6370a98e 100644 --- a/src/workflow_gen/command.rs +++ b/src/workflow_gen/command.rs @@ -2,7 +2,7 @@ //! //! This is the single code path every surface goes through: the CLI and TUI //! call it directly in-process, and the REST handler calls it after resolving -//! the ticket — so the web UI and VS Code extension reach the same logic over +//! the ticket - so the web UI and VS Code extension reach the same logic over //! HTTP. Ticket resolution (filesystem/queue I/O) stays at the edges; this //! function takes an already-resolved ticket plus the issue-type registry. @@ -99,7 +99,7 @@ pub fn export_workflow_for_ticket( /// /// Used by the UI to visualize an issue type's workflow shape. A placeholder /// ticket is synthesized so the existing renderer can interpolate handlebars -/// variables — values are illustrative, not real. This is filesystem-safe: +/// variables - values are illustrative, not real. This is filesystem-safe: /// `worktree_path: None` short-circuits any step-output loading, and the /// handlebars renderer runs with strict mode off, so missing variables render /// as empty strings rather than erroring. @@ -110,7 +110,7 @@ pub fn export_workflow_for_issuetype( let ticket = preview_ticket(issuetype); // No config/filesystem context in a preview: environment-dependent pipeline // item sources (projects/glob) render as symbolic placeholders, and with an - // empty delegator set the AGNT target never resolves a native `agnt-agent` node — + // empty delegator set the AGNT target never resolves a native `agnt-agent` node - // so those nodes appear only in real ticket exports, by design. let contents = render( &ticket, diff --git a/src/workflow_gen/export.rs b/src/workflow_gen/export.rs index eea5cda2..c980d532 100644 --- a/src/workflow_gen/export.rs +++ b/src/workflow_gen/export.rs @@ -19,9 +19,9 @@ use crate::templates::schema::{ItemSource, RagSource, ReviewType, StepSchema, St /// preview path) make those sources fall back to a GAP-marked placeholder. /// /// Resolving these at export time bakes machine state into the artifact: the -/// export is deterministic *given the same project/filesystem environment* — +/// export is deterministic *given the same project/filesystem environment* - /// the same conditional that already applies to step-output loading in -/// `build_ticket_context` — and the output still never contains wall-clock or +/// `build_ticket_context` - and the output still never contains wall-clock or /// randomness. #[derive(Debug, Clone, Default)] pub struct PipelineEnv { @@ -52,8 +52,8 @@ pub fn export_workflow( // The body is emitted as TOP-LEVEL statements (after `export const meta`), // NOT wrapped in `export default async function () { ... }`. This is the - // form the `@untra/naiveworkflow-compiler` walks — a wrapped body compiles - // to an empty graph — and mirrors the top-level shape of the Claude + // form the `@untra/naiveworkflow-compiler` walks - a wrapped body compiles + // to an empty graph - and mirrors the top-level shape of the Claude // Workflow-tool format. let mut out = String::new(); out.push_str(&provenance_header(ticket, issuetype)); @@ -106,7 +106,7 @@ fn provenance_header(ticket: &Ticket, it: &IssueType) -> String { "/* ───────────── OPERATOR PROVENANCE (audit) ─────────────\n\ \x20* Generated by: operator workflow export\n\ \x20* Ticket: {id} ({ttype}, project {project})\n\ - \x20* Issuetype: {key} — {name}\n\ + \x20* Issuetype: {key} - {name}\n\ \x20* NOTE: autonomous flattening of an operator issuetype. Human review_type/on_reject\n\ \x20* gates are emitted as judge-agent loops (see {gap} markers below). One operator\n\ \x20* step = a full session; one agent() call = one background subagent.\n\ @@ -120,14 +120,14 @@ fn provenance_header(ticket: &Ticket, it: &IssueType) -> String { ) } -/// The workflow's display name: ` — ` (falling back to the +/// The workflow's display name: ` - ` (falling back to the /// issuetype name when the ticket has no summary). Shared by the `.js` `meta` /// block and the AGNT workflow `name`. pub(super) fn meta_name(ticket: &Ticket, it: &IssueType) -> String { if ticket.summary.is_empty() { - format!("{} — {}", ticket.id, it.name) + format!("{} - {}", ticket.id, it.name) } else { - format!("{} — {}", ticket.id, ticket.summary) + format!("{} - {}", ticket.id, ticket.summary) } } @@ -234,7 +234,7 @@ fn render_step( } StepTypeTag::Rag => { s.push_str(&format!( - "// {GAP_MARKER}: rag step — the workflow sandbox has no filesystem; context sources must be gathered by the agent.\n" + "// {GAP_MARKER}: rag step - the workflow sandbox has no filesystem; context sources must be gathered by the agent.\n" )); if let Some(cfg) = &step.rag_config { let srcs: Vec = cfg.sources.iter().map(describe_rag_source).collect(); @@ -249,7 +249,7 @@ fn render_step( } StepTypeTag::Mcp => { s.push_str(&format!( - "// {GAP_MARKER}: mcp step — the workflow sandbox cannot guarantee MCP tool availability.\n" + "// {GAP_MARKER}: mcp step - the workflow sandbox cannot guarantee MCP tool availability.\n" )); if let Some(cfg) = &step.mcp_config { let tools: Vec = cfg @@ -408,7 +408,7 @@ fn render_multi_prompt( None => "Review the candidate outputs and select the single best one.".to_string(), }; let strategy = serde_json::to_string(&cfg.selection_strategy).unwrap_or_default(); - // Intermediate `..._outputs` binding + plain select `agent(...)` — see the + // Intermediate `..._outputs` binding + plain select `agent(...)` - see the // note in `render_multi_model` on why `.then()` is avoided. Ok(format!( "// multi-prompt variations + select (selection_strategy: {strategy})\n\ @@ -458,7 +458,7 @@ fn render_matrixed(hbs: &Handlebars, ctx: &Value, step: &StepSchema, var: &str) /// Render a pipeline step: one top-level `const r_x = await pipeline(items, /// …stage thunks);`. Items resolve per `ItemSource` (literal array → static /// fan-out width in the compiled graph; identifier → symbolic). The step graph -/// stays linear — the N-item fan-out lives entirely inside this one statement. +/// stays linear - the N-item fan-out lives entirely inside this one statement. fn render_pipeline( hbs: &Handlebars, ctx: &Value, @@ -527,7 +527,7 @@ fn render_pipeline( /// `Static`, `Projects`, and `Glob` emit a literal array (static ×N in the /// compiled graph); `FromStep` emits the prior step's result identifier /// (symbolic ×N). Sources the export environment cannot resolve fall back to -/// an empty, GAP-marked placeholder binding — runtime-safe (zero iterations) +/// an empty, GAP-marked placeholder binding - runtime-safe (zero iterations) /// and rendered symbolic. fn pipeline_items_expr(source: &ItemSource, var: &str, env: &PipelineEnv) -> (String, String) { match source { @@ -756,7 +756,7 @@ mod tests { "meta header missing:\n{out}" ); assert!( - out.contains(r#"name: "FEAT-1234 — Add pagination""#), + out.contains(r#"name: "FEAT-1234 - Add pagination""#), "name should combine ticket id + summary:\n{out}" ); assert!(out.contains("phases: ["), "phases array missing:\n{out}"); diff --git a/src/workflow_gen/format.rs b/src/workflow_gen/format.rs index 71b85910..a940ef76 100644 --- a/src/workflow_gen/format.rs +++ b/src/workflow_gen/format.rs @@ -3,8 +3,8 @@ //! Operator can render a ticket+issuetype into more than one orchestration //! format. `Claude` is the original Claude Code dynamic-workflow `.js`; `Agnt` //! is the AGNT.gg workflow graph JSON. The same shared code path -//! (`export_workflow_for_ticket`) dispatches on this enum so every surface — CLI, -//! REST, TUI, VS Code — selects a format uniformly. +//! (`export_workflow_for_ticket`) dispatches on this enum so every surface - CLI, +//! REST, TUI, VS Code - selects a format uniformly. use clap::ValueEnum; use serde::{Deserialize, Serialize}; @@ -28,7 +28,7 @@ impl WorkflowFormat { /// `tests/vertical_parity.rs` against the `Workflows` catalog vertical. pub const ALL: [WorkflowFormat; 2] = [WorkflowFormat::Claude, WorkflowFormat::Agnt]; - /// Stable lowercase slug — must equal the `Workflows` catalog entry slug. + /// Stable lowercase slug - must equal the `Workflows` catalog entry slug. pub fn slug(&self) -> &'static str { match self { WorkflowFormat::Claude => "claude", diff --git a/src/workflow_gen/mod.rs b/src/workflow_gen/mod.rs index 314b23a2..144479de 100644 --- a/src/workflow_gen/mod.rs +++ b/src/workflow_gen/mod.rs @@ -4,7 +4,7 @@ //! `.js` file. //! //! This is **export-only**: operator never parses `.js` back. The unit of -//! export is a ticket *and* its issuetype together — the issuetype supplies the +//! export is a ticket *and* its issuetype together - the issuetype supplies the //! step structure, the ticket supplies the concrete field values. Rendering //! them produces a workflow specialized to that exact ticket. //! @@ -350,14 +350,14 @@ mod tests { "item_source":{"type":"from_step","step":"find"}, "stages":[{"prompt":"Fix the module"}]}}]"#, ); - // Items expression is the prior step's result var — a runtime value, so + // Items expression is the prior step's result var - a runtime value, so // the compiled graph shows a symbolic (not static) fan-out width. assert!( out.contains("const r_fix = await pipeline(r_find,"), "from_step identifier items missing:\n{out}" ); // Operator cannot statically check that the prior step returns an - // array — that gap must be marked. + // array - that gap must be marked. assert!( out.contains(GAP_MARKER) && out.contains("array"), "array-ness GAP marker missing:\n{out}" diff --git a/tests/acp_integration.rs b/tests/acp_integration.rs index 61108a12..62740e7e 100644 --- a/tests/acp_integration.rs +++ b/tests/acp_integration.rs @@ -287,7 +287,7 @@ async fn test_cancel_kills_delegator() { .expect("sessionId") .to_string(); - // 3. session/prompt (delegator runs `sleep 60` — a long-running process) + // 3. session/prompt (delegator runs `sleep 60` - a long-running process) let prompt = format!( r#"{{"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{{"sessionId":"{session_id}","prompt":[{{"type":"text","text":"ignored"}}]}}}}"# ); @@ -298,7 +298,7 @@ async fn test_cancel_kills_delegator() { // Give the delegator a moment to start tokio::time::sleep(Duration::from_millis(500)).await; - // 4. Send cancel notification (no id — it's a notification) + // 4. Send cancel notification (no id - it's a notification) let cancel = format!( r#"{{"jsonrpc":"2.0","method":"session/cancel","params":{{"sessionId":"{session_id}"}}}}"# ); diff --git a/tests/agnt_plugin_tool_names.rs b/tests/agnt_plugin_tool_names.rs index 34807a9e..4c725a78 100644 --- a/tests/agnt_plugin_tool_names.rs +++ b/tests/agnt_plugin_tool_names.rs @@ -3,7 +3,7 @@ //! AGNT's `PluginManager` registers and routes each tool instance by its //! `this.name` property. Ironclad rule: the constructor's `this.name` MUST equal //! the tool's `type` in `agnt-plugin/manifest.json`. A class missing `this.name` -//! registers under `undefined` — it installs but the node never fires. +//! registers under `undefined` - it installs but the node never fires. //! //! This test reads files only (no JS runtime): it parses the manifest for the //! source-of-truth `type` -> `entryPoint` pairs, then confirms each referenced diff --git a/tests/docs_structure.rs b/tests/docs_structure.rs index 0e6a0a00..b9db493b 100644 --- a/tests/docs_structure.rs +++ b/tests/docs_structure.rs @@ -2,7 +2,7 @@ //! //! `docs/_data/navigation.yml` is hand-maintained (editorial ordering, section //! titles) but its *contents* are enforced against -//! `operator::integrations::catalog` — every documented `Alpha`+ integration +//! `operator::integrations::catalog` - every documented `Alpha`+ integration //! must be reachable from the sidebar with its catalog icon, and the nav may //! not advertise integrations the catalog doesn't know. The suite also guards //! general docs hygiene: every nav URL resolves, every published page is @@ -97,7 +97,7 @@ fn repo_path(rel: &str) -> PathBuf { /// Deserializing through the strict structs is itself the shape test: it /// rejects a 4th nesting level, `icon` on items, `codicon` on leaves, and any -/// unknown key — exactly what `docs/_includes/sidebar.html` would silently drop. +/// unknown key - exactly what `docs/_includes/sidebar.html` would silently drop. fn load_nav() -> Nav { let raw = std::fs::read_to_string(repo_path("docs/_data/navigation.yml")) .expect("docs/_data/navigation.yml should be readable"); @@ -279,7 +279,7 @@ fn test_nav_vertical_leaves_map_to_catalog() { for leaf in leaves { assert!( catalog_urls.contains(&leaf.url) || NAV_EXTRA_PAGES.contains(&leaf.url.as_str()), - "nav leaf '{}' ({}) under '{item_url}' advertises a page with no catalog entry — \ + "nav leaf '{}' ({}) under '{item_url}' advertises a page with no catalog entry - \ add it to src/integrations/catalog.rs or NAV_EXTRA_PAGES", leaf.title, leaf.url @@ -352,7 +352,7 @@ fn test_nav_icons_match_catalog() { if let Some(stem) = name.strip_suffix(".svg") { assert!( referenced.contains(stem), - "docs/assets/icons/{name} is referenced by no navigation.yml entry — remove it or wire it up" + "docs/assets/icons/{name} is referenced by no navigation.yml entry - remove it or wire it up" ); } } @@ -397,7 +397,7 @@ fn test_nav_titles_match_pages() { .unwrap_or_else(|| panic!("page for nav url '{url}' has no front-matter title")); assert_eq!( title, page_title, - "nav title for '{url}' differs from the page's front-matter title — \ + "nav title for '{url}' differs from the page's front-matter title - \ align them or add a NAV_TITLE_EXCEPTIONS entry" ); }; @@ -429,7 +429,7 @@ fn test_docs_pages_reachable() { let url = page_url(&page); assert!( reachable.contains(&url), - "docs/{page} ({url}) is published but unreachable from navigation.yml — \ + "docs/{page} ({url}) is published but unreachable from navigation.yml - \ add a nav entry or extend NAV_ORPHAN_ALLOWLIST" ); } @@ -508,7 +508,7 @@ fn test_no_duplicate_body_h1() { let first_line = body.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); assert!( !first_line.starts_with("# "), - "docs/{page} opens with a body H1 ('{first_line}') — the doc layout already \ + "docs/{page} opens with a body H1 ('{first_line}') - the doc layout already \ renders the front-matter title; remove the duplicate heading" ); } diff --git a/tests/feature_parity_test.rs b/tests/feature_parity_test.rs index c9e0fb60..c2f8b0cd 100644 --- a/tests/feature_parity_test.rs +++ b/tests/feature_parity_test.rs @@ -185,7 +185,7 @@ const CANONICAL_VIEWS: &[(&str, &str, &str)] = &[ ]; /// The canonical status-section ids, parsed from the committed ts-rs export -/// `bindings/SectionId.ts` — itself generated from the `SectionId` enum in +/// `bindings/SectionId.ts` - itself generated from the `SectionId` enum in /// `src/ui/status_panel.rs` (the single source of truth). The VS Code copy under /// `vscode-extension/src/generated/` is gitignored (it's produced by /// `npm run copy-types`), so we read the tracked `bindings/` original to stay diff --git a/tests/kanban_integration.rs b/tests/kanban_integration.rs index b08e81b8..e23d71ab 100644 --- a/tests/kanban_integration.rs +++ b/tests/kanban_integration.rs @@ -17,14 +17,14 @@ //! //! ### For GitHub Projects: //! - `OPERATOR_GITHUB_TOKEN`: PAT with `project` (or `read:project`) scope. -//! MUST be distinct from `GITHUB_TOKEN` used for PR workflows — the kanban +//! MUST be distinct from `GITHUB_TOKEN` used for PR workflows - the kanban //! provider deliberately does not fall back. See //! `docs/getting-started/kanban/github.md`. //! - `OPERATOR_GITHUB_TEST_PROJECT`: `ProjectV2` `GraphQL` node ID //! (starts with `PVT_`). Fetch via: //! `gh api graphql -f query='query { viewer { projectsV2(first: 20) { nodes { id number title } } } }'`. //! The project must have a Status single-select field with at least one -//! terminal option (Done/Complete/Closed/Resolved) — default GitHub +//! terminal option (Done/Complete/Closed/Resolved) - default GitHub //! project templates satisfy this. //! //! ## Running Tests @@ -94,7 +94,7 @@ fn linear_configured() -> bool { /// Check if GitHub Projects credentials are configured (non-empty env vars). /// -/// Only `OPERATOR_GITHUB_TOKEN` is consulted — the provider deliberately does +/// Only `OPERATOR_GITHUB_TOKEN` is consulted - the provider deliberately does /// NOT fall back to `GITHUB_TOKEN` (which is reserved for the git/PR provider /// and typically lacks the `project` scope). See /// `src/api/providers/kanban/github_projects.rs` module docs. @@ -808,7 +808,7 @@ mod github_tests { // GitHub derives users from assignees on existing project items // (list_users scans items). A fresh test project with only draft - // issues may legitimately return an empty list — unlike Jira/Linear + // issues may legitimately return an empty list - unlike Jira/Linear // where team members/assignable users are a separate endpoint. let users = provider .list_users(&project) @@ -834,7 +834,7 @@ mod github_tests { .await .expect("Should list statuses"); - // A configured Status field is a hard prerequisite — the create/ + // A configured Status field is a hard prerequisite - the create/ // update_status tests below depend on it. Fail loudly if missing. assert!( !statuses.is_empty(), @@ -929,7 +929,7 @@ mod github_tests { // ─── Cleanup: Move draft to terminal status ──────────────────────────────── // The provider exposes no deletion API; we move to Done so the test - // project remains visually sane. Drafts still accumulate — see file + // project remains visually sane. Drafts still accumulate - see file // doc comment. let statuses = provider .list_statuses(&project) @@ -974,7 +974,7 @@ mod github_tests { .await .expect("Should create draft issue"); - // GitHub create_issue returns status="" — the draft does not yet + // GitHub create_issue returns status="" - the draft does not yet // have a Status field value assigned. That's fine for this test. eprintln!( "Created draft {} (initial status: {:?})", @@ -1007,7 +1007,7 @@ mod github_tests { status: target.clone(), }; - // update_issue_status returns a minimal ExternalIssue — only + // update_issue_status returns a minimal ExternalIssue - only // id/key/status are populated (github_projects.rs:1404-1415), // so we only assert on status here. let updated = provider diff --git a/tests/launch_common/mod.rs b/tests/launch_common/mod.rs index 66901e01..52d04bbc 100644 --- a/tests/launch_common/mod.rs +++ b/tests/launch_common/mod.rs @@ -14,8 +14,6 @@ use std::process::Command; use serde::Deserialize; use tempfile::TempDir; -// ─── Configuration ──────────────────────────────────────────────────────────── - /// Which wrapper the test context should generate config for #[derive(Clone, Copy)] pub enum WrapperTestMode { @@ -31,8 +29,6 @@ pub fn launch_tests_enabled() -> bool { .unwrap_or(false) } -// ─── Test Data Structures ─────────────────────────────────────────────────── - /// Captured invocation data from mock LLM #[derive(Debug, Deserialize)] #[allow(dead_code)] @@ -49,12 +45,8 @@ pub struct MockInvocation { pub cwd: String, } -// ─── Test Context ─────────────────────────────────────────────────────────── - /// Test context holding temporary directories and providing helpers. -/// -/// Generic across all wrappers — the `WrapperTestMode` controls which -/// `[sessions]` block is written to the config TOML. +/// Generic across all wrappers. Rhe `WrapperTestMode` controls which `[sessions]` block is written to the config TOML. pub struct LaunchTestContext { pub temp_dir: TempDir, pub output_dir: TempDir, diff --git a/tests/launch_integration_cmux.rs b/tests/launch_integration_cmux.rs index 8ea56490..b242544b 100644 --- a/tests/launch_integration_cmux.rs +++ b/tests/launch_integration_cmux.rs @@ -113,8 +113,8 @@ This is a test task to verify cmux workspace creation via mock client. ctx.create_ticket("TASK", "TASK-C01", ticket_content); let mock = Arc::new(MockCmuxClient::new()); - let launcher = - Launcher::with_cmux_client(&config, mock.clone()).expect("Failed to create launcher"); + let launcher = Launcher::with_cmux_client(&config, Arc::::clone(&mock)) + .expect("Failed to create launcher"); // Load the ticket from file let queue_dir = ctx.tickets_path.join("queue"); @@ -168,8 +168,8 @@ CMUX_PROMPT_MARKER_77777 ctx.create_ticket("TASK", "TASK-C02", ticket_content); let mock = Arc::new(MockCmuxClient::new()); - let launcher = - Launcher::with_cmux_client(&config, mock.clone()).expect("Failed to create launcher"); + let launcher = Launcher::with_cmux_client(&config, Arc::::clone(&mock)) + .expect("Failed to create launcher"); let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) @@ -213,8 +213,8 @@ status: queued ctx.create_ticket("TASK", "TASK-C03", ticket_content); let mock = Arc::new(MockCmuxClient::new()); - let launcher = - Launcher::with_cmux_client(&config, mock.clone()).expect("Failed to create launcher"); + let launcher = Launcher::with_cmux_client(&config, Arc::::clone(&mock)) + .expect("Failed to create launcher"); let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) @@ -267,8 +267,8 @@ status: queued ctx.create_ticket("TASK", "TASK-C04", ticket_content); let mock = Arc::new(MockCmuxClient::new()); - let launcher = - Launcher::with_cmux_client(&config, mock.clone()).expect("Failed to create launcher"); + let launcher = Launcher::with_cmux_client(&config, Arc::::clone(&mock)) + .expect("Failed to create launcher"); let queue_dir = ctx.tickets_path.join("queue"); let ticket_file = std::fs::read_dir(&queue_dir) diff --git a/tests/launch_integration_vscode.rs b/tests/launch_integration_vscode.rs index ccb7f8c3..ac43978c 100644 --- a/tests/launch_integration_vscode.rs +++ b/tests/launch_integration_vscode.rs @@ -10,7 +10,7 @@ //! - Command file creation //! - Ticket state transitions //! -//! No VS Code instance is required — these test the Rust API path only. +//! No VS Code instance is required - these test the Rust API path only. //! //! ## Environment Variables //! diff --git a/tests/model_server_integration.rs b/tests/model_server_integration.rs index b7071fa5..cf3204f0 100644 --- a/tests/model_server_integration.rs +++ b/tests/model_server_integration.rs @@ -1,7 +1,7 @@ //! Live model-listing integration tests for the cloud model providers. //! //! These drive the real [`probe_models`] path against each provider's -//! model-listing endpoint — the same call the REST `/model-servers/.../models` +//! model-listing endpoint - the same call the REST `/model-servers/.../models` //! routes use to populate model dropdowns. Listing models consumes **no //! inference tokens**, so these are cheap to run. //! @@ -12,13 +12,13 @@ //! the instance's `api_key_env` (distinct from the operator's own runtime vars so //! test keys never collide with a developer's live `ANTHROPIC_API_KEY`, etc.): //! -//! - `OPERATOR_ANTHROPIC_API_KEY` — -//! - `OPERATOR_OPENAI_API_KEY` — -//! - `OPERATOR_GEMINI_API_KEY` — -//! - `OPERATOR_OPENROUTER_API_KEY` — (optional) +//! - `OPERATOR_ANTHROPIC_API_KEY` - +//! - `OPERATOR_OPENAI_API_KEY` - +//! - `OPERATOR_GEMINI_API_KEY` - +//! - `OPERATOR_OPENROUTER_API_KEY` - (optional) //! -//! The `openrouter_keyless` test needs **no key** — `OpenRouter`'s `/models` list -//! is public — so it runs on every CI run as the always-on baseline. It skips +//! The `openrouter_keyless` test needs **no key** - `OpenRouter`'s `/models` list +//! is public - so it runs on every CI run as the always-on baseline. It skips //! gracefully (rather than failing) if the network is unreachable. //! //! ## Running @@ -134,7 +134,7 @@ mod openrouter_keyless { #[tokio::test] async fn test_public_models_list_is_text_filtered() { - // No api_key_env and no OPENROUTER_API_KEY needed — the list is public. + // No api_key_env and no OPENROUTER_API_KEY needed - the list is public. let outcome = probe_models(&server_for("openrouter", None), &EgressPolicy::default()).await; if !outcome.reachable { @@ -258,7 +258,7 @@ mod openrouter_keyed { // ─── Cross-provider consistency ─────────────────────────────────────────────── /// Every configured + reachable provider returns the same uniform -/// `ModelInfo { id, display_name }` shape — a non-empty id for each model — so a +/// `ModelInfo { id, display_name }` shape - a non-empty id for each model - so a /// single dropdown component can render all of them. Skips when nothing is /// configured. #[tokio::test] diff --git a/tests/relay_integration.rs b/tests/relay_integration.rs index 7c97c4d9..014da3b6 100644 --- a/tests/relay_integration.rs +++ b/tests/relay_integration.rs @@ -2,11 +2,11 @@ //! //! Two layers: //! -//! **Layer 1** — `ChannelSession` over a real `RelayHub` Unix socket. +//! **Layer 1** - `ChannelSession` over a real `RelayHub` Unix socket. //! Exercises ask/reply, broadcast, rename, timeout, and peer-gone flows //! using only in-process async code (no external services needed). //! -//! **Layer 2** — `opr8r relay` binary driven via JSON-RPC stdio. +//! **Layer 2** - `opr8r relay` binary driven via JSON-RPC stdio. //! Verifies the MCP protocol surface: initialize, tools/list, `relay_peers`. //! Binary tests skip gracefully if the binary hasn't been built yet. //! @@ -700,7 +700,7 @@ async fn test_binary_relay_ask_returns_immediately() { .await; let _ = rpc_recv(&mut stdout, 1).await; - // Ask a non-existent peer — the tool call should return immediately with ask_id + // Ask a non-existent peer - the tool call should return immediately with ask_id let before = std::time::Instant::now(); rpc_send( &mut stdin, @@ -950,7 +950,7 @@ async fn test_binary_incoming_reply_notification_content_is_raw_text() { .await; let _ = rpc_recv(&mut asker_stdout, 2).await; - // Wait for the reply notification (no id — it's a JSON-RPC notification) + // Wait for the reply notification (no id - it's a JSON-RPC notification) let notification = tokio::time::timeout(Duration::from_secs(5), async { let mut line = String::new(); loop { diff --git a/tests/route_scope_parity.rs b/tests/route_scope_parity.rs index 54d77fce..ead40c43 100644 --- a/tests/route_scope_parity.rs +++ b/tests/route_scope_parity.rs @@ -2,7 +2,7 @@ //! //! Authorization is decided by matching a request against //! `operator::auth::scope::ROUTE_RULES`. A route that is mounted but missing -//! from that table is denied at runtime — which fails safe, but as a 401 on a +//! from that table is denied at runtime - which fails safe, but as a 401 on a //! working endpoint rather than as anything a developer would notice locally. //! This suite turns that into a build failure instead, and pins the public //! allowlist so widening it cannot happen quietly. @@ -19,13 +19,15 @@ const UNDOCUMENTED_MOUNTED_ROUTES: &[(&str, &str)] = /// The complete set of routes reachable without a credential. const EXPECTED_PUBLIC: &[(&str, &str)] = &[ - // Kubernetes probes — no workspace metadata. + // Kubernetes probes - no workspace metadata. ("GET", "/livez"), ("GET", "/readyz"), // The endpoints needed to *obtain* a credential. ("GET", "/api/v1/auth/bootstrap"), ("POST", "/api/v1/auth/bootstrap"), ("POST", "/api/v1/auth/login"), + ("POST", "/api/v1/auth/forgot-password"), + ("POST", "/api/v1/auth/reset-password"), ("POST", "/api/v1/auth/device/code"), ("POST", "/api/v1/auth/token"), ]; @@ -108,7 +110,7 @@ fn test_route_table_has_no_entries_for_routes_that_do_not_exist() { assert!( stale.is_empty(), - "ROUTE_RULES names routes that are not mounted — remove them:\n{stale:#?}" + "ROUTE_RULES names routes that are not mounted - remove them:\n{stale:#?}" ); } @@ -130,7 +132,7 @@ fn test_public_routes_are_exactly_the_expected_allowlist() { assert!( added.is_empty(), "these routes became public. That is a change to the security boundary, \ - not a routing detail — update docs/security/ and this allowlist \ + not a routing detail - update docs/security/ and this allowlist \ deliberately:\n{added:#?}" ); assert!( diff --git a/tests/surface_parity.rs b/tests/surface_parity.rs index cd4b5d57..82fbb26b 100644 --- a/tests/surface_parity.rs +++ b/tests/surface_parity.rs @@ -53,7 +53,7 @@ fn colon_to_curly(path: &str) -> String { .join("/") } -/// The generated OpenAPI spec — the authoritative list of mounted REST routes +/// The generated OpenAPI spec - the authoritative list of mounted REST routes /// since the router was migrated to `utoipa_axum::OpenApiRouter`. fn rest_spec() -> String { operator::rest::ApiDoc::json().expect("generate OpenAPI spec") diff --git a/tests/svg_icon_standard.rs b/tests/svg_icon_standard.rs index ca43ee5a..a5c0fbc2 100644 --- a/tests/svg_icon_standard.rs +++ b/tests/svg_icon_standard.rs @@ -40,7 +40,7 @@ const ICON_GLOBS: &[&str] = &[ /// recolor or resize safely. const EXEMPT: &[(&str, &str)] = &[( "docs/assets/img/operator_logo.svg", - "full-color brand wordmark, not a monochrome glyph — it is never tinted, \ + "full-color brand wordmark, not a monochrome glyph - it is never tinted, \ inlined, or rendered at icon sizes", )]; @@ -100,7 +100,7 @@ fn icon_files() -> Vec { files.sort(); assert!( !files.is_empty(), - "found no icons under {ICON_GLOBS:?} — has the layout moved?" + "found no icons under {ICON_GLOBS:?} - has the layout moved?" ); files } @@ -180,7 +180,7 @@ fn test_every_icon_matches_the_operator_icon_standard() { ); } - // 5. No pinned color or size — the container decides both. + // 5. No pinned color or size - the container decides both. for caps in attr_re.captures_iter(&svg) { let attr = &caps[1]; assert!( @@ -193,7 +193,7 @@ fn test_every_icon_matches_the_operator_icon_standard() { // 6. Nothing that breaks when the file is inlined into a page. assert!( !svg.contains("javascript:") && !handler_re.is_match(&svg), - "{name}: event handlers and javascript: URLs are not allowed — these files \ + "{name}: event handlers and javascript: URLs are not allowed - these files \ are inlined verbatim into generated pages" ); assert!( @@ -273,7 +273,7 @@ fn test_exemptions_are_real_and_still_needed() { let full = repo_root().join(path); assert!( full.is_file(), - "exempt file {path} no longer exists — remove it from EXEMPT ({reason})" + "exempt file {path} no longer exists - remove it from EXEMPT ({reason})" ); let svg = std::fs::read_to_string(&full).unwrap(); let compliant = svg_re @@ -282,7 +282,7 @@ fn test_exemptions_are_real_and_still_needed() { && svg.matches(" "✓", Some(_) => "✗", - None => "—", + None => "-", }; println!( "{:<14} | {:<18} | {:<6} | {:<5} | {:<5} | {}", @@ -259,7 +259,7 @@ fn test_vertical_parity_summary() { e.status.label(), badge_ok, docs_ok, - e.docs_url().unwrap_or_else(|| "—".to_string()), + e.docs_url().unwrap_or_else(|| "-".to_string()), ); } println!(); diff --git a/tests/workflow_mapper_contract.rs b/tests/workflow_mapper_contract.rs index d78ae91a..9f664754 100644 --- a/tests/workflow_mapper_contract.rs +++ b/tests/workflow_mapper_contract.rs @@ -50,7 +50,7 @@ fn test_mapper_is_typed_against_generated_bindings() { !repo_root() .join("webcomponents/src/workflow/types.ts") .exists(), - "webcomponents/src/workflow/types.ts is back. Domain types are generated from Rust — \ + "webcomponents/src/workflow/types.ts is back. Domain types are generated from Rust - \ add the field or variant in src/templates/schema.rs (or src/issuetypes/schema.rs) \ and regenerate with `make bindings`, rather than hand-mirroring it." ); @@ -64,7 +64,7 @@ fn test_mapper_switch_is_exhaustive_over_step_types() { let mapper = read(MAPPER); assert!( mapper.contains("const unhandled: never = type"), - "{MAPPER} must keep its `never` exhaustiveness check — it is what makes a new \ + "{MAPPER} must keep its `never` exhaustiveness check - it is what makes a new \ StepTypeTag variant fail the webcomponents typecheck instead of being dropped \ from the graph" ); @@ -82,7 +82,7 @@ fn test_mapper_mirrors_the_rust_step_ordering_rule() { ); assert!( read("src/workflow_gen/export.rs").contains("fn ordered_steps"), - "src/workflow_gen/export.rs no longer defines `ordered_steps` — the TypeScript \ + "src/workflow_gen/export.rs no longer defines `ordered_steps` - the TypeScript \ mapper's ordering rule was written to mirror it and needs revisiting" ); } @@ -95,7 +95,7 @@ fn test_generated_types_are_copied_before_every_compile_step() { serde_json::from_str(&read("webcomponents/package.json")).expect("package.json parses"); let scripts = pkg["scripts"].as_object().expect("scripts object"); - for step in ["typecheck", "test", "build"] { + for step in ["typecheck", "test", "build", "lint"] { let script = scripts[step].as_str().unwrap_or(""); assert!( script.contains("copy-types"), @@ -148,7 +148,7 @@ fn test_workflow_domain_types_are_exported_to_bindings() { let path = repo_root().join(format!("bindings/{name}.ts")); assert!( path.is_file(), - "bindings/{name}.ts is missing — the Rust type lost its `#[ts(export)]`. \ + "bindings/{name}.ts is missing - the Rust type lost its `#[ts(export)]`. \ Regenerate with `make bindings`." ); } diff --git a/ui/README.md b/ui/README.md index 7e188005..f21949c9 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,9 +1,8 @@ # operator/ui -The embedded web UI for Operator — a [Vite](https://vite.dev) + React 19 single-page app that talks to the operator REST API (`/api/v1/*`). It is one of Operator's **four rendering surfaces** (alongside the Ratatui TUI, the Jekyll docs site, and the VS Code webview); see the root `CLAUDE.md` "Design & UI Consistency" section for how they stay consistent. +The embedded web UI for Operator - a [Vite](https://vite.dev) + React 19 single-page app that talks to the operator REST API (`/api/v1/*`). It is one of Operator's **four rendering surfaces** (alongside the Ratatui TUI, the Jekyll docs site, and the VS Code webview); see the root `CLAUDE.md` "Design & UI Consistency" section for how they stay consistent. -At runtime this SPA is compiled and **baked into the Rust binary** — there is no separate -web server to deploy. The TUI opens it in a browser (or the VS Code extension hosts it in a +At runtime this SPA is compiled and **baked into the Rust binary** - there is no separate web server to deploy. The TUI opens it in a browser (or the VS Code extension hosts it in a webview). ## Toolchain (bun) @@ -34,19 +33,19 @@ server's `/api` proxy has something to talk to. If `ui/dist` wasn't built, `build.rs` writes a placeholder so the TUI can show an actionable message instead of a blank page. There are size-budget tests in `web_ui.rs` (10 MB gzipped / -15 MB uncompressed) — keep new assets well under them. +15 MB uncompressed) - keep new assets well under them. ## Routing model [`HashRouter`](src/main.tsx) (`#/path`), because the app is served from a `file:`-style embedded context. Two kinds of routes: -- **Status sections** — one route per concept in the `SectionId` model shared with the TUI +- **Status sections** - one route per concept in the `SectionId` model shared with the TUI and VS Code extension (`src/ui/status_panel.rs`): `#/config`, `#/connections`, `#/kanban`, `#/llm`, `#/model-servers`, `#/git`, `#/issuetypes`, `#/delegators`, `#/projects`. Each renders the live status of that section from `GET /api/v1/sections`. `#/status` is the "all sections" overview (reachable from the Dashboard). -- **Web-only pages** — `#/` (Dashboard) and `#/queue`, which have no section analog. +- **Web-only pages** - `#/` (Dashboard) and `#/queue`, which have no section analog. The sidebar (`src/Layout.tsx`) reflects each section's health and gates not-yet-available sections (disabled with a tooltip naming the unmet prerequisites). Section data is polled @@ -59,7 +58,7 @@ Brand colors come from the single shared source of truth, [`src/index.css`](src/index.css). On top of that palette `index.css` layers app-only **semantic tokens** (`--surface`, `--border`, `--text`, `--danger`, `--warning`, `--success`, radii, fonts) with light/dark variants. Components use **CSS Modules** (`*.module.css`) and -reference semantic tokens — never raw hex (per `CLAUDE.md`). +reference semantic tokens - never raw hex (per `CLAUDE.md`). ## Icons @@ -68,5 +67,5 @@ Sidebar and page icons use [`@vscode/codicons`](https://github.com/microsoft/vsc font is imported once in `src/main.tsx`; Vite fingerprints `codicon.ttf` into `dist/assets`, so it stays embedded and offline. The concept→icon mapping lives in [`src/concepts.ts`](src/concepts.ts) and follows the **canonical table** documented at -[`/design-system/`](../docs/design-system/index.md) — the single place to consult or update +[`/design-system/`](../docs/design-system/index.md) - the single place to consult or update when giving an operator concept an icon. diff --git a/ui/package.json b/ui/package.json index 52522df0..aa623c77 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "cd .. && bun run lint:ui" }, "dependencies": { "@vscode/codicons": "^0.0.45", diff --git a/ui/src/Layout.module.css b/ui/src/Layout.module.css index 415dca2c..3b0e22c9 100644 --- a/ui/src/Layout.module.css +++ b/ui/src/Layout.module.css @@ -74,6 +74,31 @@ margin-bottom: 6px; } +.signOut { + margin-top: auto; + padding: 8px 12px; + color: var(--color-green-l1); + background: transparent; + border: 1px solid var(--color-green-l1); + border-radius: var(--radius); + cursor: pointer; +} + +.signOutError { + margin: auto 0 8px; + color: var(--danger); + font-size: 0.75rem; +} + +.signOutError + .signOut { + margin-top: 0; +} + +.signOut:disabled { + opacity: 0.6; + cursor: default; +} + .navLink { display: flex; align-items: center; diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index a2b02ba7..dc3a0de6 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,4 +1,5 @@ -import { NavLink, Outlet } from 'react-router-dom'; +import { useState } from 'react'; +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import styles from './Layout.module.css'; import { useTheme } from './theme'; import type { Concept } from './concepts'; @@ -7,6 +8,8 @@ import { ConceptIcon } from './components/ConceptIcon'; import { SectionsProvider, useSections } from './sections-context'; import { RightPanelProvider, useRightPanel } from './right-panel'; import type { SectionDto } from './api-client'; +import { OperatorApi, setCsrfToken } from './api-client'; +import { useHost } from './host'; // The "Status" group mirrors the canonical section order shared with the TUI and // VS Code extension (the SectionId enum in src/ui/status_panel.rs) and reflects @@ -77,7 +80,7 @@ function NavGroup({ label, keys }: { label: string; keys: readonly string[] }) { // with a header (title + close) above the caller-supplied node. function RightPanel() { const { content, title, close } = useRightPanel(); - if (!content) return null; + if (!content) {return null;} return (