feat: apply resource policy on user create and require cron job names

Admins can set storage-root and token quota when creating a user, and
cronjob_create now requires a display name with prompt-prefix fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jubaoliang
2026-09-11 10:26:17 +08:00
committed by jubaoliang
co-authored by Cursor
parent 58b31489b2
commit fcf23bdca4
10 changed files with 293 additions and 122 deletions
+2 -2
View File
@@ -4605,9 +4605,9 @@
"createSectionAccount": "Account",
"createSectionAccess": "Role & permissions",
"createSectionPolicy": "Resource usage policy",
"policyWorkspaceRoot": "Allowed workspace root",
"policyWorkspaceRoot": "Allowed storage root",
"policyWorkspaceRootHint": "Restricts roots for “{{localShell}}” and “{{filesystem}}” when creating experts. Off means no extra limit.",
"policyWorkspaceRootRequired": "Choose an allowed root directory",
"policyWorkspaceRootRequired": "Choose an allowed storage root",
"policyTokenQuota": "Token usage limit",
"policyTokenQuotaHint": "Lifetime input + output tokens across this user's experts. Off means no limit.",
"policyTokenQuotaRequired": "Enter a token quota",
+2 -2
View File
@@ -4744,9 +4744,9 @@
"createSectionAccount": "账号信息",
"createSectionAccess": "角色与权限",
"createSectionPolicy": "资源使用策略",
"policyWorkspaceRoot": "允许的工作区根目录",
"policyWorkspaceRoot": "允许的存储根目录",
"policyWorkspaceRootHint": "限制该用户创建专家时「{{localShell}}」/「{{filesystem}}」的根目录。关闭表示不额外限制。",
"policyWorkspaceRootRequired": "请选择允许的根目录",
"policyWorkspaceRootRequired": "请选择允许的存储根目录",
"policyTokenQuota": "Token 使用上限",
"policyTokenQuotaHint": "该用户所有专家累计的输入+输出 token。关闭表示不限制。",
"policyTokenQuotaRequired": "请输入 token 上限",
@@ -111,7 +111,14 @@ function permFullLabel(item: PermissionCatalogItem): string {
return item.label;
}
interface CreateValues {
interface PolicyFormValues {
limit_workspace_root?: boolean;
workspace_root_dir?: string;
limit_token_quota?: boolean;
token_quota?: number | null;
}
interface CreateValues extends PolicyFormValues {
username: string;
display_name?: string;
email?: string;
@@ -121,15 +128,11 @@ interface CreateValues {
permissions?: string[];
}
interface EditValues {
interface EditValues extends PolicyFormValues {
display_name?: string;
email?: string;
role: "admin" | "user";
permissions?: string[];
limit_workspace_root?: boolean;
workspace_root_dir?: string;
limit_token_quota?: boolean;
token_quota?: number | null;
}
interface ResetValues {
@@ -197,6 +200,92 @@ const FIELD_ICON_PROPS = {
style: { color: "var(--fn-text-tertiary)" },
};
function policyPayload(values: PolicyFormValues): {
workspace_root_dir: string | null;
token_quota: number | null;
} {
return {
workspace_root_dir: values.limit_workspace_root
? values.workspace_root_dir?.trim() || null
: null,
token_quota: values.limit_token_quota ? values.token_quota ?? null : null,
};
}
function ResourcePolicyFields({ fsTreeRoot }: { fsTreeRoot: string }) {
const { t } = useTranslation();
return (
<div className={`${styles.createSection} ${styles.policySection}`}>
<div className={styles.createSectionTitle}>
{t("adminUsers.createSectionPolicy")}
</div>
<Form.Item
label={t("adminUsers.policyWorkspaceRoot")}
extra={t("adminUsers.policyWorkspaceRootHint", {
localShell: t("experts.backendModes.localShell"),
filesystem: t("experts.backendModes.filesystem"),
})}
>
<Form.Item name="limit_workspace_root" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) =>
prev.limit_workspace_root !== cur.limit_workspace_root
}
>
{({ getFieldValue }) =>
getFieldValue("limit_workspace_root") ? (
<Form.Item
name="workspace_root_dir"
rules={[
{
required: true,
message: t("adminUsers.policyWorkspaceRootRequired"),
},
]}
>
<RootDirSelect treeRoot={fsTreeRoot} />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item
label={t("adminUsers.policyTokenQuota")}
extra={t("adminUsers.policyTokenQuotaHint")}
>
<Form.Item name="limit_token_quota" valuePropName="checked" noStyle>
<Switch />
</Form.Item>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) =>
prev.limit_token_quota !== cur.limit_token_quota
}
>
{({ getFieldValue }) =>
getFieldValue("limit_token_quota") ? (
<Form.Item
name="token_quota"
rules={[
{
required: true,
message: t("adminUsers.policyTokenQuotaRequired"),
},
]}
>
<InputNumber min={0} step={1000} style={{ width: "100%" }} />
</Form.Item>
) : null
}
</Form.Item>
</div>
);
}
interface RolePickerProps {
value?: "admin" | "user";
onChange?: (value: "admin" | "user") => void;
@@ -980,6 +1069,7 @@ export default function UsersListPanel() {
password: values.password,
role: values.role,
permissions: values.role === "admin" ? [] : values.permissions ?? [],
...policyPayload(values),
}),
});
message.success(
@@ -1006,6 +1096,10 @@ export default function UsersListPanel() {
email: undefined,
password: undefined,
confirm: undefined,
limit_workspace_root: false,
workspace_root_dir: undefined,
limit_token_quota: false,
token_quota: undefined,
});
setCreateOpen(true);
};
@@ -1062,12 +1156,7 @@ export default function UsersListPanel() {
email: values.email?.trim() || null,
role: values.role,
permissions: values.role === "admin" ? [] : values.permissions ?? [],
workspace_root_dir: values.limit_workspace_root
? values.workspace_root_dir?.trim() || null
: null,
token_quota: values.limit_token_quota
? values.token_quota ?? null
: null,
...policyPayload(values),
}),
});
setEditTarget(null);
@@ -1511,7 +1600,12 @@ export default function UsersListPanel() {
layout="vertical"
requiredMark={false}
onFinish={onCreate}
initialValues={{ role: "user", permissions: [] }}
initialValues={{
role: "user",
permissions: [],
limit_workspace_root: false,
limit_token_quota: false,
}}
className={styles.createUserForm}
>
<div className={styles.createSection}>
@@ -1637,6 +1731,8 @@ export default function UsersListPanel() {
}}
</Form.Item>
</div>
<ResourcePolicyFields fsTreeRoot={fsTreeRoot} />
</Form>
</Drawer>
@@ -1769,86 +1865,7 @@ export default function UsersListPanel() {
</Form.Item>
</div>
<div className={`${styles.createSection} ${styles.policySection}`}>
<div className={styles.createSectionTitle}>
{t("adminUsers.createSectionPolicy")}
</div>
<Form.Item
label={t("adminUsers.policyWorkspaceRoot")}
extra={t("adminUsers.policyWorkspaceRootHint", {
localShell: t("experts.backendModes.localShell"),
filesystem: t("experts.backendModes.filesystem"),
})}
>
<Form.Item
name="limit_workspace_root"
valuePropName="checked"
noStyle
>
<Switch />
</Form.Item>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) =>
prev.limit_workspace_root !== cur.limit_workspace_root
}
>
{({ getFieldValue }) =>
getFieldValue("limit_workspace_root") ? (
<Form.Item
name="workspace_root_dir"
rules={[
{
required: true,
message: t("adminUsers.policyWorkspaceRootRequired"),
},
]}
>
<RootDirSelect treeRoot={fsTreeRoot} />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item
label={t("adminUsers.policyTokenQuota")}
extra={t("adminUsers.policyTokenQuotaHint")}
>
<Form.Item
name="limit_token_quota"
valuePropName="checked"
noStyle
>
<Switch />
</Form.Item>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, cur) =>
prev.limit_token_quota !== cur.limit_token_quota
}
>
{({ getFieldValue }) =>
getFieldValue("limit_token_quota") ? (
<Form.Item
name="token_quota"
rules={[
{
required: true,
message: t("adminUsers.policyTokenQuotaRequired"),
},
]}
>
<InputNumber
min={0}
step={1000}
style={{ width: "100%" }}
/>
</Form.Item>
) : null
}
</Form.Item>
</div>
<ResourcePolicyFields fsTreeRoot={fsTreeRoot} />
</Form>
</Drawer>
+1 -1
View File
@@ -69,7 +69,7 @@ routes until the wizard finishes.
| Method | Path | Auth | Notes |
|--------|------|------|-------|
| `GET` | `/users` | admin | `[{id, username, role, display_name, email, enabled, ...}]` |
| `POST` | `/users` | admin | body `{username, password, role, display_name?, email?}` → `201` |
| `POST` | `/users` | admin | body `{username, password, role, display_name?, email?, permissions?, workspace_root_dir?, token_quota?}` → `201` |
| `GET` | `/users/{id}` | admin | full user row |
| `PATCH` | `/users/{id}` | admin | body subset of `{role, display_name, email, enabled, locale}` |
| `POST` | `/users/{id}/reset-password` | admin | body `{new_password}` → `204` |
+24 -6
View File
@@ -12,7 +12,11 @@ from octop.api.deps import current_user, get_server, require_permission
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.users.identity import Role, User
from octop.infra.users.permissions import PERMISSIONS
from octop.infra.users.resource_policy import public_policy_fields
from octop.infra.users.resource_policy import (
normalize_token_quota,
normalize_workspace_root_dir,
public_policy_fields,
)
from octop.infra.utils.locale import resolve_request_locale
router = APIRouter()
@@ -25,6 +29,8 @@ class UserCreateBody(BaseModel):
display_name: str | None = None
email: str | None = Field(default=None, max_length=254)
permissions: list[str] = Field(default_factory=list)
workspace_root_dir: str | None = None
token_quota: int | None = Field(default=None, ge=0)
class UserPatchBody(BaseModel):
@@ -65,6 +71,15 @@ def _row_to_dict(r: Any, policy: Any | None = None) -> dict[str, Any]:
}
def _policy_kwargs_from_body(body: UserCreateBody | UserPatchBody) -> dict[str, Any]:
policy_kwargs: dict[str, Any] = {}
if "workspace_root_dir" in body.model_fields_set:
policy_kwargs["workspace_root_dir"] = body.workspace_root_dir
if "token_quota" in body.model_fields_set:
policy_kwargs["token_quota"] = body.token_quota
return policy_kwargs
def _assert_can_assign(actor: User, permissions: list[str]) -> None:
"""Non-admin actors may only grant permissions they themselves hold."""
if actor.is_admin:
@@ -148,6 +163,11 @@ async def create_user(
server: Any = Depends(get_server),
) -> dict[str, Any]:
_assert_can_assign(actor, body.permissions)
policy_kwargs = _policy_kwargs_from_body(body)
if "workspace_root_dir" in policy_kwargs:
normalize_workspace_root_dir(policy_kwargs["workspace_root_dir"])
if "token_quota" in policy_kwargs:
normalize_token_quota(policy_kwargs["token_quota"])
role = Role(body.role)
user = await server.user_manager.create(
username=body.username,
@@ -157,6 +177,8 @@ async def create_user(
email=body.email,
permissions=body.permissions,
)
if policy_kwargs:
await server.user_manager.set_resource_policy(user.username, **policy_kwargs)
row = server.user_manager.get_row(user.id)
assert row is not None
return _row_to_dict(row, server.services.user_policy_repo.list_for_user(row.id))
@@ -206,11 +228,7 @@ async def patch_user(
await server.user_manager.enable(row.username)
if body.permissions is not None:
await server.user_manager.set_permissions(row.username, body.permissions)
policy_kwargs: dict[str, Any] = {}
if "workspace_root_dir" in body.model_fields_set:
policy_kwargs["workspace_root_dir"] = body.workspace_root_dir
if "token_quota" in body.model_fields_set:
policy_kwargs["token_quota"] = body.token_quota
policy_kwargs = _policy_kwargs_from_body(body)
if policy_kwargs:
await server.user_manager.set_resource_policy(row.username, **policy_kwargs)
updated = server.user_manager.get_row(user_id)
+8 -6
View File
@@ -8,6 +8,7 @@ CronTaskType = Literal["text", "agent"]
DEFAULT_CRON_TASK_TYPE: CronTaskType = "agent"
CRON_PROMPT_MAX_LEN = 2000
CRON_NAME_MAX_LEN = 80
CRON_NAME_FALLBACK_LEN = 40
_CRON_TASK_TYPES = frozenset({"text", "agent"})
@@ -36,15 +37,16 @@ def require_cron_prompt(prompt: str) -> str:
def default_cron_name(prompt: str, cron_id: str) -> str:
"""Build a readable fallback name from the prompt or id."""
text = " ".join(prompt.strip().split())
if not text:
return cron_id
return text[:40]
"""Build a readable fallback name from the prompt prefix, else *cron_id*."""
for line in prompt.splitlines():
text = " ".join(line.strip().split())
if text:
return text[:CRON_NAME_FALLBACK_LEN]
return cron_id
def require_cron_name(name: str | None, *, prompt: str, cron_id: str) -> str:
"""Validate cron display name and fill a stable fallback when omitted."""
"""Return a non-empty display name; blank input falls back to the prompt prefix."""
text = (name or "").strip() or default_cron_name(prompt, cron_id)
if len(text) > CRON_NAME_MAX_LEN:
raise ValueError(f"name must be at most {CRON_NAME_MAX_LEN} characters")
+14 -5
View File
@@ -11,6 +11,7 @@ from pydantic import Field
from octop.infra.cron.manager import CronCreateSpec
from octop.infra.cron.task_type import (
CRON_NAME_MAX_LEN,
require_cron_name,
require_cron_prompt,
require_cron_task_type,
@@ -37,9 +38,11 @@ _TASK_TYPE_HELP = (
_CRONJOB_CREATE_DESC = (
"Create a scheduled cron job bound to the current conversation session. "
"Results are delivered to the same channel (QQ/WeChat/dashboard/…). "
"name is required: a short label for the job list. "
f"task_type: {_TASK_TYPE_HELP} "
"Examples: 'remind me to drink water at 14:00 daily' → task_type=text, prompt='该喝水了💧'; "
"'summarize my inbox every morning' → task_type=agent. "
"Examples: 'remind me to drink water at 14:00 daily' → "
"name='Daily water reminder', task_type=text, prompt='Time to drink water'; "
"'summarize my inbox every morning' → name='Morning inbox summary', task_type=agent. "
f"trigger: {_TRIGGER_HELP}"
)
@@ -128,9 +131,15 @@ def build_cronjob_tools(cron_manager: CronManager) -> list[StructuredTool]:
),
],
name: Annotated[
str | None,
Field(description="Optional display name for this cron job."),
] = None,
str,
Field(
description=(
"Required short display name shown in the job list "
f"(max {CRON_NAME_MAX_LEN} characters). "
"Summarize the user's request, e.g. 'Daily water reminder'."
),
),
],
fresh_thread: Annotated[
bool,
Field(description="If true, reset conversation context before each agent run."),
+46
View File
@@ -105,6 +105,52 @@ async def test_admin_can_unlock_login(env):
assert r.status_code == 200
async def test_admin_can_create_user_with_resource_policy(env, tmp_path):
c, _srv, auth = env
jail = tmp_path / "jail"
jail.mkdir()
r = await c.post(
"/api/users",
headers=auth,
json={
"username": "policy_create",
"password": "TestPass12",
"role": "user",
"workspace_root_dir": jail.as_posix(),
"token_quota": 2000,
},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["workspace_root_dir"] == jail.resolve().as_posix()
assert body["token_quota"] == 2000
listed = (await c.get("/api/users", headers=auth)).json()
row = next(u for u in listed if u["username"] == "policy_create")
assert row["workspace_root_dir"] == jail.resolve().as_posix()
assert row["token_quota"] == 2000
async def test_create_user_rejects_invalid_workspace_root(env, tmp_path):
c, _srv, auth = env
missing = tmp_path / "no-such-dir"
r = await c.post(
"/api/users",
headers=auth,
json={
"username": "bad_root",
"password": "TestPass12",
"role": "user",
"workspace_root_dir": missing.as_posix(),
},
)
assert r.status_code == 400, r.text
listed = (await c.get("/api/users", headers=auth)).json()
assert "bad_root" not in [u["username"] for u in listed]
async def test_admin_can_set_resource_policy(env, tmp_path):
from tests.support.auth import TEST_PASSWORD, create_user
+52 -6
View File
@@ -80,10 +80,12 @@ async def test_cronjob_create_and_list(tmp_path: Path) -> None:
{
"trigger": "interval:120",
"prompt": "say hi",
"name": "say hi",
}
)
data = json.loads(out)
assert data["prompt"] == "say hi"
assert data["name"] == "say hi"
assert data["trigger"] == "interval:120"
assert data["task_type"] == "text"
cron_id = data["id"]
@@ -109,7 +111,9 @@ async def test_cronjob_isolated_by_agent_and_user(tmp_path: Path) -> None:
list_tool = _tool_by_name(tools, "cronjob_list")
with _configurable(agent_id=agent_a, user=str(uid)):
created = json.loads(await create.ainvoke({"trigger": "interval:60", "prompt": "a"}))
created = json.loads(
await create.ainvoke({"trigger": "interval:60", "prompt": "a", "name": "a"})
)
cron_id = created["id"]
with _configurable(agent_id=agent_b, user=str(uid)):
@@ -121,7 +125,9 @@ async def test_cronjob_isolated_by_agent_and_user(tmp_path: Path) -> None:
assert "error" in err
listed = json.loads(await list_tool.ainvoke({"include_disabled": True}))
assert listed == []
created = json.loads(await create.ainvoke({"trigger": "interval:60", "prompt": "nope"}))
created = json.loads(
await create.ainvoke({"trigger": "interval:60", "prompt": "nope", "name": "nope"})
)
assert "error" in created
@@ -137,7 +143,9 @@ async def test_cronjob_delete(tmp_path: Path) -> None:
delete = _tool_by_name(tools, "cronjob_delete")
with _configurable(agent_id=agent_id, user=str(user_id)):
created = json.loads(await create.ainvoke({"trigger": "interval:30", "prompt": "p"}))
created = json.loads(
await create.ainvoke({"trigger": "interval:30", "prompt": "p", "name": "p"})
)
cron_id = created["id"]
out = json.loads(await delete.ainvoke({"cron_id": cron_id}))
assert out["deleted"] == cron_id
@@ -157,7 +165,9 @@ async def test_cronjob_run_now(tmp_path: Path) -> None:
run_now = _tool_by_name(tools, "cronjob_run_now")
with _configurable(agent_id=agent_id, user=str(user_id)):
created = json.loads(await create.ainvoke({"trigger": "interval:30", "prompt": "p"}))
created = json.loads(
await create.ainvoke({"trigger": "interval:30", "prompt": "p", "name": "p"})
)
cron_id = created["id"]
out = json.loads(await run_now.ainvoke({"cron_id": cron_id}))
assert out["triggered"] == cron_id
@@ -181,7 +191,7 @@ async def test_cronjob_create_uses_configurable_session_key(tmp_path: Path) -> N
)
with _configurable(agent_id=agent_id, user=str(user_id), session_key=feishu_sk):
out = await create.ainvoke({"trigger": "interval:30", "prompt": "ping"})
out = await create.ainvoke({"trigger": "interval:30", "prompt": "ping", "name": "ping"})
data = json.loads(out)
assert data["session_key"] == feishu_sk
@@ -198,10 +208,46 @@ async def test_cronjob_create_persists_task_type(tmp_path: Path) -> None:
with _configurable(agent_id=agent_id, user=str(user_id)):
out = await create.ainvoke(
{"trigger": "interval:30", "prompt": "ping", "task_type": "text"}
{"trigger": "interval:30", "prompt": "ping", "name": "ping", "task_type": "text"}
)
data = json.loads(out)
assert data["task_type"] == "text"
row = mgr.get(data["id"])
assert row is not None
assert row.task_type == "text"
@pytest.mark.asyncio
async def test_cronjob_create_requires_name(tmp_path: Path) -> None:
from pydantic import ValidationError
services = _make_services(tmp_path)
agent_id = new_ulid()
user_id = services.repos.user_repo.create(username="req", password_hash="x", role="user")
services.repos.agent_repo.create(agent_id=agent_id, user_id=user_id, name="a")
mgr = _make_manager(services)
create = _tool_by_name(build_cronjob_tools(mgr), "cronjob_create")
with _configurable(agent_id=agent_id, user=str(user_id)), pytest.raises(ValidationError):
await create.ainvoke({"trigger": "interval:30", "prompt": "ping"})
@pytest.mark.asyncio
async def test_cronjob_create_blank_name_uses_prompt_prefix(tmp_path: Path) -> None:
services = _make_services(tmp_path)
agent_id = new_ulid()
user_id = services.repos.user_repo.create(username="fb", password_hash="x", role="user")
services.repos.agent_repo.create(agent_id=agent_id, user_id=user_id, name="a")
mgr = _make_manager(services)
create = _tool_by_name(build_cronjob_tools(mgr), "cronjob_create")
with _configurable(agent_id=agent_id, user=str(user_id)):
out = await create.ainvoke(
{
"trigger": "interval:30",
"prompt": "该喝水了💧\n记得站起来活动一下",
"name": " ",
}
)
data = json.loads(out)
assert data["name"] == "该喝水了💧"
+34 -1
View File
@@ -4,7 +4,14 @@ from __future__ import annotations
import pytest
from octop.infra.cron.task_type import CRON_PROMPT_MAX_LEN, require_cron_prompt
from octop.infra.cron.task_type import (
CRON_NAME_FALLBACK_LEN,
CRON_NAME_MAX_LEN,
CRON_PROMPT_MAX_LEN,
default_cron_name,
require_cron_name,
require_cron_prompt,
)
def test_require_cron_prompt_rejects_empty() -> None:
@@ -19,3 +26,29 @@ def test_require_cron_prompt_rejects_too_long() -> None:
def test_require_cron_prompt_strips() -> None:
assert require_cron_prompt(" hello ") == "hello"
def test_default_cron_name_uses_first_line() -> None:
assert default_cron_name("该喝水了\n更多说明", "cron_x") == "该喝水了"
def test_default_cron_name_truncates_long_prefix() -> None:
prompt = "a" * (CRON_NAME_FALLBACK_LEN + 10)
assert default_cron_name(prompt, "cron_x") == "a" * CRON_NAME_FALLBACK_LEN
def test_default_cron_name_falls_back_to_cron_id() -> None:
assert default_cron_name(" \n ", "cron_x") == "cron_x"
def test_require_cron_name_uses_explicit_name() -> None:
assert require_cron_name(" 喝水 ", prompt="ignored", cron_id="cron_x") == "喝水"
def test_require_cron_name_blank_uses_prompt_prefix() -> None:
assert require_cron_name(" ", prompt="站起来活动", cron_id="cron_x") == "站起来活动"
def test_require_cron_name_rejects_too_long() -> None:
with pytest.raises(ValueError, match=str(CRON_NAME_MAX_LEN)):
require_cron_name("x" * (CRON_NAME_MAX_LEN + 1), prompt="p", cron_id="cron_x")