Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ on:
paths:
- 'agent/**'
- '.github/workflows/agent.yml'
schedule:
- cron: '0 6 * * *'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

workflow_dispatch:

jobs:
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ on:
branches: [ "main" ]
paths-ignore:
- 'scripts/**'
schedule:
# Run at 6:00 UTC every Monday
- cron: '0 6 * * 1'

jobs:
analyze:
Expand Down
19 changes: 0 additions & 19 deletions .github/workflows/control-plane.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,6 @@ jobs:
working-directory: control-plane
run: go run ./cmd/migrationcheck

- name: Guard against model changes without a new migration
if: github.event_name == 'pull_request' || github.event_name == 'push'
run: |
set -euo pipefail
# Compare against the merge base on PRs; against the previous commit on push.
if [ -n "${GITHUB_BASE_REF:-}" ]; then
base="origin/${GITHUB_BASE_REF}"
git fetch --no-tags --depth=1 origin "${GITHUB_BASE_REF}"
else
base="HEAD~1"
fi
changed_models=$(git diff --name-only "$base"...HEAD -- 'control-plane/internal/database/models/*.go' || true)
new_migrations=$(git diff --name-only --diff-filter=A "$base"...HEAD -- 'control-plane/internal/database/migrations/migration_*.go' || true)
if [ -n "$changed_models" ] && [ -z "$new_migrations" ]; then
echo "ERROR: models.go changed but no new migration_*.go was added."
echo " Run 'make migration' from control-plane/ to author one."
exit 1
fi

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeremyhart why did you decide to remove this step?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, that wasn't meant to make it through to the PR

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you restore the file content?

unit:
name: Unit Tests
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ for local development.
**Crypto** (`internal/crypto/crypto.go`): API keys encrypted at rest in SQLite using Fernet. The Fernet key is
auto-generated on first run and stored in the `settings` table.

**Cloudflare Access** (`internal/cfaccess/`): Optional Zero Trust header auth. Verifies the `Cf-Access-Jwt-Assertion` JWT against the team's JWKS (RS256, aud/iss/exp) and returns the email claim. Wired into `middleware.RequireAuth`, which matches the verified email to an existing user (no auto-provisioning) and replaces the built-in login when enabled. See `docs/auth.md`.

**Database migrations** (`internal/database/migrations/`): Goose v3 invoked as a library, embedded into the binary, applied at startup from `database.Init()`. New migrations are versioned Go files in the `migrations` subpackage that use the GORM Migrator interface; model types live in `internal/database/models/` and are re-exported by the `database` package via type aliases for backward compat. See `docs/migrations.md` for the full spec, including the `make migration` workflow that delegates to the `migration-author` subagent.

**SSH Proxy** (`internal/sshproxy/`): Unified package consolidating SSH key management, connection management,
Expand All @@ -78,6 +80,9 @@ Backend settings use `envconfig` with `CLAWORC_` env prefix (see `internal/confi
- `CLAWORC_TERMINAL_HISTORY_LINES` - Scrollback buffer size in lines (default: `1000`, `0` to disable)
- `CLAWORC_TERMINAL_RECORDING_DIR` - Directory for audit recordings (default: empty, disabled)
- `CLAWORC_TERMINAL_SESSION_TIMEOUT` - Idle detached session timeout (default: `30m`)
- `CLAWORC_CF_ACCESS_ENABLED` - Enable Cloudflare Access (Zero Trust) header auth; replaces built-in login (default: `false`)
- `CLAWORC_CF_ACCESS_TEAM_DOMAIN` - Cloudflare Access team domain, e.g. `https://myteam.cloudflareaccess.com` (required when CF Access is enabled)
- `CLAWORC_CF_ACCESS_AUD` - Cloudflare Access application AUD tag (required when CF Access is enabled)
- `CLAWORC_ALLOWED_HOST_MOUNTS` - Comma-separated allowlist of host path prefixes within which shared folders may be backed by a host bind mount. Empty (default) disables host-backed shared folders entirely. See `docs/shared-folders.md`.
- `CLAWORC_WEBHOOK_IDLE_TIMEOUT` - Idle gap the synchronous webhook bridge tolerates between events from OpenClaw before giving up (default: `120s`). The deadline re-arms on every event, so an actively-streaming agent is never cut off; only a genuine stall trips it.

Expand Down
12 changes: 10 additions & 2 deletions control-plane/frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import CreateAgentPage from "./pages/CreateAgentPage";
import AgentDetailPage from "./pages/AgentDetailPage";
import SettingsPage from "./pages/SettingsPage";
import LoginPage from "./pages/LoginPage";
import CloudflareLoginNotice from "./pages/CloudflareLoginNotice";
import OnboardingPage from "./pages/OnboardingPage";
import BackendUnavailablePage from "./pages/BackendUnavailablePage";
import UsersPage from "./pages/UsersPage";
Expand Down Expand Up @@ -47,9 +48,16 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
}

