diff --git a/docs/superpowers/plans/2026-04-26-multi-user-rbac-foundation.md b/docs/superpowers/plans/2026-04-26-multi-user-rbac-foundation.md new file mode 100644 index 0000000..93375ec --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-multi-user-rbac-foundation.md @@ -0,0 +1,1517 @@ +# Multi-User RBAC Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first usable multi-user RBAC foundation so Boss filters devices, conversations, projects, messages, Skill visibility, and main Agent context by the logged-in account's permissions. + +**Architecture:** Add explicit grant arrays to `BossState`, centralize authorization in `src/lib/boss-permissions.ts`, then route all API projections and task entrypoints through this module. Highest admin remains globally privileged, while non-admin users get device-derived read visibility plus explicit grants for chat, takeover, computer control, and Skill usage. + +**Tech Stack:** Next.js App Router route handlers, TypeScript, `node:test` + `tsx`, Android Java unit tests, existing file-backed `data/boss-state.json`. + +--- + +## File Structure + +Create: + +- `src/lib/boss-permissions.ts` - central permission predicates, filtering helpers, and audit helper builders. +- `tests/boss-permissions.test.ts` - pure unit tests for grants, inheritance, expiration, and legacy fallback. +- `tests/rbac-route-filtering.test.ts` - API-level tests for devices, conversations, project detail, messages, and Skill filtering. +- `tests/rbac-master-agent-scope.test.ts` - tests proving main Agent context excludes unauthorized state. +- `android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java` - Android-side tests for role-gated access entry and unauthorized messaging. + +Modify: + +- `src/lib/boss-data.ts` - add grant/audit types to `BossState`, normalize persisted grants, and expose grant mutation helpers. +- `src/lib/boss-projections.ts` - add session-aware projections for conversations, project detail, device workspace, and Skill inventory. +- `src/lib/boss-device-auth.ts` - replace raw `device.account === session.account` checks with RBAC helpers while keeping legacy fallback. +- `src/lib/boss-attachment-access.ts` - align attachment access with project permissions. +- `src/app/api/v1/devices/route.ts` - filter devices and workspace by session permissions. +- `src/app/api/v1/conversations/route.ts` - filter conversation list by session permissions. +- `src/app/api/v1/conversations/home/route.ts` - filter Android home list by session permissions. +- `src/app/api/v1/conversation-folders/[folderKey]/route.ts` - filter folder contents by session permissions. +- `src/app/api/v1/projects/[projectId]/route.ts` - require `project.view`. +- `src/app/api/v1/projects/[projectId]/messages/route.ts` - require `project.view` for read and `thread.chat` or `master_agent.ask` for write. +- `src/app/api/v1/devices/[deviceId]/skills/route.ts` - require `skill.view` for reads and keep device-token writes for local-agent. +- `src/lib/boss-master-agent.ts` - build main Agent runtime context from authorized scope. +- `src/app/api/state/route.ts` - avoid returning all devices/projects to non-admin users. +- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java` - expose account/permission menu only for eligible roles. +- `android/app/src/main/java/com/hyzq/boss/MainActivity.java` - route the new account access entry and preserve unauthorized toasts. +- `README.md` and `docs/architecture/current_runtime_and_deploy_status_cn.md` - document first-stage RBAC runtime behavior. + +--- + +### Task 1: Add State Types, Normalization, and Grant Mutators + +**Files:** +- Modify: `src/lib/boss-data.ts` +- Test: `tests/boss-permissions.test.ts` + +- [ ] **Step 1: Write failing tests for persisted grants and legacy fallback** + +Create `tests/boss-permissions.test.ts` with this initial content: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; + +let runtimeRoot = ""; +let data: typeof import("../src/lib/boss-data"); +let permissions: typeof import("../src/lib/boss-permissions"); + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-permissions-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + data = await import("../src/lib/boss-data.ts"); + permissions = await import("../src/lib/boss-permissions.ts"); +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test.beforeEach(async () => { + await setup(); + const state = await data.readState(); + await data.writeState({ + ...state, + accountDeviceGrants: [], + accountProjectGrants: [], + accountSkillGrants: [], + skillCatalog: [], + permissionAuditLogs: [], + }); +}); + +test("highest admin can access every device and project without explicit grants", async () => { + const state = await data.readState(); + const session = { + account: "17600003315", + role: "highest_admin" as const, + displayName: "Boss 超级管理员", + }; + + assert.equal(permissions.canAccessDevice(state, session, "mac-studio", "device.view"), true); + assert.equal(permissions.canAccessProject(state, session, "audit-collab", "project.view"), true); +}); + +test("device.view grant gives project read visibility but not thread chat", async () => { + const state = await data.readState(); + state.accountDeviceGrants = [ + { + grantId: "grant-device-view", + account: "worker@example.com", + deviceId: "mac-studio", + permissions: ["device.view"], + grantedBy: "17600003315", + grantedAt: "2026-04-26T12:00:00+08:00", + }, + ]; + await data.writeState(state); + + const next = await data.readState(); + const session = { + account: "worker@example.com", + role: "member" as const, + displayName: "Worker", + }; + + assert.equal(permissions.canAccessDevice(next, session, "mac-studio", "device.view"), true); + assert.equal(permissions.canAccessProject(next, session, "master-agent", "project.view"), true); + assert.equal(permissions.canAccessProject(next, session, "master-agent", "thread.chat"), false); +}); + +test("explicit project thread.chat grant allows posting to that project", async () => { + const state = await data.readState(); + state.accountProjectGrants = [ + { + grantId: "grant-thread-chat", + account: "worker@example.com", + projectId: "master-agent", + permissions: ["project.view", "thread.chat", "master_agent.ask"], + grantedBy: "17600003315", + grantedAt: "2026-04-26T12:00:00+08:00", + }, + ]; + await data.writeState(state); + + const next = await data.readState(); + const session = { + account: "worker@example.com", + role: "member" as const, + displayName: "Worker", + }; + + assert.equal(permissions.canAccessProject(next, session, "master-agent", "project.view"), true); + assert.equal(permissions.canAccessProject(next, session, "master-agent", "thread.chat"), true); + assert.equal(permissions.canAccessProject(next, session, "master-agent", "computer.control"), false); +}); + +test("expired grants are ignored", async () => { + const state = await data.readState(); + state.accountDeviceGrants = [ + { + grantId: "expired-device-grant", + account: "worker@example.com", + deviceId: "mac-studio", + permissions: ["device.view"], + grantedBy: "17600003315", + grantedAt: "2026-04-25T12:00:00+08:00", + expiresAt: "2000-01-01T00:00:00.000Z", + }, + ]; + await data.writeState(state); + + const next = await data.readState(); + const session = { + account: "worker@example.com", + role: "member" as const, + displayName: "Worker", + }; + + assert.equal(permissions.canAccessDevice(next, session, "mac-studio", "device.view"), false); +}); + +test("legacy device account ownership remains a compatibility fallback", async () => { + const state = await data.readState(); + const session = { + account: "kris.plus.gpu", + role: "member" as const, + displayName: "GPU User", + }; + + assert.equal(permissions.canAccessDevice(state, session, "win-gpu-01", "device.view"), true); + assert.equal(permissions.canAccessProject(state, session, "audit-collab", "project.view"), true); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +npx tsx --test tests/boss-permissions.test.ts +``` + +Expected: fail because `src/lib/boss-permissions.ts` and RBAC state fields do not exist. + +- [ ] **Step 3: Add RBAC types to `boss-data.ts`** + +In `src/lib/boss-data.ts`, add these exported types near `AuthRole`: + +```ts +export type BossPermission = + | "device.view" + | "device.manage" + | "project.view" + | "thread.chat" + | "master_agent.ask" + | "master_agent.takeover" + | "computer.control" + | "skill.view" + | "skill.use" + | "skill.manage" + | "account.manage" + | "audit.view"; + +export interface AccountDeviceGrant { + grantId: string; + account: string; + deviceId: string; + permissions: BossPermission[]; + grantedBy: string; + grantedAt: string; + expiresAt?: string; + note?: string; +} + +export interface AccountProjectGrant { + grantId: string; + account: string; + projectId: string; + deviceId?: string; + permissions: BossPermission[]; + inheritFromDeviceGrant?: boolean; + grantedBy: string; + grantedAt: string; + expiresAt?: string; + note?: string; +} + +export interface AccountSkillGrant { + grantId: string; + account: string; + skillId: string; + deviceId?: string; + projectId?: string; + permissions: BossPermission[]; + grantedBy: string; + grantedAt: string; + expiresAt?: string; + note?: string; +} + +export interface SkillCatalogEntry { + skillId: string; + name: string; + description: string; + sourceType: "gitea" | "skillhub" | "local"; + sourceUrl?: string; + version?: string; + checksum?: string; + category?: string; + updatedAt: string; +} + +export interface PermissionAuditLog { + auditId: string; + actorAccount: string; + action: + | "grant.created" + | "grant.updated" + | "grant.revoked" + | "skill.assigned" + | "skill.revoked" + | "task.authorized" + | "task.denied"; + targetAccount?: string; + deviceId?: string; + projectId?: string; + skillId?: string; + permissions?: BossPermission[]; + detail?: string; + createdAt: string; +} +``` + +Add these fields to `BossState`: + +```ts +accountDeviceGrants: AccountDeviceGrant[]; +accountProjectGrants: AccountProjectGrant[]; +accountSkillGrants: AccountSkillGrant[]; +skillCatalog: SkillCatalogEntry[]; +permissionAuditLogs: PermissionAuditLog[]; +``` + +Add empty arrays in `initialState`. + +- [ ] **Step 4: Normalize persisted RBAC arrays** + +In the state normalization function that builds normalized `BossState`, add: + +```ts +accountDeviceGrants: ensureArray(raw.accountDeviceGrants, []).map(normalizeAccountDeviceGrant), +accountProjectGrants: ensureArray(raw.accountProjectGrants, []).map(normalizeAccountProjectGrant), +accountSkillGrants: ensureArray(raw.accountSkillGrants, []).map(normalizeAccountSkillGrant), +skillCatalog: ensureArray(raw.skillCatalog, []).map(normalizeSkillCatalogEntry), +permissionAuditLogs: ensureArray(raw.permissionAuditLogs, []).map(normalizePermissionAuditLog), +``` + +Add local normalizers that preserve only valid permission strings: + +```ts +const validBossPermissions = new Set([ + "device.view", + "device.manage", + "project.view", + "thread.chat", + "master_agent.ask", + "master_agent.takeover", + "computer.control", + "skill.view", + "skill.use", + "skill.manage", + "account.manage", + "audit.view", +]); + +function normalizeBossPermissions(value: unknown): BossPermission[] { + return ensureArray(value, []) + .filter((item): item is BossPermission => typeof item === "string" && validBossPermissions.has(item as BossPermission)); +} +``` + +Each grant normalizer must keep the original `grantId` if present, otherwise generate a stable fallback from account, target, and permissions. + +- [ ] **Step 5: Add grant mutation helpers** + +Add these exports to `src/lib/boss-data.ts`: + +```ts +export async function saveAccountDeviceGrant(input: Omit & { grantId?: string }) { + return mutateState((state) => { + const grantedAt = nowIso(); + const grant: AccountDeviceGrant = { + ...input, + grantId: input.grantId ?? randomToken("grant-device"), + permissions: dedupeStrings(input.permissions).filter((item): item is BossPermission => validBossPermissions.has(item as BossPermission)), + grantedAt, + }; + state.accountDeviceGrants = [ + grant, + ...state.accountDeviceGrants.filter((item) => item.grantId !== grant.grantId), + ]; + state.permissionAuditLogs.unshift({ + auditId: randomToken("audit"), + actorAccount: input.grantedBy, + action: "grant.created", + targetAccount: input.account, + deviceId: input.deviceId, + permissions: grant.permissions, + createdAt: grantedAt, + }); + return grant; + }); +} +``` + +Repeat the same pattern for `saveAccountProjectGrant`, `saveAccountSkillGrant`, and `revokeAccessGrant(grantId, actorAccount)`. + +- [ ] **Step 6: Run permission tests** + +Run: + +```bash +npx tsx --test tests/boss-permissions.test.ts +``` + +Expected: still fail until Task 2 creates `boss-permissions.ts`. + +### Task 2: Implement Central Permission Module + +**Files:** +- Create: `src/lib/boss-permissions.ts` +- Test: `tests/boss-permissions.test.ts` + +- [ ] **Step 1: Create the permission module** + +Create `src/lib/boss-permissions.ts`: + +```ts +import type { + AuthSession, + BossPermission, + BossState, + Device, + Project, +} from "@/lib/boss-data"; + +export type PermissionSession = Pick; + +function isExpired(expiresAt?: string) { + return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now()); +} + +export function isHighestAdmin(session: Pick) { + return session.role === "highest_admin"; +} + +function permissionSetIncludes(permissions: BossPermission[], required: BossPermission) { + return permissions.includes(required); +} + +function projectUsesDevice(project: Project, deviceId: string) { + if (project.deviceIds.includes(deviceId)) return true; + return project.groupMembers.some((member) => member.deviceId === deviceId); +} + +function accountOwnsDevice(device: Device | undefined, account: string) { + return Boolean(device && device.account === account); +} + +export function canAccessDevice( + state: BossState, + session: PermissionSession, + deviceId: string, + permission: BossPermission = "device.view", +) { + if (isHighestAdmin(session)) return true; + const device = state.devices.find((item) => item.id === deviceId); + if (!device) return false; + if (permission === "device.view" && accountOwnsDevice(device, session.account)) return true; + return state.accountDeviceGrants.some( + (grant) => + grant.account === session.account && + grant.deviceId === deviceId && + !isExpired(grant.expiresAt) && + permissionSetIncludes(grant.permissions, permission), + ); +} + +export function canAccessProject( + state: BossState, + session: PermissionSession, + projectId: string, + permission: BossPermission = "project.view", +) { + if (isHighestAdmin(session)) return true; + const project = state.projects.find((item) => item.id === projectId); + if (!project) return false; + + const directProjectGrant = state.accountProjectGrants.some( + (grant) => + grant.account === session.account && + grant.projectId === projectId && + !isExpired(grant.expiresAt) && + permissionSetIncludes(grant.permissions, permission), + ); + if (directProjectGrant) return true; + + if (permission === "project.view") { + return state.devices.some( + (device) => + projectUsesDevice(project, device.id) && + (accountOwnsDevice(device, session.account) || + canAccessDevice(state, session, device.id, "device.view")), + ); + } + + return state.accountDeviceGrants.some( + (grant) => + grant.account === session.account && + !isExpired(grant.expiresAt) && + projectUsesDevice(project, grant.deviceId) && + permissionSetIncludes(grant.permissions, permission), + ); +} + +export function canUseSkill( + state: BossState, + session: PermissionSession, + skillId: string, + scope: { deviceId?: string; projectId?: string } = {}, +) { + if (isHighestAdmin(session)) return true; + return state.accountSkillGrants.some((grant) => { + if (grant.account !== session.account || isExpired(grant.expiresAt)) return false; + if (grant.skillId !== skillId) return false; + if (!permissionSetIncludes(grant.permissions, "skill.use")) return false; + if (grant.deviceId && scope.deviceId && grant.deviceId !== scope.deviceId) return false; + if (grant.projectId && scope.projectId && grant.projectId !== scope.projectId) return false; + return true; + }); +} + +export function canViewSkill( + state: BossState, + session: PermissionSession, + skillId: string, + scope: { deviceId?: string; projectId?: string } = {}, +) { + if (isHighestAdmin(session)) return true; + return state.accountSkillGrants.some((grant) => { + if (grant.account !== session.account || isExpired(grant.expiresAt)) return false; + if (grant.skillId !== skillId) return false; + if (!grant.permissions.includes("skill.view") && !grant.permissions.includes("skill.use")) return false; + if (grant.deviceId && scope.deviceId && grant.deviceId !== scope.deviceId) return false; + if (grant.projectId && scope.projectId && grant.projectId !== scope.projectId) return false; + return true; + }); +} + +export function filterDevicesForSession(state: BossState, session: PermissionSession) { + if (isHighestAdmin(session)) return state.devices; + return state.devices.filter((device) => canAccessDevice(state, session, device.id, "device.view")); +} + +export function filterProjectsForSession(state: BossState, session: PermissionSession) { + if (isHighestAdmin(session)) return state.projects; + return state.projects.filter((project) => canAccessProject(state, session, project.id, "project.view")); +} + +export function filterProjectDevicesForSession( + state: BossState, + session: PermissionSession, + project: Project, +) { + if (isHighestAdmin(session)) { + return state.devices.filter((device) => project.deviceIds.includes(device.id)); + } + return state.devices.filter( + (device) => + project.deviceIds.includes(device.id) && + canAccessDevice(state, session, device.id, "device.view"), + ); +} + +export function assertProjectPermission( + state: BossState, + session: PermissionSession, + projectId: string, + permission: BossPermission, +) { + if (!canAccessProject(state, session, projectId, permission)) { + return { ok: false as const, status: 403 as const, message: "FORBIDDEN" }; + } + return { ok: true as const }; +} +``` + +- [ ] **Step 2: Run permission tests** + +Run: + +```bash +npx tsx --test tests/boss-permissions.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +Run: + +```bash +git add src/lib/boss-data.ts src/lib/boss-permissions.ts tests/boss-permissions.test.ts +git commit -m "feat: add boss rbac permission foundation" +``` + +### Task 3: Filter Devices, Conversations, Project Detail, and Messages + +**Files:** +- Modify: `src/lib/boss-projections.ts` +- Modify: `src/app/api/v1/devices/route.ts` +- Modify: `src/app/api/v1/conversations/route.ts` +- Modify: `src/app/api/v1/conversations/home/route.ts` +- Modify: `src/app/api/v1/conversation-folders/[folderKey]/route.ts` +- Modify: `src/app/api/v1/projects/[projectId]/route.ts` +- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts` +- Test: `tests/rbac-route-filtering.test.ts` + +- [ ] **Step 1: Write failing API filtering tests** + +Create `tests/rbac-route-filtering.test.ts`: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let data: typeof import("../src/lib/boss-data"); +let authCookie = ""; +let getDevices: typeof import("../src/app/api/v1/devices/route")["GET"]; +let getConversations: typeof import("../src/app/api/v1/conversations/route")["GET"]; +let getProject: typeof import("../src/app/api/v1/projects/[projectId]/route")["GET"]; +let getMessages: typeof import("../src/app/api/v1/projects/[projectId]/messages/route")["GET"]; +let postMessages: typeof import("../src/app/api/v1/projects/[projectId]/messages/route")["POST"]; +let baseState: Awaited>; + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-route-filtering-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + const [dataModule, authModule, devicesRoute, conversationsRoute, projectRoute, messagesRoute] = + await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + import("../src/app/api/v1/devices/route.ts"), + import("../src/app/api/v1/conversations/route.ts"), + import("../src/app/api/v1/projects/[projectId]/route.ts"), + import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), + ]); + data = dataModule; + authCookie = authModule.AUTH_SESSION_COOKIE; + getDevices = devicesRoute.GET; + getConversations = conversationsRoute.GET; + getProject = projectRoute.GET; + getMessages = messagesRoute.GET; + postMessages = messagesRoute.POST; + baseState = structuredClone(await data.readState()); +} + +test.after(async () => { + if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true }); +}); + +test.beforeEach(async () => { + await setup(); + const state = structuredClone(baseState); + const existingProjectIds = new Set(state.projects.map((project) => project.id)); + if (!existingProjectIds.has("cloud-only-project")) { + state.projects.push({ + id: "cloud-only-project", + name: "Cloud Only Project", + pinned: false, + systemPinned: false, + deviceIds: ["cloud-backup"], + preview: "只绑定未授权设备的项目。", + updatedAt: "2026-04-26T12:00:00+08:00", + lastMessageAt: "2026-04-26T12:00:00+08:00", + isGroup: false, + threadMeta: { + projectId: "cloud-only-project", + threadId: "thread-cloud-only", + threadDisplayName: "Cloud Only", + folderName: "Cloud", + activityIconCount: 0, + updatedAt: "2026-04-26T12:00:00+08:00", + codexThreadRef: "thread-cloud-only", + codexFolderRef: "cloud", + }, + groupMembers: [], + createdByAgent: true, + collaborationMode: "development", + approvalState: "not_required", + unreadCount: 0, + riskLevel: "low", + messages: [ + { + id: "cloud-only-message", + sender: "assistant", + senderLabel: "Codex", + body: "这个项目不应该被 worker 看到。", + sentAt: "2026-04-26T12:00:00+08:00", + kind: "text", + }, + ], + goals: [], + versions: [], + }); + } + await data.writeState({ + ...state, + accountDeviceGrants: [ + { + grantId: "grant-worker-mac-view", + account: "worker@example.com", + deviceId: "mac-studio", + permissions: ["device.view"], + grantedBy: "17600003315", + grantedAt: "2026-04-26T12:00:00+08:00", + }, + ], + accountProjectGrants: [], + accountSkillGrants: [], + skillCatalog: [], + permissionAuditLogs: [], + }); +}); + +async function authedRequest(account: string, role: "member" | "admin" | "highest_admin", url: string, init: RequestInit = {}) { + const session = await data.createAuthSession({ + account, + role, + displayName: account, + loginMethod: "password", + }); + return new NextRequest(url, { + ...init, + headers: { + ...(init.headers ?? {}), + cookie: `${authCookie}=${session.sessionToken}`, + }, + }); +} + +test("device list only includes devices visible to member", async () => { + const response = await getDevices(await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/devices")); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.devices.map((device: { id: string }) => device.id), ["mac-studio"]); +}); + +test("conversation list only includes projects visible to member", async () => { + const response = await getConversations(await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/conversations")); + assert.equal(response.status, 200); + const body = await response.json(); + assert.ok(body.conversations.some((item: { projectId: string }) => item.projectId === "master-agent")); + assert.equal(body.conversations.some((item: { projectId: string }) => item.projectId === "cloud-backup"), false); + assert.equal(body.conversations.some((item: { projectId: string }) => item.projectId === "cloud-only-project"), false); +}); + +test("project detail returns 403 when member lacks project view", async () => { + const response = await getProject( + await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/cloud-only-project"), + { params: Promise.resolve({ projectId: "cloud-only-project" }) }, + ); + assert.equal(response.status, 403); +}); + +test("messages GET requires project view", async () => { + const response = await getMessages( + await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/audit-collab/messages"), + { params: Promise.resolve({ projectId: "audit-collab" }) }, + ); + assert.equal(response.status, 200); +}); + +test("messages POST requires explicit thread.chat or master_agent.ask", async () => { + const denied = await postMessages( + await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/master-agent/messages", { + method: "POST", + body: JSON.stringify({ body: "总结一下当前进度" }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(denied.status, 403); + + const state = await data.readState(); + state.accountProjectGrants.push({ + grantId: "grant-master-chat", + account: "worker@example.com", + projectId: "master-agent", + permissions: ["project.view", "thread.chat", "master_agent.ask"], + grantedBy: "17600003315", + grantedAt: "2026-04-26T12:30:00+08:00", + }); + await data.writeState(state); + + const allowed = await postMessages( + await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/projects/master-agent/messages", { + method: "POST", + body: JSON.stringify({ body: "总结一下当前进度" }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.notEqual(allowed.status, 403); +}); +``` + +- [ ] **Step 2: Run route filtering tests and verify failure** + +Run: + +```bash +npx tsx --test tests/rbac-route-filtering.test.ts +``` + +Expected: fail because API routes do not call permission filters. + +- [ ] **Step 3: Add session-aware projection helpers** + +In `src/lib/boss-projections.ts`, import: + +```ts +import type { AuthSession } from "@/lib/boss-data"; +import { + canAccessProject, + filterDevicesForSession, + filterProjectDevicesForSession, + filterProjectsForSession, +} from "@/lib/boss-permissions"; +``` + +Add: + +```ts +export function getConversationItemsForSession(state: BossState, session: Pick): ConversationItem[] { + const visibleProjectIds = new Set(filterProjectsForSession(state, session).map((project) => project.id)); + return getConversationItems({ + ...state, + projects: state.projects.filter((project) => visibleProjectIds.has(project.id)), + }); +} + +export function getConversationHomeItemsForSession(state: BossState, session: Pick): ConversationItem[] { + const visibleProjectIds = new Set(filterProjectsForSession(state, session).map((project) => project.id)); + return getConversationHomeItems({ + ...state, + projects: state.projects.filter((project) => visibleProjectIds.has(project.id)), + }); +} + +export function getProjectDetailViewForSession( + state: BossState, + projectId: string, + session: Pick, +): ProjectDetailView | null { + if (!canAccessProject(state, session, projectId, "project.view")) return null; + const detail = getProjectDetailView(state, projectId, session.account); + if (!detail) return null; + return { + ...detail, + devices: filterProjectDevicesForSession(state, session, detail.project), + }; +} +``` + +- [ ] **Step 4: Update device and conversation routes** + +In `src/app/api/v1/devices/route.ts`, replace raw state return with: + +```ts +const visibleDevices = filterDevicesForSession(state, session); +return jsonNoStore({ + ok: true, + devices: visibleDevices, + enrollments: session.role === "highest_admin" + ? state.deviceEnrollments + : state.deviceEnrollments.filter((item) => visibleDevices.some((device) => device.id === item.deviceId)), + workspace: getDeviceWorkspaceViewForSession(state, session, deviceId ?? undefined), +}); +``` + +In `src/app/api/v1/conversations/route.ts`, return: + +```ts +conversations: getConversationItemsForSession(state, session), +``` + +Apply the same pattern to `src/app/api/v1/conversations/home/route.ts` and `src/app/api/v1/conversation-folders/[folderKey]/route.ts`. + +In `src/app/api/state/route.ts`, return a session-filtered state snapshot instead of raw `state`: + +```ts +const visibleDevices = filterDevicesForSession(state, session); +const visibleProjects = filterProjectsForSession(state, session); +const visibleDeviceIds = new Set(visibleDevices.map((device) => device.id)); +const visibleProjectIds = new Set(visibleProjects.map((project) => project.id)); +return NextResponse.json({ + ...state, + devices: visibleDevices, + projects: visibleProjects, + deviceSkills: state.deviceSkills.filter((skill) => visibleDeviceIds.has(skill.deviceId)), + threadStatusDocuments: state.threadStatusDocuments.filter((doc) => visibleProjectIds.has(doc.projectId)), + threadProgressEvents: state.threadProgressEvents.filter((event) => visibleProjectIds.has(event.projectId)), + aiAccounts: state.aiAccounts.map(({ apiKey, ...account }) => account), +}); +``` + +- [ ] **Step 5: Update project detail and messages routes** + +In `src/app/api/v1/projects/[projectId]/route.ts`, replace `getProjectDetailView` with `getProjectDetailViewForSession`. Return 403 when project exists but is unauthorized: + +```ts +const projectExists = state.projects.some((project) => project.id === projectId); +const detail = getProjectDetailViewForSession(state, projectId, session); +if (!detail) { + return jsonNoStore( + { ok: false, message: projectExists ? "FORBIDDEN" : "PROJECT_NOT_FOUND" }, + { status: projectExists ? 403 : 404 }, + ); +} +``` + +In `src/app/api/v1/projects/[projectId]/messages/route.ts`: + +- `GET` requires `project.view`. +- `POST` requires `thread.chat` for regular thread messages. +- `POST` with main Agent mention requires `master_agent.ask`. +- takeover enable/disable requires `master_agent.takeover`. +- dispatch or computer-control paths require `computer.control` when they create execution tasks. + +Add a helper inside the route: + +```ts +function forbiddenResponse(message = "FORBIDDEN") { + return NextResponse.json({ ok: false, message }, { status: 403 }); +} +``` + +Then guard before appending user messages: + +```ts +if (!canAccessProject(state, session, projectId, "project.view")) { + return forbiddenResponse(); +} +if (masterAgentMention) { + if (!canAccessProject(state, session, projectId, "master_agent.ask")) { + return forbiddenResponse("MASTER_AGENT_FORBIDDEN"); + } +} else if (!canAccessProject(state, session, projectId, "thread.chat")) { + return forbiddenResponse("THREAD_CHAT_FORBIDDEN"); +} +``` + +- [ ] **Step 6: Run route filtering tests** + +Run: + +```bash +npx tsx --test tests/rbac-route-filtering.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 7: Run existing route tests touched by the change** + +Run: + +```bash +npx tsx --test tests/project-messages-route.test.ts tests/project-message-delete.test.ts tests/conversation-home-items.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add src/lib/boss-projections.ts src/app/api/state/route.ts src/app/api/v1/devices/route.ts src/app/api/v1/conversations/route.ts src/app/api/v1/conversations/home/route.ts 'src/app/api/v1/conversation-folders/[folderKey]/route.ts' 'src/app/api/v1/projects/[projectId]/route.ts' 'src/app/api/v1/projects/[projectId]/messages/route.ts' tests/rbac-route-filtering.test.ts +git commit -m "feat: filter boss api responses by rbac grants" +``` + +### Task 4: Filter Skill Visibility and Device Write Authorization + +**Files:** +- Modify: `src/app/api/v1/devices/[deviceId]/skills/route.ts` +- Modify: `src/lib/boss-device-auth.ts` +- Modify: `src/lib/boss-attachment-access.ts` +- Modify: `src/lib/boss-projections.ts` +- Test: `tests/rbac-route-filtering.test.ts` + +- [ ] **Step 1: Add failing Skill visibility tests** + +Append to `tests/rbac-route-filtering.test.ts`: + +```ts +test("device skills route only returns skills granted to member", async () => { + const state = await data.readState(); + state.deviceSkills = [ + { + skillId: "mac-studio:boss-server-debug", + deviceId: "mac-studio", + name: "boss-server-debug", + description: "服务器调试", + path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md", + invocation: "$boss-server-debug", + category: "Mac Studio", + updatedAt: "2026-04-26T12:00:00+08:00", + }, + { + skillId: "mac-studio:gitea-version-upload", + deviceId: "mac-studio", + name: "gitea-version-upload", + description: "Gitea 上传", + path: "/Users/kris/.codex/skills/gitea-version-upload/SKILL.md", + invocation: "$gitea-version-upload", + category: "Mac Studio", + updatedAt: "2026-04-26T12:00:00+08:00", + }, + ]; + state.accountSkillGrants = [ + { + grantId: "grant-skill-view", + account: "worker@example.com", + skillId: "mac-studio:boss-server-debug", + deviceId: "mac-studio", + permissions: ["skill.view", "skill.use"], + grantedBy: "17600003315", + grantedAt: "2026-04-26T12:30:00+08:00", + }, + ]; + await data.writeState(state); + + const route = await import("../src/app/api/v1/devices/[deviceId]/skills/route.ts"); + const response = await route.GET( + await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skills"), + { params: Promise.resolve({ deviceId: "mac-studio" }) }, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.skills.map((skill: { skillId: string }) => skill.skillId), ["mac-studio:boss-server-debug"]); +}); +``` + +- [ ] **Step 2: Run the route filtering test and verify failure** + +Run: + +```bash +npx tsx --test tests/rbac-route-filtering.test.ts +``` + +Expected: fail because the Skill route currently returns all device skills. + +- [ ] **Step 3: Filter `GET /devices/[deviceId]/skills`** + +In `src/app/api/v1/devices/[deviceId]/skills/route.ts`, import: + +```ts +import { canAccessDevice, canViewSkill } from "@/lib/boss-permissions"; +``` + +Then guard: + +```ts +if (!canAccessDevice(state, session, deviceId, "skill.view") && !canAccessDevice(state, session, deviceId, "device.view")) { + return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 }); +} + +const allDeviceSkills = state.deviceSkills.filter((item) => item.deviceId === deviceId); +const visibleSkills = session.role === "highest_admin" + ? allDeviceSkills + : allDeviceSkills.filter((skill) => + canViewSkill(state, session, skill.skillId, { deviceId }), + ); +``` + +Return `visibleSkills`. + +- [ ] **Step 4: Update device write auth** + +In `src/lib/boss-device-auth.ts`, replace session allow logic with: + +```ts +import { canAccessDevice } from "@/lib/boss-permissions"; + +const state = await readState(); +if (device && session && canAccessDevice(state, session, deviceId, "device.manage")) { + return { ok: true as const, device, principal: "session" as const }; +} +``` + +Keep device-token authorization unchanged so local-agent can still heartbeat and upload skills. + +- [ ] **Step 5: Update attachment project access** + +In `src/lib/boss-attachment-access.ts`, replace owned-device logic with: + +```ts +import { canAccessProject } from "@/lib/boss-permissions"; + +export function canSessionAccessAttachmentProject( + state: BossState, + session: Pick, + project: Pick, +) { + return canAccessProject(state, session, project.id, "project.view"); +} +``` + +If callers pass a `Project` type that does not include `id`, update the caller to pass the full project object. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +npx tsx --test tests/rbac-route-filtering.test.ts tests/project-messages-route.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add 'src/app/api/v1/devices/[deviceId]/skills/route.ts' src/lib/boss-device-auth.ts src/lib/boss-attachment-access.ts src/lib/boss-projections.ts tests/rbac-route-filtering.test.ts +git commit -m "feat: enforce rbac for device skills and writes" +``` + +### Task 5: Scope Main Agent Context and Task Authorization + +**Files:** +- Modify: `src/lib/boss-master-agent.ts` +- Modify: `src/lib/boss-data.ts` +- Modify: `src/app/api/v1/master-agent/tasks/claim/route.ts` +- Modify: `src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts` +- Test: `tests/rbac-master-agent-scope.test.ts` + +- [ ] **Step 1: Write failing main Agent scope tests** + +Create `tests/rbac-master-agent-scope.test.ts`: + +```ts +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; + +let runtimeRoot = ""; +let data: typeof import("../src/lib/boss-data"); +let master: typeof import("../src/lib/boss-master-agent"); + +async function setup() { + if (runtimeRoot) return; + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-master-agent-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + data = await import("../src/lib/boss-data.ts"); + master = await import("../src/lib/boss-master-agent.ts"); +} + +test.after(async () => { + if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true }); +}); + +test.beforeEach(async () => { + await setup(); + const state = await data.readState(); + state.accountDeviceGrants = [ + { + grantId: "grant-worker-mac-view", + account: "worker@example.com", + deviceId: "mac-studio", + permissions: ["device.view"], + grantedBy: "17600003315", + grantedAt: "2026-04-26T12:00:00+08:00", + }, + ]; + await data.writeState(state); +}); + +test("authorized master agent scope excludes unauthorized devices and projects", async () => { + const state = await data.readState(); + const scope = master.buildAuthorizedMasterAgentScopeForTest(state, { + account: "worker@example.com", + role: "member", + displayName: "Worker", + }); + + assert.equal(scope.devices.some((device: { id: string }) => device.id === "mac-studio"), true); + assert.equal(scope.devices.some((device: { id: string }) => device.id === "cloud-backup"), false); + assert.equal(scope.projects.every((project: { deviceIds: string[] }) => project.deviceIds.includes("mac-studio")), true); +}); +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: + +```bash +npx tsx --test tests/rbac-master-agent-scope.test.ts +``` + +Expected: fail because `buildAuthorizedMasterAgentScopeForTest` does not exist. + +- [ ] **Step 3: Add authorized scope builder** + +In `src/lib/boss-master-agent.ts`, import: + +```ts +import type { AuthSession } from "@/lib/boss-data"; +import { filterDevicesForSession, filterProjectsForSession } from "@/lib/boss-permissions"; +``` + +Add: + +```ts +function buildAuthorizedMasterAgentScope( + state: BossState, + session: Pick, +) { + const devices = filterDevicesForSession(state, session); + const deviceIds = new Set(devices.map((device) => device.id)); + const projects = filterProjectsForSession(state, session).filter( + (project) => + project.id === "master-agent" || + project.deviceIds.some((deviceId) => deviceIds.has(deviceId)) || + project.groupMembers.some((member) => deviceIds.has(member.deviceId)), + ); + const projectIds = new Set(projects.map((project) => project.id)); + return { + devices, + projects, + threadStatusDocuments: state.threadStatusDocuments.filter((doc) => projectIds.has(doc.projectId)), + threadProgressEvents: state.threadProgressEvents.filter((event) => projectIds.has(event.projectId)), + deviceSkills: state.deviceSkills.filter((skill) => deviceIds.has(skill.deviceId)), + }; +} + +export const buildAuthorizedMasterAgentScopeForTest = + process.env.NODE_ENV === "test" ? buildAuthorizedMasterAgentScope : buildAuthorizedMasterAgentScope; +``` + +Use this scope anywhere main Agent prompt context currently reads raw `state.devices`, `state.projects`, `state.threadStatusDocuments`, `state.threadProgressEvents`, or `state.deviceSkills`. + +- [ ] **Step 4: Persist requester identity into tasks** + +In `src/lib/boss-data.ts`, extend `MasterAgentTask` with: + +```ts +requestedByAccount?: string; +authorizedDeviceIds?: string[]; +authorizedProjectIds?: string[]; +authorizedSkillIds?: string[]; +requiredPermissions?: BossPermission[]; +``` + +When queueing tasks from user-originated messages, set: + +```ts +requestedByAccount: session.account, +authorizedDeviceIds: scope.devices.map((device) => device.id), +authorizedProjectIds: scope.projects.map((project) => project.id), +authorizedSkillIds: scope.deviceSkills.map((skill) => skill.skillId), +requiredPermissions: ["thread.chat"], +``` + +- [ ] **Step 5: Add task claim authorization guard** + +In `src/app/api/v1/master-agent/tasks/claim/route.ts`, after a task is selected: + +```ts +if (task.authorizedDeviceIds?.length && !task.authorizedDeviceIds.includes(deviceId)) { + await appendPermissionAuditLog({ + actorAccount: task.requestedByAccount ?? "unknown", + action: "task.denied", + deviceId, + projectId: task.projectId, + detail: "Device attempted to claim a task outside authorizedDeviceIds.", + }); + return NextResponse.json({ ok: false, message: "TASK_DEVICE_FORBIDDEN" }, { status: 403 }); +} +``` + +If existing claim code picks tasks before knowing deviceId, move the filter into task selection so unauthorized tasks are not claimed. + +- [ ] **Step 6: Run main Agent scope test** + +Run: + +```bash +npx tsx --test tests/rbac-master-agent-scope.test.ts +``` + +Expected: pass. + +- [ ] **Step 7: Run main Agent related tests** + +Run: + +```bash +npx tsx --test tests/master-agent-chat-controls.test.ts tests/master-agent-thread-status-prompt.test.ts tests/master-agent-message-queue.test.ts tests/rbac-master-agent-scope.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add src/lib/boss-master-agent.ts src/lib/boss-data.ts src/app/api/v1/master-agent/tasks/claim/route.ts src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts tests/rbac-master-agent-scope.test.ts +git commit -m "feat: scope master agent context by rbac grants" +``` + +### Task 6: Add Minimal Account and Permission Visibility in Android + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java` +- Modify: `android/app/src/main/java/com/hyzq/boss/MainActivity.java` +- Test: `android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java` + +- [ ] **Step 1: Write failing Android UI visibility tests** + +Create `android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java`: + +```java +package com.hyzq.boss; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.json.JSONObject; +import org.junit.Test; + +public class BossRbacVisibilityTest { + @Test + public void meMenuShowsAccessManagementForHighestAdminOnly() throws Exception { + JSONObject highestAdmin = new JSONObject() + .put("account", "17600003315") + .put("role", "highest_admin"); + JSONObject member = new JSONObject() + .put("account", "worker@example.com") + .put("role", "member"); + + assertTrue(WechatSurfaceMapper.meMenuContainsAccessManagement(highestAdmin)); + assertFalse(WechatSurfaceMapper.meMenuContainsAccessManagement(member)); + } + + @Test + public void unauthorizedMessageLabelIsReadable() { + assertTrue(WechatSurfaceMapper.permissionDeniedMessage("THREAD_CHAT_FORBIDDEN").contains("没有权限")); + assertTrue(WechatSurfaceMapper.permissionDeniedMessage("MASTER_AGENT_FORBIDDEN").contains("主 Agent")); + } +} +``` + +- [ ] **Step 2: Run Android test and verify failure** + +Run: + +```bash +cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest" +``` + +Expected: fail because the helper methods do not exist. + +- [ ] **Step 3: Add mapper helpers** + +In `WechatSurfaceMapper.java`, add: + +```java +public static boolean meMenuContainsAccessManagement(JSONObject session) { + return session != null && "highest_admin".equals(session.optString("role", "")); +} + +public static String permissionDeniedMessage(String code) { + if ("THREAD_CHAT_FORBIDDEN".equals(code)) { + return "当前账号没有权限和这个线程对话。"; + } + if ("MASTER_AGENT_FORBIDDEN".equals(code)) { + return "当前账号没有权限在这个会话里使用主 Agent。"; + } + if ("COMPUTER_CONTROL_FORBIDDEN".equals(code)) { + return "当前账号没有权限控制这台电脑。"; + } + return "当前账号没有权限执行这个操作。"; +} +``` + +When building the Me root menu, add `账号与权限` only if `meMenuContainsAccessManagement(sessionJson)` is true. + +- [ ] **Step 4: Route access management entry** + +In `MainActivity.java`, handle the new menu key: + +```java +case "access_management": + Toast.makeText(this, "账号与权限管理正在接入 Web 管理端", Toast.LENGTH_SHORT).show(); + break; +``` + +This first-stage Android entry is intentionally shallow; full native management UI is a later phase. + +- [ ] **Step 5: Run Android test** + +Run: + +```bash +cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest" +``` + +Expected: pass. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java android/app/src/main/java/com/hyzq/boss/MainActivity.java android/app/src/test/java/com/hyzq/boss/BossRbacVisibilityTest.java +git commit -m "feat: add android rbac visibility affordances" +``` + +### Task 7: Documentation and Full Regression + +**Files:** +- Modify: `README.md` +- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md` +- Modify: `docs/architecture/api_and_service_inventory_cn.md` + +- [ ] **Step 1: Update docs** + +Add a short runtime note to `README.md`: + +```md +### 多用户权限第一阶段 + +- Boss 当前已经引入第一阶段 RBAC:设备、会话、项目、消息和 Skill 读取会按登录账号授权范围过滤。 +- `highest_admin` 保持全局可见;子账号默认只能看到自己拥有或被授权的设备。 +- 设备授权默认继承项目只读可见;线程聊天、主 Agent 接管、电脑控制和 Skill 使用必须显式授权。 +- SkillHub 暂不作为权限源,Boss 自己负责最终授权,Gitea/SkillHub 只作为 Skill 来源。 +``` + +Add the same operational facts to `docs/architecture/current_runtime_and_deploy_status_cn.md` and endpoint inventory to `docs/architecture/api_and_service_inventory_cn.md`. + +- [ ] **Step 2: Run focused TypeScript tests** + +Run: + +```bash +npx tsx --test tests/boss-permissions.test.ts tests/rbac-route-filtering.test.ts tests/rbac-master-agent-scope.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 3: Run existing impacted tests** + +Run: + +```bash +npx tsx --test tests/project-messages-route.test.ts tests/project-message-delete.test.ts tests/conversation-home-items.test.ts tests/master-agent-message-queue.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 4: Run lint and build** + +Run: + +```bash +npm run lint +npm run build +``` + +Expected: both commands exit 0. If `npm run build` prints the known Turbopack warning for `src/lib/boss-mail.ts`, keep it as a known warning only if the build exits 0. + +- [ ] **Step 5: Run Android targeted and full unit tests** + +Run: + +```bash +cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest" +cd android && ./gradlew testDebugUnitTest +``` + +Expected: targeted test passes, then full Android unit tests pass. + +- [ ] **Step 6: Build debug APK** + +Run: + +```bash +npm run apk:debug +``` + +Expected: publishes `public/downloads/boss-android-latest.apk` and updates `public/downloads/boss-android-latest.json`. + +- [ ] **Step 7: Deploy server** + +Run: + +```bash +./scripts/deploy-server.sh +curl -fsS https://boss.hyzq.net/api/health +``` + +Expected: deploy exits 0 and health returns `{"ok":true,"service":"boss-web",...}`. + +- [ ] **Step 8: Install to connected test phone if available** + +Run: + +```bash +adb devices -l +device_serial="$(adb devices | awk 'NR>1 && $2=="device"{print $1; exit}')" +test -n "$device_serial" +adb -s "$device_serial" install -r public/downloads/boss-android-latest.apk +adb -s "$device_serial" shell monkey -p com.hyzq.boss -c android.intent.category.LAUNCHER 1 +adb -s "$device_serial" logcat -d -t 500 | rg -i "FATAL EXCEPTION|AndroidRuntime|com\\.hyzq\\.boss" || true +``` + +Expected: install succeeds, app starts, and no Boss crash appears in the recent logcat scan. + +- [ ] **Step 9: Commit docs and release metadata** + +Run: + +```bash +git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md public/downloads/boss-android-latest.apk public/downloads/boss-android-latest.json public/downloads/boss-android-v2.5.11-debug.apk +git commit -m "docs: record boss rbac foundation runtime" +``` + +## Self-Review Checklist + +- Spec coverage: This plan covers phase one only: permission data, central checks, API filtering, main Agent scope, minimal Android affordance, docs, build, deploy, and APK install. +- Deferred scope: account management UI, full Skill assignment UI, Gitea Skill sync, and SkillHub adapter are intentionally deferred to later phases. +- Permission boundary: `device.view` gives read visibility to projects; `thread.chat`, `master_agent.takeover`, `computer.control`, and `skill.use` remain explicit grants. +- Compatibility: `device.account === session.account` remains a read-only fallback for existing users until explicit grants are stable. +- No placeholders: Every task names exact files, commands, expected results, and concrete code shape.