refactor(server): make secrets and operational checks server-canonical

Move secret storage, diagnostics, and repo/provider validation behind the
server API so credentials live under the server storage dir and take effect
immediately without process env mutation.

This also removes the old .env runtime path, rewires doctor/install/secret/
provider login/repo init around the server contract, and regenerates the
TypeScript client for the new endpoints.
This commit is contained in:
Bryan Helmkamp 2026-04-05 17:34:01 -04:00
parent afc53a421d
commit e33ee6073a
No known key found for this signature in database
77 changed files with 3537 additions and 2530 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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.

View file

@ -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

View file

@ -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 `<data_dir>/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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -64,7 +64,7 @@ To migrate, regenerate your TypeScript client and update any direct API calls.
<Accordion title="CLI">
- This release added `fabro llm prompt/chat --server-url`, but the `fabro llm` CLI namespace was later removed
- `fabro model list --server-url <url>` fetches the model list from the Fabro server
- `fabro model list --server <target>` 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
</Accordion>

View file

@ -35,7 +35,7 @@ devcontainer = true
```
<Warning>
**`--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.
</Warning>
## Manage PRs with fabro pr
@ -81,7 +81,7 @@ fabro preview <run-id>
<Accordion title="CLI">
- `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

View file

@ -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.
<Accordion title="CLI">
- 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
</Accordion>

View file

@ -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

View file

@ -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 <RUN_ID>
```
The engine restores the full context, node visit counts, and retry state from the run directory, then continues execution from the next node.

View file

@ -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/).

View file

@ -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"

View file

@ -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`).

View file

@ -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 <DIR>` | Local storage directory for Fabro data (default: `~/.fabro`). |
| `--server-url <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 <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-...
| `<KEY>` | Name of the secret (required) |
| `<VALUE>` | 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 |
|---|---|
| `<KEY>` | 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

View file

@ -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

View file

@ -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<String>,
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<String>,
}
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<PathBuf>,
/// 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<String>,
/// 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<String>,
}
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)]

File diff suppressed because it is too large Load diff

View file

@ -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<McpServerSettings> = 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![]);

View file

@ -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<Provider> = 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!();

View file

@ -44,12 +44,8 @@ pub(crate) async fn execute(command: Option<ModelsCommand>, 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
}

View file

@ -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(())
}

View file

@ -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<PathBuf> {
let output = std::process::Command::new("git")
@ -22,7 +21,7 @@ pub(super) fn git_repo_root() -> Result<PathBuf> {
))
}
pub(crate) async fn run_init(globals: &GlobalArgs) -> Result<Vec<String>> {
pub(crate) async fn run_init(args: &RepoInitArgs, globals: &GlobalArgs) -> Result<Vec<String>> {
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}");
}
}
}
}

View file

@ -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)?;
}

View file

@ -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),
}
}

View file

@ -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::<serde_json::Value>::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::<Vec<_>>();
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(())
}

View file

@ -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<E>(err: progenitor_client::Error<E>) -> 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,
}
}

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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]

View file

