feat(app-server): read Fleet profile documents through a bounded effective-roster route

Adds GET /v1/fleet/profiles/{id}/document. Personal/workspace profiles and
selected named Fleet files return their exact bounded pinned text; built-in,
config, plugin and Claude members return an explicitly read-only effective
projection with a null path. The roster now comes from the captured workspace
identity and plugin discovery, and the final workspace-identity check always
runs, even when the read fails. Revision uses sha256_hex over
(id, kind, origin, path, text). Generated schema, TypeScript, SDK types and
EN/ZH docs are updated.

Verified (local, existing checks, source only): 41 passed, 0 failed
(fleet runtime API test, engine fleet::profile cases, protocol schema+wire).
Not verified: no matched native HTTP read/refusal observer, no mounted App
viewer, no full sealed gate; PUT remains unimplemented and writes stay off.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-11-03 22:13:53 -08:00
1 parent f4978e8fab
commit a86ef86df4
11 files changed
+338 -24

No files matched your search

@@ -487,6 +487,59 @@
],
"description": "Fixed capability map advertised by `GET /v1/runtime/info`.\n\nAll fields are required on serialization so clients can rely on the shape."
},
"FleetProfileDocument": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"kind": {
"$ref": "#/$defs/FleetProfileDocumentKind"
},
"origin": {
"type": "string"
},
"path": {
"type": [
"string",
"null"
]
},
"text": {
"type": "string"
},
"revision": {
"type": "string"
},
"writable": {
"type": "boolean"
},
"refusal_reason": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"kind",
"origin",
"path",
"text",
"revision",
"writable",
"refusal_reason"
]
},
"FleetProfileDocumentKind": {
"type": "string",
"enum": [
"profile",
"fleet"
]
},
"RuntimeExperimentalCapabilities": {
"type": "object",
"properties": {
@@ -39,6 +39,19 @@ export type DynamicToolSpecOutput = {
[key: string]: Json | undefined
}
export type FleetProfileDocument = {
id: string
kind: FleetProfileDocumentKind
origin: string
path: (string | null)
text: string
revision: string
writable: boolean
refusal_reason: (string | null)
}
export type FleetProfileDocumentKind = 'profile' | 'fleet'
export type RuntimeCapabilities = {
client_token_intents: boolean
account_session: boolean
+1
View File
@@ -47,6 +47,7 @@ pub fn runtime_schema() -> Value {
let _ = output.subschema_for::<ThreadLoadedListResponse>();
let _ = output.subschema_for::<ThreadSetNameResponse>();
let _ = output.subschema_for::<RuntimeCapabilities>();
let _ = output.subschema_for::<FleetProfileDocument>();
let _ = output.subschema_for::<RuntimeExperimentalCapabilities>();
let _ = output.subschema_for::<DynamicToolCallParams>();
let _ = output.subschema_for::<DynamicToolSpec>();
+22
View File
@@ -9,6 +9,28 @@ pub mod watch_event;
pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1;
pub const RUNTIME_API_VERSION: &str = "1.0";
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FleetProfileDocumentKind {
Profile,
Fleet,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FleetProfileDocument {
pub id: String,
pub kind: FleetProfileDocumentKind,
pub origin: String,
pub path: Option<String>,
pub text: String,
pub revision: String,
pub writable: bool,
pub refusal_reason: Option<String>,
}
/// Maximum JSON input (including base64 expansion) for an image turn.
pub const MAX_RUNTIME_IMAGE_BODY_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_RUNTIME_IMAGES: usize = 10;
+137 -22
View File
@@ -25,8 +25,9 @@ use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::Utc;
use codewhale_app_server_protocol::runtime::{
DynamicToolCallResult, RUNTIME_API_VERSION, RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
RuntimeCapabilities, RuntimeEventEnvelope, RuntimeExperimentalCapabilities,
DynamicToolCallResult, FleetProfileDocument, FleetProfileDocumentKind, RUNTIME_API_VERSION,
RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeCapabilities, RuntimeEventEnvelope,
RuntimeExperimentalCapabilities,
};
#[cfg(unix)]
use codewhale_paths::daemon_socket::default_socket_path;
@@ -2917,6 +2918,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/agent-runs/{run_id}", get(get_agent_run))
.route("/v1/agent-runs/{run_id}/cancel", post(cancel_agent_run))
.route("/v1/fleet/profiles", get(list_fleet_profiles))
.route(
"/v1/fleet/profiles/{id}/document",
get(get_fleet_profile_document),
)
.route(
"/v1/fleet/runs",
get(list_fleet_runs).post(create_fleet_run),
@@ -4284,30 +4289,140 @@ async fn cancel_agent_run(
}
}
async fn read_fleet_profiles<T: Send + 'static>(
state: RuntimeApiState,
read: impl FnOnce(
&RuntimeApiState,
&codewhale_engine::fleet::roster::FleetRoster,
) -> Result<T, ApiError>
+ Send
+ 'static,
) -> Result<T, ApiError> {
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
state
.workspace_scope
.validate_sync()
.map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
let plugins = state
.plugin_discovery
.registry_for_workspace(&state.workspace);
let roster = codewhale_engine::fleet::identity::load_effective_roster(
&state.config.read().fleet_config(),
&state.workspace,
Some(plugins.as_ref()),
);
let result = read(&state, &roster);
state
.workspace_scope
.validate_sync()
.map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
result
})
.await
.map_err(|_| ApiError::internal("Fleet profiles could not be read"))?
}
async fn list_fleet_profiles(
State(state): State<RuntimeApiState>,
) -> Result<Json<Value>, ApiError> {
let manager = open_fleet_manager(&state)?;
// Same roster path the manager uses to validate `agent_profile` ids on
// run creation, so GUI pickers can never offer a profile the runtime
// would reject.
let roster = manager.agent_roster();
let profiles = roster
.members()
.iter()
.map(|member| {
json!({
"id": member.id.clone(),
"display_name": member.display_name.clone(),
"description": member.description.clone(),
"origin": member.origin.to_string(),
read_fleet_profiles(state, |_, roster| {
let profiles = roster
.members()
.iter()
.map(|member| {
json!({
"id": member.id,
"display_name": member.display_name,
"description": member.description,
"origin": member.origin.to_string(),
})
})
})
.collect::<Vec<_>>();
Ok(Json(json!({
"profiles": profiles,
"load_error": roster.load_error().map(str::to_string),
})))
.collect::<Vec<_>>();
Ok(Json(
json!({"profiles": profiles, "load_error": roster.load_error()}),
))
})
.await
}
async fn get_fleet_profile_document(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
) -> Result<Json<FleetProfileDocument>, ApiError> {
if id.len() > 160 {
return Err(ApiError::bad_request("Profile identity is too long"));
}
read_fleet_profiles(state, move |state, roster| {
use codewhale_engine::fleet::profile::{
FleetProfileScope, MAX_PROFILE_DOCUMENT_BYTES, agent_profile_dir_for_scope,
parse_agent_profile_text, read_agent_profile_source_document,
};
use codewhale_engine::fleet::roster::ProfileOrigin;
if let Some(error) = roster.load_error() {
return Err(ApiError::conflict(error));
}
let member = roster.get(&id).filter(|member| member.id == id)
.ok_or_else(|| ApiError::not_found("Profile is not in the effective roster"))?;
let (kind, path, text, reason) = if matches!(member.origin, ProfileOrigin::Workspace | ProfileOrigin::Personal) {
let scope = if member.origin == ProfileOrigin::Workspace { FleetProfileScope::Project } else { FleetProfileScope::Personal };
let text = read_agent_profile_source_document(scope, &state.workspace, &member.source)
.map_err(|error| ApiError::conflict(error.to_string()))?
.ok_or_else(|| ApiError::conflict("Profile source disappeared; read the roster again"))?;
let path = member.source.to_str().ok_or_else(|| ApiError::conflict("Profile source path is not UTF-8"))?.to_owned();
let directory = agent_profile_dir_for_scope(scope, &state.workspace)
.map_err(|error| ApiError::conflict(error.to_string()))?;
let kind = if member.source.parent() == Some(directory.as_path()) {
let parsed = parse_agent_profile_text(&member.source, &text)
.map_err(|error| ApiError::conflict(error.to_string()))?;
if parsed.id != id {
return Err(ApiError::conflict("Profile identity changed; read the roster again"));
}
FleetProfileDocumentKind::Profile
} else {
let fleet = codewhale_engine::fleet::store::FleetFile::parse(&text)
.map_err(|error| ApiError::conflict(error.to_string()))?;
if fleet.member(&id).is_none() {
return Err(ApiError::conflict("Fleet member changed; read the roster again"));
}
FleetProfileDocumentKind::Fleet
};
(kind, Some(path), text, "Edit this profile or Fleet in the terminal.".to_owned())
} else {
if let Some(authority) = &member.plugin_authority {
codewhale_plugins::registry::verify_plugin_authority(authority)
.map_err(ApiError::conflict)?;
}
if member.profile.role.instructions.as_ref().map_or(0, String::len)
.saturating_add(member.profile.role.description.as_ref().map_or(0, String::len))
> MAX_PROFILE_DOCUMENT_BYTES
{
return Err(ApiError::payload_too_large("Effective profile exceeds the one MiB reading limit"));
}
let text = toml::to_string_pretty(&member.profile)
.map_err(|_| ApiError::internal("Effective profile could not be rendered"))?;
let reason = match member.origin {
ProfileOrigin::BuiltIn => "Effective built-in profile; save a personal profile in the terminal to customize this role.",
ProfileOrigin::Config => "Effective configuration profile; edit its defining configuration.",
ProfileOrigin::Plugin => "Effective plugin profile; edit its reviewed plugin source.",
ProfileOrigin::ClaudeCode => "Effective Codewhale profile from Claude Code; edit its original agent file.",
_ => return Err(ApiError::internal("Profile origin changed while reading")),
};
(FleetProfileDocumentKind::Profile, None, text, reason.to_owned())
};
if text.len() > MAX_PROFILE_DOCUMENT_BYTES {
return Err(ApiError::payload_too_large("Profile document exceeds the one MiB editing limit"));
}
let origin = member.origin.to_string();
let revision_bytes = serde_json::to_vec(&(&id, kind, &origin, &path, &text))
.map_err(|_| ApiError::internal("Profile revision could not be rendered"))?;
let revision = format!("profile-document-v1-{}", codewhale_runtime::hashing::sha256_hex(revision_bytes));
Ok(Json(FleetProfileDocument { id, kind, origin, path, text, revision,
writable: false, refusal_reason: Some(reason) }))
}).await
}
async fn create_fleet_run(
@@ -2856,6 +2856,34 @@ async fn fleet_status_runtime_api_exposes_state_and_actions() -> Result<()> {
.all(|member| member["origin"].is_string()),
"every profile must carry its roster origin"
);
let document: serde_json::Value = client
.get(format!("http://{addr}/v1/fleet/profiles/manager/document"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(document["id"], "manager");
assert_eq!(document["kind"], "profile");
assert_eq!(document["writable"], false);
assert!(document["path"].is_null());
assert!(document["text"].as_str().unwrap().contains("[role]"));
assert!(
document["revision"]
.as_str()
.unwrap()
.starts_with("profile-document-v1-")
);
assert_eq!(
client
.get(format!(
"http://{addr}/v1/fleet/profiles/not-a-member/document"
))
.send()
.await?
.status(),
reqwest::StatusCode::NOT_FOUND
);
let worker: serde_json::Value = client
.get(format!("http://{addr}/v1/fleet/workers/{worker_id}"))
+39 -1
View File
@@ -146,7 +146,11 @@ pub fn read_agent_profile_document(
path: &Path,
) -> Result<Option<String>> {
let (root, relative) = profile_document_location(scope, workspace, path)?;
let file = match codewhale_util::files::WorkspaceFile::open(&root, &relative, false) {
read_profile_document_at(&root, &relative)
}
fn read_profile_document_at(root: &Path, relative: &Path) -> Result<Option<String>> {
let file = match codewhale_util::files::WorkspaceFile::open(root, relative, false) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
@@ -154,6 +158,40 @@ pub fn read_agent_profile_document(
read_profile_document_file(&file)
}
pub fn read_agent_profile_source_document(
scope: FleetProfileScope,
workspace: &Path,
path: &Path,
) -> Result<Option<String>> {
let directory = agent_profile_dir_for_scope(scope, workspace)?;
if path.parent() == Some(directory.as_path()) {
return read_agent_profile_document(scope, workspace, path);
}
let root = match scope {
FleetProfileScope::Project => workspace.to_path_buf(),
FleetProfileScope::Personal => directory
.parent()
.context("Personal profile directory has no state root")?
.to_path_buf(),
};
let fleet_directory = match scope {
FleetProfileScope::Project => root.join(".codewhale").join(super::store::FLEET_DIR),
FleetProfileScope::Personal => root.join(super::store::FLEET_DIR),
};
anyhow::ensure!(
root.is_absolute()
&& path.parent() == Some(fleet_directory.as_path())
&& path.extension().and_then(|value| value.to_str()) == Some("toml"),
"Profile source is outside its selected directory"
);
let relative = path.strip_prefix(&root)?;
anyhow::ensure!(
codewhale_util::files::path_is_confined(relative),
"Profile source is outside its selected directory"
);
read_profile_document_at(&root, relative)
}
pub fn save_agent_profile_document(
scope: FleetProfileScope,
workspace: &Path,
+17
View File
@@ -3206,6 +3206,23 @@ allowed. Added in v0.8.10 (#561), tightened in v0.9.1 (#4454).
## Managed Fleet Runtime and SDK helpers
`GET /v1/fleet/profiles` reads the captured workspace's effective roster,
including its selected Fleet and admitted plugins, without opening a run ledger.
`GET /v1/fleet/profiles/{id}/document` addresses an exact effective member ID.
Its shared `FleetProfileDocument` contract returns `id`, `kind` (`profile` or
`fleet`), `origin`, nullable `path`, `text`, opaque source-bound `revision`,
`writable` and nullable `refusal_reason`. Personal/project profile documents and
selected Fleet files return exact UTF-8 bytes through a bounded, pinned reader
(one MiB maximum). Changed identity, missing source or linked paths are refused.
Built-in, config-backed and foreign-managed members instead return an explicitly
read-only effective profile projection with no file path; sentinel paths and
native preset entry identifiers are never opened as documents.
All documents currently advertise `writable: false`. No document PUT is exposed;
use the terminal or the defining source to edit. A revision is not write
permission or a live-roster adoption receipt. Watch clients may read these
resources under the existing workspace and authentication boundaries.
The Runtime SDK lives in `npm/runtime-sdk` and is exposed as
the `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every
helper calls the local Rust Runtime API and therefore cannot bypass Codewhale's
+14
View File
@@ -2565,6 +2565,20 @@ Fleet 流(`/v1/fleet/runs/{run_id}/events`)保留自己的结束帧,
## 托管 Fleet 运行时与 SDK 助手
`GET /v1/fleet/profiles` 从已捕获的工作区读取实际生效的成员列表,包含选中的
Fleet 和已获准的插件,不会为此打开运行账本。
`GET /v1/fleet/profiles/{id}/document` 使用精确的生效成员 ID。共享的
`FleetProfileDocument` 契约返回 `id`、`kind`(`profile` 或 `fleet`)、`origin`、
可空的 `path`、`text`、绑定源身份的不透明 `revision`、`writable` 和可空的
`refusal_reason`。个人/项目配置及选中的 Fleet 文件通过固定目录句柄读取原始
UTF-8 字节,上限为一 MiB。身份改变、源文件消失或链接路径会被拒绝。
内置、配置文件和外部管理的成员只返回明确只读的生效配置视图,不提供文件路径;
不会把占位路径或原生预设入口标识当作配置文件打开。
所有文档目前均返回 `writable: false`,尚未提供文档 PUT。请在终端或定义该成员的
源中编辑。修订号不代表写入权限,也不代表运行中的成员列表已采用更改。只读客户端
可在现有工作区和身份验证边界内读取这些资源。
运行时 SDK 位于 `npm/runtime-sdk`,并作为
`@codewhale/runtime-sdk` 工作区包对外暴露。它有意保持轻薄:每个
助手都只是调用本地 Rust 运行时 API,因此无法绕过 Codewhale 的
+13
View File
@@ -39,6 +39,19 @@ export type DynamicToolSpecOutput = {
[key: string]: Json | undefined
}
export type FleetProfileDocument = {
id: string
kind: FleetProfileDocumentKind
origin: string
path: (string | null)
text: string
revision: string
writable: boolean
refusal_reason: (string | null)
}
export type FleetProfileDocumentKind = 'profile' | 'fleet'
export type RuntimeCapabilities = {
client_token_intents: boolean
account_session: boolean
+1 -1
View File
@@ -1,5 +1,5 @@
import type { RuntimeEventEnvelope, DynamicToolCallParams, DynamicToolCallResult } from "./generated/runtime.js";
export type { ThreadLoadedListParams, ThreadLoadedListResponse, ThreadSetNameParams, ThreadSetNameResponse, RuntimeImageInput, RuntimeEventEnvelope, RuntimeCapabilities, RuntimeExperimentalCapabilities, DynamicToolSpec, DynamicToolSpecOutput, DynamicToolItemStatus, DynamicToolCallParams, DynamicToolCallResult, DynamicToolCallContent, TurnEnvironmentParams } from "./generated/runtime.js";
export type { FleetProfileDocument, FleetProfileDocumentKind, ThreadLoadedListParams, ThreadLoadedListResponse, ThreadSetNameParams, ThreadSetNameResponse, RuntimeImageInput, RuntimeEventEnvelope, RuntimeCapabilities, RuntimeExperimentalCapabilities, DynamicToolSpec, DynamicToolSpecOutput, DynamicToolItemStatus, DynamicToolCallParams, DynamicToolCallResult, DynamicToolCallContent, TurnEnvironmentParams } from "./generated/runtime.js";
export type FleetRunId = string;
export type FleetRunStatus =