Skip to content

Commit f82020c

Browse files
authored
Merge pull request #3465 from kev1n77/fmy/bugfix
fix(pet): load user pet spritesheets through the host and keep the pet above the app window
2 parents 9eebcbd + 3c4e46a commit f82020c

12 files changed

Lines changed: 289 additions & 26 deletions

File tree

‎docs/interactive-capabilities/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **23 features**, **21 settings pages**, a
2727
- Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json`
2828
- Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json`
2929

30-
说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **692** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。
30+
说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **693** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。
3131

32-
Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **692** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.
32+
Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **693** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.
3333

3434
## 控制边界 / Control boundary
3535

‎docs/interactive-capabilities/technical/tauri-command-map.json‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
"schemaVersion": 2,
33
"generatedFrom": "src/shared/interactive-capabilities/catalog.json",
44
"catalogDigest": "05afb29f354928738ed26d4275819bbe3c165c7878bb24996b5169639ba03330",
5-
"commandCount": 692,
5+
"commandCount": 693,
66
"coverage": {
7-
"commandCount": 692,
7+
"commandCount": 693,
88
"documentedCommandCount": 642,
9-
"implementationCommandCount": 50,
10-
"implementationDigest": "1dc90be1a699f521e7faee78c128d6332ef0806daccb389b288fbe2fa3fd3c2c"
9+
"implementationCommandCount": 51,
10+
"implementationDigest": "b9ffc435b3b60049737b988fa48409ec88e570fcf52eb71a79d75ce13877086f"
1111
},
1212
"commands": [
1313
{
@@ -5615,6 +5615,20 @@
56155615
"signature": "fn load_acp_json_config(state: State<'_, AppState>) -> Result<String, String>",
56165616
"remoteWorkspacePolicy": "LegacyUnaudited"
56175617
},
5618+
{
5619+
"id": "load_agent_companion_pet_spritesheet",
5620+
"moduleId": "commands",
5621+
"capabilityId": "feature.projects",
5622+
"capabilityIds": [
5623+
"feature.projects"
5624+
],
5625+
"documentedItemIds": [],
5626+
"visibility": "implementation",
5627+
"rustPath": "load_agent_companion_pet_spritesheet",
5628+
"sourceFile": "src/apps/desktop/src/api/commands.rs",
5629+
"signature": "fn load_agent_companion_pet_spritesheet( state: State<'_, AppState>, request: LoadAgentCompanionPetSpritesheetRequest, ) -> Result<tauri::ipc::Response, String>",
5630+
"remoteWorkspacePolicy": "LocalOnly"
5631+
},
56185632
{
56195633
"id": "load_canvas_artifact",
56205634
"moduleId": "canvas",

‎src/apps/desktop/src/api/commands.rs‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,12 @@ pub struct ListAgentCompanionPetsRequest {
518518
pub builtin_import_version: Option<u32>,
519519
}
520520

521+
#[derive(Debug, Deserialize)]
522+
#[serde(rename_all = "camelCase")]
523+
pub struct LoadAgentCompanionPetSpritesheetRequest {
524+
pub spritesheet_path: String,
525+
}
526+
521527
#[derive(Debug, Serialize)]
522528
#[serde(rename_all = "camelCase")]
523529
pub struct ListAgentCompanionPetsResponse {
@@ -2609,6 +2615,48 @@ mod pet_package_tests {
26092615
.contains("reviewed fingerprint"));
26102616
}
26112617

2618+
#[test]
2619+
fn pet_spritesheet_read_is_confined_to_the_companion_directory() {
2620+
let temp = tempfile::tempdir().unwrap();
2621+
let root = temp.path().join("agent-companions");
2622+
let pet_dir = root.join("pet-abc");
2623+
std::fs::create_dir_all(&pet_dir).unwrap();
2624+
let spritesheet = pet_dir.join("spritesheet.webp");
2625+
std::fs::write(&spritesheet, [1u8, 2, 3]).unwrap();
2626+
2627+
assert_eq!(
2628+
read_agent_companion_pet_spritesheet(&root, &spritesheet).unwrap(),
2629+
vec![1, 2, 3]
2630+
);
2631+
2632+
let outside = temp.path().join("credentials.txt");
2633+
std::fs::write(&outside, b"secret").unwrap();
2634+
let error = read_agent_companion_pet_spritesheet(&root, &outside).unwrap_err();
2635+
assert!(
2636+
error.contains("outside the companion pet directory"),
2637+
"{error}"
2638+
);
2639+
}
2640+
2641+
#[cfg(unix)]
2642+
#[test]
2643+
fn pet_spritesheet_read_rejects_a_symlinked_escape() {
2644+
let temp = tempfile::tempdir().unwrap();
2645+
let root = temp.path().join("agent-companions");
2646+
let pet_dir = root.join("pet-abc");
2647+
std::fs::create_dir_all(&pet_dir).unwrap();
2648+
let outside = temp.path().join("credentials.txt");
2649+
std::fs::write(&outside, b"secret").unwrap();
2650+
let link = pet_dir.join("spritesheet.webp");
2651+
std::os::unix::fs::symlink(&outside, &link).unwrap();
2652+
2653+
let error = read_agent_companion_pet_spritesheet(&root, &link).unwrap_err();
2654+
assert!(
2655+
error.contains("outside the companion pet directory"),
2656+
"{error}"
2657+
);
2658+
}
2659+
26122660
#[test]
26132661
#[ignore = "Imports installed Codex pets into an isolated temporary directory"]
26142662
fn installed_builtin_pets_can_be_previewed_and_imported() {
@@ -2825,6 +2873,77 @@ pub(crate) async fn list_agent_companion_pets_impl(
28252873
})
28262874
}
28272875

2876+
/// Upper bound for a single pet spritesheet. A spritesheet is a small sprite
2877+
/// atlas, so a larger file is not a pet package asset and must not be buffered
2878+
/// through the IPC channel.
2879+
const MAX_AGENT_COMPANION_SPRITESHEET_BYTES: u64 = 16 * 1024 * 1024;
2880+
2881+
/// Reads a user pet spritesheet through the host.
2882+
///
2883+
/// The webview used to read this file with the filesystem plugin, whose scope
2884+
/// requires a literal leading dot on unix. Every XDG directory starts with a
2885+
/// dot, so a Linux install could never resolve a spritesheet that lives under
2886+
/// the app data directory; the read always failed with `forbidden path`
2887+
/// (#3370). The host performs the read instead and keeps it confined to the
2888+
/// managed companion directory, which is the only place a user pet package can
2889+
/// be imported to.
2890+
#[tauri::command]
2891+
pub async fn load_agent_companion_pet_spritesheet(
2892+
state: State<'_, AppState>,
2893+
request: LoadAgentCompanionPetSpritesheetRequest,
2894+
) -> Result<tauri::ipc::Response, String> {
2895+
let packages_root = companion_user_packages_dir(&state);
2896+
let spritesheet_path = request.spritesheet_path;
2897+
let bytes = tokio::task::spawn_blocking(move || {
2898+
read_agent_companion_pet_spritesheet(&packages_root, Path::new(&spritesheet_path))
2899+
})
2900+
.await
2901+
.map_err(|e| e.to_string())??;
2902+
Ok(tauri::ipc::Response::new(bytes))
2903+
}
2904+
2905+
fn read_agent_companion_pet_spritesheet(
2906+
packages_root: &Path,
2907+
spritesheet_path: &Path,
2908+
) -> Result<Vec<u8>, String> {
2909+
let root = packages_root.canonicalize().map_err(|error| {
2910+
format!(
2911+
"Companion pet directory is unavailable ({}): {}",
2912+
packages_root.display(),
2913+
error
2914+
)
2915+
})?;
2916+
let requested = spritesheet_path.canonicalize().map_err(|error| {
2917+
format!(
2918+
"Failed to resolve {}: {}",
2919+
spritesheet_path.display(),
2920+
error
2921+
)
2922+
})?;
2923+
if !requested.starts_with(&root) {
2924+
return Err(format!(
2925+
"Refused to read {} outside the companion pet directory",
2926+
spritesheet_path.display()
2927+
));
2928+
}
2929+
let metadata = std::fs::metadata(&requested)
2930+
.map_err(|error| format!("Failed to read {}: {}", requested.display(), error))?;
2931+
if !metadata.is_file() {
2932+
return Err(format!(
2933+
"Not a companion pet spritesheet file: {}",
2934+
requested.display()
2935+
));
2936+
}
2937+
if metadata.len() > MAX_AGENT_COMPANION_SPRITESHEET_BYTES {
2938+
return Err(format!(
2939+
"Companion pet spritesheet is too large: {} bytes",
2940+
metadata.len()
2941+
));
2942+
}
2943+
std::fs::read(&requested)
2944+
.map_err(|error| format!("Failed to read {}: {}", requested.display(), error))
2945+
}
2946+
28282947
#[tauri::command]
28292948
pub async fn import_agent_companion_pet_package(
28302949
state: State<'_, AppState>,

‎src/apps/desktop/src/appearance.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,20 @@ fn resize_agent_companion_window(
978978
}
979979
}
980980

981+
/// Re-asserts the pet stacking hint after the native window is mapped.
982+
///
983+
/// GTK applies keep-above on map, and a reused window may have been raised
984+
/// behind the main window while it was hidden, so both show paths re-apply it.
985+
#[cfg(target_os = "linux")]
986+
fn enforce_agent_companion_window_stacking(window: &tauri::WebviewWindow) {
987+
if let Err(error) = window.set_always_on_top(true) {
988+
warn!(
989+
"Failed to re-apply Agent companion always-on-top: {}",
990+
error
991+
);
992+
}
993+
}
994+
981995
#[tauri::command]
982996
pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> {
983997
let started_at = Instant::now();
@@ -997,6 +1011,8 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(
9971011
error!("Failed to show Agent companion window: {}", e);
9981012
format!("Failed to show Agent companion window: {}", e)
9991013
})?;
1014+
#[cfg(target_os = "linux")]
1015+
enforce_agent_companion_window_stacking(&window);
10001016
debug!(
10011017
"Agent companion window reused: total_duration_ms={}",
10021018
started_at.elapsed().as_millis()
@@ -1042,6 +1058,22 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(
10421058

10431059
builder = builder.disable_drag_drop_handler();
10441060

1061+
// Wayland compositors drop GTK's keep-above request, so the only stacking
1062+
// relationship the platform can still guarantee is "above its parent".
1063+
// Attaching the pet to the main window keeps the app's own page from covering
1064+
// it there, while `always_on_top` keeps floating it above other applications
1065+
// on X11, where the window manager honors the hint.
1066+
#[cfg(target_os = "linux")]
1067+
if let Some(main_window) = app.get_webview_window("main") {
1068+
match main_window.gtk_window() {
1069+
Ok(parent) => builder = builder.transient_for_raw(&parent),
1070+
Err(error) => warn!(
1071+
"Failed to read the main window GTK handle for Agent companion stacking: {}",
1072+
error
1073+
),
1074+
}
1075+
}
1076+
10451077
let build_started_at = Instant::now();
10461078
let window = builder.build().map_err(|e| {
10471079
error!(
@@ -1064,6 +1096,8 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(
10641096
error!("Failed to show Agent companion window: {}", e);
10651097
format!("Failed to show Agent companion window: {}", e)
10661098
})?;
1099+
#[cfg(target_os = "linux")]
1100+
enforce_agent_companion_window_stacking(&window);
10671101
debug!(
10681102
"Agent companion window shown: show_duration_ms={} total_duration_ms={}",
10691103
show_started_at.elapsed().as_millis(),

‎src/apps/desktop/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,7 @@ pub async fn run() {
14861486
desktop_cards::commands::desktop_card_set_checklist_item,
14871487
desktop_cards::commands::desktop_card_set_always_on_top,
14881488
list_agent_companion_pets,
1489+
load_agent_companion_pet_spritesheet,
14891490
import_agent_companion_pet_package,
14901491
delete_agent_companion_pet_package,
14911492
read_file_content,

‎src/crates/contracts/product-domains/src/generated/remote-surface-registry.json‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schemaVersion": 1,
3-
"digest": "fnv1a64:bbd47100ae85c396",
3+
"digest": "fnv1a64:ddf61f87bff00c48",
44
"retiredCommandPrefixes": [
55
{
66
"prefix": "lsp_",
@@ -4417,6 +4417,18 @@
44174417
"reason": "the CLI peer host has no handler for this command"
44184418
}
44194419
},
4420+
{
4421+
"id": "load_agent_companion_pet_spritesheet",
4422+
"surface": "tauri_command",
4423+
"remoteWorkspace": "LocalOnly",
4424+
"peer": {
4425+
"kind": "controller_local"
4426+
},
4427+
"cliPeer": {
4428+
"kind": "unsupported",
4429+
"reason": "the controller keeps this command; peer hosts refuse it before dispatch"
4430+
}
4431+
},
44204432
{
44214433
"id": "load_canvas_artifact",
44224434
"surface": "tauri_command",

‎src/crates/contracts/product-domains/src/remote_surface/table.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[
456456
op("list_subscription_accounts", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED),
457457
op("list_visible_subagents", Routed, Proxied, HANDLED),
458458
op("load_acp_json_config", Unaudited, Proxied, CLI_NOT_IMPLEMENTED),
459+
op("load_agent_companion_pet_spritesheet", LocalOnly, ControllerLocal, REFUSED),
459460
op("load_canvas_artifact", Agnostic, Proxied, CLI_NOT_IMPLEMENTED),
460461
op("load_canvas_state", Agnostic, Proxied, CLI_NOT_IMPLEMENTED),
461462
op("load_git_repo_history", Unaudited, Proxied, CLI_NOT_IMPLEMENTED),

‎src/shared/interactive-capabilities/catalog.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@
254254
"fileTransferPrimitives": [
255255
"local_file_download",
256256
"export_local_file_to_path",
257+
"load_agent_companion_pet_spritesheet",
257258
"paste_files",
258259
"workspace_file_upload"
259260
],

‎src/web-ui/src/infrastructure/api/generated/remoteSurface.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Generated by scripts/generate-interactive-capabilities.mjs; do not edit.
22
// Source: openbitfun_product_domains::remote_surface (Product Operation Registry).
3-
export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:bbd47100ae85c396" as const;
3+
export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:ddf61f87bff00c48" as const;
44

55
/**
66
* Registered Tauri commands the Peer Device controller keeps on the controller
@@ -101,6 +101,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet<string> = new Set([
101101
"install_pending_update",
102102
"install_update",
103103
"list_agent_companion_pets",
104+
"load_agent_companion_pet_spritesheet",
104105
"load_insights_report",
105106
"local_file_download",
106107
"mark_openbitfun_control_surface_ready",

‎src/web-ui/src/infrastructure/config/services/AgentCompanionPetCompatibility.test.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

3-
const { invoke, readFile } = vi.hoisted(() => ({ invoke: vi.fn(), readFile: vi.fn() }));
3+
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
44
vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke } }));
5-
vi.mock('@tauri-apps/plugin-fs', () => ({ readFile }));
65
vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true }));
76

87
import { resolveAgentCompanionPet } from './AgentCompanionPetService';
@@ -13,36 +12,57 @@ const legacy: AgentCompanionPetSelection = {
1312
spritesheetPath: '/pets/sample/spritesheet.webp', spritesheetMimeType: 'image/webp',
1413
};
1514

15+
/**
16+
* The host answers both the pet enumeration and the spritesheet read, so the
17+
* mock dispatches by command instead of returning one fixed value.
18+
*/
19+
function mockHost(petsResponse: unknown): void {
20+
invoke.mockImplementation((command: string) => {
21+
if (command === 'list_agent_companion_pets') return Promise.resolve(petsResponse);
22+
if (command === 'load_agent_companion_pet_spritesheet') {
23+
return Promise.resolve(new Uint8Array([0]).buffer);
24+
}
25+
return Promise.reject(new Error(`unexpected command: ${command}`));
26+
});
27+
}
28+
29+
function invokedCommands(): string[] {
30+
return invoke.mock.calls.map(([command]) => command as string);
31+
}
32+
1633
describe('saved pet version recovery', () => {
1734
afterEach(() => vi.unstubAllGlobals());
1835
beforeEach(() => {
1936
vi.clearAllMocks();
20-
readFile.mockResolvedValue(new Uint8Array([0]));
2137
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:pet'), revokeObjectURL: vi.fn() });
2238
});
2339

2440
it('recovers v2 from an already imported package without writing settings', async () => {
25-
invoke.mockResolvedValue({ pets: [{ ...legacy, spriteVersionNumber: 2 }] });
41+
mockHost({ pets: [{ ...legacy, spriteVersionNumber: 2 }] });
2642
const resolved = await resolveAgentCompanionPet(legacy);
2743
expect(resolved.layout.rows).toBe(11);
28-
expect(invoke.mock.calls).toEqual([['list_agent_companion_pets']]);
44+
expect(invokedCommands()).toEqual([
45+
'list_agent_companion_pets',
46+
'load_agent_companion_pet_spritesheet',
47+
]);
2948
expect(legacy.spriteVersionNumber).toBeUndefined();
3049
});
3150

3251
it('retains legacy manifests returned by an older host', async () => {
33-
invoke.mockResolvedValue({ pets: [legacy] });
52+
mockHost({ pets: [legacy] });
3453
expect((await resolveAgentCompanionPet(legacy)).layout.rows).toBe(9);
3554
});
3655

3756
it('does not require package enumeration for explicit v1 or v2 selections', async () => {
57+
mockHost({ pets: [legacy] });
3858
for (const version of [1, 2]) {
3959
expect((await resolveAgentCompanionPet({ ...legacy, spriteVersionNumber: version })).layout.version).toBe(version);
4060
}
41-
expect(invoke).not.toHaveBeenCalled();
61+
expect(invokedCommands()).not.toContain('list_agent_companion_pets');
4262
});
4363

4464
it('retains an unavailable selection and reports failure instead of guessing v1', async () => {
45-
invoke.mockResolvedValue({ pets: [] });
65+
mockHost({ pets: [] });
4666
await expect(resolveAgentCompanionPet(legacy)).rejects.toThrow('unavailable');
4767
invoke.mockRejectedValue(new Error('Host unavailable'));
4868
await expect(resolveAgentCompanionPet(legacy)).rejects.toThrow('Host unavailable');

0 commit comments

Comments
 (0)