@ -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<ServerStoreClie
pub(crate) async fn connect_api_client(storage_dir: &Path) -> Result<fabro_api::Client> {
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<fabro_api::Client> {
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<fabro_api::Client> {
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<fabro_api::Client> {
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<fabro_api::Client> {
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<fabro_api::
))
}
pub(crate) fn connect_remote_api_client(
base_url: &str,
tls: Option<&user_config::ClientTlsSettings>,
) -> Result<fabro_api::Client> {
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;

View file

@ -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<String> {
.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

View file

@ -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<Settings> {
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<ClientTlsSettings>,
pub(crate) enum ServerTarget {
HttpUrl {
base_url: String,
tls: Option<ClientTlsSettings>,
},
UnixSocket(PathBuf),
}
fn configured_server_target(settings: &Settings) -> Option<ServerTarget> {
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<Option<ServerTarget>> {
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<ServerTarget> {
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<ClientTlsSettings>) -> Result<ServerTarget> {
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<ServerTarget> {
let target = if let Some(server_base_url) = args.server_url() {
Some(ServerTarget {
server_base_url: server_base_url.to_string(),
tls: settings
) -> Result<Option<ServerTarget>> {
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<ServerConnection> {
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<Option<ServerTarget>> {
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<ServerConnection> {
resolve_server_connection(args, settings, true)
}
pub(crate) fn server_backed_command_connection(
args: &ServerConnectionArgs,
settings: &Settings,
) -> Result<ServerConnection> {
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"
);
}
}

View file

@ -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 <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 <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());

View file

@ -29,7 +29,7 @@ fn help() {
Options:
--json Output as JSON [env: FABRO_JSON=]
--server-url <SERVER_URL> Fabro API server URL (overrides server.base_url from user.toml when supported) [env: FABRO_SERVER_URL=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--provider <PROVIDER> LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception)
--model <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}"
);
}

View file

@ -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

View file

@ -14,13 +14,14 @@ fn help() {
Usage: fabro install [OPTIONS]
Options:
--json Output as JSON [env: FABRO_JSON=]
--web-url <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 <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 <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 -----
");
}

View file

@ -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;

View file

@ -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();

View file

@ -17,7 +17,7 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <SERVER_URL> Fabro API server URL (overrides server.base_url from user.toml when supported) [env: FABRO_SERVER_URL=]
--server <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 <PROVIDER> Filter by provider
-q, --query <QUERY> Search for models matching this string

View file

@ -17,7 +17,7 @@ fn help() {
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <SERVER_URL> Fabro API server URL (overrides server.base_url from user.toml when supported) [env: FABRO_SERVER_URL=]
--server <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 <PROVIDER> Filter by provider
-m, --model <MODEL> Test a specific model

View file

@ -14,13 +14,15 @@ fn help() {
Usage: fabro provider login [OPTIONS] --provider <PROVIDER>
Options:
--json Output as JSON [env: FABRO_JSON=]
--provider <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 <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 <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 <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 -----
");
}

View file

@ -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 <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 <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 -----
");
}

View file

@ -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 <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 <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 -----
");
}

View file

@ -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] <COMMAND>
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 <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 <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());
}

View file

@ -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] <KEY>
Arguments:
<KEY> 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 -----
");
}

View file

@ -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());
}

View file

@ -43,7 +43,7 @@ impl TryFrom<ClientTlsConfig> for ClientTlsSettings {
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct ServerConfig {
pub base_url: Option<String>,
pub target: Option<String>,
pub tls: Option<ClientTlsConfig>,
}
@ -52,7 +52,7 @@ impl TryFrom<ServerConfig> for ServerSettings {
fn try_from(value: ServerConfig) -> Result<Self, Self::Error> {
Ok(Self {
base_url: value.base_url,
target: value.target,
tls: value.tls.map(TryInto::try_into).transpose()?,
})
}

View file

@ -38,6 +38,18 @@ impl Client {
///
/// Returns `SdkError` if any provider adapter fails to initialize.
pub async fn from_env() -> Result<Self, SdkError> {
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<F>(lookup: F) -> Result<Self, SdkError>
where
F: Fn(&str) -> Option<String>,
{
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");

View file

@ -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<Client>,
) -> ModelTestOutcome {
run_model_test_inner(info, mode, Some(client)).await
}
async fn run_model_test_inner(
info: &Model,
mode: ModelTestMode,
client: Option<Arc<Client>>,
) -> 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<Arc<Client>>) -> 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<Arc<Client>>) -> 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<GenerateParams> {
fn build_deep_test_params(info: &Model, client: Option<Arc<Client>>) -> Option<GenerateParams> {
if !info.features.tools {
return None;
}
@ -175,6 +196,10 @@ fn build_deep_test_params(info: &Model) -> Option<GenerateParams> {
params = params.reasoning_effort(ReasoningEffort::High);
}
if let Some(client) = client {
params = params.client(client);
}
Some(params)
}

View file

@ -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"] }

View file

@ -432,6 +432,110 @@ pub(crate) async fn create_session_stub(
.into_response()
}
pub(crate) async fn list_secrets(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
) -> 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<Arc<AppState>>,
Path(name): Path<String>,
) -> 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<Arc<AppState>>,
Path(_name): Path<String>,
) -> Response {
StatusCode::NO_CONTENT.into_response()
}
pub(crate) async fn get_github_repo(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
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<Arc<AppState>>,
) -> 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<Arc<AppState>>,

View file

@ -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<CheckSection>,
}
#[derive(Debug, Clone, PartialEq)]
enum ProbeOutcome {
NotFound,
Failed,
Ok { version: Option<Version> },
}
static DOT_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"graphviz version (\d+)\.(\d+)\.(\d+)").unwrap());
fn parse_version(re: &Regex, output: &str) -> Option<Version> {
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<String, String> {
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<String, String> {
let mut reader = std::io::Cursor::new(pem.as_bytes());
let certs: Vec<_> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.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::<Result<Vec<_>, _>>()
.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> = 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<String, String> {
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("; ")),
}
}
}

