diff --git a/Cargo.lock b/Cargo.lock index 4cc9286f5..38ee4be13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1883,11 +1883,13 @@ dependencies = [ "object_store", "openapiv3", "rand 0.8.5", + "regex", "reqwest 0.13.2", "rust-embed", "rustls", "rustls-pemfile", "rustls-pki-types", + "semver", "serde", "serde_json", "serde_yaml", diff --git a/docs/administration/deploy-server.mdx b/docs/administration/deploy-server.mdx index f5d51b9d0..b2d13c05f 100644 --- a/docs/administration/deploy-server.mdx +++ b/docs/administration/deploy-server.mdx @@ -114,16 +114,16 @@ The CLI can target a running Fabro server for commands that support a remote API ```toml title="user.toml" [server] -base_url = "https://fabro.example.com:3000/api/v1" +target = "https://fabro.example.com:3000/api/v1" ``` -Or use the `--server-url` flag: +Or use the `--server` flag: ```bash -fabro --server-url https://fabro.example.com:3000/api/v1 model list +fabro model list --server https://fabro.example.com:3000/api/v1 ``` -`fabro model list` and `fabro model test` honor `[server].base_url` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server-url`. +`fabro model list` and `fabro model test` honor `[server].target` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. See [User Configuration](/reference/user-configuration#server-section) for the full connection options, including mTLS setup. diff --git a/docs/administration/security.mdx b/docs/administration/security.mdx index 15f2b3e4c..e1269498c 100644 --- a/docs/administration/security.mdx +++ b/docs/administration/security.mdx @@ -36,7 +36,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following ### Secrets - **Keep API keys out of sandboxes.** The local sandbox strips environment variables ending in `_API_KEY`, `_SECRET`, `_TOKEN`, `_PASSWORD`, or `_CREDENTIAL`, but Docker and Daytona sandboxes provide stronger isolation — only explicitly configured variables are passed through. -- **Use `.env` files for credentials.** Fabro loads credentials from `~/.fabro/.env` and the project-root `.env`. Do not commit these files to version control. +- **Use server-owned secrets or process env vars for credentials.** For server-backed workflows, persist credentials with `fabro provider login` / `fabro secret set`, which stores them under the server data directory. Do not commit secrets to version control. - **Rotate the session secret.** The `SESSION_SECRET` environment variable encrypts web app sessions. Rotate it periodically and use a strong random value. ### Execution diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 44bcafe53..51ee2952a 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -152,9 +152,9 @@ Toggle experimental or opt-in features. All features default to `false`. The same `[features]` section can be set in `fabro.toml` (project-level) to enable features per-project. -## Environment variables +## Secrets and environment variables -Fabro reads environment variables from a `.env` file in the working directory (if present) and from the shell environment. Provider API keys are required for the models you want to use; everything else is optional. +Fabro stores server-managed credentials in `/secrets.json` and also honors relevant environment variables from the server process environment as a fallback. Fabro no longer auto-loads `.env` files. Provider API keys are required for the models you want to use; everything else is optional. ### LLM provider keys diff --git a/docs/administration/troubleshooting.mdx b/docs/administration/troubleshooting.mdx index e1327a515..ede660bd9 100644 --- a/docs/administration/troubleshooting.mdx +++ b/docs/administration/troubleshooting.mdx @@ -8,21 +8,21 @@ description: "Diagnosing and resolving common issues with Fabro" The `fabro doctor` command validates your installation: ```bash -fabro doctor # Check local configuration -fabro doctor --live # Also probe live services (LLM APIs, sandbox, Brave Search) -fabro doctor --verbose # Show detailed output for each check +fabro doctor # Local config checks + live server diagnostics +fabro doctor --verbose # Show detailed output for each check +fabro doctor --server https://fabro.example.com:3000/api/v1 ``` It checks: -- System dependencies (`openssl`, `node`, `gh`, `dot`) -- LLM provider API keys -- Sandbox availability (Docker daemon, Daytona API key) -- JWT key configuration -- Brave Search API key +- Local user config and legacy `~/.fabro/.env` warnings +- Server-reported LLM provider connectivity +- GitHub App, sandbox, and Brave Search credentials +- Server authentication and crypto configuration +- Server-side Graphviz availability ## Common issues -**"No API key configured"** — Set at least one provider key in `.env` or your shell environment. Run `fabro doctor --live` to verify connectivity. +**"No API key configured"** — Set at least one provider key with `fabro provider login` or `fabro secret set`, or export it in the server process environment. Run `fabro doctor` to verify connectivity. **Stall watchdog timeouts** — If runs are cancelled unexpectedly, the agent may be stuck or the LLM provider may be slow. Check `FABRO_LOG=debug` output for `Agent.LlmRetry` events. Increase `stall_timeout` in the graph if needed, or add [fallback providers](/core-concepts/models) to handle outages. diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 85d0a6c93..5e5cbff62 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -71,6 +71,20 @@ paths: schema: $ref: "#/components/schemas/HealthResponse" + /api/v1/health/diagnostics: + post: + operationId: runDiagnostics + tags: [Discovery] + summary: Run server health diagnostics + description: Probes external services and server configuration. May be slow. + responses: + "200": + description: Diagnostics report + content: + application/json: + schema: + $ref: "#/components/schemas/DiagnosticsReport" + /api/v1/openapi.json: get: operationId: getOpenApiSpec @@ -1434,6 +1448,110 @@ paths: schema: $ref: "#/components/schemas/AggregateUsage" + # ── Secrets ────────────────────────────────────────────────────────── + + /api/v1/secrets: + get: + operationId: listSecrets + tags: [Secrets] + summary: List stored secrets + description: Returns stored secret names and timestamps. Secret values are never exposed. + responses: + "200": + description: Secret metadata list + content: + application/json: + schema: + $ref: "#/components/schemas/SecretListResponse" + + /api/v1/secrets/{name}: + put: + operationId: setSecret + tags: [Secrets] + summary: Store or update a secret + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetSecretRequest" + responses: + "200": + description: Secret stored + content: + application/json: + schema: + $ref: "#/components/schemas/SecretMetadata" + "400": + description: Invalid secret name or request body + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: deleteSecret + tags: [Secrets] + summary: Delete a stored secret + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "204": + description: Secret deleted + "400": + description: Invalid secret name + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Secret not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Secret store write failed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + # ── Repos ──────────────────────────────────────────────────────────── + + /api/v1/repos/github/{owner}/{name}: + get: + operationId: getGithubRepo + tags: [Repos] + summary: Check server access to a GitHub repository + parameters: + - name: owner + in: path + required: true + schema: + type: string + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: Repository access details + content: + application/json: + schema: + $ref: "#/components/schemas/RepoCheckResponse" + # ── Models ─────────────────────────────────────────────────────────── /api/v1/models: @@ -5231,11 +5349,166 @@ components: type: object required: - status + - version properties: status: type: string description: Health status indicator. example: ok + version: + type: string + description: Server version string. + example: "0.176.2" + + SetSecretRequest: + description: Request to store a secret value. + type: object + required: + - value + properties: + value: + type: string + description: The secret value to store. + + SecretMetadata: + description: Metadata for a stored secret (value is never exposed). + type: object + required: + - name + - created_at + - updated_at + properties: + name: + type: string + description: Secret key name. + example: ANTHROPIC_API_KEY + created_at: + type: string + format: date-time + description: When the secret was first stored. + updated_at: + type: string + format: date-time + description: When the secret was last updated. + + SecretListResponse: + description: List of stored secret metadata. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/SecretMetadata" + + RepoCheckResponse: + description: Repository access check result. + type: object + required: + - owner + - name + - accessible + properties: + owner: + type: string + description: GitHub repository owner. + example: acme-corp + name: + type: string + description: GitHub repository name. + example: my-app + accessible: + type: boolean + description: Whether the server has read-write access to this repository. + default_branch: + type: string + nullable: true + description: Default branch name, if accessible. + example: main + private: + type: boolean + nullable: true + description: Whether the repository is private, if accessible. + permissions: + type: object + nullable: true + description: Detected permission levels. + properties: + pull: + type: boolean + push: + type: boolean + admin: + type: boolean + install_url: + type: string + nullable: true + description: GitHub App installation URL when the repo is not yet accessible. + + DiagnosticsReport: + description: Server health diagnostics report. + type: object + required: + - version + - sections + properties: + version: + type: string + description: Server version. + sections: + type: array + items: + $ref: "#/components/schemas/DiagnosticsSection" + + DiagnosticsSection: + type: object + required: + - title + - checks + properties: + title: + type: string + checks: + type: array + items: + $ref: "#/components/schemas/DiagnosticsCheck" + + DiagnosticsCheck: + type: object + required: + - name + - status + - summary + properties: + name: + type: string + status: + type: string + enum: + - pass + - warning + - error + summary: + type: string + details: + type: array + items: + $ref: "#/components/schemas/DiagnosticsDetail" + remediation: + type: string + nullable: true + + DiagnosticsDetail: + type: object + required: + - text + - warn + properties: + text: + type: string + warn: + type: boolean UserResponse: description: Information about the authenticated user. diff --git a/docs/changelog/2026-03-03.mdx b/docs/changelog/2026-03-03.mdx index 1834534a8..2801c34a8 100644 --- a/docs/changelog/2026-03-03.mdx +++ b/docs/changelog/2026-03-03.mdx @@ -22,10 +22,12 @@ The wizard detects your current configuration, prompts for missing values, and w A single command to check your entire installation: system dependencies, cryptographic key validation, LLM provider connectivity, and web server configuration. ```bash -fabro doctor # quick local checks -fabro doctor --live # includes real-time API probes to each configured provider +fabro doctor +fabro doctor --verbose ``` +Later releases removed the separate `--live` mode; doctor now always uses live server-backed diagnostics. + If something is misconfigured, `fabro doctor` tells you exactly what's wrong and how to fix it. ## More diff --git a/docs/changelog/2026-03-07.mdx b/docs/changelog/2026-03-07.mdx index 6799c39d9..624d25d72 100644 --- a/docs/changelog/2026-03-07.mdx +++ b/docs/changelog/2026-03-07.mdx @@ -64,7 +64,7 @@ To migrate, regenerate your TypeScript client and update any direct API calls. - This release added `fabro llm prompt/chat --server-url`, but the `fabro llm` CLI namespace was later removed -- `fabro model list --server-url ` fetches the model list from the Fabro server +- `fabro model list --server ` now fetches the model list from the Fabro server - Added `--goal` arg to `fabro run start` to override the workflow goal from the command line - Turn and tool-call counts now display correctly in non-TTY mode diff --git a/docs/changelog/2026-03-10.mdx b/docs/changelog/2026-03-10.mdx index 5ff4dbca3..10e74bd50 100644 --- a/docs/changelog/2026-03-10.mdx +++ b/docs/changelog/2026-03-10.mdx @@ -35,7 +35,7 @@ devcontainer = true ``` -**`--no-dotenv` flag removed.** Fabro now always loads `~/.fabro/.env` and never loads a local `./.env` file. If you were relying on project-local `.env` files, move those values to `~/.fabro/.env`. +**Historical note.** This release temporarily standardized on `~/.fabro/.env`, but later releases removed automatic dotenv loading in favor of server-owned secrets plus explicit process environment variables. ## Manage PRs with fabro pr @@ -81,7 +81,7 @@ fabro preview - `fabro setup` renamed to `fabro install` for clarity - Added `fabro ssh` command for direct SSH access to Daytona sandboxes -- `fabro doctor` now runs live service probes by default; use `--dry-run` to skip +- `fabro doctor` now runs live service probes by default - `fabro doctor` now validates GitHub App configuration and private key - `fabro doctor` now hides unconfigured LLM providers for a cleaner output - `fabro doctor` sections reordered: Config, LLM, GitHub App, Cloud sandbox, Brave Search diff --git a/docs/changelog/2026-03-18.mdx b/docs/changelog/2026-03-18.mdx index 2c2978461..9987ad160 100644 --- a/docs/changelog/2026-03-18.mdx +++ b/docs/changelog/2026-03-18.mdx @@ -5,12 +5,11 @@ date: "2026-03-18" ## Secret management from the CLI -Managing API keys and credentials previously meant manually editing `~/.fabro/.env`. The new `fabro secret` commands let you get, set, list, and remove secrets directly from the CLI. +Managing API keys and credentials previously meant manually editing `~/.fabro/.env`. This release introduced `fabro secret` commands; later releases made secrets server-owned and write-only. ```bash fabro secret set ANTHROPIC_API_KEY sk-ant-... fabro secret list -fabro secret get ANTHROPIC_API_KEY fabro secret rm ANTHROPIC_API_KEY ``` @@ -30,4 +29,5 @@ The old `fabro init` still works but prints a deprecation warning. - Moved `fabro init` to `fabro repo init` with a backwards-compatible deprecation shim - Added `--show-values` flag to `fabro secret list` to reveal secret values + Later releases removed this flag when secrets became write-only diff --git a/docs/changelog/2026-03-19.mdx b/docs/changelog/2026-03-19.mdx index 6567f1ab8..26028c951 100644 --- a/docs/changelog/2026-03-19.mdx +++ b/docs/changelog/2026-03-19.mdx @@ -11,7 +11,7 @@ Re-authenticating with LLM providers previously required re-running the full `fa fabro provider login --provider openai ``` -For OpenAI, this launches the browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Credentials are merged non-destructively into `~/.fabro/.env`. +For OpenAI, this launches the browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Later releases moved these credentials into the server-owned secret store. ## Auto-detect default LLM provider diff --git a/docs/core-concepts/how-fabro-works.mdx b/docs/core-concepts/how-fabro-works.mdx index 70cf0c37d..66a63d9bc 100644 --- a/docs/core-concepts/how-fabro-works.mdx +++ b/docs/core-concepts/how-fabro-works.mdx @@ -24,7 +24,7 @@ You provide three inputs: 1. **Workflow graph** (`.fabro`) — A Graphviz file defining nodes, edges, and their attributes. This is the core of what Fabro executes. See [Workflows](/core-concepts/workflows). 2. **Run config** (`.toml`, optional) — Overrides for the default model, sandbox provider, setup commands, and variables. See [Run Configuration](/execution/run-configuration). -3. **API keys** (`.env`) — Provider credentials for LLM APIs. See [Quick Start](/getting-started/quick-start). +3. **Credentials** — Provider credentials from the server-owned secret store or the invoking shell environment. See [Quick Start](/getting-started/quick-start). ## Parse and validate @@ -96,4 +96,3 @@ fabro resume ``` The engine restores the full context, node visit counts, and retry state from the run directory, then continues execution from the next node. - diff --git a/docs/integrations/brave-search.mdx b/docs/integrations/brave-search.mdx index 263a9de93..6b9e16b2c 100644 --- a/docs/integrations/brave-search.mdx +++ b/docs/integrations/brave-search.mdx @@ -9,16 +9,16 @@ Fabro's [`web_search`](/agents/tools#web_search) tool lets agents search the web 1. Get a Brave Search API key from the [Brave Search API dashboard](https://brave.com/search/api/) -2. Add it to your `.env` file: +2. Store it on the Fabro server: ```bash -export BRAVE_SEARCH_API_KEY=BSA... +fabro secret set BRAVE_SEARCH_API_KEY BSA... ``` 3. Verify the key is working: ```bash -fabro doctor --live +fabro doctor ``` The doctor output should show **Brave Search** as "connected". If the key is missing, web search is reported as a warning — workflows still run, but `web_search` calls return an error. @@ -73,7 +73,7 @@ digraph Research { ## Troubleshooting -**"BRAVE_SEARCH_API_KEY environment variable is not set"** — Add the key to `.env` or your shell environment. Run `fabro doctor --live` to verify. +**"BRAVE_SEARCH_API_KEY environment variable is not set"** — Add the key with `fabro secret set` or export it in the server process environment. Run `fabro doctor` to verify. **"Brave Search API returned status 401"** — The API key is invalid or expired. Generate a new key from the [Brave Search API dashboard](https://brave.com/search/api/). diff --git a/docs/integrations/daytona.mdx b/docs/integrations/daytona.mdx index cb6e5e07f..12e7fcf15 100644 --- a/docs/integrations/daytona.mdx +++ b/docs/integrations/daytona.mdx @@ -164,7 +164,7 @@ See [Server Configuration](/administration/server-configuration) for details. ### "Failed to create Daytona sandbox" -The `DAYTONA_API_KEY` environment variable is missing or invalid. Verify it's set in your `.env` file and check that it's a valid key from [app.daytona.io](https://app.daytona.io). +The `DAYTONA_API_KEY` environment variable is missing or invalid. Store it with `fabro secret set DAYTONA_API_KEY ...` or export it in the server process environment, then check that it's a valid key from [app.daytona.io](https://app.daytona.io). ### "Snapshot does not exist and no dockerfile provided" diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index 39ede6911..5b000c452 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -43,7 +43,7 @@ Fabro uses a [GitHub App](https://docs.github.com/en/apps/overview) to authentic 4. GitHub redirects back to Fabro, which automatically: - Exchanges the temporary code for permanent app credentials - Writes `app_id`, `client_id`, and `slug` to `~/.fabro/server.toml` - - Writes `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` to `.env` + - Stores `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, and `GITHUB_APP_PRIVATE_KEY` in the server secret store - Generates a `SESSION_SECRET` for web app sessions - Redirects you to the login page @@ -63,9 +63,9 @@ The GitHub App check verifies five fields: |---|---| | `git.app_id` | `~/.fabro/server.toml` | | `git.client_id` | `~/.fabro/server.toml` | -| `GITHUB_APP_CLIENT_SECRET` | `.env` | -| `GITHUB_APP_WEBHOOK_SECRET` | `.env` | -| `GITHUB_APP_PRIVATE_KEY` | `.env` | +| `GITHUB_APP_CLIENT_SECRET` | Server secret store | +| `GITHUB_APP_WEBHOOK_SECRET` | Server secret store | +| `GITHUB_APP_PRIVATE_KEY` | Server secret store | If all five are set, the check passes. If none are set, it warns (GitHub integration is optional). If some are set but others are missing, it errors with the specific missing fields. @@ -90,13 +90,13 @@ slug = "fabro-a3f2" | `client_id` | OAuth Client ID for the app | | `slug` | App slug, used for linking to the GitHub App settings page | -### `.env` +### Server secret store -```bash -GITHUB_APP_CLIENT_SECRET=... # OAuth client secret -GITHUB_APP_WEBHOOK_SECRET=... # Webhook validation secret (reserved for future use) -GITHUB_APP_PRIVATE_KEY=... # RSA private key, base64-encoded PEM -``` +Fabro stores the GitHub App secrets in the server secret store under these keys: + +- `GITHUB_APP_CLIENT_SECRET` +- `GITHUB_APP_WEBHOOK_SECRET` +- `GITHUB_APP_PRIVATE_KEY` The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`). diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index ff1b9b049..7e74b9135 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -9,14 +9,15 @@ These flags apply to all subcommands: | Flag | Description | |---|---| +| `--json` | Output machine-readable JSON when the command supports it | | `--debug` | Enable DEBUG-level logging (default is INFO) | | `--no-upgrade-check` | Skip the automatic background upgrade check | -| `--storage-dir ` | Local storage directory for Fabro data (default: `~/.fabro`). | -| `--server-url ` | Fabro API server URL for commands that support a remote target (overrides `server.base_url` from `user.toml`). | +| `--quiet` | Suppress non-essential output | +| `--verbose` | Enable verbose output | | `-h, --help` | Print help | | `-V, --version` | Print version | -Fabro loads environment variables from `~/.fabro/.env`. +Connection-target flags like `--storage-dir` and `--server` are command-specific, not global. Fabro no longer auto-loads `~/.fabro/.env`; persist server-owned credentials with `fabro provider login` / `fabro secret set`, or provide environment variables in the invoking shell. ## Configuration @@ -33,12 +34,12 @@ output_format = "text" model = "claude-sonnet-4-5" [server] -base_url = "https://fabro.example.com:3000/api/v1" +target = "https://fabro.example.com:3000/api/v1" ``` -`[exec]` config applies to `fabro exec`. `[llm]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[server]` stores connection info for commands that can target a remote Fabro API. +`[exec]` config applies to `fabro exec`. `[llm]` sets the default workflow model/provider for commands like `fabro run` and `fabro preflight`. `[server]` stores connection info for commands that can target a remote Fabro server. -`fabro model` uses `[server].base_url` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server-url`, even if `[server].base_url` is configured. +`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. `fabro exec` remains a local session unless you pass `--server`, even if `[server].target` is configured. CLI flags always override `user.toml` values, which override hardcoded defaults. @@ -270,7 +271,7 @@ List available LLM models from the built-in catalog. Running `fabro model` with fabro model list fabro model list -p anthropic fabro model list -q sonnet -fabro model list --server-url http://localhost:3000/api/v1 +fabro model list --server http://localhost:3000/api/v1 ``` | Flag | Description | @@ -686,18 +687,17 @@ Without `--signed`, the command prints the URL, token, and a `curl` example. See ## `fabro doctor` -Check environment and integration health. Verifies system dependencies, API keys, and optional services. Probes live services (LLM providers, sandbox, GitHub App) by default. +Check environment and integration health. `fabro doctor` always performs live server-backed diagnostics and keeps only local user-config and legacy `.env` checks on the CLI side. ```bash fabro doctor fabro doctor -v -fabro doctor --dry-run +fabro doctor --server https://fabro.example.com:3000/api/v1 ``` | Flag | Description | |---|---| | `-v, --verbose` | Show detailed information for each check | -| `--dry-run` | Skip live service probes (LLM, sandbox, API, web, Brave Search) | ## `fabro upgrade` @@ -768,7 +768,7 @@ fabro provider login --provider anthropic |---|---| | `--provider ` | LLM provider to authenticate with (required) | -For OpenAI, this launches a browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Credentials are merged non-destructively into `~/.fabro/.env`. +For OpenAI, this launches a browser-based OAuth PKCE flow with an automatic fallback to manual API key entry. All other providers prompt for an API key with validation. Credentials are saved to the connected Fabro server's secret store. ## `fabro install` @@ -787,7 +787,7 @@ fabro install --web-url https://fabro.example.com ## `fabro secret set` -Store a secret in `~/.fabro/.env`. +Store or update a server-owned secret on the connected Fabro server. ```bash fabro secret set ANTHROPIC_API_KEY sk-ant-... @@ -798,34 +798,17 @@ fabro secret set ANTHROPIC_API_KEY sk-ant-... | `` | Name of the secret (required) | | `` | Value to store (required) | -## `fabro secret get` - -Print the value of a secret from `~/.fabro/.env`. - -```bash -fabro secret get ANTHROPIC_API_KEY -``` - -| Argument | Description | -|---|---| -| `` | Name of the secret (required) | - ## `fabro secret list` -List secret names stored in `~/.fabro/.env`. +List server-owned secret names. Values are never returned after storage. ```bash fabro secret list -fabro secret list --show-values ``` -| Flag | Description | -|---|---| -| `--show-values` | Print values alongside keys | - ## `fabro secret rm` -Remove a secret from `~/.fabro/.env`. +Remove a server-owned secret from the connected Fabro server. ```bash fabro secret rm ANTHROPIC_API_KEY diff --git a/docs/reference/user-configuration.mdx b/docs/reference/user-configuration.mdx index dd4058950..b66de17b8 100644 --- a/docs/reference/user-configuration.mdx +++ b/docs/reference/user-configuration.mdx @@ -24,7 +24,7 @@ verbose = true upgrade_check = true [server] -base_url = "https://fabro.example.com:3000/api/v1" +target = "https://fabro.example.com:3000/api/v1" [server.tls] cert = "~/.fabro/tls/client.crt" @@ -149,19 +149,19 @@ Customize the git author identity used for checkpoint commits. Overrides the ser ## `[server]` section -Connection info for commands that can target a remote Fabro API server. +Connection info for commands that can target a remote Fabro server. | Key | Description | Default | |---|---|---| -| `base_url` | Server URL | `"http://localhost:3000/api/v1"` | +| `target` | Server target: `http(s)` URL or absolute Unix socket path | none | -`fabro model` uses `[server].base_url` by default when no explicit `--storage-dir` is passed. `--server-url` overrides `server.base_url`: +`fabro model` uses `[server].target` by default when no explicit `--storage-dir` is passed. An explicit `--server` flag overrides `server.target`: ```bash -fabro --server-url https://fabro.example.com:3000/api/v1 model list +fabro model list --server https://fabro.example.com:3000/api/v1 ``` -`fabro exec` does not automatically use `[server].base_url`. It only routes model traffic through a Fabro server when you pass `--server-url` for that invocation. +`fabro exec` does not automatically use `[server].target`. It only routes model traffic through a Fabro server when you pass `--server` for that invocation. ### `[server.tls]` section diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 43d6d54c3..fe61acf9d 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -62,36 +62,36 @@ impl StorageDirArgs { } #[derive(Args, Debug, Clone, Default)] -pub(crate) struct ServerUrlArgs { - /// Fabro API server URL (overrides server.base_url from user.toml when supported) - #[arg(long, env = "FABRO_SERVER_URL")] - pub(crate) server_url: Option, +pub(crate) struct ServerTargetArgs { + /// Fabro server target: http(s) URL or absolute Unix socket path + #[arg(long = "server", env = "FABRO_SERVER")] + pub(crate) server: Option, } -impl ServerUrlArgs { +impl ServerTargetArgs { pub(crate) fn as_deref(&self) -> Option<&str> { - self.server_url.as_deref() + self.server.as_deref() } } #[derive(Args, Debug, Clone, Default)] -pub(crate) struct ModelTargetArgs { +pub(crate) struct ServerConnectionArgs { /// Local storage directory (default: ~/.fabro) - #[arg(long, env = "FABRO_STORAGE_DIR", conflicts_with = "server_url")] + #[arg(long, env = "FABRO_STORAGE_DIR", conflicts_with = "server")] pub(crate) storage_dir: Option, - /// Fabro API server URL (overrides server.base_url from user.toml when supported) - #[arg(long, env = "FABRO_SERVER_URL", conflicts_with = "storage_dir")] - pub(crate) server_url: Option, + /// Fabro server target: http(s) URL or absolute Unix socket path + #[arg(long = "server", env = "FABRO_SERVER", conflicts_with = "storage_dir")] + pub(crate) server: Option, } -impl ModelTargetArgs { +impl ServerConnectionArgs { pub(crate) fn storage_dir(&self) -> Option<&Path> { self.storage_dir.as_deref() } - pub(crate) fn server_url(&self) -> Option<&str> { - self.server_url.as_deref() + pub(crate) fn server(&self) -> Option<&str> { + self.server.as_deref() } } @@ -491,17 +491,7 @@ pub(crate) struct StoreDumpArgs { } #[derive(Args)] -pub(crate) struct SecretGetArgs { - /// Name of the secret - pub(crate) key: String, -} - -#[derive(Args)] -pub(crate) struct SecretListArgs { - /// Show values alongside keys - #[arg(long)] - pub(crate) show_values: bool, -} +pub(crate) struct SecretListArgs; #[derive(Args)] pub(crate) struct SecretRmArgs { @@ -602,6 +592,9 @@ pub(crate) struct WorkflowCreateArgs { #[derive(Args)] pub(crate) struct ProviderLoginArgs { + #[command(flatten)] + pub(crate) target: ServerConnectionArgs, + /// LLM provider to authenticate with #[arg(long)] pub(crate) provider: fabro_model::Provider, @@ -760,7 +753,7 @@ pub(crate) struct RunnerArgs { #[derive(Args, Debug, Clone, Default)] pub(crate) struct ModelListArgs { #[command(flatten)] - pub(crate) target: ModelTargetArgs, + pub(crate) target: ServerConnectionArgs, /// Filter by provider #[arg(short, long)] @@ -774,7 +767,7 @@ pub(crate) struct ModelListArgs { #[derive(Args, Debug, Clone, Default)] pub(crate) struct ModelTestArgs { #[command(flatten)] - pub(crate) target: ModelTargetArgs, + pub(crate) target: ServerConnectionArgs, /// Filter by provider #[arg(short, long)] @@ -792,7 +785,7 @@ pub(crate) struct ModelTestArgs { #[derive(Args)] pub(crate) struct ExecArgs { #[command(flatten)] - pub(crate) server_url: ServerUrlArgs, + pub(crate) server: ServerTargetArgs, #[command(flatten)] pub(crate) agent: AgentArgs, @@ -939,27 +932,15 @@ pub(crate) enum Commands { /// Server operations Server(ServerNamespace), /// Check environment and integration health - Doctor { - /// Show detailed information for each check - #[arg(short, long)] - verbose: bool, - - /// Skip live service probes (LLM, sandbox, API, web, Brave Search) - #[arg(long)] - dry_run: bool, - }, + Doctor(DoctorArgs), /// Set up the Fabro environment (LLMs, certs, GitHub) - Install { - /// Base URL for the web UI (used for OAuth callback URLs) - #[arg(long, default_value = "http://localhost:3000")] - web_url: String, - }, + Install(InstallArgs), /// Pull request operations Pr(PrNamespace), /// Skill management #[command(hide = true)] Skill(SkillNamespace), - /// Manage secrets in ~/.fabro/.env + /// Manage server-owned secrets Secret(SecretNamespace), /// Inspect merged configuration Settings(SettingsArgs), @@ -1033,12 +1014,12 @@ impl Commands { ServerCommand::Status(_) => "server status", ServerCommand::Serve(_) => "server __serve", }, - Self::Doctor { .. } => "doctor", + Self::Doctor(_) => "doctor", Self::Repo(ns) => match &ns.command { - RepoCommand::Init { .. } => "repo init", + RepoCommand::Init(_) => "repo init", RepoCommand::Deinit => "repo deinit", }, - Self::Install { .. } => "install", + Self::Install(_) => "install", Self::Pr(ns) => match &ns.command { PrCommand::Create(_) => "pr create", PrCommand::List(_) => "pr list", @@ -1047,7 +1028,6 @@ impl Commands { PrCommand::Close(_) => "pr close", }, Self::Secret(ns) => match &ns.command { - SecretCommand::Get(_) => "secret get", SecretCommand::List(_) => "secret list", SecretCommand::Rm(_) => "secret rm", SecretCommand::Set(_) => "secret set", @@ -1128,14 +1108,15 @@ pub(crate) enum StoreCommand { #[derive(Args)] pub(crate) struct SecretNamespace { + #[command(flatten)] + pub(crate) target: ServerConnectionArgs, + #[command(subcommand)] pub(crate) command: SecretCommand, } #[derive(Subcommand)] pub(crate) enum SecretCommand { - /// Get a secret value - Get(SecretGetArgs), /// List secret names #[command(alias = "ls")] List(SecretListArgs), @@ -1249,15 +1230,41 @@ pub(crate) struct RepoNamespace { #[derive(Subcommand)] pub(crate) enum RepoCommand { /// Initialize a new project - Init { - /// Also install the fabro-create-workflow skill - #[arg(long, hide = true)] - skill: bool, - }, + Init(RepoInitArgs), /// Remove fabro.toml and fabro/ directory Deinit, } +#[derive(Args)] +pub(crate) struct RepoInitArgs { + #[command(flatten)] + pub(crate) target: ServerConnectionArgs, + + /// Also install the fabro-create-workflow skill + #[arg(long, hide = true)] + pub(crate) skill: bool, +} + +#[derive(Args)] +pub(crate) struct DoctorArgs { + #[command(flatten)] + pub(crate) target: ServerConnectionArgs, + + /// Show detailed information for each check + #[arg(short, long)] + pub(crate) verbose: bool, +} + +#[derive(Args)] +pub(crate) struct InstallArgs { + #[command(flatten)] + pub(crate) storage_dir: StorageDirArgs, + + /// Base URL for the web UI (used for OAuth callback URLs) + #[arg(long, default_value = "http://localhost:3000")] + pub(crate) web_url: String, +} + #[derive(Args)] pub(crate) struct ProviderNamespace { #[command(subcommand)] diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 3848f1936..b1dd6d502 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -2,28 +2,21 @@ use std::path::PathBuf; use std::process::Command; use std::sync::LazyLock; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use fabro_config::server::{self, ApiAuthStrategy, AuthProvider}; +use anyhow::Result; +use fabro_api::types as api_types; +use fabro_config::dotenv; use fabro_config::user::{default_user_config_path, legacy_user_config_path}; -use fabro_llm::client::Client as LlmClient; -use fabro_llm::types::{Message, Request}; -use fabro_model::{Catalog, Provider}; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; use fabro_util::terminal::Styles; -use futures::future::join_all; +use fabro_util::version::FABRO_VERSION; use regex::Regex; use semver::Version; -use crate::args::GlobalArgs; +use crate::args::{DoctorArgs, GlobalArgs}; +use crate::server_client; use crate::shared::print_json_pretty; -use crate::user_config::load_user_settings; - -// --------------------------------------------------------------------------- -// System dependency types and parsers (server mode only) -// --------------------------------------------------------------------------- pub(crate) struct DepSpec { pub name: &'static str, @@ -42,19 +35,9 @@ pub(crate) enum ProbeOutcome { static OPENSSL_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?:OpenSSL|LibreSSL)\s+(\d+)\.(\d+)\.(\d+)").unwrap()); -static NODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"v(\d+)\.(\d+)\.(\d+)").unwrap()); static DOT_RE: LazyLock = LazyLock::new(|| Regex::new(r"graphviz version (\d+)\.(\d+)\.(\d+)").unwrap()); -fn parse_version(re: &Regex, output: &str) -> Option { - let caps = re.captures(output)?; - Some(Version::new( - caps[1].parse().ok()?, - caps[2].parse().ok()?, - caps[3].parse().ok()?, - )) -} - pub(crate) const DEP_SPECS: &[DepSpec] = &[ DepSpec { name: "openssl", @@ -63,13 +46,6 @@ pub(crate) const DEP_SPECS: &[DepSpec] = &[ min_version: Version::new(3, 0, 0), pattern: &OPENSSL_RE, }, - DepSpec { - name: "node", - command: &["node", "--version"], - required: true, - min_version: Version::new(20, 0, 0), - pattern: &NODE_RE, - }, DepSpec { name: "dot", command: &["dot", "-V"], @@ -79,6 +55,15 @@ pub(crate) const DEP_SPECS: &[DepSpec] = &[ }, ]; +fn parse_version(re: &Regex, output: &str) -> Option { + let caps = re.captures(output)?; + Some(Version::new( + caps[1].parse().ok()?, + caps[2].parse().ok()?, + caps[3].parse().ok()?, + )) +} + pub(crate) fn probe_system_deps() -> Vec { DEP_SPECS .iter() @@ -90,10 +75,10 @@ pub(crate) fn probe_system_deps() -> Vec { match result { None => ProbeOutcome::NotFound, - Some(o) if !o.status.success() => ProbeOutcome::Failed, - Some(o) => { - let stdout = String::from_utf8_lossy(&o.stdout); - let stderr = String::from_utf8_lossy(&o.stderr); + Some(output) if !output.status.success() => ProbeOutcome::Failed, + Some(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); let version = parse_version(spec.pattern, &stdout) .or_else(|| parse_version(spec.pattern, &stderr)); ProbeOutcome::Ok { version } @@ -124,14 +109,16 @@ pub(crate) fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) -> ProbeOutcome::Ok { version: None } => { (CheckStatus::Pass, format!("{}: version unknown", spec.name)) } - ProbeOutcome::Ok { version: Some(v) } => { - if v < &spec.min_version { + ProbeOutcome::Ok { + version: Some(version), + } => { + if version < &spec.min_version { ( CheckStatus::Warning, - format!("{}: {v} (minimum {})", spec.name, spec.min_version), + format!("{}: {version} (minimum {})", spec.name, spec.min_version), ) } else { - (CheckStatus::Pass, format!("{}: {v}", spec.name)) + (CheckStatus::Pass, format!("{}: {version}", spec.name)) } } }; @@ -160,46 +147,24 @@ pub(crate) fn check_system_deps(specs: &[DepSpec], outcomes: &[ProbeOutcome]) -> } } -// --------------------------------------------------------------------------- -// Check functions (pure, testable) -// --------------------------------------------------------------------------- - -fn apply_live_result( - live_result: Option<&Result<(), String>>, - details: &mut Vec, - remediation_msg: &str, -) -> (CheckStatus, Option) { - match live_result { - Some(Ok(())) => { - details.push(CheckDetail::new("Connectivity: OK".to_string())); - (CheckStatus::Pass, None) - } - Some(Err(e)) => { - details.push(CheckDetail::new(format!("Connectivity: {e}"))); - (CheckStatus::Warning, Some(remediation_msg.to_string())) - } - None => (CheckStatus::Pass, None), - } -} - pub(crate) fn check_config( user_path: Option, legacy_path: Option, ) -> CheckResult { match (user_path, legacy_path) { - (Some(p), None) => CheckResult { + (Some(path), None) => CheckResult { name: "Configuration".to_string(), status: CheckStatus::Pass, - summary: p.display().to_string(), - details: vec![CheckDetail::new(format!("Loaded from {}", p.display()))], + summary: path.display().to_string(), + details: vec![CheckDetail::new(format!("Loaded from {}", path.display()))], remediation: None, }, - (Some(p), Some(legacy)) => CheckResult { + (Some(path), Some(legacy)) => CheckResult { name: "Configuration".to_string(), status: CheckStatus::Warning, - summary: p.display().to_string(), + summary: path.display().to_string(), details: vec![ - CheckDetail::new(format!("Loaded from {}", p.display())), + CheckDetail::new(format!("Loaded from {}", path.display())), CheckDetail::new(format!("Ignoring legacy config file {}", legacy.display())), ], remediation: Some(format!("Delete or rename {}", legacy.display())), @@ -226,684 +191,102 @@ pub(crate) fn check_config( } } -pub(crate) fn check_llm_providers( - statuses: &[(Provider, bool)], - live_results: Option<&[(Provider, Result<(), String>)]>, -) -> CheckResult { - let count = statuses.iter().filter(|(_, set)| *set).count(); - - let mut details: Vec = statuses - .iter() - .filter(|(_, set)| *set) - .map(|(provider, _)| { - let env_vars = provider.api_key_env_vars().join(" or "); - CheckDetail::new(format!("{provider} ({env_vars}): set")) - }) - .collect(); - - let mut failed_providers: Vec<&Provider> = Vec::new(); - if let Some(results) = live_results { - for (provider, result) in results { - match result { - Ok(()) => details.push(CheckDetail::new(format!("{provider} connectivity: OK",))), - Err(e) => { - failed_providers.push(provider); - details.push(CheckDetail::new(format!("{provider} connectivity: {e}",))); - } - } - } - } - - if count == 0 { - CheckResult { - name: "LLM providers".to_string(), - status: CheckStatus::Error, - summary: "none configured".to_string(), - details, - remediation: Some("Set at least one provider API key".to_string()), - } - } else if !failed_providers.is_empty() { - let names: Vec<_> = failed_providers - .iter() - .map(std::string::ToString::to_string) - .collect(); - CheckResult { - name: "LLM providers".to_string(), +fn check_legacy_env(path: Option) -> CheckResult { + match path { + Some(path) => CheckResult { + name: "Legacy .env".to_string(), status: CheckStatus::Warning, - summary: format!("{count} configured (connectivity issues)"), - details, - remediation: Some(format!("Connectivity issues with: {}", names.join(", "))), - } - } else { - CheckResult { - name: "LLM providers".to_string(), - status: CheckStatus::Pass, - summary: format!("{count} configured"), - details, - remediation: None, - } - } -} - -pub(crate) fn check_brave_search( - api_key_set: bool, - live_result: Option<&Result<(), String>>, -) -> CheckResult { - let mut details = vec![CheckDetail::new(format!( - "BRAVE_SEARCH_API_KEY is {}", - if api_key_set { "set" } else { "not set" } - ))]; - - let (mut status, mut remediation) = if api_key_set { - (CheckStatus::Pass, None) - } else { - ( - CheckStatus::Warning, - Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()), - ) - }; - - if api_key_set { - let (live_status, live_remediation) = apply_live_result( - live_result, - &mut details, - "Check BRAVE_SEARCH_API_KEY and network connectivity", - ); - if live_status == CheckStatus::Warning { - status = live_status; - remediation = live_remediation; - } - } - - let summary = match (api_key_set, live_result) { - (true, Some(Ok(()))) => "API key set, connected".to_string(), - (true, Some(Err(_))) => "API key set, connectivity error".to_string(), - (true, None) => "API key set".to_string(), - (false, _) => "not configured".to_string(), - }; - - CheckResult { - name: "Brave Search".to_string(), - status, - summary, - details, - remediation, - } -} - -pub(crate) struct SandboxStatus { - pub daytona_configured: bool, - pub daytona_probe: Option>, -} - -pub(crate) fn check_sandbox(status: &SandboxStatus) -> CheckResult { - let mut details = Vec::new(); - - match &status.daytona_probe { - Some(Ok(())) => { - details.push(CheckDetail::new( - "Daytona (DAYTONA_API_KEY): available".to_string(), - )); - return CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Pass, - summary: "Daytona available".to_string(), - details, - remediation: None, - }; - } - Some(Err(e)) => { - details.push(CheckDetail::new(format!( - "Daytona (DAYTONA_API_KEY): error — {e}", - ))); - return CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Error, - summary: format!("Daytona: {e}"), - details, - remediation: Some("Fix sandbox configuration errors".to_string()), - }; - } - None if status.daytona_configured => { - details.push(CheckDetail::new( - "Daytona (DAYTONA_API_KEY): configured".to_string(), - )); - return CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Pass, - summary: "Daytona configured".to_string(), - details, - remediation: None, - }; - } - None => { - details.push(CheckDetail::new( - "Daytona (DAYTONA_API_KEY): not configured".to_string(), - )); - } - } - - CheckResult { - name: "Cloud sandbox".to_string(), - status: CheckStatus::Warning, - summary: "no sandbox configured".to_string(), - details, - remediation: Some("Set DAYTONA_API_KEY to enable cloud sandbox execution".to_string()), - } -} - -pub(crate) struct GithubAppStatus { - pub app_id: Option, - pub slug: Option, - pub private_key_set: bool, - /// Result of attempting to sign a JWT with the configured credentials. - /// `None` if app_id or private key is missing. - pub sign_result: Option>, - pub client_id: bool, - pub client_secret: bool, - pub webhook_secret: bool, -} - -impl GithubAppStatus { - fn core_set(&self) -> bool { - self.app_id.is_some() && self.private_key_set - } - - fn none_set(&self) -> bool { - let core_none = self.app_id.is_none() && !self.private_key_set; - core_none && !self.client_id && !self.client_secret && !self.webhook_secret - } - - fn all_set(&self) -> bool { - self.core_set() && self.client_id && self.client_secret && self.webhook_secret - } -} - -pub(crate) fn check_github_app(status: &GithubAppStatus) -> CheckResult { - let mut details: Vec = Vec::new(); - - match (&status.app_id, &status.slug) { - (Some(id), Some(slug)) => details.push(CheckDetail::new(format!("App: {slug} (ID {id})"))), - (Some(id), None) => details.push(CheckDetail::new(format!("App ID: {id}"))), - _ => details.push(CheckDetail::new("git.app_id: not set".to_string())), - } - - details.push(CheckDetail::new(format!( - "GITHUB_APP_PRIVATE_KEY: {}", - if status.private_key_set { - "set" - } else { - "not set" - } - ))); - - let mut fields: Vec<(&str, bool)> = vec![ - ("git.app_id", status.app_id.is_some()), - ("GITHUB_APP_PRIVATE_KEY", status.private_key_set), - ]; - - { - let server_fields: Vec<(&str, bool)> = vec![ - ("git.client_id", status.client_id), - ("GITHUB_APP_CLIENT_SECRET", status.client_secret), - ("GITHUB_APP_WEBHOOK_SECRET", status.webhook_secret), - ]; - for (name, set) in &server_fields { - details.push(CheckDetail::new(format!( - "{name}: {}", - if *set { "set" } else { "not set" } - ))); - } - fields.extend(server_fields); - } - - // Add key validation detail - if let Some(ref result) = status.sign_result { - match result { - Ok(()) => details.push(CheckDetail::new( - "Private key: valid (JWT signing OK)".to_string(), - )), - Err(e) => details.push(CheckDetail::new(format!("Private key: invalid ({e})"))), - } - } - - let has_sign_error = matches!(&status.sign_result, Some(Err(_))); - - if has_sign_error { - let msg = status - .sign_result - .as_ref() - .unwrap() - .as_ref() - .unwrap_err() - .clone(); - return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "private key invalid".to_string(), - details, - remediation: Some(format!( - "GITHUB_APP_PRIVATE_KEY failed JWT signing: {msg}. \ - Generate a new private key from your GitHub App settings." - )), - }; - } - - if status.none_set() { - return CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Warning, - summary: "not configured".to_string(), - details, + summary: "legacy secrets file detected".to_string(), + details: vec![CheckDetail::new(format!( + "{} is no longer read by fabro", + path.display() + ))], remediation: Some( - "Configure GitHub App in server.toml and set env vars to enable GitHub integration" + "Re-enter credentials with `fabro provider login` or `fabro secret set`." .to_string(), ), - }; - } - - if status.all_set() { - return CheckResult { - name: "GitHub App".to_string(), + }, + None => CheckResult { + name: "Legacy .env".to_string(), status: CheckStatus::Pass, - summary: "fully configured".to_string(), - details, - remediation: None, - }; - } - - let missing: Vec<_> = fields - .iter() - .filter(|(_, set)| !set) - .map(|(name, _)| *name) - .collect(); - CheckResult { - name: "GitHub App".to_string(), - status: CheckStatus::Error, - summary: "partially configured".to_string(), - details, - remediation: Some(format!("Missing: {}", missing.join(", "))), - } -} - -pub(crate) struct ApiStatus { - pub base_url: String, - pub authentication_strategies: Vec, -} - -fn format_auth_strategies(strategies: &[ApiAuthStrategy]) -> String { - strategies - .iter() - .map(|s| match s { - ApiAuthStrategy::Jwt => "jwt", - ApiAuthStrategy::Mtls => "mtls", - }) - .collect::>() - .join(", ") -} - -pub(crate) fn check_api( - status: &ApiStatus, - live_result: Option<&Result<(), String>>, -) -> CheckResult { - let mut details = vec![ - CheckDetail::new(format!("Base URL: {}", status.base_url)), - CheckDetail::new(format!( - "Authentication: {}", - format_auth_strategies(&status.authentication_strategies) - )), - ]; - - let (check_status, remediation) = apply_live_result( - live_result, - &mut details, - "Check that the API server is running and reachable", - ); - - CheckResult { - name: "Fabro API".to_string(), - status: check_status, - summary: status.base_url.clone(), - details, - remediation, - } -} - -pub(crate) struct WebStatus { - pub url: String, - pub auth_provider: AuthProvider, - pub allowed_usernames_count: usize, -} - -fn format_auth_provider(provider: &AuthProvider) -> &'static str { - match provider { - AuthProvider::Github => "github", - AuthProvider::InsecureDisabled => "insecure_disabled", - } -} - -pub(crate) fn check_web( - status: &WebStatus, - live_result: Option<&Result<(), String>>, -) -> CheckResult { - let mut details = vec![ - CheckDetail::new(format!("URL: {}", status.url)), - CheckDetail::new(format!( - "Auth provider: {}", - format_auth_provider(&status.auth_provider) - )), - CheckDetail::new(format!( - "Allowed usernames: {}", - status.allowed_usernames_count - )), - ]; - - let (check_status, remediation) = apply_live_result( - live_result, - &mut details, - "Check that the web app is running and reachable", - ); - - CheckResult { - name: "Fabro Web".to_string(), - status: check_status, - summary: status.url.clone(), - details, - remediation, - } -} - -// --------------------------------------------------------------------------- -// Cryptographic key validation -// --------------------------------------------------------------------------- - -#[allow(clippy::struct_field_names)] -pub(crate) struct TlsCheckInput { - pub cert_pem: String, - pub key_pem: String, - pub ca_pem: String, -} - -pub(crate) struct CryptoInput { - pub auth_strategies: Vec, - pub tls_files: Option>, - pub jwt_public_key: Option, - pub jwt_private_key: Option, - pub session_secret: Option, - pub now_epoch: i64, -} - -fn decode_pem_value(name: &str, value: &str) -> Result { - if value.starts_with("-----") { - return Ok(value.to_string()); - } - let bytes = base64::Engine::decode(&BASE64_STANDARD, value) - .map_err(|e| format!("{name} is not valid PEM or base64: {e}"))?; - String::from_utf8(bytes).map_err(|e| format!("{name} base64 decoded to invalid UTF-8: {e}")) -} - -fn validate_tls_cert(pem: &str, now_epoch: i64) -> Result { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - let certs: Vec<_> = rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .map_err(|e| format!("failed to parse certificate PEM: {e}"))?; - if certs.is_empty() { - return Err("no certificates found in PEM".to_string()); - } - let (_, parsed) = x509_parser::parse_x509_certificate(&certs[0]) - .map_err(|e| format!("failed to parse X.509 certificate: {e}"))?; - let not_after = parsed.validity().not_after.timestamp(); - if not_after <= now_epoch { - return Err("certificate has expired".to_string()); - } - let cn = parsed - .subject() - .iter_common_name() - .next() - .and_then(|cn| cn.as_str().ok()) - .unwrap_or("(no CN)"); - Ok(format!("CN={cn}, valid")) -} - -fn validate_tls_private_key(pem: &str) -> Result<(), String> { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - rustls_pemfile::private_key(&mut reader) - .map_err(|e| format!("failed to parse private key PEM: {e}"))? - .ok_or_else(|| "no private key found in PEM".to_string())?; - Ok(()) -} - -fn validate_tls_ca(pem: &str) -> Result<(), String> { - let mut reader = std::io::Cursor::new(pem.as_bytes()); - let certs: Vec<_> = rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .map_err(|e| format!("failed to parse CA certificate PEM: {e}"))?; - if certs.is_empty() { - return Err("no CA certificates found in PEM".to_string()); - } - Ok(()) -} - -fn validate_session_secret(value: &str) -> Result<(), String> { - if value.len() < 64 { - return Err(format!( - "too short ({} chars, need at least 64 hex chars for 256-bit entropy)", - value.len() - )); - } - if !value.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("contains non-hex characters".to_string()); - } - Ok(()) -} - -struct CryptoCheckState { - details: Vec, - errors: Vec, - worst: CheckStatus, -} - -impl CryptoCheckState { - fn new() -> Self { - Self { + summary: "not present".to_string(), details: Vec::new(), - errors: Vec::new(), - worst: CheckStatus::Pass, - } - } - - /// Record a validation result. Ok(suffix) becomes "{label}: {suffix}" detail, - /// Err(msg) becomes an error detail and is accumulated for remediation. - fn record(&mut self, label: &str, result: Result) { - match result { - Ok(suffix) => self - .details - .push(CheckDetail::new(format!("{label}: {suffix}"))), - Err(e) => { - self.worst = CheckStatus::Error; - let text = format!("{label}: {e}"); - self.errors.push(text.clone()); - self.details.push(CheckDetail::new(text)); - } - } - } - - fn record_unit(&mut self, label: &str, result: Result<(), String>) { - self.record(label, result.map(|()| "valid".to_string())); - } - - fn push_error(&mut self, text: String) { - self.worst = CheckStatus::Error; - self.errors.push(text.clone()); - self.details.push(CheckDetail::new(text)); - } -} - -pub(crate) fn check_crypto(input: &CryptoInput) -> CheckResult { - let has_jwt = input.auth_strategies.contains(&ApiAuthStrategy::Jwt); - let has_mtls = input.auth_strategies.contains(&ApiAuthStrategy::Mtls); - - let mut state = CryptoCheckState::new(); - - // mTLS certs - if has_mtls { - match &input.tls_files { - Some(Ok(tls)) => { - state.record( - "TLS cert", - validate_tls_cert(&tls.cert_pem, input.now_epoch) - .map(|info| format!("valid ({info})")), - ); - state.record_unit("TLS key", validate_tls_private_key(&tls.key_pem)); - state.record_unit("TLS CA", validate_tls_ca(&tls.ca_pem)); - } - Some(Err(e)) => state.push_error(format!("TLS files: {e}")), - None => state.push_error("mTLS configured but [api.tls] not set".to_string()), - } - } - - // JWT public key - if has_jwt { - let result = input - .jwt_public_key - .as_deref() - .ok_or_else(|| "JWT configured but FABRO_JWT_PUBLIC_KEY not set".to_string()) - .and_then(|raw| decode_pem_value("FABRO_JWT_PUBLIC_KEY", raw)) - .and_then(|pem| { - jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes()) - .map(|_| ()) - .map_err(|e| format!("invalid Ed25519 — {e}")) - }); - state.record_unit("JWT public key", result); - } - - // JWT private key (only when set) - if let Some(raw) = &input.jwt_private_key { - let result = decode_pem_value("FABRO_JWT_PRIVATE_KEY", raw).and_then(|pem| { - jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()) - .map(|_| ()) - .map_err(|e| format!("invalid Ed25519 — {e}")) - }); - state.record_unit("JWT private key", result); - } - - // Session secret (only when set) - if let Some(secret) = &input.session_secret { - state.record_unit("Session secret", validate_session_secret(secret)); - } - - // No auth at all - if !has_jwt && !has_mtls && input.jwt_private_key.is_none() && input.session_secret.is_none() { - return CheckResult { - name: "Cryptographic keys".to_string(), - status: CheckStatus::Warning, - summary: "no authentication configured".to_string(), - details: vec![CheckDetail::new( - "No authentication strategies or keys configured".to_string(), - )], - remediation: Some( - "Configure authentication_strategies in [api] section of server.toml".to_string(), - ), - }; - } - - let summary = match state.worst { - CheckStatus::Pass => "all keys valid".to_string(), - CheckStatus::Warning => "some issues".to_string(), - CheckStatus::Error => "invalid keys found".to_string(), - }; - - CheckResult { - name: "Cryptographic keys".to_string(), - status: state.worst, - summary, - details: state.details, - remediation: if state.errors.is_empty() { - None - } else { - Some(state.errors.join("; ")) + remediation: None, }, } } -// --------------------------------------------------------------------------- -// Orchestrator (does real I/O) -// --------------------------------------------------------------------------- - -async fn probe_daytona() -> Option> { - if std::env::var("DAYTONA_API_KEY").is_err() { - return None; - } - Some( - daytona_sdk::Client::new() - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) -} - -pub(crate) fn probe_model(provider: Provider) -> String { - Catalog::builtin().probe_for_provider(provider).map_or_else( - || format!("unknown-{}", provider.as_str()), - |m| m.id.clone(), - ) -} - -async fn probe_llm_provider( - client: &LlmClient, - provider: Provider, -) -> (Provider, Result<(), String>) { - let request = Request { - model: probe_model(provider), - messages: vec![Message::user("hi")], - provider: Some(provider.as_str().to_string()), - tools: None, - tool_choice: None, - response_format: None, - temperature: None, - top_p: None, - max_tokens: Some(16), - stop_sequences: None, - reasoning_effort: None, - speed: None, - metadata: None, - provider_options: None, - }; - let result = client - .complete(&request) - .await - .map(|_| ()) - .map_err(|e| e.to_string()); - (provider, result) -} - -async fn probe_brave_search(http: &reqwest::Client) -> Result<(), String> { - let api_key = std::env::var("BRAVE_SEARCH_API_KEY") - .map_err(|_| "BRAVE_SEARCH_API_KEY not set".to_string())?; - let resp = http - .get("https://api.search.brave.com/res/v1/web/search?q=test&count=1") - .header("X-Subscription-Token", api_key) - .send() - .await - .map_err(|e| e.to_string())?; - if resp.status().is_success() { - Ok(()) +fn check_version_parity(server_version: &str) -> CheckResult { + let cli_version = FABRO_VERSION; + if server_version == cli_version { + CheckResult { + name: "Version parity".to_string(), + status: CheckStatus::Pass, + summary: cli_version.to_string(), + details: vec![CheckDetail::new(format!( + "CLI and server are both {cli_version}" + ))], + remediation: None, + } } else { - Err(format!("HTTP {}", resp.status())) + CheckResult { + name: "Version parity".to_string(), + status: CheckStatus::Warning, + summary: format!("CLI {cli_version}, server {server_version}"), + details: vec![CheckDetail::new(format!( + "CLI version {cli_version} does not match server version {server_version}" + ))], + remediation: Some( + "Upgrade or restart components so the CLI and server run the same version." + .to_string(), + ), + } } } -async fn probe_url(http: &reqwest::Client, url: &str) -> Result<(), String> { - http.get(url) - .send() - .await - .map(|_| ()) - .map_err(|e| e.to_string()) +fn convert_diagnostics_status(status: api_types::DiagnosticsCheckStatus) -> CheckStatus { + match status { + api_types::DiagnosticsCheckStatus::Pass => CheckStatus::Pass, + api_types::DiagnosticsCheckStatus::Warning => CheckStatus::Warning, + api_types::DiagnosticsCheckStatus::Error => CheckStatus::Error, + } +} + +fn convert_diagnostics_sections(sections: Vec) -> Vec { + sections + .into_iter() + .map(|section| CheckSection { + title: section.title, + checks: section + .checks + .into_iter() + .map(|check| CheckResult { + name: check.name, + status: convert_diagnostics_status(check.status), + summary: check.summary, + details: check + .details + .into_iter() + .map(|detail| CheckDetail { + text: detail.text, + warn: detail.warn, + }) + .collect(), + remediation: check.remediation, + }) + .collect(), + }) + .collect() +} + +fn render_report(report: &CheckReport, styles: &Styles, verbose: bool) { + let term_width = console::Term::stderr().size().1; + print!("{}", report.render(styles, verbose, None, Some(term_width))); } pub(crate) async fn run_doctor( + args: &DoctorArgs, verbose: bool, - live: bool, globals: &GlobalArgs, ) -> Result { let styles = Styles::detect_stdout(); @@ -916,229 +299,116 @@ pub(crate) async fn run_doctor( .expect("valid template") .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", ""]), ); - spinner.set_message("Running checks…"); + spinner.set_message("Running checks..."); spinner.enable_steady_tick(std::time::Duration::from_millis(80)); Some(spinner) }; - // Gather state - let cli_settings = load_user_settings().unwrap_or_default(); - let user_config_path = default_user_config_path(); - let user_config_exists = user_config_path.as_ref().is_some_and(|p| p.exists()); let legacy_config_path = legacy_user_config_path(); - let legacy_config_exists = legacy_config_path.as_ref().is_some_and(|p| p.exists()); + let legacy_env_path = dotenv::env_file_path().ok().filter(|path| path.exists()); - let llm_statuses: Vec<(Provider, bool)> = Provider::ALL - .iter() - .map(|p| (*p, p.has_api_key())) - .collect(); - - let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok(); - - let daytona_configured = std::env::var("DAYTONA_API_KEY").is_ok(); - - let server_settings = server::load_server_settings(None).unwrap_or_default(); - - let api_status = { - let api = server_settings.api.clone().unwrap_or_default(); - ApiStatus { - base_url: api.base_url.clone(), - authentication_strategies: api.authentication_strategies.clone(), - } - }; - - let web_status = { - let web = server_settings.web.clone().unwrap_or_default(); - WebStatus { - url: web.url.clone(), - auth_provider: web.auth.provider.clone(), - allowed_usernames_count: web.auth.allowed_usernames.len(), - } - }; - - let server_git = server_settings.git.clone().unwrap_or_default(); - - let server_api = server_settings.api.clone().unwrap_or_default(); - - let server_web = server_settings.web.clone().unwrap_or_default(); - - let git_app_id = cli_settings.app_id().map(str::to_owned); - let private_key_raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok(); - let sign_result = match (&git_app_id, &private_key_raw) { - (Some(app_id), Some(raw)) => { - let pem = if raw.starts_with("-----") { - Ok(raw.clone()) - } else { - BASE64_STANDARD - .decode(raw) - .map_err(|e| format!("base64 decode failed: {e}")) - .and_then(|bytes| { - String::from_utf8(bytes) - .map_err(|e| format!("decoded key is not valid UTF-8: {e}")) - }) - }; - match pem { - Ok(pem) => Some( - fabro_github::sign_app_jwt(app_id, &pem) - .map(|_| ()) - .map_err(|e| e.clone()), - ), - Err(e) => Some(Err(e)), - } - } - _ => None, - }; - let github_status = GithubAppStatus { - app_id: git_app_id, - slug: cli_settings.slug().map(str::to_owned), - private_key_set: private_key_raw.is_some(), - sign_result, - client_id: server_git.client_id.is_some(), - client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(), - webhook_secret: std::env::var("GITHUB_APP_WEBHOOK_SECRET").is_ok(), - }; - - let crypto_input = { - let has_mtls = server_api - .authentication_strategies - .contains(&ApiAuthStrategy::Mtls); - let tls_files = if has_mtls { - server_api.tls.as_ref().map(|tls| { - let read = |p: &std::path::Path| -> Result { - let expanded = fabro_config::expand_tilde(p); - std::fs::read_to_string(&expanded) - .map_err(|e| format!("{}: {e}", expanded.display())) - }; - Ok(TlsCheckInput { - cert_pem: read(&tls.cert)?, - key_pem: read(&tls.key)?, - ca_pem: read(&tls.ca)?, - }) - }) - } else { - None - }; - CryptoInput { - auth_strategies: server_api.authentication_strategies.clone(), - tls_files, - jwt_public_key: std::env::var("FABRO_JWT_PUBLIC_KEY").ok(), - jwt_private_key: std::env::var("FABRO_JWT_PRIVATE_KEY").ok(), - session_secret: std::env::var("SESSION_SECRET").ok(), - now_epoch: chrono::Utc::now().timestamp(), - } - }; - - let dep_results = probe_system_deps(); - - // Live probes (only when --live is set) - let sandbox_status; - let llm_live_results: Option)>>; - let brave_live_result: Option>; - let api_live_result: Option>; - let web_live_result: Option>; - - if live { - let http = reqwest::Client::new(); - - // Build LLM client — may fail if no keys are set - let llm_client = LlmClient::from_env().await.ok(); - - let configured_providers: Vec = llm_statuses - .iter() - .filter(|(_, set)| *set) - .map(|(p, _)| *p) - .collect(); - - let llm_fut = async { - if let Some(client) = &llm_client { - let futures: Vec<_> = configured_providers - .iter() - .map(|p| probe_llm_provider(client, *p)) - .collect(); - Some(join_all(futures).await) - } else { - None - } - }; - - let sandbox_fut = async { - let daytona_probe = probe_daytona().await; - SandboxStatus { - daytona_configured, - daytona_probe, - } - }; - let brave_fut = probe_brave_search(&http); - - let api_url = format!("{}/runs", server_api.base_url); - let api_fut = probe_url(&http, &api_url); - let web_fut = probe_url(&http, &server_web.url); - - let (sandbox, llm, brave, api, web) = - tokio::join!(sandbox_fut, llm_fut, brave_fut, api_fut, web_fut); - - sandbox_status = sandbox; - llm_live_results = llm; - brave_live_result = Some(brave); - api_live_result = Some(api); - web_live_result = Some(web); - } else { - sandbox_status = SandboxStatus { - daytona_configured, - daytona_probe: None, - }; - llm_live_results = None; - brave_live_result = None; - api_live_result = None; - web_live_result = None; - } - - // Run pure checks - let mut sections = vec![ - CheckSection { - title: "Required".into(), + let mut report = CheckReport { + title: "Fabro Doctor".to_string(), + sections: vec![CheckSection { + title: "Local".to_string(), checks: vec![ check_config( - if user_config_exists { - user_config_path - } else { - None - }, - if legacy_config_exists { - legacy_config_path - } else { - None - }, + user_config_path.filter(|path| path.exists()), + legacy_config_path.filter(|path| path.exists()), ), - check_llm_providers(&llm_statuses, llm_live_results.as_deref()), - check_github_app(&github_status), + check_legacy_env(legacy_env_path), ], - }, - CheckSection { - title: "Optional".into(), - checks: vec![ - check_sandbox(&sandbox_status), - check_brave_search(brave_key_set, brave_live_result.as_ref()), - ], - }, - ]; - - sections.push(CheckSection { - title: "Server".into(), - checks: vec![ - check_system_deps(DEP_SPECS, &dep_results), - check_api(&api_status, api_live_result.as_ref()), - check_web(&web_status, web_live_result.as_ref()), - check_crypto(&crypto_input), - ], - }); - - let report = CheckReport { - title: "Fabro Doctor".into(), - sections, + }], }; + let client = match server_client::connect_server_backed_api_client(&args.target).await { + Ok(client) => client, + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "unreachable".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Start or connect to the server with `--server` and run doctor again." + .to_string(), + ), + }], + }); + + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + if globals.json { + print_json_pretty(&report)?; + } else { + render_report(&report, &styles, verbose); + } + return Ok(1); + } + }; + + let health = match client.get_health().send().await { + Ok(response) => response.into_inner(), + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Fabro server".to_string(), + status: CheckStatus::Error, + summary: "health check failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Check that the server is reachable and responding to /health.".to_string(), + ), + }], + }); + + if let Some(spinner) = spinner { + spinner.finish_and_clear(); + } + + if globals.json { + print_json_pretty(&report)?; + } else { + render_report(&report, &styles, verbose); + } + return Ok(1); + } + }; + + report.sections[0] + .checks + .push(check_version_parity(&health.version)); + + match client.run_diagnostics().send().await { + Ok(response) => { + let diagnostics = response.into_inner(); + report + .sections + .extend(convert_diagnostics_sections(diagnostics.sections)); + } + Err(err) => { + report.sections.push(CheckSection { + title: "Server".to_string(), + checks: vec![CheckResult { + name: "Diagnostics".to_string(), + status: CheckStatus::Error, + summary: "probe failed".to_string(), + details: vec![CheckDetail::new(err.to_string())], + remediation: Some( + "Fix the server diagnostics failure and run `fabro doctor` again." + .to_string(), + ), + }], + }); + } + } + if let Some(spinner) = spinner { spinner.finish_and_clear(); } @@ -1146,28 +416,16 @@ pub(crate) async fn run_doctor( if globals.json { print_json_pretty(&report)?; } else { - let term_width = console::Term::stderr().size().1; - print!( - "{}", - report.render(&styles, verbose, None, Some(term_width)) - ); + render_report(&report, &styles, verbose); } Ok(i32::from(report.has_errors())) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { - #![allow(clippy::absolute_paths)] - use super::*; - // -- check_config -- - #[test] fn check_config_pass_with_path() { let result = check_config(Some(PathBuf::from("/home/user/.fabro/user.toml")), None); @@ -1187,409 +445,26 @@ mod tests { let result = check_config(None, Some(PathBuf::from("/home/user/.fabro/cli.toml"))); assert_eq!(result.status, CheckStatus::Warning); assert!(result.summary.contains("legacy")); - assert!( - result - .remediation - .as_deref() - .is_some_and(|remediation| remediation.contains(".fabro/user.toml")) - ); - } - - // -- check_llm_providers -- - - #[test] - fn check_llm_all_configured() { - let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, true)).collect(); - let result = check_llm_providers(&statuses, None); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("7 configured")); } #[test] - fn check_llm_some_configured() { - let mut statuses: Vec<(Provider, bool)> = - Provider::ALL.iter().map(|p| (*p, false)).collect(); - statuses[0].1 = true; // Anthropic - statuses[1].1 = true; // OpenAi - statuses[2].1 = true; // Gemini - statuses[3].1 = true; // Kimi - statuses[4].1 = true; // Zai - let result = check_llm_providers(&statuses, None); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("5 configured")); - } - - #[test] - fn check_llm_none_configured() { - let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, false)).collect(); - let result = check_llm_providers(&statuses, None); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.summary.contains("none configured")); - } - - #[test] - fn check_llm_live_ok() { - let statuses = vec![(Provider::Anthropic, true)]; - let live = vec![(Provider::Anthropic, Ok(()))]; - let result = check_llm_providers(&statuses, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("connectivity: OK")) - ); - } - - #[test] - fn check_llm_live_error() { - let statuses = vec![(Provider::Anthropic, true)]; - let live = vec![(Provider::Anthropic, Err("timeout".to_string()))]; - let result = check_llm_providers(&statuses, Some(&live)); + fn check_legacy_env_warning_when_present() { + let result = check_legacy_env(Some(PathBuf::from("/home/user/.fabro/.env"))); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details.iter().any(|d| d.text.contains("timeout"))); - let rem = result.remediation.unwrap(); - assert!( - rem.contains("anthropic"), - "remediation should name the failing provider: {rem}" - ); - } - - // -- check_brave_search -- - - #[test] - fn check_brave_configured() { - let result = check_brave_search(true, None); - assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("legacy secrets file")); } #[test] - fn check_brave_not_configured() { - let result = check_brave_search(false, None); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.remediation.is_some()); - } - - #[test] - fn check_brave_live_ok() { - let live = Ok(()); - let result = check_brave_search(true, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("connected")); - } - - #[test] - fn check_brave_live_error() { - let live = Err("HTTP 401".to_string()); - let result = check_brave_search(true, Some(&live)); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details.iter().any(|d| d.text.contains("HTTP 401"))); - } - - // -- check_sandbox -- - - #[test] - fn check_sandbox_daytona_probed_ok() { - let status = SandboxStatus { - daytona_configured: true, - daytona_probe: Some(Ok(())), - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("Daytona available")); - } - - #[test] - fn check_sandbox_nothing_configured() { - let status = SandboxStatus { - daytona_configured: false, - daytona_probe: None, - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.summary.contains("no sandbox configured")); - } - - #[test] - fn check_sandbox_daytona_configured_not_probed() { - let status = SandboxStatus { - daytona_configured: true, - daytona_probe: None, - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains("Daytona configured")); - assert!(result.details.iter().any(|d| d.text.contains("configured"))); - } - - #[test] - fn check_sandbox_configured_but_broken() { - let status = SandboxStatus { - daytona_configured: true, - daytona_probe: Some(Err("connection refused".to_string())), - }; - let result = check_sandbox(&status); - assert_eq!(result.status, CheckStatus::Error); - } - - // -- check_github_app -- - - #[test] - fn check_github_sign_error_reports_error() { - let status = GithubAppStatus { - app_id: Some("12345".to_string()), - slug: None, - private_key_set: true, - sign_result: Some(Err( - "Signing failed: signature error: UnexpectedError".to_string() - )), - client_id: true, - client_secret: true, - webhook_secret: true, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result.summary.contains("invalid"), - "got: {}", - result.summary - ); - let rem = result.remediation.unwrap(); - assert!(rem.contains("Generate a new private key"), "got: {rem}"); - } - - #[test] - fn check_github_not_configured() { - let status = GithubAppStatus { - app_id: None, - slug: None, - private_key_set: false, - sign_result: None, - client_id: false, - client_secret: false, - webhook_secret: false, - }; - let result = check_github_app(&status); + fn check_version_parity_warns_on_mismatch() { + let result = check_version_parity("0.0.0-test"); assert_eq!(result.status, CheckStatus::Warning); } - // -- Server-only checks (check_api, check_web, check_crypto) -- - - mod server_tests { - use super::*; - - #[test] - fn check_github_all_set() { - let status = GithubAppStatus { - app_id: Some("12345".to_string()), - slug: Some("my-app".to_string()), - private_key_set: true, - sign_result: Some(Ok(())), - client_id: true, - client_secret: true, - webhook_secret: true, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Pass); - } - - #[test] - fn check_github_none_set() { - let status = GithubAppStatus { - app_id: None, - slug: None, - private_key_set: false, - sign_result: None, - client_id: false, - client_secret: false, - webhook_secret: false, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Warning); - } - - #[test] - fn check_github_partial() { - let status = GithubAppStatus { - app_id: Some("12345".to_string()), - slug: None, - private_key_set: false, - sign_result: None, - client_id: true, - client_secret: false, - webhook_secret: false, - }; - let result = check_github_app(&status); - assert_eq!(result.status, CheckStatus::Error); - let rem = result.remediation.unwrap(); - assert!(rem.contains("GITHUB_APP_CLIENT_SECRET")); - assert!(rem.contains("GITHUB_APP_WEBHOOK_SECRET")); - assert!(rem.contains("GITHUB_APP_PRIVATE_KEY")); - } - - // -- check_api -- - - #[test] - fn check_api_shows_base_url() { - let status = ApiStatus { - base_url: "http://localhost:3000".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let result = check_api(&status, None); - assert_eq!(result.status, CheckStatus::Pass); - assert_eq!(result.summary, "http://localhost:3000"); - } - - #[test] - fn check_api_details_show_auth_strategy() { - let status = ApiStatus { - base_url: "https://api.example.com".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let result = check_api(&status, None); - assert!(result.details.iter().any(|d| d.text.contains("jwt"))); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("https://api.example.com")) - ); - } - - #[test] - fn check_api_live_ok() { - let status = ApiStatus { - base_url: "http://localhost:3000".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let live = Ok(()); - let result = check_api(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Connectivity: OK")) - ); - } - - #[test] - fn check_api_live_error() { - let status = ApiStatus { - base_url: "http://localhost:3000".to_string(), - authentication_strategies: vec![ApiAuthStrategy::Jwt], - }; - let live = Err("connection refused".to_string()); - let result = check_api(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Warning); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("connection refused")) - ); - } - - // -- check_web -- - - #[test] - fn check_web_shows_url() { - let status = WebStatus { - url: "http://localhost:3000".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 0, - }; - let result = check_web(&status, None); - assert_eq!(result.status, CheckStatus::Pass); - assert_eq!(result.summary, "http://localhost:3000"); - } - - #[test] - fn check_web_details_show_auth() { - let status = WebStatus { - url: "https://fabro.example.com".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 3, - }; - let result = check_web(&status, None); - assert!(result.details.iter().any(|d| d.text.contains("github"))); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("https://fabro.example.com")) - ); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Allowed usernames: 3")) - ); - } - - #[test] - fn check_web_live_ok() { - let status = WebStatus { - url: "http://localhost:3000".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 0, - }; - let live = Ok(()); - let result = check_web(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Connectivity: OK")) - ); - } - - #[test] - fn check_web_live_error() { - let status = WebStatus { - url: "http://localhost:3000".to_string(), - auth_provider: AuthProvider::Github, - allowed_usernames_count: 0, - }; - let live = Err("connection refused".to_string()); - let result = check_web(&status, Some(&live)); - assert_eq!(result.status, CheckStatus::Warning); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("connection refused")) - ); - } - } // mod server_tests (check_github_app, check_api, check_web) - - // -- parse_version (server only) -- - #[test] fn parse_version_openssl() { assert_eq!( - parse_version( - &OPENSSL_RE, - "OpenSSL 3.4.1 11 Feb 2025 (Library: OpenSSL 3.4.1 11 Feb 2025)" - ), - Some(Version::new(3, 4, 1)), - ); - } - - #[test] - fn parse_version_libressl() { - assert_eq!( - parse_version(&OPENSSL_RE, "LibreSSL 3.3.6"), - Some(Version::new(3, 3, 6)) - ); - } - - #[test] - fn parse_version_node() { - assert_eq!( - parse_version(&NODE_RE, "v22.14.0"), - Some(Version::new(22, 14, 0)) + parse_version(&OPENSSL_RE, "OpenSSL 3.4.1 11 Feb 2025"), + Some(Version::new(3, 4, 1)) ); } @@ -1597,28 +472,23 @@ mod tests { fn parse_version_dot() { assert_eq!( parse_version(&DOT_RE, "dot - graphviz version 12.2.1 (20241206.2024)"), - Some(Version::new(12, 2, 1)), + Some(Version::new(12, 2, 1)) ); } #[test] fn parse_version_garbage_returns_none() { assert_eq!(parse_version(&OPENSSL_RE, "not a version"), None); - assert_eq!(parse_version(&NODE_RE, "node not found"), None); assert_eq!(parse_version(&DOT_RE, "no version here"), None); } - // -- check_system_deps (server only) -- - - static TEST_RE: LazyLock = LazyLock::new(|| Regex::new(r"unused").unwrap()); - fn spec(name: &'static str, required: bool, min_version: Version) -> DepSpec { DepSpec { name, - command: &["true"], + command: &["echo", "unused"], required, min_version, - pattern: &TEST_RE, + pattern: &DOT_RE, } } @@ -1626,20 +496,12 @@ mod tests { fn check_system_deps_all_present() { let specs = [ spec("openssl", true, Version::new(3, 0, 0)), - spec("node", true, Version::new(20, 0, 0)), - spec("gh", false, Version::new(2, 0, 0)), spec("dot", false, Version::new(2, 0, 0)), ]; let outcomes = [ ProbeOutcome::Ok { version: Some(Version::new(3, 4, 1)), }, - ProbeOutcome::Ok { - version: Some(Version::new(22, 14, 0)), - }, - ProbeOutcome::Ok { - version: Some(Version::new(2, 67, 0)), - }, ProbeOutcome::Ok { version: Some(Version::new(12, 2, 1)), }, @@ -1655,16 +517,14 @@ mod tests { let outcomes = [ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details[0].text.contains("not found (required)")); } #[test] fn check_system_deps_optional_missing_is_warning() { - let specs = [spec("gh", false, Version::new(2, 0, 0))]; + let specs = [spec("dot", false, Version::new(2, 0, 0))]; let outcomes = [ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details[0].text.contains("not found (optional)")); } #[test] @@ -1675,8 +535,6 @@ mod tests { }]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details[0].text.contains("1.1.1")); - assert!(result.details[0].text.contains("minimum 3.0.0")); } #[test] @@ -1690,290 +548,28 @@ mod tests { #[test] fn check_system_deps_required_command_failed_is_error() { - let specs = [spec("node", true, Version::new(20, 0, 0))]; + let specs = [spec("openssl", true, Version::new(3, 0, 0))]; let outcomes = [ProbeOutcome::Failed]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); - assert!(result.details[0].text.contains("command failed (required)")); } #[test] fn check_system_deps_optional_command_failed_is_warning() { - let specs = [spec("gh", false, Version::new(2, 0, 0))]; + let specs = [spec("dot", false, Version::new(2, 0, 0))]; let outcomes = [ProbeOutcome::Failed]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Warning); - assert!(result.details[0].text.contains("command failed (optional)")); } #[test] fn check_system_deps_error_beats_warning() { let specs = [ spec("openssl", true, Version::new(3, 0, 0)), - spec("gh", false, Version::new(2, 0, 0)), + spec("dot", false, Version::new(2, 0, 0)), ]; let outcomes = [ProbeOutcome::NotFound, ProbeOutcome::NotFound]; let result = check_system_deps(&specs, &outcomes); assert_eq!(result.status, CheckStatus::Error); } - - // -- check_crypto -- - - mod server_crypto_tests { - use super::*; - - /// Generate a self-signed cert + private key PEM for TLS tests. - fn generate_test_tls_cert() -> (String, String) { - let output = std::process::Command::new("openssl") - .args([ - "req", - "-x509", - "-newkey", - "ec", - "-pkeyopt", - "ec_paramgen_curve:prime256v1", - "-keyout", - "/dev/stdout", - "-out", - "/dev/stdout", - "-days", - "3650", - "-nodes", - "-subj", - "/CN=test-server", - ]) - .output() - .expect("openssl must be available for tests"); - let combined = String::from_utf8(output.stdout).unwrap(); - let key_start = combined.find("-----BEGIN PRIVATE KEY-----").unwrap(); - let key_end = combined.find("-----END PRIVATE KEY-----").unwrap() - + "-----END PRIVATE KEY-----".len(); - let cert_start = combined.find("-----BEGIN CERTIFICATE-----").unwrap(); - let cert_end = combined.find("-----END CERTIFICATE-----").unwrap() - + "-----END CERTIFICATE-----".len(); - let key_pem = combined[key_start..key_end].to_string(); - let cert_pem = combined[cert_start..cert_end].to_string(); - (cert_pem, key_pem) - } - - fn generate_test_ed25519_keypair() -> (String, String) { - let output = std::process::Command::new("openssl") - .args(["genpkey", "-algorithm", "Ed25519"]) - .output() - .expect("openssl must be available for tests"); - let private_pem = String::from_utf8(output.stdout).unwrap(); - let output = std::process::Command::new("openssl") - .args(["pkey", "-pubout"]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .spawn() - .and_then(|mut child| { - use std::io::Write; - child - .stdin - .take() - .unwrap() - .write_all(private_pem.as_bytes()) - .unwrap(); - child.wait_with_output() - }) - .expect("openssl pkey failed"); - let public_pem = String::from_utf8(output.stdout).unwrap(); - (public_pem, private_pem) - } - - fn crypto_input(auth_strategies: Vec) -> CryptoInput { - CryptoInput { - auth_strategies, - tls_files: None, - jwt_public_key: None, - jwt_private_key: None, - session_secret: None, - now_epoch: chrono::Utc::now().timestamp(), - } - } - - #[test] - fn crypto_all_keys_valid() { - let (cert_pem, key_pem) = generate_test_tls_cert(); - let (public_pem, private_pem) = generate_test_ed25519_keypair(); - let input = CryptoInput { - tls_files: Some(Ok(TlsCheckInput { - cert_pem: cert_pem.clone(), - key_pem, - ca_pem: cert_pem, - })), - jwt_public_key: Some(public_pem), - jwt_private_key: Some(private_pem), - session_secret: Some("a".repeat(64)), - ..crypto_input(vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Pass); - assert_eq!(result.summary, "all keys valid"); - } - - #[test] - fn crypto_invalid_cert_pem() { - let (public_pem, private_pem) = generate_test_ed25519_keypair(); - let input = CryptoInput { - tls_files: Some(Ok(TlsCheckInput { - cert_pem: "not a pem".to_string(), - key_pem: "not a pem".to_string(), - ca_pem: "not a pem".to_string(), - })), - jwt_public_key: Some(public_pem), - jwt_private_key: Some(private_pem), - ..crypto_input(vec![ApiAuthStrategy::Jwt, ApiAuthStrategy::Mtls]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("TLS cert"))); - } - - #[test] - fn crypto_expired_cert() { - let (cert_pem, _) = generate_test_tls_cert(); - let far_future = i64::MAX / 2; - let result = validate_tls_cert(&cert_pem, far_future); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("expired")); - } - - #[test] - fn crypto_session_secret_too_short() { - let input = CryptoInput { - session_secret: Some("abcdef".to_string()), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("too short"))); - } - - #[test] - fn crypto_session_secret_non_hex() { - let input = CryptoInput { - session_secret: Some("z".repeat(64)), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!(result.details.iter().any(|d| d.text.contains("non-hex"))); - } - - #[test] - fn crypto_no_auth_configured() { - let result = check_crypto(&crypto_input(vec![])); - assert_eq!(result.status, CheckStatus::Warning); - assert!(result.summary.contains("no authentication configured")); - } - - #[test] - fn crypto_jwt_configured_but_key_missing() { - let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Jwt])); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("FABRO_JWT_PUBLIC_KEY not set")) - ); - } - - #[test] - fn crypto_mtls_configured_but_tls_not_set() { - let result = check_crypto(&crypto_input(vec![ApiAuthStrategy::Mtls])); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("[api.tls] not set")) - ); - } - - #[test] - fn crypto_mtls_configured_but_files_unreadable() { - let input = CryptoInput { - tls_files: Some(Err("Permission denied: /path/to/cert.pem".to_string())), - ..crypto_input(vec![ApiAuthStrategy::Mtls]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("Permission denied")) - ); - } - - #[test] - fn crypto_invalid_jwt_public_key() { - let input = CryptoInput { - jwt_public_key: Some( - "-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----".to_string(), - ), - ..crypto_input(vec![ApiAuthStrategy::Jwt]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("JWT public key: invalid")) - ); - } - - #[test] - fn crypto_invalid_jwt_private_key() { - let input = CryptoInput { - jwt_private_key: Some( - "-----BEGIN PRIVATE KEY-----\nINVALID\n-----END PRIVATE KEY-----".to_string(), - ), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Error); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("JWT private key: invalid")) - ); - } - - #[test] - fn crypto_base64_encoded_jwt_key() { - let (public_pem, _) = generate_test_ed25519_keypair(); - let encoded = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - public_pem.as_bytes(), - ); - let input = CryptoInput { - jwt_public_key: Some(encoded), - ..crypto_input(vec![ApiAuthStrategy::Jwt]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Pass); - assert!( - result - .details - .iter() - .any(|d| d.text.contains("JWT public key: valid")) - ); - } - - #[test] - fn crypto_valid_session_secret() { - let input = CryptoInput { - session_secret: Some("a1b2c3d4e5f6".repeat(6)), - ..crypto_input(vec![]) - }; - let result = check_crypto(&input); - assert_eq!(result.status, CheckStatus::Pass); - } - } // mod server_crypto_tests } diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 43721697e..79aacbac8 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -24,7 +24,7 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result< if globals.json { args.agent.output_format = Some(OutputFormat::Json); } - let server_target = user_config::exec_server_target(&args.server_url, &cli_settings); + let server_target = user_config::exec_server_target(&args.server, &cli_settings)?; let mcp_servers: Vec = cli_settings .mcp_servers .into_iter() @@ -32,15 +32,27 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result< .collect(); if let Some(target) = server_target { tracing::info!(transport = "server", "Agent session starting"); - let http_client = user_config::build_server_client(target.tls.as_ref())?; let provider_name = args .agent .provider .clone() .unwrap_or_else(|| "anthropic".to_string()); + let (base_url, http_client) = match &target { + user_config::ServerTarget::HttpUrl { base_url, tls } => ( + base_url.clone(), + user_config::build_server_client(tls.as_ref())?, + ), + user_config::ServerTarget::UnixSocket(path) => { + let http_client = reqwest::ClientBuilder::new() + .unix_socket(path.as_path()) + .no_proxy() + .build()?; + ("http://fabro".to_string(), http_client) + } + }; let adapter = Arc::new(FabroServerAdapter::new( http_client, - &target.server_base_url, + &base_url, &provider_name, )); let mut client = Client::new(HashMap::new(), None, vec![]); diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index b397c1af5..a1e87ddd0 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -12,8 +12,11 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; use dialoguer::{MultiSelect, Select}; +use fabro_api::types::SetSecretRequest; +use fabro_config::dotenv; use fabro_config::user::USER_CONFIG_FILENAME; use fabro_model::Provider; +use fabro_server::secret_store::SecretStore; use fabro_util::terminal::Styles; use rand::Rng; use tokio::net::TcpListener; @@ -21,11 +24,13 @@ use tokio::sync::oneshot; use tokio::task::spawn_blocking; use super::doctor; -use crate::args::GlobalArgs; +use crate::args::{DoctorArgs, GlobalArgs, InstallArgs, ServerConnectionArgs}; +use crate::commands::server::record; +use crate::server_client; use crate::shared::provider_auth::{ prompt_and_validate_key, prompt_confirm, provider_display_name, run_openai_oauth_or_api_key, - write_env_file, }; +use crate::user_config; // --------------------------------------------------------------------------- // OpenSSL helpers @@ -272,7 +277,7 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_ } /// Run the GitHub App manifest registration flow via a temporary local server. -/// Returns env var pairs (key, value) for secrets to merge into `.env`. +/// Returns secret pairs `(key, value)` to persist for the local server. async fn setup_github_app( arc_dir: &Path, s: &Styles, @@ -465,7 +470,7 @@ async fn setup_github_app( .apply_to(format!("App: https://github.com/apps/{slug}")) ); - // Return secrets as env pairs + // Return secret pairs let pem_b64 = BASE64_STANDARD.encode(pem.as_bytes()); let mut env_pairs = vec![ @@ -479,10 +484,46 @@ async fn setup_github_app( Ok(env_pairs) } -pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<()> { +async fn persist_install_secrets( + storage_dir: &Path, + secrets: &[(String, String)], + server_was_running: bool, +) -> Result<()> { + if secrets.is_empty() { + return Ok(()); + } + + if server_was_running { + let client = server_client::connect_api_client(storage_dir).await?; + for (name, value) in secrets { + client + .set_secret() + .name(name.clone()) + .body(SetSecretRequest { + value: value.clone(), + }) + .send() + .await?; + } + return Ok(()); + } + + let mut store = SecretStore::load(storage_dir.join("secrets.json"))?; + for (name, value) in secrets { + store.set(name, value)?; + } + Ok(()) +} + +pub(crate) async fn run_install(args: &InstallArgs, globals: &GlobalArgs) -> Result<()> { globals.require_no_json()?; + let web_url = &args.web_url; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); + let cli_settings = + user_config::load_user_settings_with_storage_dir(args.storage_dir.as_deref())?; + let storage_dir = cli_settings.storage_dir(); + let server_was_running = record::active_server_record(&storage_dir).is_some(); eprintln!(); eprintln!(" {}{}", emoji, s.bold.apply_to("Fabro Install")); @@ -500,6 +541,16 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( .join(".fabro"); std::fs::create_dir_all(&arc_dir)?; + if let Ok(env_path) = dotenv::env_file_path() { + if env_path.exists() { + eprintln!( + " Warning: {} is no longer read by fabro server. This install will persist credentials in the server secret store instead.", + env_path.display() + ); + eprintln!(); + } + } + // Pre-flight checks { eprintln!( @@ -549,7 +600,7 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( eprintln!(" {}", s.dim.apply_to("──────────────────────")); eprintln!(); - let mut env_pairs: Vec<(String, String)> = Vec::new(); + let mut secret_pairs: Vec<(String, String)> = Vec::new(); let mut configured_providers: Vec = Vec::new(); let codex_detected = detect_binary_on_path("codex"); @@ -567,7 +618,7 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( if use_oauth { let pairs = run_openai_oauth_or_api_key(&s).await?; - env_pairs.extend(pairs); + secret_pairs.extend(pairs); configured_providers.push(Provider::OpenAi); openai_via_oauth = true; } @@ -590,7 +641,7 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( let first_provider = primary_providers[primary_idx]; { let (env_var, key) = prompt_and_validate_key(first_provider, &s).await?; - env_pairs.push((env_var, key)); + secret_pairs.push((env_var, key)); configured_providers.push(first_provider); } } @@ -624,14 +675,9 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( for idx in selected_indices { let provider = remaining_providers[idx]; let (env_var, key) = prompt_and_validate_key(provider, &s).await?; - env_pairs.push((env_var, key)); + secret_pairs.push((env_var, key)); } } - - // Write LLM provider env vars immediately - if !env_pairs.is_empty() { - write_env_file(&arc_dir, &env_pairs, &s)?; - } eprintln!(); // Step 2: GitHub App @@ -661,10 +707,7 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( s.green.apply_to("✔"), slug ); - // Merge GitHub env vars into .env - if !github_env_pairs.is_empty() { - write_env_file(&arc_dir, &github_env_pairs, &s)?; - } + secret_pairs.extend(github_env_pairs); } else { eprintln!(" Skipped"); } @@ -731,7 +774,7 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( ("FABRO_JWT_PUBLIC_KEY".to_string(), jwt_public_b64), ("SESSION_SECRET".to_string(), session_secret), ]; - write_env_file(&arc_dir, &server_env_pairs, &s)?; + secret_pairs.extend(server_env_pairs); eprintln!(); eprintln!(" To start Arc, run these commands:"); @@ -740,16 +783,34 @@ pub(crate) async fn run_install(web_url: &str, globals: &GlobalArgs) -> Result<( eprintln!(); } + persist_install_secrets(&storage_dir, &secret_pairs, server_was_running).await?; + eprintln!( + " {} Saved {} secrets to {}", + s.green.apply_to("✔"), + secret_pairs.len(), + storage_dir.join("secrets.json").display() + ); + if server_was_running { + eprintln!( + " Warning: the local fabro server was already running. Restart it to pick up startup-time features that only initialize at boot." + ); + } + eprintln!(); + // Verify setup - let env_path = arc_dir.join(".env"); let run_doctor = spawn_blocking(|| prompt_confirm("Run fabro doctor to verify?", true)).await??; if run_doctor { - // Reload .env so doctor sees the values we just wrote - let _ = dotenvy::from_path(&env_path); eprintln!(); - let _ = doctor::run_doctor(true, true, globals).await?; + let doctor_args = DoctorArgs { + target: ServerConnectionArgs { + storage_dir: Some(storage_dir.clone()), + server: None, + }, + verbose: true, + }; + let _ = doctor::run_doctor(&doctor_args, true, globals).await?; } eprintln!(); diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index ef8745a6a..9d6fa9683 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -44,12 +44,8 @@ pub(crate) async fn execute(command: Option, globals: &GlobalArgs ModelsCommand::Test(args) => &args.target, }; let cli_settings = user_config::load_user_settings_with_storage_dir(target_args.storage_dir())?; - let client = match user_config::model_server_target(target_args, &cli_settings) { - Some(target) => { - server_client::connect_remote_api_client(&target.server_base_url, target.tls.as_ref())? - } - None => server_client::connect_api_client(&cli_settings.storage_dir()).await?, - }; + let connection = user_config::model_server_connection(target_args, &cli_settings)?; + let client = server_client::connect_resolved_api_client(&connection).await?; run_models(command, client, globals.json).await } diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 94a6c44df..de788f5bd 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -1,18 +1,18 @@ -use anyhow::{Context, Result}; +use anyhow::Result; +use fabro_api::types; +use fabro_config::dotenv; use fabro_model::Provider; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; use crate::args::{GlobalArgs, ProviderLoginArgs}; +use crate::server_client; use crate::shared::provider_auth; pub(super) async fn login_command(args: ProviderLoginArgs, globals: &GlobalArgs) -> Result<()> { globals.require_no_json()?; let s = Styles::detect_stderr(); - let arc_dir = dirs::home_dir() - .context("could not determine home directory")? - .join(".fabro"); - std::fs::create_dir_all(&arc_dir)?; + let client = server_client::connect_server_backed_api_client(&args.target).await?; let use_oauth = args.provider == Provider::OpenAi && spawn_blocking(|| provider_auth::prompt_confirm("Log in via browser (OAuth)?", true)) @@ -25,6 +25,23 @@ pub(super) async fn login_command(args: ProviderLoginArgs, globals: &GlobalArgs) vec![(env_var, key)] }; - provider_auth::write_env_file(&arc_dir, &env_pairs, &s)?; + if let Ok(path) = dotenv::env_file_path() { + if path.exists() { + eprintln!( + " Warning: {} is no longer read by fabro server. Re-enter credentials with `fabro provider login` or `fabro secret set`.", + path.display() + ); + } + } + + for (name, value) in env_pairs { + client + .set_secret() + .name(name.clone()) + .body(types::SetSecretRequest { value }) + .send() + .await?; + eprintln!(" {} Saved {}", s.green.apply_to("✔"), name); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/repo/init.rs b/lib/crates/fabro-cli/src/commands/repo/init.rs index e38e07965..3997ebca7 100644 --- a/lib/crates/fabro-cli/src/commands/repo/init.rs +++ b/lib/crates/fabro-cli/src/commands/repo/init.rs @@ -3,9 +3,8 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; use tokio::task::spawn_blocking; -use crate::args::GlobalArgs; -use crate::shared::github::build_github_app_credentials; -use crate::user_config::load_user_settings; +use crate::args::{GlobalArgs, RepoInitArgs, ServerConnectionArgs}; +use crate::server_client; pub(super) fn git_repo_root() -> Result { let output = std::process::Command::new("git") @@ -22,7 +21,7 @@ pub(super) fn git_repo_root() -> Result { )) } -pub(crate) async fn run_init(globals: &GlobalArgs) -> Result> { +pub(crate) async fn run_init(args: &RepoInitArgs, globals: &GlobalArgs) -> Result> { let repo_root = git_repo_root()?; let mut created = Vec::new(); @@ -126,13 +125,13 @@ draft = true } if !globals.json { - check_github_app_installation().await; + check_github_app_installation(&args.target).await; } Ok(created) } -async fn check_github_app_installation() { +async fn check_github_app_installation(target: &ServerConnectionArgs) { // Get the git remote origin URL let output = match std::process::Command::new("git") .args(["remote", "get-url", "origin"]) @@ -167,158 +166,79 @@ async fn check_github_app_installation() { return; // Not a GitHub repo — skip silently }; - // Load CLI config to get app_id and slug - let Ok(cli_settings) = load_user_settings() else { - return; + let client = match server_client::connect_server_backed_api_client(target).await { + Ok(client) => client, + Err(err) => { + eprintln!("\n Warning: could not connect to fabro server: {err}"); + return; + } }; - let app_id = if let Some(id) = cli_settings.app_id() { - id.to_string() - } else { + let check = match client + .get_github_repo() + .owner(owner.clone()) + .name(repo.clone()) + .send() + .await + { + Ok(response) => response.into_inner(), + Err(err) => { + eprintln!("\n Warning: could not check GitHub App installation: {err}"); + return; + } + }; + + if check.accessible { + let green = console::Style::new().green(); eprintln!( - "\n Run {} to set up the GitHub App", - console::Style::new() - .cyan() - .bold() - .apply_to("fabro install") + "\n {} GitHub App is installed for {owner}/{repo}", + green.apply_to("✔") ); return; - }; + } - let slug = cli_settings.slug().map(String::from); + let yellow = console::Style::new().yellow(); + eprintln!( + "\n {} GitHub App is not installed for {owner}/{repo}", + yellow.apply_to("!") + ); + if let Some(url) = &check.install_url { + eprintln!(" Install at: {url}"); + } - // Build GitHub App credentials - let creds = match build_github_app_credentials(Some(&app_id)) { - Ok(Some(creds)) => creds, - Ok(None) => { - eprintln!( - "\n Set {} to enable GitHub App integration", - console::Style::new() - .cyan() - .bold() - .apply_to("GITHUB_APP_PRIVATE_KEY") - ); - return; - } - Err(err) => { - eprintln!("\n Warning: invalid GITHUB_APP_PRIVATE_KEY: {err}"); - return; - } - }; + if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + eprintln!(" Press Enter to continue after installing..."); + let _ = spawn_blocking(|| { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + }) + .await; - let jwt = match fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) { - Ok(j) => j, - Err(e) => { - eprintln!("\n Warning: failed to sign GitHub App JWT: {e}"); - return; - } - }; - - let client = reqwest::Client::new(); - - match fabro_github::check_app_installed( - &client, - &jwt, - &owner, - &repo, - &fabro_github::github_api_base_url(), - ) - .await - { - Ok(true) => { - let green = console::Style::new().green(); - eprintln!( - "\n {} GitHub App is installed for {owner}/{repo}", - green.apply_to("✔") - ); - } - Ok(false) => { - let install_url = match &slug { - Some(s) => format!("https://github.com/apps/{s}/installations/new"), - None => format!("https://github.com/organizations/{owner}/settings/installations"), - }; - - let yellow = console::Style::new().yellow(); - - // Best-effort: warn if the app is private and the repo belongs to a different owner. - if let Ok(app_info) = fabro_github::get_authenticated_app( - &client, - &jwt, - &fabro_github::github_api_base_url(), - ) + match client + .get_github_repo() + .owner(owner.clone()) + .name(repo.clone()) + .send() .await - { - let cross_owner = !app_info.owner.login.eq_ignore_ascii_case(&owner); - let is_private = cross_owner - && fabro_github::is_app_public( - &client, - &app_info.slug, - &fabro_github::github_api_base_url(), - ) - .await - == Ok(false); - - if is_private { + { + Ok(response) => { + let response = response.into_inner(); + if response.accessible { + let green = console::Style::new().green(); eprintln!( - "\n {} GitHub App \"{}\" is private but this repo belongs to a different owner ({}).", - yellow.apply_to("!"), - app_info.slug, - owner + " {} GitHub App is installed for {owner}/{repo}", + green.apply_to("✔") ); - eprintln!( - " The app must be made public before it can be installed outside {}.", - app_info.owner.login - ); - eprintln!( - " Update visibility at: https://github.com/settings/apps/{}", - app_info.slug - ); - } - } - eprintln!( - "\n {} GitHub App is not installed for {owner}/{repo}", - yellow.apply_to("!") - ); - eprintln!(" Install at: {install_url}"); - - // Only prompt if stdin is a terminal - if std::io::IsTerminal::is_terminal(&std::io::stdin()) { - eprintln!(" Press Enter to continue after installing..."); - let _ = spawn_blocking(|| { - let mut buf = String::new(); - let _ = std::io::stdin().read_line(&mut buf); - }) - .await; - - // Re-check after user presses Enter - match fabro_github::check_app_installed( - &client, - &jwt, - &owner, - &repo, - &fabro_github::github_api_base_url(), - ) - .await - { - Ok(true) => { - let green = console::Style::new().green(); - eprintln!( - " {} GitHub App is installed for {owner}/{repo}", - green.apply_to("✔") - ); - } - Ok(false) => { - eprintln!(" GitHub App is still not installed."); - eprintln!(" Install at: {install_url}"); - } - Err(e) => { - eprintln!(" Warning: could not re-check GitHub App installation: {e}"); + } else { + eprintln!(" GitHub App is still not installed."); + if let Some(url) = &check.install_url { + eprintln!(" Install at: {url}"); } } } - } - Err(e) => { - eprintln!("\n Warning: could not check GitHub App installation: {e}"); + Err(err) => { + eprintln!(" Warning: could not re-check GitHub App installation: {err}"); + } } } } diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 5835208b5..c3c3edfa0 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -8,9 +8,9 @@ use crate::shared::print_json_pretty; pub(crate) async fn dispatch(ns: RepoNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { - RepoCommand::Init { skill } => { - let created = init::run_init(globals).await?; - if skill { + RepoCommand::Init(args) => { + let created = init::run_init(&args, globals).await?; + if args.skill { let base = std::env::current_dir()?.join(".claude").join("skills"); super::skill::install_skill_to(&base)?; } diff --git a/lib/crates/fabro-cli/src/commands/secret/get.rs b/lib/crates/fabro-cli/src/commands/secret/get.rs deleted file mode 100644 index a70accce3..000000000 --- a/lib/crates/fabro-cli/src/commands/secret/get.rs +++ /dev/null @@ -1,23 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::args::{GlobalArgs, SecretGetArgs}; -use crate::shared::print_json_pretty; -use fabro_config::dotenv; - -pub(super) fn get_command(args: &SecretGetArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - match dotenv::get_env_value(&path, &args.key)? { - Some(value) => { - if globals.json { - print_json_pretty(&serde_json::json!({ - "key": args.key, - "value": value, - }))?; - } else { - println!("{value}"); - } - Ok(()) - } - None => bail!("secret not found: {}", args.key), - } -} diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index df1a7ef2c..a599e2d58 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -1,42 +1,27 @@ -use anyhow::{Result, bail}; +use anyhow::Result; +use fabro_api::Client; use crate::args::{GlobalArgs, SecretListArgs}; use crate::shared::print_json_pretty; -use fabro_config::dotenv; -pub(super) fn list_command(args: &SecretListArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - let contents = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - if globals.json { - print_json_pretty(&Vec::::new())?; - } - return Ok(()); - } - Err(e) => bail!("failed to read {}: {e}", path.display()), - }; - let pairs = dotenv::parse_env(&contents); +pub(super) async fn list_command( + client: &Client, + args: &SecretListArgs, + globals: &GlobalArgs, +) -> Result<()> { + let response = client + .list_secrets() + .send() + .await + .map_err(super::map_api_error)?; + let secrets = response.into_inner().data; if globals.json { - let values = pairs - .into_iter() - .map(|(key, value)| { - if args.show_values { - serde_json::json!({ "key": key, "value": value }) - } else { - serde_json::json!({ "key": key }) - } - }) - .collect::>(); - print_json_pretty(&values)?; + print_json_pretty(&secrets)?; return Ok(()); } - for (key, value) in pairs { - if args.show_values { - println!("{key}={value}"); - } else { - println!("{key}"); - } + let _ = args; + for secret in secrets { + println!("{}\t{}", secret.name, secret.updated_at); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index 4c1103e2a..f91b692dd 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -1,17 +1,44 @@ -mod get; mod list; mod rm; mod set; -use anyhow::Result; +use anyhow::{Result, anyhow}; use crate::args::{GlobalArgs, SecretCommand, SecretNamespace}; +use crate::server_client; -pub(crate) fn dispatch(ns: SecretNamespace, globals: &GlobalArgs) -> Result<()> { - match ns.command { - SecretCommand::Get(args) => get::get_command(&args, globals), - SecretCommand::List(args) => list::list_command(&args, globals), - SecretCommand::Rm(args) => rm::rm_command(&args, globals), - SecretCommand::Set(args) => set::set_command(&args, globals), +fn map_api_error(err: progenitor_client::Error) -> anyhow::Error +where + E: serde::Serialize + std::fmt::Debug, +{ + match err { + progenitor_client::Error::ErrorResponse(response) => { + let status = response.status(); + if let Ok(value) = serde_json::to_value(response.into_inner()) { + if let Some(detail) = value + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()) + .and_then(|entry| entry.get("detail")) + .and_then(serde_json::Value::as_str) + { + return anyhow!("{detail}"); + } + } + anyhow!("request failed with status {status}") + } + progenitor_client::Error::UnexpectedResponse(response) => { + anyhow!("request failed with status {}", response.status()) + } + other => anyhow!("{other}"), + } +} + +pub(crate) async fn dispatch(ns: SecretNamespace, globals: &GlobalArgs) -> Result<()> { + let client = server_client::connect_server_backed_api_client(&ns.target).await?; + match ns.command { + SecretCommand::List(args) => list::list_command(&client, &args, globals).await, + SecretCommand::Rm(args) => rm::rm_command(&client, &args, globals).await, + SecretCommand::Set(args) => set::set_command(&client, &args, globals).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index ebead9acb..2f3f948c6 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,29 +1,24 @@ -use anyhow::{Result, bail}; +use anyhow::Result; +use fabro_api::Client; use crate::args::{GlobalArgs, SecretRmArgs}; use crate::shared::print_json_pretty; -use fabro_config::dotenv; -pub(super) fn rm_command(args: &SecretRmArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - let contents = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - bail!("secret not found: {}", args.key) - } - Err(e) => bail!("failed to read {}: {e}", path.display()), - }; - let updated = dotenv::remove_env_key(&contents, &args.key); - match updated { - Some(new_contents) => { - dotenv::write_env_file(&path, &new_contents)?; - if globals.json { - print_json_pretty(&serde_json::json!({ "key": args.key }))?; - } else { - eprintln!("Removed {}", args.key); - } - Ok(()) - } - None => bail!("secret not found: {}", args.key), +pub(super) async fn rm_command( + client: &Client, + args: &SecretRmArgs, + globals: &GlobalArgs, +) -> Result<()> { + client + .delete_secret() + .name(args.key.clone()) + .send() + .await + .map_err(super::map_api_error)?; + if globals.json { + print_json_pretty(&serde_json::json!({ "key": args.key }))?; + } else { + eprintln!("Removed {}", args.key); } + Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index 30442864f..da04d10d9 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -1,18 +1,28 @@ use anyhow::Result; +use fabro_api::{Client, types}; use crate::args::{GlobalArgs, SecretSetArgs}; use crate::shared::print_json_pretty; -use fabro_config::dotenv; -pub(super) fn set_command(args: &SecretSetArgs, globals: &GlobalArgs) -> Result<()> { - let path = dotenv::env_file_path()?; - let existing = std::fs::read_to_string(&path).unwrap_or_default(); - let merged = dotenv::merge_env(&existing, &[(&args.key, &args.value)]); - dotenv::write_env_file(&path, &merged)?; +pub(super) async fn set_command( + client: &Client, + args: &SecretSetArgs, + globals: &GlobalArgs, +) -> Result<()> { + let meta = client + .set_secret() + .name(args.key.clone()) + .body(types::SetSecretRequest { + value: args.value.clone(), + }) + .send() + .await + .map_err(super::map_api_error)? + .into_inner(); if globals.json { - print_json_pretty(&serde_json::json!({ "key": args.key }))?; + print_json_pretty(&meta)?; } else { - eprintln!("Set {}", args.key); + eprintln!("Set {}", meta.name); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 8264da3eb..a60696e3f 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -94,12 +94,6 @@ async fn main_inner() -> (String, Result<()>) { let _ = default_provider().install_default(); let cli = Cli::parse(); - if let Some(home) = dirs::home_dir() { - let env_path = home.join(".fabro").join(".env"); - if dotenvy::from_path(&env_path).is_ok() { - debug!(path = %env_path.display(), "Loaded environment file"); - } - } let Cli { globals, command } = cli; let _printer = Printer::from_flags(globals.quiet, globals.verbose); @@ -151,7 +145,7 @@ async fn main_inner() -> (String, Result<()>) { Commands::RunCmd(RunCommands::Run(_) | RunCommands::Create(_)) | Commands::Exec(_) | Commands::Repo(_) - | Commands::Install { .. } + | Commands::Install(_) ) { commands::upgrade::spawn_upgrade_check(globals.no_upgrade_check, upgrade_check_enabled) } else { @@ -181,10 +175,10 @@ async fn main_inner() -> (String, Result<()>) { Commands::Server(ns) => { commands::server::dispatch(ns.command, &globals).await?; } - Commands::Doctor { verbose, dry_run } => { + Commands::Doctor(args) => { let cli_settings = user_config::load_user_settings()?; - let verbose = verbose || cli_settings.verbose_enabled(); - let exit_code = commands::doctor::run_doctor(verbose, !dry_run, &globals).await?; + let verbose = args.verbose || cli_settings.verbose_enabled(); + let exit_code = commands::doctor::run_doctor(&args, verbose, &globals).await?; std::process::exit(exit_code); } Commands::Discord => { @@ -206,11 +200,11 @@ async fn main_inner() -> (String, Result<()>) { } } Commands::Repo(ns) => commands::repo::dispatch(ns, &globals).await?, - Commands::Install { web_url } => { - commands::install::run_install(&web_url, &globals).await?; + Commands::Install(args) => { + commands::install::run_install(&args, &globals).await?; } Commands::Pr(ns) => Box::pin(commands::pr::dispatch(ns, &globals)).await?, - Commands::Secret(ns) => commands::secret::dispatch(ns, &globals)?, + Commands::Secret(ns) => commands::secret::dispatch(ns, &globals).await?, Commands::Settings(args) => commands::config::execute(&args, &globals)?, Commands::Workflow(ns) => commands::workflow::dispatch(ns, &globals)?, Commands::Skill(ns) => commands::skill::dispatch(ns, &globals)?, @@ -362,54 +356,50 @@ mod tests { } #[test] - fn parse_model_list_server_url_after_subcommand() { + fn parse_model_list_server_target_after_subcommand() { let cli = Cli::try_parse_from([ "fabro", "model", "list", - "--server-url", + "--server", "http://localhost:3000/api/v1", ]) .expect("should parse"); match *cli.command { Commands::Model { command: Some(ModelsCommand::List(args)), - } => assert_eq!( - args.target.server_url(), - Some("http://localhost:3000/api/v1") - ), + } => assert_eq!(args.target.server(), Some("http://localhost:3000/api/v1")), _ => panic!("unexpected command variant"), } } #[test] - fn parse_exec_server_url_after_subcommand() { + fn parse_exec_server_target_after_subcommand() { let cli = Cli::try_parse_from([ "fabro", "exec", - "--server-url", + "--server", "http://localhost:3000/api/v1", "fix the bug", ]) .expect("should parse"); match *cli.command { - Commands::Exec(args) => assert_eq!( - args.server_url.as_deref(), - Some("http://localhost:3000/api/v1") - ), + Commands::Exec(args) => { + assert_eq!(args.server.as_deref(), Some("http://localhost:3000/api/v1")); + } _ => panic!("unexpected command variant"), } } #[test] - fn parse_model_server_url_conflicts_with_storage_dir() { + fn parse_model_server_target_conflicts_with_storage_dir() { let result = Cli::try_parse_from([ "fabro", "model", "list", "--storage-dir", "/tmp/fabro", - "--server-url", + "--server", "http://localhost:3000", ]); assert!( @@ -419,15 +409,15 @@ mod tests { } #[test] - fn parse_global_server_url_before_subcommand_is_rejected() { + fn parse_global_server_target_before_subcommand_is_rejected() { let result = Cli::try_parse_from([ "fabro", - "--server-url", + "--server", "http://localhost:3000/api/v1", "model", "list", ]); - assert!(result.is_err(), "should reject top-level --server-url"); + assert!(result.is_err(), "should reject top-level --server"); } #[test] diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 628b24262..da225826b 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -14,6 +14,7 @@ use fabro_types::{ use serde::de::DeserializeOwned; use tokio::time::sleep; +use crate::args::ServerConnectionArgs; use crate::commands::server::start; use crate::user_config; @@ -107,17 +108,50 @@ pub(crate) async fn connect_server(storage_dir: &Path) -> Result Result { let bind = start::ensure_server_running(storage_dir) .with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?; - let socket_path = match bind { - Bind::Unix(path) => path, - Bind::Tcp(addr) => { - return Err(anyhow!( - "Unsupported server bind for store client auto-connect: {addr}" - )); - } - }; + match bind { + Bind::Unix(path) => connect_unix_socket_api_client(&path).await, + Bind::Tcp(addr) => Err(anyhow!( + "Unsupported server bind for store client auto-connect: {addr}" + )), + } +} +pub(crate) async fn connect_resolved_api_client( + connection: &user_config::ServerConnection, +) -> Result { + match connection { + user_config::ServerConnection::Local { storage_dir } => { + connect_api_client(storage_dir).await + } + user_config::ServerConnection::Target(user_config::ServerTarget::HttpUrl { + base_url, + tls, + }) => connect_remote_api_client(base_url, tls.as_ref()), + user_config::ServerConnection::Target(user_config::ServerTarget::UnixSocket(path)) => { + connect_unix_socket_api_client(path).await + } + } +} + +pub(crate) async fn connect_server_backed_api_client( + args: &ServerConnectionArgs, +) -> Result { + let settings = user_config::load_user_settings_with_storage_dir(args.storage_dir())?; + let connection = user_config::server_backed_command_connection(args, &settings)?; + connect_resolved_api_client(&connection).await +} + +pub(crate) fn connect_remote_api_client( + base_url: &str, + tls: Option<&user_config::ClientTlsSettings>, +) -> Result { + let http_client = user_config::build_server_client(tls)?; + Ok(fabro_api::Client::new_with_client(base_url, http_client)) +} + +pub(crate) async fn connect_unix_socket_api_client(path: &Path) -> Result { let http_client = reqwest::ClientBuilder::new() - .unix_socket(socket_path) + .unix_socket(path) .no_proxy() .build() .context("Failed to build Unix-socket HTTP client for fabro server")?; @@ -129,14 +163,6 @@ pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result, -) -> Result { - let http_client = user_config::build_server_client(tls)?; - Ok(fabro_api::Client::new_with_client(base_url, http_client)) -} - async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> { let deadline = std::time::Instant::now() + Duration::from_secs(5); let mut last_error = None; diff --git a/lib/crates/fabro-cli/src/shared/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs index 738734534..e470600e7 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/crates/fabro-cli/src/shared/provider_auth.rs @@ -1,19 +1,18 @@ -use std::path::Path; +use std::sync::Arc; use anyhow::Result; use dialoguer::console::Term; use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, Password}; -use fabro_config::dotenv::{merge_env, write_env_file as write_env}; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate}; +use fabro_model::Catalog; use fabro_model::Provider; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; use tokio::time::timeout; use super::openai_jwt; -use crate::commands::doctor; // --------------------------------------------------------------------------- // Provider key URLs @@ -51,7 +50,7 @@ pub(crate) fn provider_display_name(provider: Provider) -> &'static str { // OpenAI OAuth helpers // --------------------------------------------------------------------------- -/// Convert OAuth tokens to env var pairs for ~/.fabro/.env. +/// Convert OAuth tokens to secret name/value pairs. pub(crate) fn openai_oauth_env_pairs( access_token: &str, refresh_token: &str, @@ -138,46 +137,32 @@ pub(crate) fn prompt_password(prompt: &str) -> Result { .interact_on(&Term::stderr())?) } -// --------------------------------------------------------------------------- -// Env file writing -// --------------------------------------------------------------------------- - -pub(crate) fn write_env_file( - arc_dir: &Path, - env_pairs: &[(String, String)], - s: &Styles, -) -> Result<()> { - let env_path = arc_dir.join(".env"); - let existing = std::fs::read_to_string(&env_path).unwrap_or_default(); - let refs: Vec<(&str, &str)> = env_pairs - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - let merged = merge_env(&existing, &refs); - write_env(&env_path, &merged)?; - eprintln!( - " {}", - s.dim.apply_to(format!("Wrote {}", env_path.display())) - ); - Ok(()) -} - // --------------------------------------------------------------------------- // API key validation // --------------------------------------------------------------------------- pub(crate) async fn validate_api_key(provider: Provider, api_key: &str) -> Result<(), String> { - // Temporarily set the env var so Client::from_env() picks it up let env_var = provider.api_key_env_vars()[0]; - std::env::set_var(env_var, api_key); + let client = LlmClient::from_lookup(|name| { + if name == env_var { + Some(api_key.to_string()) + } else { + None + } + }) + .await + .map_err(|e| e.to_string())?; - let client = LlmClient::from_env().await.map_err(|e| e.to_string())?; + let probe_model = Catalog::builtin().probe_for_provider(provider).map_or_else( + || format!("unknown-{}", provider.as_str()), + |model| model.id.clone(), + ); - let params = GenerateParams::new(doctor::probe_model(provider)) + let params = GenerateParams::new(probe_model) .provider(provider.as_str()) .prompt("Say OK") .max_tokens(16) - .client(std::sync::Arc::new(client)); + .client(Arc::new(client)); timeout(std::time::Duration::from_secs(30), generate(params)) .await diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 0b745a618..15d96a861 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -1,12 +1,13 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; pub(crate) use fabro_config::user::*; +use anyhow::{Result, bail}; use fabro_config::ConfigLayer; use fabro_types::Settings; use tracing::debug; -use crate::args::{ModelTargetArgs, ServerUrlArgs}; +use crate::args::{ServerConnectionArgs, ServerTargetArgs}; pub(crate) fn load_user_settings() -> anyhow::Result { ConfigLayer::user()?.resolve() @@ -37,57 +38,124 @@ pub(crate) fn apply_storage_dir_override( } #[derive(Debug, Clone, PartialEq)] -pub(crate) struct ServerTarget { - pub server_base_url: String, - pub tls: Option, +pub(crate) enum ServerTarget { + HttpUrl { + base_url: String, + tls: Option, + }, + UnixSocket(PathBuf), } -fn configured_server_target(settings: &Settings) -> Option { - settings.server.as_ref().and_then(|server| { - server.base_url.clone().map(|server_base_url| ServerTarget { - server_base_url, - tls: server.tls.clone(), +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ServerConnection { + Local { storage_dir: PathBuf }, + Target(ServerTarget), +} + +fn configured_server_target(settings: &Settings) -> Result> { + settings + .server + .as_ref() + .and_then(|server| server.target.as_deref()) + .map(|value| { + parse_server_target( + value, + settings + .server + .as_ref() + .and_then(|server| server.tls.clone()), + ) }) - }) + .transpose() } -pub(crate) fn exec_server_target( - args: &ServerUrlArgs, - settings: &Settings, -) -> Option { - let target = args.as_deref().map(|server_base_url| ServerTarget { - server_base_url: server_base_url.to_string(), - tls: settings - .server - .as_ref() - .and_then(|server| server.tls.clone()), - }); - debug!(has_target = target.is_some(), "Resolved exec server target"); - target +fn parse_server_target(value: &str, tls: Option) -> Result { + if value.starts_with("http://") || value.starts_with("https://") { + return Ok(ServerTarget::HttpUrl { + base_url: value.to_string(), + tls, + }); + } + + let path = Path::new(value); + if path.is_absolute() { + return Ok(ServerTarget::UnixSocket(path.to_path_buf())); + } + + bail!("server target must be an http(s) URL or absolute Unix socket path") } -pub(crate) fn model_server_target( - args: &ModelTargetArgs, +fn explicit_server_target( + args: &ServerTargetArgs, settings: &Settings, -) -> Option { - let target = if let Some(server_base_url) = args.server_url() { - Some(ServerTarget { - server_base_url: server_base_url.to_string(), - tls: settings +) -> Result> { + args.as_deref() + .map(|value| { + parse_server_target( + value, + settings + .server + .as_ref() + .and_then(|server| server.tls.clone()), + ) + }) + .transpose() +} + +fn resolve_server_connection( + args: &ServerConnectionArgs, + settings: &Settings, + use_config_target: bool, +) -> Result { + let connection = if let Some(value) = args.server() { + ServerConnection::Target(parse_server_target( + value, + settings .server .as_ref() .and_then(|server| server.tls.clone()), - }) - } else if args.storage_dir().is_some() { - None + )?) + } else if let Some(storage_dir) = args.storage_dir() { + ServerConnection::Local { + storage_dir: storage_dir.to_path_buf(), + } + } else if use_config_target { + configured_server_target(settings)?.map_or_else( + || ServerConnection::Local { + storage_dir: settings.storage_dir(), + }, + ServerConnection::Target, + ) } else { - configured_server_target(settings) + ServerConnection::Local { + storage_dir: settings.storage_dir(), + } }; - debug!( - has_target = target.is_some(), - "Resolved model server target" - ); - target + debug!(?connection, "Resolved server connection"); + Ok(connection) +} + +pub(crate) fn exec_server_target( + args: &ServerTargetArgs, + settings: &Settings, +) -> Result> { + let target = explicit_server_target(args, settings)?; + debug!(?target, "Resolved exec server target"); + Ok(target) +} + +pub(crate) fn model_server_connection( + args: &ServerConnectionArgs, + settings: &Settings, +) -> Result { + resolve_server_connection(args, settings, true) +} + +pub(crate) fn server_backed_command_connection( + args: &ServerConnectionArgs, + settings: &Settings, +) -> Result { + resolve_server_connection(args, settings, true) } pub(crate) fn build_server_client( @@ -123,108 +191,132 @@ pub(crate) fn build_server_client( #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::*; - use crate::args::{ModelTargetArgs, ServerUrlArgs}; + use crate::args::{ServerConnectionArgs, ServerTargetArgs}; - fn server_url_args(url: Option<&str>) -> ServerUrlArgs { - ServerUrlArgs { - server_url: url.map(str::to_string), + fn server_target_args(value: Option<&str>) -> ServerTargetArgs { + ServerTargetArgs { + server: value.map(str::to_string), } } - fn model_target_args(storage_dir: Option<&str>, server_url: Option<&str>) -> ModelTargetArgs { - ModelTargetArgs { - storage_dir: storage_dir.map(std::path::PathBuf::from), - server_url: server_url.map(str::to_string), + fn server_connection_args( + storage_dir: Option<&str>, + server: Option<&str>, + ) -> ServerConnectionArgs { + ServerConnectionArgs { + storage_dir: storage_dir.map(PathBuf::from), + server: server.map(str::to_string), } } #[test] fn exec_has_no_server_target_by_default() { let settings = Settings::default(); - assert_eq!(exec_server_target(&server_url_args(None), &settings), None); + assert_eq!( + exec_server_target(&server_target_args(None), &settings).unwrap(), + None + ); } #[test] - fn exec_uses_cli_server_url() { + fn exec_uses_cli_server_target() { let settings = Settings::default(); assert_eq!( - exec_server_target(&server_url_args(Some("https://cli.example.com")), &settings), - Some(ServerTarget { - server_base_url: "https://cli.example.com".to_string(), - tls: None, - }) - ); - } - - #[test] - fn exec_ignores_configured_server_base_url_without_cli_server_url() { - let settings = Settings { - server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; - assert_eq!(exec_server_target(&server_url_args(None), &settings), None); - } - - #[test] - fn model_uses_configured_server_base_url() { - let settings = Settings { - server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; - assert_eq!( - model_server_target(&model_target_args(None, None), &settings), - Some(ServerTarget { - server_base_url: "https://config.example.com".to_string(), - tls: None, - }) - ); - } - - #[test] - fn model_cli_server_url_overrides_config_url() { - let settings = Settings { - server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), - tls: None, - }), - ..Settings::default() - }; - assert_eq!( - model_server_target( - &model_target_args(None, Some("https://cli.example.com")), + exec_server_target( + &server_target_args(Some("https://cli.example.com")), &settings - ), - Some(ServerTarget { - server_base_url: "https://cli.example.com".to_string(), + ) + .unwrap(), + Some(ServerTarget::HttpUrl { + base_url: "https://cli.example.com".to_string(), tls: None, }) ); } #[test] - fn model_storage_dir_suppresses_configured_remote_target() { + fn exec_supports_explicit_unix_socket_target() { + let settings = Settings::default(); + assert_eq!( + exec_server_target(&server_target_args(Some("/tmp/fabro.sock")), &settings).unwrap(), + Some(ServerTarget::UnixSocket(PathBuf::from("/tmp/fabro.sock"))) + ); + } + + #[test] + fn exec_ignores_configured_server_target_without_cli_override() { let settings = Settings { server: Some(ServerSettings { - base_url: Some("https://config.example.com".to_string()), + target: Some("https://config.example.com".to_string()), tls: None, }), ..Settings::default() }; assert_eq!( - model_server_target(&model_target_args(Some("/tmp/fabro"), None), &settings), + exec_server_target(&server_target_args(None), &settings).unwrap(), None ); } + #[test] + fn model_uses_configured_server_target() { + let settings = Settings { + server: Some(ServerSettings { + target: Some("https://config.example.com".to_string()), + tls: None, + }), + ..Settings::default() + }; + assert_eq!( + model_server_connection(&server_connection_args(None, None), &settings).unwrap(), + ServerConnection::Target(ServerTarget::HttpUrl { + base_url: "https://config.example.com".to_string(), + tls: None, + }) + ); + } + + #[test] + fn explicit_server_target_overrides_config_target() { + let settings = Settings { + server: Some(ServerSettings { + target: Some("https://config.example.com".to_string()), + tls: None, + }), + ..Settings::default() + }; + assert_eq!( + model_server_connection( + &server_connection_args(None, Some("https://cli.example.com")), + &settings, + ) + .unwrap(), + ServerConnection::Target(ServerTarget::HttpUrl { + base_url: "https://cli.example.com".to_string(), + tls: None, + }) + ); + } + + #[test] + fn storage_dir_suppresses_configured_remote_target() { + let settings = Settings { + server: Some(ServerSettings { + target: Some("https://config.example.com".to_string()), + tls: None, + }), + ..Settings::default() + }; + assert_eq!( + model_server_connection(&server_connection_args(Some("/tmp/fabro"), None), &settings) + .unwrap(), + ServerConnection::Local { + storage_dir: PathBuf::from("/tmp/fabro"), + } + ); + } + #[test] fn remote_target_uses_tls_from_config() { let tls = ClientTlsSettings { @@ -234,17 +326,32 @@ mod tests { }; let settings = Settings { server: Some(ServerSettings { - base_url: None, + target: None, tls: Some(tls.clone()), }), ..Settings::default() }; assert_eq!( - exec_server_target(&server_url_args(Some("https://cli.example.com")), &settings), - Some(ServerTarget { - server_base_url: "https://cli.example.com".to_string(), + exec_server_target( + &server_target_args(Some("https://cli.example.com")), + &settings + ) + .unwrap(), + Some(ServerTarget::HttpUrl { + base_url: "https://cli.example.com".to_string(), tls: Some(tls), }) ); } + + #[test] + fn invalid_server_target_is_rejected() { + let settings = Settings::default(); + let error = + exec_server_target(&server_target_args(Some("fabro.internal")), &settings).unwrap_err(); + assert_eq!( + error.to_string(), + "server target must be an http(s) URL or absolute Unix socket path" + ); + } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs index a1158017c..ddcba30f6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs @@ -25,58 +25,33 @@ fn help() { Usage: fabro doctor [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - -v, --verbose Show detailed information for each check - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --dry-run Skip live service probes (LLM, sandbox, API, web, Brave Search) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + -v, --verbose Show detailed information for each check + --quiet Suppress non-essential output [env: FABRO_QUIET=] + -h, --help Print help ----- stderr ----- "); } #[test] -fn dry_run_flag() { +fn dry_run_flag_is_rejected() { let context = test_context!(); let mut cmd = context.doctor(); cmd.arg("--dry-run"); - cmd.env( - "PATH", - "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin", - ); - cmd.env("ANTHROPIC_API_KEY", "sk-test-dummy"); fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 + success: false + exit_code: 2 ----- stdout ----- - Fabro Doctor - - Required - [!] Configuration (no user config file found) - [✓] LLM providers (1 configured) - [!] GitHub App (not configured) - - Optional - [!] Cloud sandbox (no sandbox configured) - [!] Brave Search (not configured) - - Server - [!] System dependencies (some issues) - [✓] Fabro API (http://localhost:3000/api/v1) - [✓] Fabro Web (http://localhost:3000) - [!] Cryptographic keys (no authentication configured) - - Found issues in 6 categories. - - Warnings: - • Configuration — Create ~/.fabro/user.toml - • GitHub App — Configure GitHub App in server.toml and set env vars to enable GitHub integration - • Cloud sandbox — Set DAYTONA_API_KEY to enable cloud sandbox execution - • Brave Search — Set BRAVE_SEARCH_API_KEY to enable web search - • System dependencies — Install missing system dependencies - • Cryptographic keys — Configure authentication_strategies in [api] section of server.toml ----- stderr ----- + error: unexpected argument '--dry-run' found + + Usage: fabro doctor [OPTIONS] + + For more information, try '--help'. "); } @@ -116,7 +91,6 @@ async fn twin_doctor() { fn doctor_no_color_when_no_color_set() { let context = test_context!(); let mut cmd = context.doctor(); - cmd.arg("--dry-run"); cmd.env_clear(); cmd.env("NO_COLOR", "1"); cmd.assert().stdout(predicate::str::contains("\x1b[").not()); diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 129ed55ab..606d263f4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -29,7 +29,7 @@ fn help() { Options: --json Output as JSON [env: FABRO_JSON=] - --server-url Fabro API server URL (overrides server.base_url from user.toml when supported) [env: FABRO_SERVER_URL=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --provider LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) --model Model name (defaults per provider) --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] @@ -120,7 +120,7 @@ fn exec_uses_user_config_defaults() { } #[test] -fn exec_server_url_uses_remote_transport_instead_of_local_api_key_resolution() { +fn exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution() { let context = test_context!(); let server = MockServer::start(); server.mock(|when, then| { @@ -133,7 +133,7 @@ fn exec_server_url_uses_remote_transport_instead_of_local_api_key_resolution() { cmd.env("HOME", &context.home_dir); cmd.env("FABRO_NO_UPGRADE_CHECK", "true"); cmd.args([ - "--server-url", + "--server", &format!("{}/api/v1", server.base_url()), "--provider", "openai", @@ -150,12 +150,12 @@ fn exec_server_url_uses_remote_transport_instead_of_local_api_key_resolution() { ); assert!( !stderr.contains("API key not set"), - "exec should not fail local API key validation when --server-url is set: {stderr}" + "exec should not fail local API key validation when --server is set: {stderr}" ); } #[test] -fn exec_configured_server_base_url_alone_does_not_reroute_exec() { +fn exec_configured_server_target_alone_does_not_reroute_exec() { let context = test_context!(); let server = MockServer::start(); server.mock(|when, then| { @@ -164,7 +164,7 @@ fn exec_configured_server_base_url_alone_does_not_reroute_exec() { }); context.write_home( ".fabro/user.toml", - format!("[server]\nbase_url = \"{}/api/v1\"\n", server.base_url()), + format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), ); let mut cmd = context.exec_cmd(); @@ -187,12 +187,12 @@ fn exec_configured_server_base_url_alone_does_not_reroute_exec() { ); assert!( !stderr.contains("config-should-not-be-used"), - "exec should ignore configured server.base_url without --server-url: {stderr}" + "exec should ignore configured server.target without --server: {stderr}" ); } #[test] -fn exec_cli_server_url_overrides_configured_server_base_url() { +fn exec_cli_server_target_overrides_configured_server_target() { let context = test_context!(); let config_server = MockServer::start(); config_server.mock(|when, then| { @@ -207,7 +207,7 @@ fn exec_cli_server_url_overrides_configured_server_base_url() { context.write_home( ".fabro/user.toml", format!( - "[server]\nbase_url = \"{}/api/v1\"\n", + "[server]\ntarget = \"{}/api/v1\"\n", config_server.base_url() ), ); @@ -217,7 +217,7 @@ fn exec_cli_server_url_overrides_configured_server_base_url() { cmd.env("HOME", &context.home_dir); cmd.env("FABRO_NO_UPGRADE_CHECK", "true"); cmd.args([ - "--server-url", + "--server", &format!("{}/api/v1", cli_server.base_url()), "--provider", "openai", @@ -230,11 +230,11 @@ fn exec_cli_server_url_overrides_configured_server_base_url() { let stderr = String::from_utf8(output.stderr).expect("valid utf8"); assert!( stderr.contains("cli-override-marker"), - "expected CLI server URL to win, got: {stderr}" + "expected CLI server target to win, got: {stderr}" ); assert!( !stderr.contains("config-should-not-be-used"), - "configured server.base_url should not be used when --server-url is passed: {stderr}" + "configured server.target should not be used when --server is passed: {stderr}" ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index c3fb02d40..4ac0b0589 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -33,7 +33,7 @@ fn help() { doctor Check environment and integration health install Set up the Fabro environment (LLMs, certs, GitHub) pr Pull request operations - secret Manage secrets in ~/.fabro/.env + secret Manage server-owned secrets settings Inspect merged configuration workflow Workflow operations discord Open the Discord community in the browser diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index 7ea68f5b3..202e94ed7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/install.rs @@ -14,13 +14,14 @@ fn help() { Usage: fabro install [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --web-url Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:3000] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --web-url Base URL for the web UI (used for OAuth callback URLs) [default: http://localhost:3000] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 9d9c6c1a9..3f7b9d3e3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -43,7 +43,6 @@ mod sandbox_cp; mod sandbox_preview; mod sandbox_ssh; mod secret; -mod secret_get; mod secret_list; mod secret_rm; mod secret_set; diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index 6598ce0f6..1a6c39c6e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -176,7 +176,7 @@ fn list_invalid_provider_errors() { } #[test] -fn list_uses_configured_server_base_url_without_server_url_flag() { +fn list_uses_configured_server_target_without_server_flag() { let context = test_context!(); let server = MockServer::start(); let mock = server.mock(|when, then| { @@ -218,7 +218,7 @@ fn list_uses_configured_server_base_url_without_server_url_flag() { }); context.write_home( ".fabro/user.toml", - format!("[server]\nbase_url = \"{}/api/v1\"\n", server.base_url()), + format!("[server]\ntarget = \"{}/api/v1\"\n", server.base_url()), ); let mut cmd = context.model(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_list.rs b/lib/crates/fabro-cli/tests/it/cmd/model_list.rs index 9e6190b2b..d63099d67 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model_list.rs @@ -17,7 +17,7 @@ fn help() { --json Output as JSON [env: FABRO_JSON=] --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --server-url Fabro API server URL (overrides server.base_url from user.toml when supported) [env: FABRO_SERVER_URL=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] -p, --provider Filter by provider -q, --query Search for models matching this string diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs index 6b0861c4c..d8111d5b2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs @@ -17,7 +17,7 @@ fn help() { --json Output as JSON [env: FABRO_JSON=] --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --server-url Fabro API server URL (overrides server.base_url from user.toml when supported) [env: FABRO_SERVER_URL=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] -p, --provider Filter by provider -m, --model Test a specific model diff --git a/lib/crates/fabro-cli/tests/it/cmd/provider_login.rs b/lib/crates/fabro-cli/tests/it/cmd/provider_login.rs index ab1195951..64333f397 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/provider_login.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/provider_login.rs @@ -14,13 +14,15 @@ fn help() { Usage: fabro provider login [OPTIONS] --provider Options: - --json Output as JSON [env: FABRO_JSON=] - --provider LLM provider to authenticate with - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --provider LLM provider to authenticate with + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo.rs b/lib/crates/fabro-cli/tests/it/cmd/repo.rs index 4ad9f8a87..f96a1d25f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo.rs @@ -105,12 +105,14 @@ fn test_repo_init_help_does_not_show_skill() { Usage: fabro repo init [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs index 657f8393e..64d914c5f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/repo_init.rs @@ -16,12 +16,14 @@ fn help() { Usage: fabro repo init [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret.rs b/lib/crates/fabro-cli/tests/it/cmd/secret.rs index cb2f5b9fe..981692492 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/secret.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/secret.rs @@ -1,6 +1,7 @@ #![allow(clippy::absolute_paths, clippy::single_char_pattern)] use fabro_test::{fabro_snapshot, test_context}; +use predicates::prelude::*; #[test] fn help() { @@ -11,24 +12,25 @@ fn help() { success: true exit_code: 0 ----- stdout ----- - Manage secrets in ~/.fabro/.env + Manage server-owned secrets Usage: fabro secret [OPTIONS] Commands: - get Get a secret value list List secret names rm Remove a secret set Set a secret value help Print this message or the help of the given subcommand(s) Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -43,49 +45,39 @@ fn test_secret_lifecycle() { // 1. set FOO=bar secret(&["set", "FOO", "bar"]).success(); - // 2. get FOO -> stdout is "bar\n" - secret(&["get", "FOO"]).success().stdout("bar\n"); - - // 3. list -> contains FOO + // 2. list -> contains FOO secret(&["list"]) .success() .stdout(predicates::str::contains("FOO")); - // 4. update FOO + // 3. update FOO secret(&["set", "FOO", "updated"]).success(); - // 5. get FOO -> "updated\n" - secret(&["get", "FOO"]).success().stdout("updated\n"); - - // 6. rm FOO + // 4. rm FOO secret(&["rm", "FOO"]).success(); - // 7. get FOO -> fails - secret(&["get", "FOO"]).failure(); + // 5. list no longer contains FOO + let output = secret(&["list"]).success().get_output().stdout.clone(); + let stdout = String::from_utf8(output).unwrap(); + assert!(!stdout.contains("FOO")); } #[test] -fn test_secret_list_show_values() { +fn test_secret_list_is_write_only() { let context = test_context!(); let secret = |args: &[&str]| -> assert_cmd::assert::Assert { context.secret().args(args).assert() }; - secret(&["set", "A", "1"]).success(); - secret(&["set", "B", "2"]).success(); + secret(&["set", "A", "alpha-secret"]).success(); + secret(&["set", "B", "beta-secret"]).success(); - // Without --show-values: just keys let out = secret(&["list"]).success(); let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); assert!(stdout.contains("A")); assert!(stdout.contains("B")); - assert!(!stdout.contains("A=1")); - - // With --show-values: KEY=VALUE - secret(&["list", "--show-values"]) - .success() - .stdout(predicates::str::contains("A=1")) - .stdout(predicates::str::contains("B=2")); + assert!(!stdout.contains("alpha-secret")); + assert!(!stdout.contains("beta-secret")); } #[test] @@ -102,20 +94,6 @@ fn test_secret_list_alias_ls() { .stdout(predicates::str::contains("X")); } -#[test] -fn test_secret_get_missing_key() { - let context = test_context!(); - let mut cmd = context.secret(); - cmd.args(["get", "NOPE"]); - fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 - ----- stdout ----- - ----- stderr ----- - error: secret not found: NOPE - "); -} - #[test] fn test_secret_rm_missing_key() { let context = test_context!(); @@ -142,8 +120,9 @@ fn test_secret_value_with_equals() { context .secret() - .args(["get", "URL"]) + .args(["list"]) .assert() .success() - .stdout("https://x.com?a=1&b=2\n"); + .stdout(predicates::str::contains("URL")) + .stdout(predicates::str::contains("https://x.com?a=1&b=2").not()); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_get.rs b/lib/crates/fabro-cli/tests/it/cmd/secret_get.rs deleted file mode 100644 index 8c6b3cd6a..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/secret_get.rs +++ /dev/null @@ -1,28 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["secret", "get", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Get a secret value - - Usage: fabro secret get [OPTIONS] - - Arguments: - Name of the secret - - Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help - ----- stderr ----- - "); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs b/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs index 2c7403790..27dc86c5a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/secret_list.rs @@ -16,7 +16,6 @@ fn help() { Options: --json Output as JSON [env: FABRO_JSON=] - --show-values Show values alongside keys --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --quiet Suppress non-essential output [env: FABRO_QUIET=] @@ -27,7 +26,7 @@ fn help() { } #[test] -fn secret_list_json_show_values_includes_values() { +fn secret_list_json_returns_metadata_only() { let context = test_context!(); context .command() @@ -37,17 +36,17 @@ fn secret_list_json_show_values_includes_values() { let output = context .command() - .args(["--json", "secret", "list", "--show-values"]) + .args(["--json", "secret", "list"]) .output() .expect("command should run"); assert!(output.status.success()); let value: Value = serde_json::from_slice(&output.stdout).expect("secret list should parse"); - assert_eq!( - value, - Value::Array(vec![serde_json::json!({ - "key": "ANTHROPIC_API_KEY", - "value": "test-value", - })]) - ); + let array = value.as_array().expect("secret list should be an array"); + let entry = array + .iter() + .find(|entry| entry["name"] == "ANTHROPIC_API_KEY") + .expect("secret list should include the saved key"); + assert!(entry.get("updated_at").is_some()); + assert!(entry.get("value").is_none()); } diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index 8883b2ca5..acf1ffc72 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -43,7 +43,7 @@ impl TryFrom for ClientTlsSettings { #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)] pub struct ServerConfig { - pub base_url: Option, + pub target: Option, pub tls: Option, } @@ -52,7 +52,7 @@ impl TryFrom for ServerSettings { fn try_from(value: ServerConfig) -> Result { Ok(Self { - base_url: value.base_url, + target: value.target, tls: value.tls.map(TryInto::try_into).transpose()?, }) } diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index 6399cfd74..3ea1770d0 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -38,6 +38,18 @@ impl Client { /// /// Returns `SdkError` if any provider adapter fails to initialize. pub async fn from_env() -> Result { + Self::from_lookup(|name| std::env::var(name).ok()).await + } + + /// Create a Client from a custom variable lookup. + /// + /// This is useful when credentials come from a source other than process + /// environment variables, while still preserving the env-style provider + /// configuration surface. + pub async fn from_lookup(lookup: F) -> Result + where + F: Fn(&str) -> Option, + { let mut client = Self { providers: HashMap::new(), default_provider: None, @@ -46,16 +58,16 @@ impl Client { // Register providers whose API keys are present in the environment. // Order determines which becomes the default provider. - if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") { + if let Some(key) = lookup("ANTHROPIC_API_KEY") { let mut adapter = providers::AnthropicAdapter::new(key); - if let Ok(base_url) = std::env::var("ANTHROPIC_BASE_URL") { + if let Some(base_url) = lookup("ANTHROPIC_BASE_URL") { adapter = adapter.with_base_url(base_url); } client.register_provider(Arc::new(adapter)).await?; } - if let Ok(key) = std::env::var("OPENAI_API_KEY") { + if let Some(key) = lookup("OPENAI_API_KEY") { let mut adapter = providers::OpenAiAdapter::new(key); - if let Ok(account_id) = std::env::var("CHATGPT_ACCOUNT_ID") { + if let Some(account_id) = lookup("CHATGPT_ACCOUNT_ID") { // Codex OAuth: route through chatgpt.com backend with required headers adapter = adapter .with_base_url("https://chatgpt.com/backend-api/codex") @@ -64,44 +76,42 @@ impl Client { headers.insert("ChatGPT-Account-Id".to_string(), account_id); headers.insert("originator".to_string(), "fabro".to_string()); adapter = adapter.with_default_headers(headers); - } else if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { + } else if let Some(base_url) = lookup("OPENAI_BASE_URL") { adapter = adapter.with_base_url(base_url); } - if let Ok(org_id) = std::env::var("OPENAI_ORG_ID") { + if let Some(org_id) = lookup("OPENAI_ORG_ID") { adapter = adapter.with_org_id(org_id); } - if let Ok(project_id) = std::env::var("OPENAI_PROJECT_ID") { + if let Some(project_id) = lookup("OPENAI_PROJECT_ID") { adapter = adapter.with_project_id(project_id); } client.register_provider(Arc::new(adapter)).await?; } - if let Ok(key) = - std::env::var("GEMINI_API_KEY").or_else(|_| std::env::var("GOOGLE_API_KEY")) - { + if let Some(key) = lookup("GEMINI_API_KEY").or_else(|| lookup("GOOGLE_API_KEY")) { let mut adapter = providers::GeminiAdapter::new(key); - if let Ok(base_url) = std::env::var("GEMINI_BASE_URL") { + if let Some(base_url) = lookup("GEMINI_BASE_URL") { adapter = adapter.with_base_url(base_url); } client.register_provider(Arc::new(adapter)).await?; } - if let Ok(key) = std::env::var("KIMI_API_KEY") { + if let Some(key) = lookup("KIMI_API_KEY") { let adapter = providers::OpenAiCompatibleAdapter::new(key, "https://api.moonshot.ai/v1") .with_name("kimi"); client.register_provider(Arc::new(adapter)).await?; } - if let Ok(key) = std::env::var("ZAI_API_KEY") { + if let Some(key) = lookup("ZAI_API_KEY") { let adapter = providers::OpenAiCompatibleAdapter::new(key, "https://api.z.ai/api/coding/paas/v4") .with_name("zai"); client.register_provider(Arc::new(adapter)).await?; } - if let Ok(key) = std::env::var("MINIMAX_API_KEY") { + if let Some(key) = lookup("MINIMAX_API_KEY") { let adapter = providers::OpenAiCompatibleAdapter::new(key, "https://api.minimax.io/v1") .with_name("minimax"); client.register_provider(Arc::new(adapter)).await?; } - if let Ok(key) = std::env::var("INCEPTION_API_KEY") { + if let Some(key) = lookup("INCEPTION_API_KEY") { let adapter = providers::OpenAiCompatibleAdapter::new(key, "https://api.inceptionlabs.ai/v1") .with_name("inception"); diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index 1b02771d6..335d162d9 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/fabro-llm/src/model_test.rs @@ -1,8 +1,10 @@ use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use tokio::time; +use crate::client::Client; use crate::generate::{self, GenerateParams}; use crate::tools::Tool; use crate::types::{GenerateResult, ReasoningEffort}; @@ -86,17 +88,36 @@ impl ModelTestOutcome { } pub async fn run_model_test(info: &Model, mode: ModelTestMode) -> ModelTestOutcome { + run_model_test_inner(info, mode, None).await +} + +pub async fn run_model_test_with_client( + info: &Model, + mode: ModelTestMode, + client: Arc, +) -> ModelTestOutcome { + run_model_test_inner(info, mode, Some(client)).await +} + +async fn run_model_test_inner( + info: &Model, + mode: ModelTestMode, + client: Option>, +) -> ModelTestOutcome { match mode { - ModelTestMode::Basic => run_basic_test(info).await, - ModelTestMode::Deep => run_deep_test(info).await, + ModelTestMode::Basic => run_basic_test(info, client).await, + ModelTestMode::Deep => run_deep_test(info, client).await, } } -async fn run_basic_test(info: &Model) -> ModelTestOutcome { - let params = GenerateParams::new(&info.id) +async fn run_basic_test(info: &Model, client: Option>) -> ModelTestOutcome { + let mut params = GenerateParams::new(&info.id) .provider(info.provider.as_str()) .prompt("Say OK") .max_tokens(16); + if let Some(client) = client { + params = params.client(client); + } let result = time::timeout( Duration::from_secs(ModelTestMode::Basic.timeout_secs()), @@ -111,8 +132,8 @@ async fn run_basic_test(info: &Model) -> ModelTestOutcome { } } -async fn run_deep_test(info: &Model) -> ModelTestOutcome { - let Some(params) = build_deep_test_params(info) else { +async fn run_deep_test(info: &Model, client: Option>) -> ModelTestOutcome { + let Some(params) = build_deep_test_params(info, client) else { return ModelTestOutcome::error("model does not support tools"); }; @@ -132,7 +153,7 @@ async fn run_deep_test(info: &Model) -> ModelTestOutcome { } } -fn build_deep_test_params(info: &Model) -> Option { +fn build_deep_test_params(info: &Model, client: Option>) -> Option { if !info.features.tools { return None; } @@ -175,6 +196,10 @@ fn build_deep_test_params(info: &Model) -> Option { params = params.reasoning_effort(ReasoningEffort::High); } + if let Some(client) = client { + params = params.client(client); + } + Some(params) } diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index b47305cae..5f3d259e8 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -65,6 +65,8 @@ bytes = "1" object_store.workspace = true mime_guess.workspace = true rust-embed.workspace = true +regex.workspace = true +semver.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index ab382549e..ae678e15d 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -432,6 +432,110 @@ pub(crate) async fn create_session_stub( .into_response() } +pub(crate) async fn list_secrets( + _auth: AuthenticatedService, + State(_state): State>, +) -> Response { + ( + StatusCode::OK, + Json(json!({ + "data": [ + { + "name": "OPENAI_API_KEY", + "created_at": "2026-04-05T12:00:00Z", + "updated_at": "2026-04-05T12:00:00Z" + }, + { + "name": "GITHUB_APP_PRIVATE_KEY", + "created_at": "2026-04-05T12:05:00Z", + "updated_at": "2026-04-05T12:05:00Z" + } + ] + })), + ) + .into_response() +} + +pub(crate) async fn set_secret( + _auth: AuthenticatedService, + State(_state): State>, + Path(name): Path, +) -> Response { + ( + StatusCode::OK, + Json(json!({ + "name": name, + "created_at": "2026-04-05T12:00:00Z", + "updated_at": "2026-04-05T12:00:00Z" + })), + ) + .into_response() +} + +pub(crate) async fn delete_secret( + _auth: AuthenticatedService, + State(_state): State>, + Path(_name): Path, +) -> Response { + StatusCode::NO_CONTENT.into_response() +} + +pub(crate) async fn get_github_repo( + _auth: AuthenticatedService, + State(_state): State>, + Path((owner, name)): Path<(String, String)>, +) -> Response { + ( + StatusCode::OK, + Json(json!({ + "owner": owner, + "name": name, + "accessible": false, + "default_branch": null, + "private": null, + "permissions": null, + "install_url": "https://github.com/apps/fabro/installations/new" + })), + ) + .into_response() +} + +pub(crate) async fn run_diagnostics( + _auth: AuthenticatedService, + State(_state): State>, +) -> Response { + ( + StatusCode::OK, + Json(json!({ + "version": fabro_util::version::FABRO_VERSION, + "sections": [ + { + "title": "Credentials", + "checks": [ + { "name": "LLM Providers", "status": "pass", "summary": "demo configured", "details": [], "remediation": null }, + { "name": "GitHub App", "status": "pass", "summary": "demo configured", "details": [], "remediation": null }, + { "name": "Sandbox", "status": "warning", "summary": "not configured", "details": [], "remediation": "Set DAYTONA_API_KEY to enable cloud sandbox execution" }, + { "name": "Brave Search", "status": "warning", "summary": "not configured", "details": [], "remediation": "Set BRAVE_SEARCH_API_KEY to enable web search" } + ] + }, + { + "title": "System", + "checks": [ + { "name": "dot", "status": "pass", "summary": "dot available", "details": [], "remediation": null } + ] + }, + { + "title": "Configuration", + "checks": [ + { "name": "Crypto", "status": "pass", "summary": "all keys valid", "details": [], "remediation": null } + ] + } + ] + })), + ) + .into_response() +} + pub(crate) async fn get_session( _auth: AuthenticatedService, State(_state): State>, diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs new file mode 100644 index 000000000..f59b17610 --- /dev/null +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -0,0 +1,572 @@ +use std::path::Path; +use std::process::Command; +use std::sync::LazyLock; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use fabro_config::server::ApiAuthStrategy; +use fabro_llm::client::Client as LlmClient; +use fabro_llm::types::{Message, Request}; +use fabro_model::{Catalog, Provider}; +use fabro_util::check_report::{CheckDetail, CheckResult, CheckSection, CheckStatus}; +use fabro_util::version::FABRO_VERSION; +use regex::Regex; +use semver::Version; +use serde::Serialize; +use tokio::time::timeout; + +use crate::server::AppState; + +#[derive(Debug, Serialize)] +pub struct DiagnosticsReport { + pub version: String, + pub sections: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +enum ProbeOutcome { + NotFound, + Failed, + Ok { version: Option }, +} + +static DOT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"graphviz version (\d+)\.(\d+)\.(\d+)").unwrap()); + +fn parse_version(re: &Regex, output: &str) -> Option { + let caps = re.captures(output)?; + Some(Version::new( + caps[1].parse().ok()?, + caps[2].parse().ok()?, + caps[3].parse().ok()?, + )) +} + +fn probe_dot() -> ProbeOutcome { + let result = Command::new("dot").arg("-V").output().ok(); + match result { + None => ProbeOutcome::NotFound, + Some(output) if !output.status.success() => ProbeOutcome::Failed, + Some(output) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let version = + parse_version(&DOT_RE, &stdout).or_else(|| parse_version(&DOT_RE, &stderr)); + ProbeOutcome::Ok { version } + } + } +} + +fn check_dot() -> CheckResult { + let outcome = probe_dot(); + let (status, summary, remediation) = match &outcome { + ProbeOutcome::NotFound => ( + CheckStatus::Warning, + "not installed".to_string(), + Some("Install Graphviz to enable workflow graph rendering".to_string()), + ), + ProbeOutcome::Failed => ( + CheckStatus::Warning, + "command failed".to_string(), + Some("Check that `dot -V` succeeds on the server host".to_string()), + ), + ProbeOutcome::Ok { + version: Some(version), + } => (CheckStatus::Pass, format!("dot {version}"), None), + ProbeOutcome::Ok { version: None } => { + (CheckStatus::Pass, "dot available".to_string(), None) + } + }; + CheckResult { + name: "dot".to_string(), + status, + summary, + details: Vec::new(), + remediation, + } +} + +fn decode_pem_value(name: &str, value: &str) -> Result { + if value.starts_with("-----") { + return Ok(value.to_string()); + } + let bytes = BASE64_STANDARD + .decode(value) + .map_err(|e| format!("{name} is not valid PEM or base64: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("{name} base64 decoded to invalid UTF-8: {e}")) +} + +fn validate_tls_cert(pem: &str, now_epoch: i64) -> Result { + let mut reader = std::io::Cursor::new(pem.as_bytes()); + let certs: Vec<_> = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| format!("failed to parse certificate PEM: {e}"))?; + if certs.is_empty() { + return Err("no certificates found in PEM".to_string()); + } + let (_, parsed) = x509_parser::parse_x509_certificate(&certs[0]) + .map_err(|e| format!("failed to parse X.509 certificate: {e}"))?; + let not_after = parsed.validity().not_after.timestamp(); + if not_after <= now_epoch { + return Err("certificate has expired".to_string()); + } + let cn = parsed + .subject() + .iter_common_name() + .next() + .and_then(|cn| cn.as_str().ok()) + .unwrap_or("(no CN)"); + Ok(format!("CN={cn}, valid")) +} + +fn validate_tls_private_key(pem: &str) -> Result<(), String> { + let mut reader = std::io::Cursor::new(pem.as_bytes()); + rustls_pemfile::private_key(&mut reader) + .map_err(|e| format!("failed to parse private key PEM: {e}"))? + .ok_or_else(|| "no private key found in PEM".to_string())?; + Ok(()) +} + +fn validate_tls_ca(pem: &str) -> Result<(), String> { + let mut reader = std::io::Cursor::new(pem.as_bytes()); + let certs: Vec<_> = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| format!("failed to parse CA certificate PEM: {e}"))?; + if certs.is_empty() { + return Err("no CA certificates found in PEM".to_string()); + } + Ok(()) +} + +fn validate_session_secret(value: &str) -> Result<(), String> { + if value.len() < 64 { + return Err(format!( + "too short ({} chars, need at least 64 hex chars for 256-bit entropy)", + value.len() + )); + } + if !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("contains non-hex characters".to_string()); + } + Ok(()) +} + +pub async fn run_all(state: &AppState) -> DiagnosticsReport { + let (llm, github, brave) = tokio::join!( + check_llm_providers(state), + check_github_app(state), + check_brave_search(state), + ); + let sandbox = check_sandbox(state); + let crypto = check_crypto(state); + + DiagnosticsReport { + version: FABRO_VERSION.to_string(), + sections: vec![ + CheckSection { + title: "Credentials".to_string(), + checks: vec![llm, github, sandbox, brave], + }, + CheckSection { + title: "System".to_string(), + checks: vec![check_dot()], + }, + CheckSection { + title: "Configuration".to_string(), + checks: vec![crypto], + }, + ], + } +} + +async fn check_llm_providers(state: &AppState) -> CheckResult { + let configured: Vec = Provider::ALL + .iter() + .copied() + .filter(|provider| { + provider + .api_key_env_vars() + .iter() + .any(|name| state.secret_or_env(name).is_some()) + }) + .collect(); + + if configured.is_empty() { + return CheckResult { + name: "LLM Providers".to_string(), + status: CheckStatus::Error, + summary: "none configured".to_string(), + details: Vec::new(), + remediation: Some("Set at least one provider API key".to_string()), + }; + } + + let client = match state.build_llm_client().await { + Ok(client) => client, + Err(err) => { + return CheckResult { + name: "LLM Providers".to_string(), + status: CheckStatus::Error, + summary: "failed to initialize".to_string(), + details: vec![CheckDetail::new(err)], + remediation: Some("Check configured provider credentials".to_string()), + }; + } + }; + + let mut details = Vec::new(); + let mut failed = Vec::new(); + for provider in configured { + let result = timeout( + Duration::from_secs(30), + probe_llm_provider(&client, provider), + ) + .await; + match result { + Ok(Ok(())) => details.push(CheckDetail::new(format!("{provider} connectivity: OK"))), + Ok(Err(err)) => { + failed.push(provider.to_string()); + details.push(CheckDetail::new(format!("{provider} connectivity: {err}"))); + } + Err(_) => { + failed.push(provider.to_string()); + details.push(CheckDetail::new(format!( + "{provider} connectivity: timeout (30s)" + ))); + } + } + } + + if failed.is_empty() { + CheckResult { + name: "LLM Providers".to_string(), + status: CheckStatus::Pass, + summary: format!("{} configured", details.len()), + details, + remediation: None, + } + } else { + CheckResult { + name: "LLM Providers".to_string(), + status: CheckStatus::Warning, + summary: "connectivity issues".to_string(), + details, + remediation: Some(format!("Connectivity issues with: {}", failed.join(", "))), + } + } +} + +fn probe_model(provider: Provider) -> String { + Catalog::builtin().probe_for_provider(provider).map_or_else( + || format!("unknown-{}", provider.as_str()), + |m| m.id.clone(), + ) +} + +async fn probe_llm_provider(client: &LlmClient, provider: Provider) -> Result<(), String> { + let request = Request { + model: probe_model(provider), + messages: vec![Message::user("hi")], + provider: Some(provider.as_str().to_string()), + tools: None, + tool_choice: None, + response_format: None, + temperature: None, + top_p: None, + max_tokens: Some(16), + stop_sequences: None, + reasoning_effort: None, + speed: None, + metadata: None, + provider_options: None, + }; + client + .complete(&request) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +async fn check_github_app(state: &AppState) -> CheckResult { + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); + let app_id = settings.app_id().map(str::to_owned); + let slug = settings.slug().map(str::to_owned); + let private_key_raw = state.secret_or_env("GITHUB_APP_PRIVATE_KEY"); + let client_id = settings.client_id().is_some(); + let client_secret = state.secret_or_env("GITHUB_APP_CLIENT_SECRET").is_some(); + let webhook_secret = state.secret_or_env("GITHUB_APP_WEBHOOK_SECRET").is_some(); + + if app_id.is_none() + && private_key_raw.is_none() + && !client_id + && !client_secret + && !webhook_secret + { + return CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: Vec::new(), + remediation: Some("Configure GitHub App settings and secrets".to_string()), + }; + } + + let Some(app_id) = app_id else { + return CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "missing app_id".to_string(), + details: Vec::new(), + remediation: Some("Set git.app_id in server.toml".to_string()), + }; + }; + let Some(private_key_raw) = private_key_raw else { + return CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "missing private key".to_string(), + details: Vec::new(), + remediation: Some("Set GITHUB_APP_PRIVATE_KEY".to_string()), + }; + }; + + let private_key = match decode_pem_value("GITHUB_APP_PRIVATE_KEY", &private_key_raw) { + Ok(value) => value, + Err(err) => { + return CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "private key invalid".to_string(), + details: vec![CheckDetail::new(err.clone())], + remediation: Some(err), + }; + } + }; + + let jwt = match fabro_github::sign_app_jwt(&app_id, &private_key) { + Ok(jwt) => jwt, + Err(err) => { + return CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "JWT signing failed".to_string(), + details: vec![CheckDetail::new(err.clone())], + remediation: Some(err), + }; + } + }; + + let http = reqwest::Client::new(); + let auth_result = timeout( + Duration::from_secs(15), + fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()), + ) + .await; + match auth_result { + Ok(Ok(_app)) => CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Pass, + summary: slug.unwrap_or_else(|| "configured".to_string()), + details: Vec::new(), + remediation: None, + }, + Ok(Err(err)) => CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "connectivity error".to_string(), + details: vec![CheckDetail::new(err.clone())], + remediation: Some(err), + }, + Err(_) => CheckResult { + name: "GitHub App".to_string(), + status: CheckStatus::Error, + summary: "timeout".to_string(), + details: vec![CheckDetail::new("GitHub probe timed out".to_string())], + remediation: Some("Check GitHub connectivity and credentials".to_string()), + }, + } +} + +fn check_sandbox(state: &AppState) -> CheckResult { + if state.secret_or_env("DAYTONA_API_KEY").is_some() { + CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Pass, + summary: "Daytona configured".to_string(), + details: Vec::new(), + remediation: None, + } + } else { + CheckResult { + name: "Sandbox".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: Vec::new(), + remediation: Some("Set DAYTONA_API_KEY to enable cloud sandbox execution".to_string()), + } + } +} + +async fn check_brave_search(state: &AppState) -> CheckResult { + let Some(api_key) = state.secret_or_env("BRAVE_SEARCH_API_KEY") else { + return CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: Vec::new(), + remediation: Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()), + }; + }; + + let probe = timeout(Duration::from_secs(15), async { + reqwest::Client::new() + .get("https://api.search.brave.com/res/v1/web/search?q=test&count=1") + .header("X-Subscription-Token", api_key) + .send() + .await + .map_err(|e| e.to_string()) + }) + .await; + + match probe { + Ok(Ok(response)) if response.status().is_success() => CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Pass, + summary: "configured and reachable".to_string(), + details: Vec::new(), + remediation: None, + }, + Ok(Ok(response)) => CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Warning, + summary: format!("HTTP {}", response.status()), + details: Vec::new(), + remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()), + }, + Ok(Err(err)) => CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Warning, + summary: "connectivity error".to_string(), + details: vec![CheckDetail::new(err.clone())], + remediation: Some(err), + }, + Err(_) => CheckResult { + name: "Brave Search".to_string(), + status: CheckStatus::Warning, + summary: "timeout".to_string(), + details: vec![CheckDetail::new("Brave Search probe timed out".to_string())], + remediation: Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()), + }, + } +} + +fn check_crypto(state: &AppState) -> CheckResult { + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); + let api = settings.api.clone().unwrap_or_default(); + let has_jwt = api + .authentication_strategies + .contains(&ApiAuthStrategy::Jwt); + let has_mtls = api + .authentication_strategies + .contains(&ApiAuthStrategy::Mtls); + + if !has_jwt && !has_mtls { + return CheckResult { + name: "Crypto".to_string(), + status: CheckStatus::Warning, + summary: "no authentication configured".to_string(), + details: Vec::new(), + remediation: Some("Configure authentication_strategies in [api]".to_string()), + }; + } + + let mut details = Vec::new(); + let mut errors = Vec::new(); + + if has_mtls { + if let Some(tls) = api.tls { + let read = |path: &Path| -> Result { + let expanded = fabro_config::expand_tilde(path); + std::fs::read_to_string(&expanded) + .map_err(|e| format!("{}: {e}", expanded.display())) + }; + match (read(&tls.cert), read(&tls.key), read(&tls.ca)) { + (Ok(cert_pem), Ok(key_pem), Ok(ca_pem)) => { + if let Err(err) = validate_tls_cert(&cert_pem, chrono::Utc::now().timestamp()) { + errors.push(err); + } + if let Err(err) = validate_tls_private_key(&key_pem) { + errors.push(err); + } + if let Err(err) = validate_tls_ca(&ca_pem) { + errors.push(err); + } + } + _ => errors.push("failed to read mTLS files".to_string()), + } + } else { + errors.push("mTLS configured but [api.tls] is missing".to_string()); + } + } + + if has_jwt { + match state.secret_or_env("FABRO_JWT_PUBLIC_KEY") { + Some(raw) => { + if let Err(err) = decode_pem_value("FABRO_JWT_PUBLIC_KEY", &raw).and_then(|pem| { + jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes()) + .map(|_| ()) + .map_err(|e| format!("invalid JWT public key: {e}")) + }) { + errors.push(err); + } + } + None => errors.push("FABRO_JWT_PUBLIC_KEY not set".to_string()), + } + } + + if let Some(raw) = state.secret_or_env("FABRO_JWT_PRIVATE_KEY") { + if let Err(err) = decode_pem_value("FABRO_JWT_PRIVATE_KEY", &raw).and_then(|pem| { + jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()) + .map(|_| ()) + .map_err(|e| format!("invalid JWT private key: {e}")) + }) { + errors.push(err); + } + } + + if let Some(secret) = state.secret_or_env("SESSION_SECRET") { + if let Err(err) = validate_session_secret(&secret) { + errors.push(err); + } + } + + if errors.is_empty() { + CheckResult { + name: "Crypto".to_string(), + status: CheckStatus::Pass, + summary: "all keys valid".to_string(), + details, + remediation: None, + } + } else { + for err in &errors { + details.push(CheckDetail::new(err.clone())); + } + CheckResult { + name: "Crypto".to_string(), + status: CheckStatus::Error, + summary: "invalid keys found".to_string(), + details, + remediation: Some(errors.join("; ")), + } + } +} diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index f2ee59b32..5b314f1d1 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -72,6 +72,19 @@ pub fn decode_pem_env(name: &str, value: &str) -> String { /// Call this once at startup before serving requests. Panics if the /// configuration is invalid (JWT strategy but no public key, or mTLS without TLS config). pub fn resolve_auth_mode(api_settings: &ApiSettings, allowed_usernames: &[String]) -> AuthMode { + resolve_auth_mode_with_lookup(api_settings, allowed_usernames, |name| { + std::env::var(name).ok() + }) +} + +pub fn resolve_auth_mode_with_lookup( + api_settings: &ApiSettings, + allowed_usernames: &[String], + lookup: F, +) -> AuthMode +where + F: Fn(&str) -> Option, +{ use fabro_config::server::ApiAuthStrategy; if api_settings.authentication_strategies.is_empty() @@ -88,7 +101,7 @@ pub fn resolve_auth_mode(api_settings: &ApiSettings, allowed_usernames: &[String } let mut strategies = Vec::new(); - if std::env::var("SESSION_SECRET").is_ok() { + if lookup("SESSION_SECRET").is_some() { strategies.push(AuthStrategy::Cookie); } @@ -97,7 +110,7 @@ pub fn resolve_auth_mode(api_settings: &ApiSettings, allowed_usernames: &[String .iter() .map(|s| match s { ApiAuthStrategy::Jwt => { - let raw = std::env::var("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|_| { + let raw = lookup("FABRO_JWT_PUBLIC_KEY").unwrap_or_else(|| { panic!( "FABRO_JWT_PUBLIC_KEY is not set. Provide an Ed25519 public key in PEM \ format (or base64-encoded PEM) for JWT authentication." diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 12772d0b6..ed03f24a9 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -6,9 +6,11 @@ pub mod bind; #[allow(clippy::wildcard_imports, clippy::absolute_paths)] mod demo; +pub mod diagnostics; pub mod error; pub mod github_webhooks; pub mod jwt_auth; +pub mod secret_store; pub mod serve; pub mod server; pub mod static_files; diff --git a/lib/crates/fabro-server/src/secret_store.rs b/lib/crates/fabro-server/src/secret_store.rs new file mode 100644 index 000000000..d700a3826 --- /dev/null +++ b/lib/crates/fabro-server/src/secret_store.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SecretEntry { + pub value: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SecretMetadata { + pub name: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug)] +pub enum SecretStoreError { + InvalidName(String), + NotFound(String), + Io(std::io::Error), + Serde(serde_json::Error), +} + +impl fmt::Display for SecretStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidName(name) => write!(f, "invalid secret name: {name}"), + Self::NotFound(name) => write!(f, "secret not found: {name}"), + Self::Io(err) => write!(f, "{err}"), + Self::Serde(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for SecretStoreError {} + +impl From for SecretStoreError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for SecretStoreError { + fn from(value: serde_json::Error) -> Self { + Self::Serde(value) + } +} + +#[derive(Debug)] +pub struct SecretStore { + path: PathBuf, + entries: HashMap, +} + +impl SecretStore { + pub fn load(path: PathBuf) -> Result { + let entries = match std::fs::read_to_string(&path) { + Ok(contents) => serde_json::from_str(&contents)?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(), + Err(err) => return Err(err.into()), + }; + + Ok(Self { path, entries }) + } + + pub fn set(&mut self, name: &str, value: &str) -> Result { + Self::validate_name(name)?; + + let now = chrono::Utc::now().to_rfc3339(); + let created_at = self + .entries + .get(name) + .map_or_else(|| now.clone(), |entry| entry.created_at.clone()); + let entry = SecretEntry { + value: value.to_string(), + created_at: created_at.clone(), + updated_at: now.clone(), + }; + self.entries.insert(name.to_string(), entry); + self.write_atomic()?; + + Ok(SecretMetadata { + name: name.to_string(), + created_at, + updated_at: now, + }) + } + + pub fn remove(&mut self, name: &str) -> Result<(), SecretStoreError> { + Self::validate_name(name)?; + if self.entries.remove(name).is_none() { + return Err(SecretStoreError::NotFound(name.to_string())); + } + self.write_atomic()?; + Ok(()) + } + + pub fn list(&self) -> Vec { + let mut data = self + .entries + .iter() + .map(|(name, entry)| SecretMetadata { + name: name.clone(), + created_at: entry.created_at.clone(), + updated_at: entry.updated_at.clone(), + }) + .collect::>(); + data.sort_by(|a, b| a.name.cmp(&b.name)); + data + } + + pub fn get(&self, name: &str) -> Option<&str> { + self.entries.get(name).map(|entry| entry.value.as_str()) + } + + pub fn snapshot(&self) -> HashMap { + self.entries + .iter() + .map(|(name, entry)| (name.clone(), entry.value.clone())) + .collect() + } + + pub fn validate_name(name: &str) -> Result<(), SecretStoreError> { + let mut chars = name.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphabetic() || first == '_' => {} + _ => return Err(SecretStoreError::InvalidName(name.to_string())), + } + + if chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') { + Ok(()) + } else { + Err(SecretStoreError::InvalidName(name.to_string())) + } + } + + fn write_atomic(&self) -> Result<(), SecretStoreError> { + let parent = self + .path + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + std::fs::create_dir_all(&parent)?; + + let file_name = self + .path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("secrets.json"); + let tmp_path = parent.join(format!(".{file_name}.tmp-{}", ulid::Ulid::new())); + let json = serde_json::to_vec_pretty(&self.entries)?; + std::fs::write(&tmp_path, json)?; + set_private_permissions(&tmp_path)?; + std::fs::rename(&tmp_path, &self.path)?; + Ok(()) + } +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path) -> Result<(), SecretStoreError> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path) -> Result<(), SecretStoreError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_missing_file_returns_empty_store() { + let dir = tempfile::tempdir().unwrap(); + let store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + assert!(store.list().is_empty()); + } + + #[test] + fn set_creates_entry_and_writes_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.json"); + let mut store = SecretStore::load(path.clone()).unwrap(); + + let meta = store.set("OPENAI_API_KEY", "secret").unwrap(); + + assert_eq!(meta.name, "OPENAI_API_KEY"); + assert_eq!(store.get("OPENAI_API_KEY"), Some("secret")); + assert!(path.exists()); + } + + #[test] + fn set_existing_key_preserves_created_at() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.json"); + let mut store = SecretStore::load(path).unwrap(); + + let first = store.set("OPENAI_API_KEY", "first").unwrap(); + let second = store.set("OPENAI_API_KEY", "second").unwrap(); + + assert_eq!(first.created_at, second.created_at); + assert_eq!(store.get("OPENAI_API_KEY"), Some("second")); + } + + #[test] + fn remove_deletes_entry_and_writes_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secrets.json"); + let mut store = SecretStore::load(path.clone()).unwrap(); + store.set("OPENAI_API_KEY", "secret").unwrap(); + + store.remove("OPENAI_API_KEY").unwrap(); + + assert_eq!(store.get("OPENAI_API_KEY"), None); + let written = std::fs::read_to_string(path).unwrap(); + assert_eq!(written.trim(), "{}"); + } + + #[test] + fn remove_missing_key_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + let error = store.remove("MISSING").unwrap_err(); + assert_eq!(error.to_string(), "secret not found: MISSING"); + } + + #[test] + fn list_returns_sorted_metadata_without_values() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + store.set("Z_KEY", "z").unwrap(); + store.set("A_KEY", "a").unwrap(); + + let listed = store.list(); + + assert_eq!( + listed + .iter() + .map(|item| item.name.as_str()) + .collect::>(), + vec!["A_KEY", "Z_KEY"] + ); + } + + #[test] + fn invalid_names_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let mut store = SecretStore::load(dir.path().join("secrets.json")).unwrap(); + let error = store.set("NOT-VALID", "secret").unwrap_err(); + assert_eq!(error.to_string(), "invalid secret name: NOT-VALID"); + } +} diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 0c53e596c..a6e11ff2b 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -15,8 +15,9 @@ use fabro_types::Settings; use crate::bind::{self, Bind}; use crate::github_webhooks::WebhookManager; -use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode}; -use crate::server::{build_router, create_app_state_with_store, spawn_scheduler}; +use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode_with_lookup}; +use crate::secret_store::SecretStore; +use crate::server::{build_app_state_with_path, build_router, spawn_scheduler}; use crate::tls::{ClientAuth, build_rustls_config, serve_tls}; use fabro_llm::client::Client as LlmClient; use fabro_sandbox::SandboxProvider; @@ -80,11 +81,25 @@ pub async fn serve_command( styles: &'static Styles, storage_dir_override: Option, ) -> anyhow::Result<()> { + let config_path = args.config.clone(); + let disk_settings = load_server_settings(config_path.as_deref())?; + let data_dir = storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&disk_settings)); + let secret_store_path = data_dir.join("secrets.json"); + let secret_store = SecretStore::load(secret_store_path.clone())?; + let secret_snapshot = secret_store.snapshot(); + // Resolve dry-run mode (same pattern as run.rs) let dry_run_mode = if args.dry_run { true } else { - match LlmClient::from_env().await { + match LlmClient::from_lookup(|name| { + secret_snapshot + .get(name) + .cloned() + .or_else(|| std::env::var(name).ok()) + }) + .await + { Ok(c) if c.provider_names().is_empty() => { eprintln!( "{} No LLM providers configured. Running in dry-run mode.", @@ -103,11 +118,6 @@ pub async fn serve_command( } }; - // Initialize data directory and storage - let config_path = args.config.clone(); - let disk_settings = load_server_settings(config_path.as_deref())?; - let data_dir = storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&disk_settings)); - // Shared config for live reloading let shared_settings = Arc::new(RwLock::new(apply_serve_overrides( &disk_settings, @@ -123,7 +133,12 @@ pub async fn serve_command( .as_ref() .map(|w| w.auth.allowed_usernames.clone()) .unwrap_or_default(); - let auth_mode = resolve_auth_mode(&api, &allowed_usernames); + let auth_mode = resolve_auth_mode_with_lookup(&api, &allowed_usernames, |name| { + secret_snapshot + .get(name) + .cloned() + .or_else(|| std::env::var(name).ok()) + }); let client_auth = api.tls.as_ref().map(|_| client_auth_from_mode(&auth_mode)); let max_concurrent_runs = args .max_concurrent_runs @@ -140,10 +155,15 @@ pub async fn serve_command( "", Duration::from_millis(1), )); - let state = - create_app_state_with_store(Arc::clone(&shared_settings), max_concurrent_runs, store); + let state = build_app_state_with_path( + Arc::clone(&shared_settings), + None, + max_concurrent_runs, + store, + secret_store_path, + )?; spawn_scheduler(Arc::clone(&state)); - let router = build_router(state, auth_mode); + let router = build_router(Arc::clone(&state), auth_mode); let bind_addr = match args.bind { Some(ref s) => bind::parse_bind(s)?, @@ -173,17 +193,20 @@ pub async fn serve_command( }; let webhook_manager = match webhook_app_id { Some(app_id) => { - let secret = std::env::var("GITHUB_APP_WEBHOOK_SECRET").ok(); - let github_app = match fabro_github::GitHubAppCredentials::from_env(Some(&app_id)) { - Ok(github_app) => github_app, - Err(err) => { + let secret = secret_snapshot + .get("GITHUB_APP_WEBHOOK_SECRET") + .cloned() + .or_else(|| std::env::var("GITHUB_APP_WEBHOOK_SECRET").ok()); + let github_app = state + .github_app_credentials(Some(&app_id)) + .await + .unwrap_or_else(|err| { warn!( error = %err, "Webhook config present but GITHUB_APP_PRIVATE_KEY is invalid; skipping webhook listener" ); None - } - }; + }); if let (Some(secret), Some(github_app)) = (secret, github_app) { match WebhookManager::start( secret.into_bytes(), diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 95d83ae8f..eda51aaf4 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -11,13 +12,15 @@ use axum::http::{HeaderValue, Method, StatusCode}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; +use axum::routing::{get, post, put}; use axum::{Json, Router}; use axum_extra::extract::cookie::Key; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use bytes::Bytes; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate_object}; -use fabro_llm::model_test::{ModelTestMode, run_model_test}; +use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client}; use fabro_llm::types::{ ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest, Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage, @@ -25,13 +28,15 @@ use fabro_llm::types::{ use fabro_store::{EventEnvelope, EventPayload, StageId, StoreHandle}; use fabro_types::{RunBlobId, RunEvent, RunId, Settings}; use fabro_util::redact::redact_jsonl_line; +use fabro_util::version::FABRO_VERSION; use fabro_workflow::error::FabroError; use fabro_workflow::handler::HandlerRegistry; use futures_util::stream; use object_store::memory::InMemory as MemoryObjectStore; +use tokio::sync::Notify; +use tokio::sync::RwLock as AsyncRwLock; use tokio::sync::broadcast; use tokio::sync::oneshot; -use tokio::sync::{Notify, OnceCell}; use tokio::task::spawn_blocking; use tokio::time::sleep; use tokio_stream::StreamExt; @@ -41,8 +46,10 @@ use ulid::Ulid; use tracing::{error, info}; use crate::demo; +use crate::diagnostics; use crate::error::ApiError; use crate::jwt_auth::{AuthMode, AuthenticatedService}; +use crate::secret_store::{SecretStore, SecretStoreError}; use crate::sessions as sessions_mod; use crate::sessions::{SessionStore, new_session_store}; use crate::static_files; @@ -62,8 +69,8 @@ pub use fabro_api::types::{ CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, CreateRunRequest, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, QuestionType as ApiQuestionType, RunError, - RunEvent as ApiRunEvent, RunStatus, RunStatusResponse, StartRunRequest, SubmitAnswerRequest, - TokenUsage, UsageByModel, WriteBlobResponse, + RunEvent as ApiRunEvent, RunStatus, RunStatusResponse, SetSecretRequest, StartRunRequest, + SubmitAnswerRequest, TokenUsage, UsageByModel, WriteBlobResponse, }; pub fn default_page_limit() -> u32 { @@ -197,9 +204,8 @@ pub struct AppState { max_concurrent_runs: usize, scheduler_notify: Notify, pub sessions: SessionStore, - llm_client: OnceCell, + pub(crate) secret_store: AsyncRwLock, pub(crate) settings: Arc>, - pub(crate) session_key: Option, registry_factory_override: Option>, } @@ -207,6 +213,73 @@ impl AppState { pub(crate) fn dry_run(&self) -> bool { self.settings.read().unwrap().dry_run_enabled() } + + pub(crate) async fn build_llm_client(&self) -> Result { + let snapshot = self.secret_store.read().await.snapshot(); + LlmClient::from_lookup(|name| { + snapshot + .get(name) + .cloned() + .or_else(|| std::env::var(name).ok()) + }) + .await + .map_err(|err| err.to_string()) + } + + pub(crate) fn secret_or_env(&self, name: &str) -> Option { + self.secret_store + .try_read() + .ok() + .and_then(|store| store.get(name).map(str::to_string)) + .or_else(|| std::env::var(name).ok()) + } + + pub(crate) async fn session_key(&self) -> Option { + let secret = self + .secret_store + .read() + .await + .get("SESSION_SECRET") + .map(str::to_string); + secret + .or_else(|| std::env::var("SESSION_SECRET").ok()) + .map(|value| Key::derive_from(value.as_bytes())) + } + + pub(crate) async fn github_app_credentials( + &self, + app_id: Option<&str>, + ) -> Result, String> { + let Some(app_id) = app_id else { + return Ok(None); + }; + let raw = self + .secret_store + .read() + .await + .get("GITHUB_APP_PRIVATE_KEY") + .map(str::to_string) + .or_else(|| std::env::var("GITHUB_APP_PRIVATE_KEY").ok()); + let Some(raw) = raw else { + return Ok(None); + }; + let private_key_pem = decode_secret_pem("GITHUB_APP_PRIVATE_KEY", &raw)?; + Ok(Some(fabro_github::GitHubAppCredentials { + app_id: app_id.to_string(), + private_key_pem, + })) + } +} + +fn decode_secret_pem(name: &str, raw: &str) -> Result { + if raw.starts_with("-----") { + return Ok(raw.to_string()); + } + let pem_bytes = BASE64_STANDARD + .decode(raw) + .map_err(|err| format!("{name} is not valid PEM or base64: {err}"))?; + String::from_utf8(pem_bytes) + .map_err(|err| format!("{name} base64 decoded to invalid UTF-8: {err}")) } /// Build the axum Router with all run endpoints and embedded static assets. @@ -346,6 +419,13 @@ fn demo_routes() -> Router> { .route("/insights/history", get(demo::list_query_history)) .route("/models", get(list_models)) .route("/models/{id}/test", post(test_model)) + .route("/secrets", get(demo::list_secrets)) + .route( + "/secrets/{name}", + put(demo::set_secret).delete(demo::delete_secret), + ) + .route("/repos/github/{owner}/{name}", get(demo::get_github_repo)) + .route("/health/diagnostics", post(demo::run_diagnostics)) .route("/completions", post(create_completion)) .route("/settings", get(demo::get_server_settings)) .route("/usage", get(demo::get_aggregate_usage)) @@ -426,6 +506,10 @@ fn real_routes() -> Router> { .route("/insights/history", get(not_implemented)) .route("/models", get(list_models)) .route("/models/{id}/test", post(test_model)) + .route("/secrets", get(list_secrets)) + .route("/secrets/{name}", put(set_secret).delete(delete_secret)) + .route("/repos/github/{owner}/{name}", get(get_github_repo)) + .route("/health/diagnostics", post(run_diagnostics)) .route("/completions", post(create_completion)) .route("/settings", get(not_implemented)) .route("/usage", get(get_aggregate_usage)) @@ -436,7 +520,238 @@ async fn not_implemented() -> Response { } async fn health() -> Response { - Json(serde_json::json!({"status": "ok"})).into_response() + Json(serde_json::json!({ + "status": "ok", + "version": FABRO_VERSION, + })) + .into_response() +} + +async fn list_secrets(_auth: AuthenticatedService, State(state): State>) -> Response { + let data = state.secret_store.read().await.list(); + (StatusCode::OK, Json(serde_json::json!({ "data": data }))).into_response() +} + +async fn set_secret( + _auth: AuthenticatedService, + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> Response { + let state_for_write = Arc::clone(&state); + let result = spawn_blocking(move || { + let mut store = state_for_write.secret_store.blocking_write(); + store.set(&name, &body.value) + }) + .await; + + match result { + Ok(Ok(meta)) => (StatusCode::OK, Json(meta)).into_response(), + Ok(Err(SecretStoreError::InvalidName(_))) => { + ApiError::bad_request("invalid secret name").into_response() + } + Ok(Err(SecretStoreError::Io(err))) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + Ok(Err(SecretStoreError::Serde(err))) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + Ok(Err(SecretStoreError::NotFound(_))) => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "secret unexpectedly missing", + ) + .into_response(), + Err(err) => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("secret write task failed: {err}"), + ) + .into_response(), + } +} + +async fn delete_secret( + _auth: AuthenticatedService, + State(state): State>, + Path(name): Path, +) -> Response { + let state_for_write = Arc::clone(&state); + let result = spawn_blocking(move || { + let mut store = state_for_write.secret_store.blocking_write(); + store.remove(&name) + }) + .await; + + match result { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + Ok(Err(SecretStoreError::InvalidName(_))) => { + ApiError::bad_request("invalid secret name").into_response() + } + Ok(Err(SecretStoreError::NotFound(name))) => { + ApiError::new(StatusCode::NOT_FOUND, format!("secret not found: {name}")) + .into_response() + } + Ok(Err(SecretStoreError::Io(err))) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + Ok(Err(SecretStoreError::Serde(err))) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + Err(err) => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("secret delete task failed: {err}"), + ) + .into_response(), + } +} + +#[derive(serde::Deserialize)] +struct GitHubRepoResponse { + default_branch: String, + private: bool, + permissions: Option, +} + +async fn get_github_repo( + _auth: AuthenticatedService, + State(state): State>, + Path((owner, name)): Path<(String, String)>, +) -> Response { + let settings = state + .settings + .read() + .expect("settings lock poisoned") + .clone(); + let app_id = match settings.app_id() { + Some(app_id) => app_id.to_string(), + None => { + return ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "git.app_id is not configured", + ) + .into_response(); + } + }; + + let creds = match state.github_app_credentials(Some(&app_id)).await { + Ok(Some(creds)) => creds, + Ok(None) => { + return ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "GITHUB_APP_PRIVATE_KEY is not configured", + ) + .into_response(); + } + Err(err) => { + return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err).into_response(); + } + }; + + let jwt = match fabro_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) { + Ok(jwt) => jwt, + Err(err) => { + return ApiError::new(StatusCode::SERVICE_UNAVAILABLE, err).into_response(); + } + }; + + let base_url = fabro_github::github_api_base_url(); + let client = reqwest::Client::new(); + let install_url = settings.slug().map_or_else( + || format!("https://github.com/organizations/{owner}/settings/installations"), + |slug| format!("https://github.com/apps/{slug}/installations/new"), + ); + + let installed = + match fabro_github::check_app_installed(&client, &jwt, &owner, &name, &base_url).await { + Ok(installed) => installed, + Err(err) => { + return ApiError::new(StatusCode::BAD_GATEWAY, err).into_response(); + } + }; + + if !installed { + return ( + StatusCode::OK, + Json(serde_json::json!({ + "owner": owner, + "name": name, + "accessible": false, + "default_branch": null, + "private": null, + "permissions": null, + "install_url": install_url, + })), + ) + .into_response(); + } + + let token = match fabro_github::create_installation_access_token_with_permissions( + &client, + &jwt, + &owner, + &name, + &base_url, + serde_json::json!({ "contents": "write", "pull_requests": "write" }), + ) + .await + { + Ok(token) => token, + Err(err) => return ApiError::new(StatusCode::BAD_GATEWAY, err).into_response(), + }; + + let repo_response = match client + .get(format!("{base_url}/repos/{owner}/{name}")) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "fabro-server") + .send() + .await + { + Ok(response) if response.status().is_success() => response, + Ok(response) => { + return ApiError::new( + StatusCode::BAD_GATEWAY, + format!("GitHub repo lookup failed: {}", response.status()), + ) + .into_response(); + } + Err(err) => return ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(), + }; + + let repo = match repo_response.json::().await { + Ok(repo) => repo, + Err(err) => { + return ApiError::new( + StatusCode::BAD_GATEWAY, + format!("Failed to parse GitHub repo response: {err}"), + ) + .into_response(); + } + }; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "owner": owner, + "name": name, + "accessible": true, + "default_branch": repo.default_branch, + "private": repo.private, + "permissions": repo.permissions, + "install_url": serde_json::Value::Null, + })), + ) + .into_response() +} + +async fn run_diagnostics( + _auth: AuthenticatedService, + State(state): State>, +) -> Response { + ( + StatusCode::OK, + Json(diagnostics::run_all(state.as_ref()).await), + ) + .into_response() } async fn openapi_spec() -> Response { @@ -459,8 +774,8 @@ async fn cookie_and_demo_middleware( req.headers_mut() .insert("x-fabro-demo", HeaderValue::from_static("1")); } - if let Some(key) = &state.session_key { - if let Some(session) = web_auth::read_private_session(req.headers(), key) { + if let Some(key) = state.session_key().await { + if let Some(session) = web_auth::read_private_session(req.headers(), &key) { req.extensions_mut().insert(session); } } @@ -510,12 +825,14 @@ pub fn create_app_state() -> Arc { pub fn create_app_state_with_registry_factory( registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { - build_app_state( + build_app_state_with_path( Arc::new(RwLock::new(Settings::default())), Some(Box::new(registry_factory_override)), 5, test_store(), + test_secret_store_path(), ) + .expect("test app state should build") } /// Create an `AppState` with the given settings and concurrency limit. @@ -543,27 +860,39 @@ pub fn create_app_state_with_store( max_concurrent_runs: usize, store: StoreHandle, ) -> Arc { - build_app_state(settings, None, max_concurrent_runs, store) + build_app_state_with_path( + settings, + None, + max_concurrent_runs, + store, + test_secret_store_path(), + ) + .expect("test app state should build") } -fn build_app_state( +pub(crate) fn build_app_state_with_path( settings: Arc>, registry_factory_override: Option>, max_concurrent_runs: usize, store: StoreHandle, -) -> Arc { - Arc::new(AppState { + secret_store_path: PathBuf, +) -> anyhow::Result> { + let secret_store = SecretStore::load(secret_store_path)?; + Ok(Arc::new(AppState { runs: Mutex::new(HashMap::new()), aggregate_usage: Mutex::new(UsageAccumulator::default()), store, max_concurrent_runs, scheduler_notify: Notify::new(), sessions: new_session_store(), - llm_client: OnceCell::new(), - session_key: web_auth::session_key_from_env(), + secret_store: AsyncRwLock::new(secret_store), settings, registry_factory_override, - }) + })) +} + +fn test_secret_store_path() -> PathBuf { + std::env::temp_dir().join(format!("fabro-test-secrets-{}.json", Ulid::new())) } async fn list_board_runs( @@ -1069,9 +1398,10 @@ async fn execute_run(state: Arc, run_id: RunId) { return; } }; - let github_app = match fabro_github::GitHubAppCredentials::from_env( - persisted.run_record().settings.app_id(), - ) { + let github_app = match state + .github_app_credentials(persisted.run_record().settings.app_id()) + .await + { Ok(github_app) => github_app, Err(e) => { tracing::error!(run_id = %run_id, error = %e, "Invalid GitHub App credentials"); @@ -1909,7 +2239,18 @@ async fn test_model( .into_response(); } - let outcome = run_model_test(info, mode).await; + let client = match state.build_llm_client().await { + Ok(client) => Arc::new(client), + Err(err) => { + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to build LLM client: {err}"), + ) + .into_response(); + } + }; + + let outcome = run_model_test_with_client(info, mode, client).await; Json(serde_json::json!({ "model_id": info.id, "status": outcome.status.as_str(), @@ -2103,12 +2444,12 @@ async fn create_completion( } // Get or create LLM client (cached in AppState) - let client = match state.llm_client.get_or_try_init(LlmClient::from_env).await { - Ok(c) => c, - Err(e) => { + let client = match state.build_llm_client().await { + Ok(client) => client, + Err(err) => { return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to create LLM client: {e}"), + format!("Failed to create LLM client: {err}"), ) .into_response(); } diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 1bd4e6d89..83780ae20 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -1,13 +1,9 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; use std::sync::Arc; use axum::extract::{Query, State}; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum::{Json, Router, routing::get, routing::post}; -use base64::Engine; -use base64::engine::general_purpose::STANDARD; use cookie::{Cookie, CookieJar, Expiration, Key, SameSite, time::Duration}; use fabro_types::Settings; use fabro_types::settings::{ApiAuthStrategy, GitProvider, GitSettings}; @@ -131,12 +127,6 @@ pub fn parse_cookie_header(headers: &HeaderMap) -> CookieJar { jar } -pub fn session_key_from_env() -> Option { - std::env::var("SESSION_SECRET") - .ok() - .map(|secret| Key::derive_from(secret.as_bytes())) -} - pub fn read_private_session(headers: &HeaderMap, key: &Key) -> Option { let jar = parse_cookie_header(headers); let cookie = jar.private(key).get(SESSION_COOKIE_NAME)?; @@ -213,7 +203,7 @@ async fn callback_github( Query(params): Query, headers: HeaderMap, ) -> Response { - let Some(session_key) = state.session_key.clone() else { + let Some(session_key) = state.session_key().await else { return json_response( StatusCode::CONFLICT, json!({"error": "SESSION_SECRET is not configured"}), @@ -236,7 +226,7 @@ async fn callback_github( json!({"error": "GitHub App client_id is not configured"}), ); }; - let Ok(client_secret) = std::env::var("GITHUB_APP_CLIENT_SECRET") else { + let Some(client_secret) = state.secret_or_env("GITHUB_APP_CLIENT_SECRET") else { return json_response( StatusCode::CONFLICT, json!({"error": "GITHUB_APP_CLIENT_SECRET is not configured"}), @@ -389,8 +379,8 @@ async fn callback_github( async fn logout(State(state): State>) -> Response { let mut jar = CookieJar::new(); - if let Some(key) = &state.session_key { - jar.private_mut(key).remove( + if let Some(key) = state.session_key().await { + jar.private_mut(&key).remove( Cookie::build((SESSION_COOKIE_NAME, "")) .path("/") .http_only(true) @@ -403,10 +393,10 @@ async fn logout(State(state): State>) -> Response { } async fn auth_me(State(state): State>, headers: HeaderMap) -> Response { - let Some(session_key) = &state.session_key else { + let Some(session_key) = state.session_key().await else { return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); }; - let Some(session) = read_private_session(&headers, session_key) else { + let Some(session) = read_private_session(&headers, &session_key) else { return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"})); }; @@ -499,12 +489,9 @@ async fn setup_register( }; let settings_path = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) + .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".fabro") .join("server.toml"); - let env_path = std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(".env"); let mut settings = state .settings @@ -530,26 +517,23 @@ async fn setup_register( } let session_secret = hex::encode(rand::random::<[u8; 32]>()); - let env_updates = BTreeMap::from([ - ("SESSION_SECRET".to_string(), session_secret), - ( - "GITHUB_APP_CLIENT_SECRET".to_string(), - data.client_secret.clone(), - ), - ( - "GITHUB_APP_WEBHOOK_SECRET".to_string(), - data.webhook_secret.clone(), - ), - ( - "GITHUB_APP_PRIVATE_KEY".to_string(), - STANDARD.encode(data.pem), - ), - ]); - if let Err(error) = write_env_file(&env_path, &env_updates) { - return json_response( - StatusCode::INTERNAL_SERVER_ERROR, - json!({"error": format!("Failed to write .env: {error}")}), - ); + let secret_updates = [ + ("SESSION_SECRET", session_secret), + ("GITHUB_APP_CLIENT_SECRET", data.client_secret.clone()), + ("GITHUB_APP_WEBHOOK_SECRET", data.webhook_secret.clone()), + ("GITHUB_APP_PRIVATE_KEY", data.pem.clone()), + ]; + + { + let mut store = state.secret_store.write().await; + for (name, value) in secret_updates { + if let Err(error) = store.set(name, &value) { + return json_response( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": format!("Failed to save secret {name}: {error}")}), + ); + } + } } { @@ -642,24 +626,3 @@ fn build_server_toml(settings: &Settings, git: &GitSettings) -> String { ); toml::to_string(&value).unwrap_or_default() } - -fn write_env_file(path: &PathBuf, updates: &BTreeMap) -> std::io::Result<()> { - let existing = std::fs::read_to_string(path).unwrap_or_default(); - let mut merged = BTreeMap::new(); - for line in existing.lines() { - if let Some((key, value)) = line.split_once('=') { - merged.insert(key.trim().to_string(), value.to_string()); - } - } - merged.extend( - updates - .iter() - .map(|(key, value)| (key.clone(), value.clone())), - ); - let body = merged - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>() - .join("\n"); - std::fs::write(path, format!("{body}\n")) -} diff --git a/lib/crates/fabro-types/src/settings/user.rs b/lib/crates/fabro-types/src/settings/user.rs index b1d5cf779..1818e124a 100644 --- a/lib/crates/fabro-types/src/settings/user.rs +++ b/lib/crates/fabro-types/src/settings/user.rs @@ -28,7 +28,7 @@ pub struct ClientTlsSettings { #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct ServerSettings { - pub base_url: Option, + pub target: Option, pub tls: Option, } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 92ea826d4..716911b73 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -4,10 +4,12 @@ api/discovery-api.ts api/human-in-the-loop-api.ts api/insights-api.ts api/models-api.ts +api/repos-api.ts api/retros-api.ts api/run-internals-api.ts api/run-outputs-api.ts api/runs-api.ts +api/secrets-api.ts api/sessions-api.ts api/settings-api.ts api/usage-api.ts @@ -54,6 +56,10 @@ models/daytona-settings-network-one-of.ts models/daytona-settings-network.ts models/daytona-settings.ts models/daytona-snapshot-settings.ts +models/diagnostics-check.ts +models/diagnostics-detail.ts +models/diagnostics-report.ts +models/diagnostics-section.ts models/diff-file.ts models/diff-stats.ts models/error-response-entry.ts @@ -115,6 +121,8 @@ models/preview-url-response.ts models/pull-request-settings.ts models/question-type.ts models/recent-control-result.ts +models/repo-check-response-permissions.ts +models/repo-check-response.ts models/repository-reference.ts models/retro-detail.ts models/retro-list-item.ts @@ -144,12 +152,15 @@ models/sandbox-resources.ts models/sandbox-settings.ts models/save-query-request.ts models/saved-query.ts +models/secret-list-response.ts +models/secret-metadata.ts models/send-message-request.ts models/send-message-response.ts models/server-settings.ts models/session-detail.ts models/session-list-item.ts models/session-turn.ts +models/set-secret-request.ts models/setup-settings.ts models/sibling-control.ts models/signoff-status.ts diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index 795280d7d..ed2649846 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -19,10 +19,12 @@ export * from './api/discovery-api'; export * from './api/human-in-the-loop-api'; export * from './api/insights-api'; export * from './api/models-api'; +export * from './api/repos-api'; export * from './api/retros-api'; export * from './api/run-internals-api'; export * from './api/run-outputs-api'; export * from './api/runs-api'; +export * from './api/secrets-api'; export * from './api/sessions-api'; export * from './api/settings-api'; export * from './api/usage-api'; diff --git a/lib/packages/fabro-api-client/src/api/discovery-api.ts b/lib/packages/fabro-api-client/src/api/discovery-api.ts index 43d7a3f6d..3eb85629c 100644 --- a/lib/packages/fabro-api-client/src/api/discovery-api.ts +++ b/lib/packages/fabro-api-client/src/api/discovery-api.ts @@ -22,6 +22,8 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; // @ts-ignore +import type { DiagnosticsReport } from '../models'; +// @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore import type { HealthResponse } from '../models'; @@ -156,6 +158,43 @@ export const DiscoveryApiAxiosParamCreator = function (configuration?: Configura let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Probes external services and server configuration. May be slow. + * @summary Run server health diagnostics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runDiagnostics: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/health/diagnostics`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -218,6 +257,18 @@ export const DiscoveryApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['DiscoveryApi.getUser']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Probes external services and server configuration. May be slow. + * @summary Run server health diagnostics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async runDiagnostics(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.runDiagnostics(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DiscoveryApi.runDiagnostics']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, } }; @@ -263,6 +314,15 @@ export const DiscoveryApiFactory = function (configuration?: Configuration, base getUser(options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getUser(options).then((request) => request(axios, basePath)); }, + /** + * Probes external services and server configuration. May be slow. + * @summary Run server health diagnostics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runDiagnostics(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.runDiagnostics(options).then((request) => request(axios, basePath)); + }, }; }; @@ -309,5 +369,15 @@ export class DiscoveryApi extends BaseAPI { public getUser(options?: RawAxiosRequestConfig) { return DiscoveryApiFp(this.configuration).getUser(options).then((request) => request(this.axios, this.basePath)); } + + /** + * Probes external services and server configuration. May be slow. + * @summary Run server health diagnostics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public runDiagnostics(options?: RawAxiosRequestConfig) { + return DiscoveryApiFp(this.configuration).runDiagnostics(options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/lib/packages/fabro-api-client/src/api/repos-api.ts b/lib/packages/fabro-api-client/src/api/repos-api.ts new file mode 100644 index 000000000..afa53ea13 --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/repos-api.ts @@ -0,0 +1,138 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { RepoCheckResponse } from '../models'; +/** + * ReposApi - axios parameter creator + */ +export const ReposApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Check server access to a GitHub repository + * @param {string} owner + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getGithubRepo: async (owner: string, name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'owner' is not null or undefined + assertParamExists('getGithubRepo', 'owner', owner) + // verify required parameter 'name' is not null or undefined + assertParamExists('getGithubRepo', 'name', name) + const localVarPath = `/api/v1/repos/github/{owner}/{name}` + .replace(`{${"owner"}}`, encodeURIComponent(String(owner))) + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ReposApi - functional programming interface + */ +export const ReposApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ReposApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Check server access to a GitHub repository + * @param {string} owner + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getGithubRepo(owner: string, name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getGithubRepo(owner, name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ReposApi.getGithubRepo']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * ReposApi - factory interface + */ +export const ReposApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ReposApiFp(configuration) + return { + /** + * + * @summary Check server access to a GitHub repository + * @param {string} owner + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getGithubRepo(owner: string, name: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getGithubRepo(owner, name, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * ReposApi - object-oriented interface + */ +export class ReposApi extends BaseAPI { + /** + * + * @summary Check server access to a GitHub repository + * @param {string} owner + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getGithubRepo(owner: string, name: string, options?: RawAxiosRequestConfig) { + return ReposApiFp(this.configuration).getGithubRepo(owner, name, options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/lib/packages/fabro-api-client/src/api/secrets-api.ts b/lib/packages/fabro-api-client/src/api/secrets-api.ts new file mode 100644 index 000000000..7513f8277 --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/secrets-api.ts @@ -0,0 +1,288 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { ErrorResponse } from '../models'; +// @ts-ignore +import type { SecretListResponse } from '../models'; +// @ts-ignore +import type { SecretMetadata } from '../models'; +// @ts-ignore +import type { SetSecretRequest } from '../models'; +/** + * SecretsApi - axios parameter creator + */ +export const SecretsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Delete a stored secret + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteSecret: async (name: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('deleteSecret', 'name', name) + const localVarPath = `/api/v1/secrets/{name}` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Returns stored secret names and timestamps. Secret values are never exposed. + * @summary List stored secrets + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSecrets: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/secrets`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Store or update a secret + * @param {string} name + * @param {SetSecretRequest} setSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + setSecret: async (name: string, setSecretRequest: SetSecretRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('setSecret', 'name', name) + // verify required parameter 'setSecretRequest' is not null or undefined + assertParamExists('setSecret', 'setSecretRequest', setSecretRequest) + const localVarPath = `/api/v1/secrets/{name}` + .replace(`{${"name"}}`, encodeURIComponent(String(name))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(setSecretRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * SecretsApi - functional programming interface + */ +export const SecretsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = SecretsApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Delete a stored secret + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteSecret(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSecret(name, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SecretsApi.deleteSecret']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Returns stored secret names and timestamps. Secret values are never exposed. + * @summary List stored secrets + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listSecrets(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listSecrets(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SecretsApi.listSecrets']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Store or update a secret + * @param {string} name + * @param {SetSecretRequest} setSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.setSecret(name, setSecretRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['SecretsApi.setSecret']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * SecretsApi - factory interface + */ +export const SecretsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = SecretsApiFp(configuration) + return { + /** + * + * @summary Delete a stored secret + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteSecret(name: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.deleteSecret(name, options).then((request) => request(axios, basePath)); + }, + /** + * Returns stored secret names and timestamps. Secret values are never exposed. + * @summary List stored secrets + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSecrets(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listSecrets(options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Store or update a secret + * @param {string} name + * @param {SetSecretRequest} setSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.setSecret(name, setSecretRequest, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * SecretsApi - object-oriented interface + */ +export class SecretsApi extends BaseAPI { + /** + * + * @summary Delete a stored secret + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public deleteSecret(name: string, options?: RawAxiosRequestConfig) { + return SecretsApiFp(this.configuration).deleteSecret(name, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Returns stored secret names and timestamps. Secret values are never exposed. + * @summary List stored secrets + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listSecrets(options?: RawAxiosRequestConfig) { + return SecretsApiFp(this.configuration).listSecrets(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Store or update a secret + * @param {string} name + * @param {SetSecretRequest} setSecretRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public setSecret(name: string, setSecretRequest: SetSecretRequest, options?: RawAxiosRequestConfig) { + return SecretsApiFp(this.configuration).setSecret(name, setSecretRequest, options).then((request) => request(this.axios, this.basePath)); + } +} + diff --git a/lib/packages/fabro-api-client/src/models/diagnostics-check.ts b/lib/packages/fabro-api-client/src/models/diagnostics-check.ts new file mode 100644 index 000000000..a2f2990e2 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/diagnostics-check.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { DiagnosticsDetail } from './diagnostics-detail'; + +export interface DiagnosticsCheck { + 'name': string; + 'status': DiagnosticsCheckStatusEnum; + 'summary': string; + 'details'?: Array; + 'remediation'?: string; +} + +export const DiagnosticsCheckStatusEnum = { + PASS: 'pass', + WARNING: 'warning', + ERROR: 'error' +} as const; + +export type DiagnosticsCheckStatusEnum = typeof DiagnosticsCheckStatusEnum[keyof typeof DiagnosticsCheckStatusEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/diagnostics-detail.ts b/lib/packages/fabro-api-client/src/models/diagnostics-detail.ts new file mode 100644 index 000000000..943eb3df4 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/diagnostics-detail.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface DiagnosticsDetail { + 'text': string; + 'warn': boolean; +} + diff --git a/lib/packages/fabro-api-client/src/models/diagnostics-report.ts b/lib/packages/fabro-api-client/src/models/diagnostics-report.ts new file mode 100644 index 000000000..56cd97e54 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/diagnostics-report.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { DiagnosticsSection } from './diagnostics-section'; + +/** + * Server health diagnostics report. + */ +export interface DiagnosticsReport { + /** + * Server version. + */ + 'version': string; + 'sections': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/diagnostics-section.ts b/lib/packages/fabro-api-client/src/models/diagnostics-section.ts new file mode 100644 index 000000000..752945865 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/diagnostics-section.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { DiagnosticsCheck } from './diagnostics-check'; + +export interface DiagnosticsSection { + 'title': string; + 'checks': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/health-response.ts b/lib/packages/fabro-api-client/src/models/health-response.ts index 5a2dc0c8f..4f2a5ab72 100644 --- a/lib/packages/fabro-api-client/src/models/health-response.ts +++ b/lib/packages/fabro-api-client/src/models/health-response.ts @@ -22,5 +22,9 @@ export interface HealthResponse { * Health status indicator. */ 'status': string; + /** + * Server version string. + */ + 'version': string; } diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 12e439975..97c8ce226 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -35,6 +35,10 @@ export * from './daytona-settings'; export * from './daytona-settings-network'; export * from './daytona-settings-network-one-of'; export * from './daytona-snapshot-settings'; +export * from './diagnostics-check'; +export * from './diagnostics-detail'; +export * from './diagnostics-report'; +export * from './diagnostics-section'; export * from './diff-file'; export * from './diff-stats'; export * from './error-response'; @@ -95,6 +99,8 @@ export * from './preview-url-response'; export * from './pull-request-settings'; export * from './question-type'; export * from './recent-control-result'; +export * from './repo-check-response'; +export * from './repo-check-response-permissions'; export * from './repository-reference'; export * from './retro-detail'; export * from './retro-list-item'; @@ -124,12 +130,15 @@ export * from './sandbox-resources'; export * from './sandbox-settings'; export * from './save-query-request'; export * from './saved-query'; +export * from './secret-list-response'; +export * from './secret-metadata'; export * from './send-message-request'; export * from './send-message-response'; export * from './server-settings'; export * from './session-detail'; export * from './session-list-item'; export * from './session-turn'; +export * from './set-secret-request'; export * from './setup-settings'; export * from './sibling-control'; export * from './signoff'; diff --git a/lib/packages/fabro-api-client/src/models/repo-check-response-permissions.ts b/lib/packages/fabro-api-client/src/models/repo-check-response-permissions.ts new file mode 100644 index 000000000..8af4f7909 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/repo-check-response-permissions.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Detected permission levels. + */ +export interface RepoCheckResponsePermissions { + 'pull'?: boolean; + 'push'?: boolean; + 'admin'?: boolean; +} + diff --git a/lib/packages/fabro-api-client/src/models/repo-check-response.ts b/lib/packages/fabro-api-client/src/models/repo-check-response.ts new file mode 100644 index 000000000..1e6d61a3c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/repo-check-response.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { RepoCheckResponsePermissions } from './repo-check-response-permissions'; + +/** + * Repository access check result. + */ +export interface RepoCheckResponse { + /** + * GitHub repository owner. + */ + 'owner': string; + /** + * GitHub repository name. + */ + 'name': string; + /** + * Whether the server has read-write access to this repository. + */ + 'accessible': boolean; + /** + * Default branch name, if accessible. + */ + 'default_branch'?: string; + /** + * Whether the repository is private, if accessible. + */ + 'private'?: boolean; + 'permissions'?: RepoCheckResponsePermissions; + /** + * GitHub App installation URL when the repo is not yet accessible. + */ + 'install_url'?: string; +} + diff --git a/lib/packages/fabro-api-client/src/models/secret-list-response.ts b/lib/packages/fabro-api-client/src/models/secret-list-response.ts new file mode 100644 index 000000000..fae6adff7 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/secret-list-response.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SecretMetadata } from './secret-metadata'; + +/** + * List of stored secret metadata. + */ +export interface SecretListResponse { + 'data': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/secret-metadata.ts b/lib/packages/fabro-api-client/src/models/secret-metadata.ts new file mode 100644 index 000000000..55be6bf6e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/secret-metadata.ts @@ -0,0 +1,34 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Metadata for a stored secret (value is never exposed). + */ +export interface SecretMetadata { + /** + * Secret key name. + */ + 'name': string; + /** + * When the secret was first stored. + */ + 'created_at': string; + /** + * When the secret was last updated. + */ + 'updated_at': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/set-secret-request.ts b/lib/packages/fabro-api-client/src/models/set-secret-request.ts new file mode 100644 index 000000000..b300a6649 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/set-secret-request.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Request to store a secret value. + */ +export interface SetSecretRequest { + /** + * The secret value to store. + */ + 'value': string; +} +