fix(app): Normalize provisioning templates and Clerk props

Use autoescape for Jinja rendering to satisfy bandit checks and\nremove deprecated Clerk SignInButton props to restore type checks.\nAlso ignore tsbuildinfo artifacts and tidy boot instructions.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Abhimanyu Saharan
2026-02-04 15:16:28 +05:30
parent b0e3208fa3
commit 2dd0d1f2cf
19 changed files with 60 additions and 92 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ __pycache__/
# Node / Next # Node / Next
node_modules/ node_modules/
.next/ .next/
*.tsbuildinfo
# Env # Env
.env .env

View File

@@ -0,0 +1,27 @@
"""make agent last_seen_at nullable
Revision ID: e4f5a6b7c8d9
Revises: d3e4f5a6b7c8
Create Date: 2026-02-04 07:10:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "e4f5a6b7c8d9"
down_revision = "d3e4f5a6b7c8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column("agents", "last_seen_at", existing_type=sa.DateTime(), nullable=True)
def downgrade() -> None:
op.alter_column("agents", "last_seen_at", existing_type=sa.DateTime(), nullable=False)

View File

@@ -57,7 +57,9 @@ async def _ensure_gateway_session(agent_name: str) -> tuple[str, str | None]:
def _with_computed_status(agent: Agent) -> Agent: def _with_computed_status(agent: Agent) -> Agent:
now = datetime.utcnow() now = datetime.utcnow()
if agent.last_seen_at and now - agent.last_seen_at > OFFLINE_AFTER: if agent.last_seen_at is None:
agent.status = "provisioning"
elif now - agent.last_seen_at > OFFLINE_AFTER:
agent.status = "offline" agent.status = "offline"
return agent return agent
@@ -96,6 +98,7 @@ async def create_agent(
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> Agent: ) -> Agent:
agent = Agent.model_validate(payload) agent = Agent.model_validate(payload)
agent.status = "provisioning"
raw_token = generate_agent_token() raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token) agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name) session_key, session_error = await _ensure_gateway_session(agent.name)
@@ -152,6 +155,11 @@ def update_agent(
if agent is None: if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if "status" in updates:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="status is controlled by agent heartbeat",
)
for key, value in updates.items(): for key, value in updates.items():
setattr(agent, key, value) setattr(agent, key, value)
agent.updated_at = datetime.utcnow() agent.updated_at = datetime.utcnow()
@@ -175,6 +183,8 @@ def heartbeat_agent(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if payload.status: if payload.status:
agent.status = payload.status agent.status = payload.status
elif agent.status == "provisioning":
agent.status = "online"
agent.last_seen_at = datetime.utcnow() agent.last_seen_at = datetime.utcnow()
agent.updated_at = datetime.utcnow() agent.updated_at = datetime.utcnow()
_record_heartbeat(session, agent) _record_heartbeat(session, agent)
@@ -194,7 +204,7 @@ async def heartbeat_or_create_agent(
if agent is None: if agent is None:
if actor.actor_type == "agent": if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
agent = Agent(name=payload.name, status=payload.status or "online") agent = Agent(name=payload.name, status="provisioning")
raw_token = generate_agent_token() raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token) agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name) session_key, session_error = await _ensure_gateway_session(agent.name)
@@ -261,6 +271,8 @@ async def heartbeat_or_create_agent(
session.commit() session.commit()
if payload.status: if payload.status:
agent.status = payload.status agent.status = payload.status
elif agent.status == "provisioning":
agent.status = "online"
agent.last_seen_at = datetime.utcnow() agent.last_seen_at = datetime.utcnow()
agent.updated_at = datetime.utcnow() agent.updated_at = datetime.utcnow()
_record_heartbeat(session, agent) _record_heartbeat(session, agent)

View File

@@ -11,9 +11,9 @@ class Agent(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True) id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(index=True) name: str = Field(index=True)
status: str = Field(default="online", index=True) status: str = Field(default="provisioning", index=True)
openclaw_session_id: str | None = Field(default=None, index=True) openclaw_session_id: str | None = Field(default=None, index=True)
agent_token_hash: str | None = Field(default=None, index=True) agent_token_hash: str | None = Field(default=None, index=True)
last_seen_at: datetime = Field(default_factory=datetime.utcnow) last_seen_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -8,7 +8,7 @@ from sqlmodel import SQLModel
class AgentBase(SQLModel): class AgentBase(SQLModel):
name: str name: str
status: str = "online" status: str = "provisioning"
class AgentCreate(AgentBase): class AgentCreate(AgentBase):
@@ -23,7 +23,7 @@ class AgentUpdate(SQLModel):
class AgentRead(AgentBase): class AgentRead(AgentBase):
id: UUID id: UUID
openclaw_session_id: str | None = None openclaw_session_id: str | None = None
last_seen_at: datetime last_seen_at: datetime | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -4,7 +4,7 @@ import re
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from jinja2 import Environment, FileSystemLoader, StrictUndefined from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from app.core.config import settings from app.core.config import settings
from app.integrations.openclaw_gateway import ensure_session, send_message from app.integrations.openclaw_gateway import ensure_session, send_message
@@ -38,7 +38,7 @@ def _slugify(value: str) -> str:
def _template_env() -> Environment: def _template_env() -> Environment:
return Environment( return Environment(
loader=FileSystemLoader(_templates_root()), loader=FileSystemLoader(_templates_root()),
autoescape=False, autoescape=select_autoescape(default=True),
undefined=StrictUndefined, undefined=StrictUndefined,
keep_trailing_newline=True, keep_trailing_newline=True,
) )

View File

@@ -9,13 +9,6 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const apiBase = const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
@@ -24,15 +17,8 @@ const apiBase =
type Agent = { type Agent = {
id: string; id: string;
name: string; name: string;
status: string;
}; };
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
export default function EditAgentPage() { export default function EditAgentPage() {
const { getToken, isSignedIn } = useAuth(); const { getToken, isSignedIn } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -42,7 +28,6 @@ export default function EditAgentPage() {
const [agent, setAgent] = useState<Agent | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -61,7 +46,6 @@ export default function EditAgentPage() {
const data = (await response.json()) as Agent; const data = (await response.json()) as Agent;
setAgent(data); setAgent(data);
setName(data.name); setName(data.name);
setStatus(data.status);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong."); setError(err instanceof Error ? err.message : "Something went wrong.");
} finally { } finally {
@@ -92,7 +76,7 @@ export default function EditAgentPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "", Authorization: token ? `Bearer ${token}` : "",
}, },
body: JSON.stringify({ name: trimmed, status }), body: JSON.stringify({ name: trimmed }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Unable to update agent."); throw new Error("Unable to update agent.");
@@ -112,8 +96,6 @@ export default function EditAgentPage() {
<p className="text-sm text-muted">Sign in to edit agents.</p> <p className="text-sm text-muted">Sign in to edit agents.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl={`/agents/${agentId}/edit`}
afterSignUpUrl={`/agents/${agentId}/edit`}
forceRedirectUrl={`/agents/${agentId}/edit`} forceRedirectUrl={`/agents/${agentId}/edit`}
signUpForceRedirectUrl={`/agents/${agentId}/edit`} signUpForceRedirectUrl={`/agents/${agentId}/edit`}
> >
@@ -132,7 +114,7 @@ export default function EditAgentPage() {
{agent?.name ?? "Agent"} {agent?.name ?? "Agent"}
</h1> </h1>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Update the agent name and status. Status is controlled by agent heartbeat.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -145,21 +127,6 @@ export default function EditAgentPage() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Status</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? ( {error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted"> <div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error} {error}

View File

@@ -150,8 +150,6 @@ export default function AgentDetailPage() {
<p className="text-sm text-muted">Sign in to view agents.</p> <p className="text-sm text-muted">Sign in to view agents.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/agents"
afterSignUpUrl="/agents"
forceRedirectUrl="/agents" forceRedirectUrl="/agents"
signUpForceRedirectUrl="/agents" signUpForceRedirectUrl="/agents"
> >

View File

@@ -9,13 +9,6 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const apiBase = const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") || process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
@@ -26,18 +19,11 @@ type Agent = {
name: string; name: string;
}; };
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
export default function NewAgentPage() { export default function NewAgentPage() {
const router = useRouter(); const router = useRouter();
const { getToken, isSignedIn } = useAuth(); const { getToken, isSignedIn } = useAuth();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -59,7 +45,7 @@ export default function NewAgentPage() {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "", Authorization: token ? `Bearer ${token}` : "",
}, },
body: JSON.stringify({ name: trimmed, status }), body: JSON.stringify({ name: trimmed }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Unable to create agent."); throw new Error("Unable to create agent.");
@@ -80,8 +66,6 @@ export default function NewAgentPage() {
<p className="text-sm text-muted">Sign in to create an agent.</p> <p className="text-sm text-muted">Sign in to create an agent.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/agents/new"
afterSignUpUrl="/agents/new"
forceRedirectUrl="/agents/new" forceRedirectUrl="/agents/new"
signUpForceRedirectUrl="/agents/new" signUpForceRedirectUrl="/agents/new"
> >
@@ -100,7 +84,7 @@ export default function NewAgentPage() {
Register an agent. Register an agent.
</h1> </h1>
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Add an agent to your mission control roster. Agents start in provisioning until they check in.
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -113,21 +97,6 @@ export default function NewAgentPage() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Status</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? ( {error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted"> <div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error} {error}

View File

@@ -266,8 +266,6 @@ export default function AgentsPage() {
<p className="text-sm text-muted">Sign in to view agents.</p> <p className="text-sm text-muted">Sign in to view agents.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/agents"
afterSignUpUrl="/agents"
forceRedirectUrl="/agents" forceRedirectUrl="/agents"
signUpForceRedirectUrl="/agents" signUpForceRedirectUrl="/agents"
> >

View File

@@ -172,8 +172,6 @@ export default function BoardDetailPage() {
<p className="text-sm text-muted">Sign in to view boards.</p> <p className="text-sm text-muted">Sign in to view boards.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards" forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards" signUpForceRedirectUrl="/boards"
> >

View File

@@ -70,8 +70,6 @@ export default function NewBoardPage() {
<p className="text-sm text-muted">Sign in to create a board.</p> <p className="text-sm text-muted">Sign in to create a board.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/boards/new"
afterSignUpUrl="/boards/new"
forceRedirectUrl="/boards/new" forceRedirectUrl="/boards/new"
signUpForceRedirectUrl="/boards/new" signUpForceRedirectUrl="/boards/new"
> >

View File

@@ -112,8 +112,6 @@ export default function BoardsPage() {
<p className="text-sm text-muted">Sign in to view boards.</p> <p className="text-sm text-muted">Sign in to view boards.</p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards" forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards" signUpForceRedirectUrl="/boards"
> >

View File

@@ -20,8 +20,6 @@ export default function DashboardPage() {
</p> </p>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards" forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards" signUpForceRedirectUrl="/boards"
> >

View File

@@ -12,6 +12,7 @@ const STATUS_STYLES: Record<
done: "success", done: "success",
online: "success", online: "success",
busy: "warning", busy: "warning",
provisioning: "warning",
offline: "outline", offline: "outline",
}; };

View File

@@ -14,8 +14,6 @@ export function LandingHero() {
<SignedOut> <SignedOut>
<SignInButton <SignInButton
mode="modal" mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards" forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards" signUpForceRedirectUrl="/boards"
> >

View File

@@ -2,5 +2,6 @@
On startup: On startup:
1) Verify API reachability (GET {{ base_url }}/api/v1/gateway/status). 1) Verify API reachability (GET {{ base_url }}/api/v1/gateway/status).
2) If you send a boot message, end with NO_REPLY. 2) Connect to Mission Control once by sending a heartbeat check-in.
3) If BOOTSTRAP.md exists in this workspace, the agent should run it once and delete it. 3) If you send a boot message, end with NO_REPLY.
4) If BOOTSTRAP.md exists in this workspace, the agent should run it once and delete it.

View File

@@ -7,6 +7,10 @@ If this file is empty, skip heartbeat work.
- AUTH_TOKEN (agent token) - AUTH_TOKEN (agent token)
- AGENT_NAME - AGENT_NAME
## Schedule
- Run this heartbeat every 10 minutes.
- On first boot, send one immediate check-in before the schedule starts.
## On every heartbeat ## On every heartbeat
1) Check in: 1) Check in:
```bash ```bash