View file

@ -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<F>(
api_settings: &ApiSettings,
allowed_usernames: &[String],
lookup: F,
) -> AuthMode
where
F: Fn(&str) -> Option<String>,
{
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."

View file

@ -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;

View file

@ -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<std::io::Error> for SecretStoreError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for SecretStoreError {
fn from(value: serde_json::Error) -> Self {
Self::Serde(value)
}
}
#[derive(Debug)]
pub struct SecretStore {
path: PathBuf,
entries: HashMap<String, SecretEntry>,
}
impl SecretStore {
pub fn load(path: PathBuf) -> Result<Self, SecretStoreError> {
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<SecretMetadata, SecretStoreError> {
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<SecretMetadata> {
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::<Vec<_>>();
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<String, String> {
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<_>>(),
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");
}
}

View file

@ -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<PathBuf>,
) -> 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(),

View file

@ -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<LlmClient>,
pub(crate) secret_store: AsyncRwLock<SecretStore>,
pub(crate) settings: Arc<RwLock<Settings>>,
pub(crate) session_key: Option<Key>,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
}
@ -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<LlmClient, String> {
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<String> {
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<Key> {
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<Option<fabro_github::GitHubAppCredentials>, 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<String, String> {
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<Arc<AppState>> {
.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<Arc<AppState>> {
.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<Arc<AppState>>) -> 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<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<SetSecretRequest>,
) -> 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<Arc<AppState>>,
Path(name): Path<String>,
) -> 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<serde_json::Value>,
}
async fn get_github_repo(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
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::<GitHubRepoResponse>().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<Arc<AppState>>,
) -> 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<AppState> {
pub fn create_app_state_with_registry_factory(
registry_factory_override: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
) -> Arc<AppState> {
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<AppState> {
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<RwLock<Settings>>,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
max_concurrent_runs: usize,
store: StoreHandle,
) -> Arc<AppState> {
Arc::new(AppState {
secret_store_path: PathBuf,
) -> anyhow::Result<Arc<AppState>> {
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<AppState>, 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();
}

View file

@ -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<Key> {
std::env::var("SESSION_SECRET")
.ok()
.map(|secret| Key::derive_from(secret.as_bytes()))
}
pub fn read_private_session(headers: &HeaderMap, key: &Key) -> Option<SessionCookie> {
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<OAuthCallbackParams>,
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<Arc<AppState>>) -> 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<Arc<AppState>>) -> Response {
}
async fn auth_me(State(state): State<Arc<AppState>>, 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<String, String>) -> 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::<Vec<_>>()
.join("\n");
std::fs::write(path, format!("{body}\n"))
}

View file

@ -28,7 +28,7 @@ pub struct ClientTlsSettings {
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct ServerSettings {
pub base_url: Option<String>,
pub target: Option<String>,
pub tls: Option<ClientTlsSettings>,
}

View file

@ -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

View file

@ -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';

View file

@ -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<RequestArgs> => {
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<DiagnosticsReport>> {
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<UserResponse> {
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<DiagnosticsReport> {
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));
}
}

View file

@ -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<RequestArgs> => {
// 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<RepoCheckResponse>> {
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<RepoCheckResponse> {
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));
}
}

View file

@ -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<RequestArgs> => {
// 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<RequestArgs> => {
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<RequestArgs> => {
// 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<void>> {
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<SecretListResponse>> {
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<SecretMetadata>> {
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<void> {
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<SecretListResponse> {
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<SecretMetadata> {
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));
}
}

View file

@ -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<DiagnosticsDetail>;
'remediation'?: string;
}
export const DiagnosticsCheckStatusEnum = {
PASS: 'pass',
WARNING: 'warning',
ERROR: 'error'
} as const;
export type DiagnosticsCheckStatusEnum = typeof DiagnosticsCheckStatusEnum[keyof typeof DiagnosticsCheckStatusEnum];

View file

@ -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;
}

View file

@ -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<DiagnosticsSection>;
}

View file

@ -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<DiagnosticsCheck>;
}

View file

@ -22,5 +22,9 @@ export interface HealthResponse {
* Health status indicator.
*/
'status': string;
/**
* Server version string.
*/
'version': string;
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<SecretMetadata>;
}

View file

@ -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;
}

View file

@ -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;
}