Files
boss/docs/superpowers/plans/2026-04-26-multi-user-rbac-foundation.md
2026-04-26 18:50:42 +08:00

50 KiB
Raw Permalink Blame History

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:

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:

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:

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:

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:

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:

const validBossPermissions = new Set<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",
]);

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:

export async function saveAccountDeviceGrant(input: Omit<AccountDeviceGrant, "grantId" | "grantedAt"> & { 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:

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:

import type {
  AuthSession,
  BossPermission,
  BossState,
  Device,
  Project,
} from "@/lib/boss-data";

export type PermissionSession = Pick<AuthSession, "account" | "role" | "displayName">;

function isExpired(expiresAt?: string) {
  return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
}

export function isHighestAdmin(session: Pick<PermissionSession, "role">) {
  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:

npx tsx --test tests/boss-permissions.test.ts

Expected: all tests pass.

  • Step 3: Commit

Run:

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:

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<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;

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:

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:

import type { AuthSession } from "@/lib/boss-data";
import {
  canAccessProject,
  filterDevicesForSession,
  filterProjectDevicesForSession,
  filterProjectsForSession,
} from "@/lib/boss-permissions";

Add:

export function getConversationItemsForSession(state: BossState, session: Pick<AuthSession, "account" | "role" | "displayName">): 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<AuthSession, "account" | "role" | "displayName">): 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<AuthSession, "account" | "role" | "displayName">,
): 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:

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:

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:

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:

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:

function forbiddenResponse(message = "FORBIDDEN") {
  return NextResponse.json({ ok: false, message }, { status: 403 });
}

Then guard before appending user messages:

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:

npx tsx --test tests/rbac-route-filtering.test.ts

Expected: all tests pass.

  • Step 7: Run existing route tests touched by the change

Run:

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:

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:

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:

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:

import { canAccessDevice, canViewSkill } from "@/lib/boss-permissions";

Then guard:

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:

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:

import { canAccessProject } from "@/lib/boss-permissions";

export function canSessionAccessAttachmentProject(
  state: BossState,
  session: Pick<AuthSession, "account" | "role" | "displayName">,
  project: Pick<Project, "id" | "deviceIds" | "groupMembers">,
) {
  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:

npx tsx --test tests/rbac-route-filtering.test.ts tests/project-messages-route.test.ts

Expected: all tests pass.

  • Step 7: Commit

Run:

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:

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:

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:

import type { AuthSession } from "@/lib/boss-data";
import { filterDevicesForSession, filterProjectsForSession } from "@/lib/boss-permissions";

Add:

function buildAuthorizedMasterAgentScope(
  state: BossState,
  session: Pick<AuthSession, "account" | "role" | "displayName">,
) {
  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:

requestedByAccount?: string;
authorizedDeviceIds?: string[];
authorizedProjectIds?: string[];
authorizedSkillIds?: string[];
requiredPermissions?: BossPermission[];

When queueing tasks from user-originated messages, set:

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:

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:

npx tsx --test tests/rbac-master-agent-scope.test.ts

Expected: pass.

  • Step 7: Run main Agent related tests

Run:

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:

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:

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:

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:

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:

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:

cd android && ./gradlew testDebugUnitTest --tests "com.hyzq.boss.BossRbacVisibilityTest"

Expected: pass.

  • Step 6: Commit

Run:

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:

### 多用户权限第一阶段

- 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:

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:

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:

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:

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:

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:

./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:

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:

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.