function LoginRoute() {
const { isBackendUnavailable, isLoading } = useAuth();
if (isLoading) return null;
const { user, isBackendUnavailable, isLoading, cfAccessEnabled, cfConfigLoading } =
useAuth();
if (isLoading || cfConfigLoading) return null;
if (isBackendUnavailable) return <BackendUnavailablePage />;
if (cfAccessEnabled) {
// Cloudflare Access establishes identity; a signed-in user shouldn't see a
// login page. Otherwise show the notice instead of the built-in form.
if (user) return <Navigate to="/" replace />;
return <CloudflareLoginNotice />;
}
return <LoginPage />;
}

Expand Down
42 changes: 42 additions & 0 deletions control-plane/frontend/src/app/pages/CloudflareLoginNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ShieldCheck } from "lucide-react";

// Shown on /login when Cloudflare Access (Zero Trust) is the active auth mode.
// There is no username/password form: identity is established by Cloudflare at
// the edge. A user landing here is either signed out of Cloudflare Access or has
// no matching Claworc account, so reloading re-runs the Access challenge.
export default function CloudflareLoginNotice() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-sm">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 text-center">
<div className="flex justify-center mb-3">
<ShieldCheck size={32} className="text-blue-600" />
</div>
<h1
data-testid="cf-login-title"
className="text-xl font-semibold text-gray-900 mb-1"
>
Sign in via Cloudflare Access
</h1>
<p className="text-sm text-gray-500 mb-6">OpenClaw Orchestrator</p>
<p className="text-sm text-gray-600 mb-6">
This deployment authenticates through your organization's Cloudflare
Access. If you reached this page, your session may have expired or
your account isn't provisioned yet.
</p>
<button
data-testid="cf-reload-button"
onClick={() => window.location.reload()}
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
Retry sign in
</button>
<p className="text-xs text-gray-400 mt-4">
If the problem persists, contact your administrator to confirm your
email is registered.
</p>
</div>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions control-plane/frontend/src/app/pages/UsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export default function UsersPage() {
<th className="text-left px-4 py-3 font-medium text-gray-600">
Username
</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">
Email
</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">
Access
</th>
Expand Down Expand Up @@ -193,6 +196,13 @@ function UserRow({
{user.username}
</button>
</td>
<td className="px-4 py-3 text-gray-600">
{user.email ? (
user.email
) : (
<span className="text-xs text-gray-400">—</span>
)}
</td>
<td className="px-4 py-3">
<AccessSummary user={user} />
</td>
Expand Down
6 changes: 6 additions & 0 deletions control-plane/frontend/src/common/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import client from "./client";
import type {
User,
AuthConfig,
LoginRequest,
SetupRequest,
WebAuthnCredential,
} from "@common/types/auth";

export async function getAuthConfig(): Promise<AuthConfig> {
const res = await client.get("/auth/config");
return res.data;
}

export async function login(data: LoginRequest): Promise<User> {
const res = await client.post("/auth/login", data);
return res.data;
Expand Down
10 changes: 10 additions & 0 deletions control-plane/frontend/src/common/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface UserListInstanceRef {
export interface UserListItem {
id: number;
username: string;
email: string;
role: string;
last_login_at: string;
created_at: string;
Expand All @@ -32,11 +33,13 @@ export async function fetchUsers(): Promise<UserListItem[]> {
export interface CreatedUser {
id: number;
username: string;
email: string;
role: string;
}

export async function createUser(data: {
username: string;
email?: string;
password: string;
role: string;
}): Promise<CreatedUser> {
Expand All @@ -55,6 +58,13 @@ export async function updateUserRole(
await client.put(`/users/${id}/role`, { role });
}

export async function updateUserEmail(
id: number,
email: string,
): Promise<void> {
await client.put(`/users/${id}/email`, { email });
}

export async function getUserInstances(
id: number,
): Promise<{ instance_ids: number[] }> {
Expand Down
Loading
Loading