# Agent Sidecar 第一阶段技术设计

创建日期：2026-09-02

状态：Draft

关联 PRD：`docs/design/2026-09-02-agent-codex-workspace-sidecar-prd.md`

实现阶段：第一阶段——Sidecar、内部合约、镜像与 dev 单实例部署

## 1. 目标

第一阶段交付一个可独立构建和部署的 `agent-sidecar` Go 服务。服务与
`agent-codex` 位于同一 Pod，共享 Projects 和 CODEX_HOME PVC，并通过内部 gRPC 提供
Project、Global、文件和目录管理能力。

`agent-sidecar` 是可继续扩展其他能力的通用 Sidecar。第一阶段只实现 `project`
Service，不把文件管理能力固化为 Sidecar 自身的唯一职责。

第一阶段完成后，应能使用生成的 gRPC Client 或 `grpcurl` 在 dev 测试实例上完成：

1. 创建并 Git 初始化 Project；
2. 列举和永久删除 Project；
3. 管理 Project 下的普通文件与目录；
4. 管理 `$CODEX_HOME/skills/**` 和 `$CODEX_HOME/AGENTS.md`；
5. 验证 denylist、Global allowlist、符号链接隔离、revision 冲突和原子保存；
6. 构建并部署独立的 `agent-sidecar` 镜像。

本阶段只定义 Sidecar 自身的内部 gRPC contract。面向管理端的上游 contract、实例路由、
认证、审计和 Portal 接入统一在第二阶段设计，本阶段不预先固定其 API 形态。

## 2. 范围

### 2.1 本阶段范围

- 新增 `packages/contracts/agent-sidecar`，保存 Sidecar 内部 gRPC contract；
- 定义内部 gRPC `ProjectService`；
- 生成 Go message 和 gRPC Client/Server；
- 新增独立 Go module `services/agent-sidecar`；
- 代码框架参考 `services/agent-gateway`，使用 `app + deps + services` 分层；
- 将 Project 管理作为 `internal/services/project` 下的一个 Service 实现；
- 在 Project Service 内继续拆分 `filesystem`、`git` 等内核模块；
- 实现配置加载、依赖装配、生命周期管理、gRPC Handler 和安全文件系统访问；
- 实现 Project、Global、文件操作、分页、revision、错误映射和日志；
- 新增 `infra/images/agent-sidecar/Dockerfile`、构建目标和 CI；
- 在 dev 的现有 `agent-codex` Pod 增加 Sidecar container，并挂载相同两个 PVC；
- 使用真实临时目录和真实 Git binary 完成集成测试。

### 2.2 不在本阶段

- 任何上游管理 API、HTTP/OpenAPI、grpc-gateway 或 SDK contract；
- Agent 实例注册、实例选择、请求路由、管理员认证和权威审计；
- Management Portal 页面；
- 多 Agent 实例的运行时验证；
- Sidecar token、mTLS 或其他 RPC 身份认证；
- Task 状态查询、文件锁协调或 Agent app-server 改造；
- staging、prod 部署；
- ProjectBundle、Git clone/pull/push/commit/branch/remote；
- 二进制上传下载、全文搜索、文件系统监听、回收站和持久化数据库。

## 3. 术语与核心技术决策

### 3.1 统一使用 Project 语义

本文不再把可管理文件根称为 Workspace，原因如下：

- 产品中的一级业务对象已经是 Project；
- Codex 自身也会使用 workspace 表达工作目录或执行上下文；
- 再引入 Workspace 作为 `PROJECT | GLOBAL` 的抽象会使 Project、Global 和执行工作区三个
  概念互相混淆；
- 第一阶段实际实现的是 Project 管理 Service，而不是通用 Workspace 生命周期系统。

因此采用以下术语：

| 术语 | 含义 |
| --- | --- |
| Project | `/data/projects` 下的一个一级目录 |
| Global | `$CODEX_HOME` 中受 allowlist 限制的管理视图 |
| Project Service | Sidecar 中负责 Project 生命周期以及 Project/Global 文件操作的 Service |
| `ProjectRef` | 文件操作的目标引用，区分普通 Project 与 Global 视图 |
| relative path | 相对于 `ProjectRef` 所指逻辑根的路径 |

`Global` 不是一个真实 Project，不出现在 `ListProjects` 中，也不能执行 Project 创建或删除。
`ProjectRef` 只是文件操作的统一路由结构，不表示 Global 被持久化为 Project。

### 3.2 通用 Sidecar 与分层框架

- binary、container、image 和 module 均命名为 `agent-sidecar`；
- Project Service 配置放在 `services.project`，不在配置中引入 Sidecar 或 Workspace 语义；
- 代码结构参考 `agent-gateway`，由 `internal/deps` 使用 `go.uber.org/dig` 装配依赖；
- `internal/app` 只负责进程入口层和 gRPC Handler 注册；
- `internal/services` 持有可独立扩展的业务 Service；
- 第一阶段只注册 `project` Service，后续能力以新的同级 Service 加入；
- Sidecar 不放入 `agent-gateway` 或 `agent-codex` 镜像，保持独立构建和发布。

### 3.3 只提供内部 gRPC

- Sidecar binary 只启动 gRPC server，默认监听 `:9091`；
- 不启动 HTTP、grpc-gateway 或外部 Ingress；
- 使用共享 `rpcserver` 注册标准 gRPC Health 和 reflection；
- Pod 所在内部网络视为可信，不验证 token；
- request ID 和 operator metadata 只作为可选日志上下文，不作为授权依据；
- server 和 middleware 不记录请求或响应 payload，避免记录文件正文。

### 3.4 Linux 目录 FD 是安全边界

Sidecar 运行环境固定为 Linux。实现不使用“拼接绝对路径后调用 `os.*`”作为安全边界，
而是在启动时打开 Projects Root 和 CODEX_HOME Root 的目录 FD，后续操作使用
`openat2`、`openat`、`mkdirat`、`renameat2`、`unlinkat` 等相对 FD syscall。

打开既有路径时统一使用：

```text
RESOLVE_BENEATH
RESOLVE_NO_SYMLINKS
RESOLVE_NO_MAGICLINKS
```

这保证路径不能逃逸逻辑根，任一路径 segment 中的符号链接都会失败，并避免先 `Lstat`
再 `Open` 的主要 TOCTOU 窗口。

服务不提供非 Linux fallback。内核不支持所需能力时启动失败，不降级到字符串路径实现。

### 3.5 单写锁与外部写入边界

- Project Service 持有一个进程级 `sync.RWMutex`；
- List/Get 使用读锁，所有 mutation 和 Create/Delete Project 使用写锁；
- 锁只序列化通过本 Sidecar 的请求，不协调 Agent Codex 进程；
- revision 防止两个 Sidecar 客户端基于同一旧版本静默覆盖；
- 非协作进程仍可能在最终 revision 检查与 rename 之间写入同一文件。实现把最终检查放在
  rename 前，但不承诺跨进程文件系统 CAS。

### 3.6 无业务数据库和状态文件

- Project、Entry、revision 和 cursor 全部从当前文件系统计算；
- Sidecar 不创建业务数据库、Project metadata 或 applied state；
- 只允许使用保留前缀的短生命周期临时文件完成原子保存；
- Sidecar 重启后无需恢复业务状态。

## 4. 代码与合约布局

### 4.1 目录结构

```text
packages/contracts/agent-sidecar/
  api/agent-sidecar/project/v1/project_service.proto
  entity/agent-sidecar/project/v1/project.proto
  buf.yaml
  buf.gen.yaml

packages/sdk-go/contracts/
  api/agent-sidecar/project/v1/...
  entity/agent-sidecar/project/v1/...

services/agent-sidecar/
  cmd/agent-sidecar/main.go
  cmd/agent-sidecar/run.go
  config/root_config.go
  internal/app/app.go
  internal/app/handlers/project/handler.go
  internal/app/handlers/project/handler_test.go
  internal/deps/deps.go
  internal/services/service.go
  internal/services/project/api/service.go
  internal/services/project/api/service_mock.go
  internal/services/project/service.go
  internal/services/project/service_test.go
  internal/services/project/filesystem/root.go
  internal/services/project/filesystem/path.go
  internal/services/project/filesystem/entry.go
  internal/services/project/filesystem/remove.go
  internal/services/project/filesystem/revision.go
  internal/services/project/filesystem/cursor.go
  internal/services/project/filesystem/*_test.go
  internal/services/project/git/initializer.go
  internal/services/project/git/initializer_mock.go
  internal/services/project/git/initializer_test.go
  Makefile
  go.mod
  go.sum

infra/images/agent-sidecar/Dockerfile
.github/workflows/ci-agent-sidecar.yml
.github/workflows/deploy-agent-sidecar-dev.yml
```

`services/agent-sidecar` 加入根 `go.work`。

### 4.2 `app + deps + services` 职责

#### `internal/app`

- `app.go` 创建和运行共享 gRPC server；
- `handlers/project` 实现 Proto Server，只做参数转换、metadata 读取和错误映射；
- Handler 依赖 `project/api.Service`，不直接依赖 filesystem 或 Git；
- Handler 通过与 `agent-gateway` 相同的 `app.HandlerResult` 注册到 `rpcHandlers` group，
  便于增加其他 Service。

#### `internal/deps`

- `Deps` 持有 `dig.Container`；
- `Init` 依次 provide context、config、logger、业务 Service、Handler、RPC server
  和 Service Manager；
- `run.go` 一次性 invoke Service Manager 和 RPC server，全部解析成功后再启动；
- 关闭时先 shutdown RPC server，再调用 `Service Manager.StopServices()`；
- Project Service 在 `Run` 退出时关闭自己持有的 filesystem Roots，`Deps` 不参与关闭；
- 不在 `cmd` 中手工 new 全部依赖。

#### `internal/services`

- `service.go` 定义与 `agent-gateway` 同型的 Service Manager；
- Manager 通过 `group:"service"` 收集实现；
- Service 复用 `packages/sdk-go/infra/service` 的 `Service`、`RunnableService` 和
  `BaseService`；
- `run.go` 启动 `Manager.RunServices()`，退出时调用 `Manager.StopServices()`；
- 第一阶段只有 `project` Service，后续功能以同级目录注册。

#### `internal/services/project`

- `api` 只定义 Handler 所依赖的业务接口，并生成 mock；
- `service.go` 负责 Project/Global policy、读写锁、操作顺序和补偿；
- `filesystem` 负责目录 FD、路径 confinement、stat、分页、revision 和安全 syscall；
- `git` 只封装参数化 `git init`，便于测试失败回滚；
- Project Service 嵌入 `service.BaseService` 并实现阻塞到 context 结束的 `Run`；
- 对外 constructor 根据 Project 配置构建 filesystem Roots 和 Git initializer；非导出的
  constructor 保留依赖注入能力，用于测试 Git 失败回滚；
- constructor 完成 Git binary 查找、root FD 打开和启动前校验，`Run` 退出时关闭持有的
  root FD；
- filesystem 和 git 都是 Project Service 的内核实现细节，禁止 Handler 直接调用。

依赖方向固定为：

```text
cmd -> deps -> app / services manager
app handler -> project/api
project service -> project/filesystem + project/git
project/filesystem -> x/sys/unix
```

### 4.3 依赖装配

`internal/deps/deps.go` 至少 provide：

```text
context.Context
*config.RootConfig
logr.Logger
project/api.Service
service.Service group:"service"
rpcserver.RPCHandler group:"rpcHandlers"
*services.Manager
*rpcserver.RPCServer
```

Project Service constructor 使用 `dig.Out` 同时输出业务 API 和生命周期 Service：

```go
type Result struct {
    dig.Out

    API     api.Service
    Service service.Service `group:"service"`
}
```

这与 `agent-gateway/internal/services/gateway.ServiceResult` 的输出方式一致。Handler 直接按
`project/api.Service` 类型取得业务接口，不依赖 Project Service 的具体 struct。

优先复用：

- `packages/sdk-go/infra/config/loader`；
- `packages/sdk-go/infra/logger`；
- `packages/sdk-go/infra/rpcserver`；
- `packages/sdk-go/infra/rpcserver/middlewares`；
- `packages/sdk-go/infra/utils/signals`；
- `packages/sdk-go/infra/utils/container/str` 的安全随机字符串能力。

## 5. Sidecar 内部合约

### 5.1 合约模块边界

`packages/contracts/agent-sidecar` 只包含 Sidecar 自身的 entity 和内部 gRPC API：

- 所有 Proto 生成 Go message 和 gRPC Client/Server；
- 不引入 `google.api.http` 注解；
- 不生成 grpc-gateway 或 OpenAPI；
- 不定义 `agent_id`、实例状态、管理员身份或外部路由字段；
- 第二阶段可复用或映射这些内部 entity，但第一阶段不为上游 contract 预设 envelope。

### 5.2 Entity

`entity/agent-sidecar/project/v1/project.proto` 定义：

```proto
enum ProjectScope {
  PROJECT_SCOPE_UNSPECIFIED = 0;
  PROJECT_SCOPE_PROJECT = 1;
  PROJECT_SCOPE_GLOBAL = 2;
}

message ProjectRef {
  ProjectScope scope = 1;
  string project_name = 2;
}

enum EntryType {
  ENTRY_TYPE_UNSPECIFIED = 0;
  ENTRY_TYPE_FILE = 1;
  ENTRY_TYPE_DIRECTORY = 2;
}

message Project {
  string name = 1;
  bool git_initialized = 2;
  google.protobuf.Timestamp modified_at = 3;
}

message Entry {
  string name = 1;
  string path = 2;
  EntryType type = 3;
  int64 size = 4;
  google.protobuf.Timestamp modified_at = 5;
  string revision = 6;
  bool editable = 7;
}

message FileContent {
  Entry entry = 1;
  string content = 2;
}
```

`ProjectRef` 约束：

- `PROJECT_SCOPE_PROJECT` 要求 `project_name` 为合法且非空的 Project 名称；
- `PROJECT_SCOPE_GLOBAL` 要求 `project_name` 为空；
- `PROJECT_SCOPE_UNSPECIFIED` 返回 InvalidArgument；
- Project 生命周期 RPC 不使用 `ProjectRef`，只接受 Project name；
- Global 不出现在 Project 列表中。

Entry 约束：

- `FileContent.content` 仅在 `entry.editable=true` 时返回；
- 不可编辑文件返回空 content，调用方必须根据 editable 判断；
- ListEntries 中 editable 只表示普通文件且大小未超限，是可读取候选；
- GetFile 完成 UTF-8 与 NUL 检查后返回最终 editable；
- revision 是 opaque string；
- 不暴露物理绝对路径、uid、gid、inode、mode 或 Sidecar 配置。

### 5.3 `ProjectService`

```proto
service ProjectService {
  rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
  rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse);
  rpc DeleteProject(DeleteProjectRequest) returns (DeleteProjectResponse);
  rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
  rpc GetFile(GetFileRequest) returns (GetFileResponse);
  rpc CreateEntry(CreateEntryRequest) returns (CreateEntryResponse);
  rpc UpdateFile(UpdateFileRequest) returns (UpdateFileResponse);
  rpc MoveEntry(MoveEntryRequest) returns (MoveEntryResponse);
  rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse);
}
```

请求与响应字段：

```text
ListProjectsRequest  { page_size, cursor }
ListProjectsResponse { projects[], next_cursor }

CreateProjectRequest  { name }
CreateProjectResponse { project }

DeleteProjectRequest  { name, confirm_name }
DeleteProjectResponse {}

ListEntriesRequest  { project, path, page_size, cursor }
ListEntriesResponse { entries[], next_cursor, directory_revision }

GetFileRequest  { project, path }
GetFileResponse { file }

CreateEntryRequest  { project, parent_path, name, type }
CreateEntryResponse { entry }

UpdateFileRequest  { project, path, content, expected_revision }
UpdateFileResponse { entry }

MoveEntryRequest {
  project,
  source_path,
  destination_parent_path,
  destination_name,
  expected_revision
}
MoveEntryResponse { entry }

DeleteEntryRequest { project, path, recursive, expected_revision }
DeleteEntryResponse {}
```

请求字段 `project` 的类型是 `ProjectRef`。`path` 和 `parent_path` 使用 slash-separated
相对路径；空字符串只表示该引用的逻辑根，不能出现在非根路径 segment 中。

### 5.4 Metadata

Sidecar 接受以下可选 gRPC metadata：

| Key | 用途 |
| --- | --- |
| `x-request-id` | 串联调用日志 |
| `x-operator` | mutation 诊断日志中的操作者 |

metadata 缺失不影响请求。`x-operator` 不可信且不参与授权；未来权威调用方负责注入、校验
和持久化审计。日志不得包含请求或响应 Proto payload。

## 6. 配置与启动

### 6.1 配置结构

Project 专属配置放入 `services.project`：

```yaml
log:
  level: 0
  isJSON: true

rpcServer:
  address: :9091

services:
  project:
    projectsRootDir: /data/projects
    codexHomeDir: /home/node/.codex
    gitBinary: git
    maxEditableFileSize: 1048576
    defaultPageSize: 100
    maxPageSize: 500
    maxPathLength: 4096
    maxPathDepth: 64
    deniedDirectories:
      - .git
      - .hg
      - .svn
      - node_modules
      - .venv
      - venv
      - vendor
      - dist
      - build
      - .next
      - .cache
      - __pycache__
```

Normalize：

- 为数值字段填默认值；
- denylist 去重；
- 无论用户配置为何都把 `.git` 加入 denylist；
- 加入内部临时文件前缀 `.agent-sidecar-tmp-` 的保护规则；
- 不对普通字符串做与业务无关的 Trim。

Validate：

- RPC address 非空；
- 两个 root 都是绝对路径且彼此不同；
- page size、文件大小、路径长度和深度大于 0；
- default page size 不大于 max page size；
- denylist 每项是合法单一 segment；
- Git binary 非空。

### 6.2 启动与关闭顺序

1. `cmd/main.go` 加载 default + file config、初始化 logger，并调用 `deps.Init` 注册
   constructor；
2. `cmd/run.go` 在启动 goroutine 前一次性解析 `service.Manager` 和 RPC server；解析依赖时
   构造 Project Service；
3. Project Service constructor 查找 Git binary；
4. 打开 Projects Root 和 CODEX_HOME Root 目录 FD；
5. 验证 root 是普通目录而非符号链接；
6. 探测 `openat2`、`O_TMPFILE` 和 `renameat2(RENAME_NOREPLACE)`；
7. 检查 CODEX_HOME 下的 `skills`：不存在则 `mkdirat(..., 0755)`；若是文件或符号链接则
   启动失败；
8. 启动 `Manager.RunServices()`，Project Service 的 `Run` 阻塞等待 context；
9. 启动 gRPC server；
10. 收到 SIGINT/SIGTERM 后 shutdown gRPC server，等待进行中的 RPC 结束；
11. 调用 `Manager.StopServices()`，Project Service 的 `Run` 退出并关闭 root FD。

Project Service constructor 中途失败时直接关闭已经打开的 root FD。服务不提供独立的
`--check` 模式；正常启动使用同一套 constructor fail fast。

初始化失败时不启动 gRPC server，也不修改 Projects 下的任何已有内容。唯一允许的启动期
写操作是按产品约定创建缺失的 `$CODEX_HOME/skills`。

## 7. Project 引用解析

Project Service 将 `ProjectRef` 转换为单次请求使用的 `ProjectHandle`：

```text
ProjectHandle {
  scope
  projectName
  rootFD
  globalPolicy
}
```

### 7.1 普通 Project

- 校验 Project 名称 regexp 和 denylist；
- 使用 Projects Root FD + `openat2` 打开 Project 目录；
- 要求目标是普通目录；
- Project root FD 生命周期只覆盖当前 RPC；
- 空 path 映射该 Project root FD 本身。

### 7.2 Global 视图

- `PROJECT_SCOPE_GLOBAL` 不允许 `project_name`；
- rootFD 使用 CODEX_HOME Root FD 的安全 duplicate；
- 空 path 只允许 List；
- 第一 segment 只能是 `skills` 或 `AGENTS.md`；
- `AGENTS.md` 后不能再有 segment；
- `skills` 根受保护，但其后路径使用普通 Entry 规则；
- 其他第一 segment 在访问磁盘前返回 PermissionDenied；
- `auth.json`、`config.toml`、sessions 等内容永远不可寻址。

## 8. 路径解析与文件类型

### 8.1 语法校验

在 syscall 前对 API path 做纯语法校验：

- 必须是 UTF-8；
- 必须使用 `/` 分隔；
- 拒绝前导 `/`、尾随 `/`、连续 `//`；
- 拒绝空 segment、`.`、`..`、NUL；
- 按 UTF-8 bytes 校验总长度；
- 按 segment 数校验深度；
- 对目录 segment 应用 denylist；
- Entry name 只能是单一 segment。

不自动 Clean 非法路径。输入 `a/../b` 返回 InvalidArgument，而不是转换成 `b`。

### 8.2 安全打开

- 既有文件或目录用 `openat2(rootFD, relativePath, ...)`；
- 创建目标时先安全打开 parent directory FD，再使用 `openat` 或 `mkdirat`；
- stat 使用安全打开 FD 的 `fstat`，不重新按完整路径 stat；
- rename 同时安全打开 source parent 和 destination parent，再使用 `renameat2`；
- delete 使用 parent FD + `unlinkat`；
- 不使用 `EvalSymlinks`，也不跟随任何符号链接。

只返回普通文件和普通目录。FIFO、socket、device、symbolic link 等其他类型：

- List 中过滤；
- 直接访问返回 PermissionDenied；
- Project 整体删除时作为目录项安全 unlink，但不打开或跟随。

## 9. Revision

### 9.1 Entry revision

revision 格式：

```text
r1_<base64url(sha256(canonical-stat-fields))>
```

canonical stat fields 按固定大端编码包含：

```text
entry type
device
inode
permission bits
size
mtime sec + nsec
ctime sec + nsec
```

revision 不包含 path，因此同一 inode rename 后 revision 不变；Move 的 destination Entry
返回相同 revision。调用方只比较 opaque string。

### 9.2 Revision 使用

- ListEntries 和 GetFile 都返回 revision；
- UpdateFile、MoveEntry、DeleteEntry 必须提供非空 expected_revision；
- mutation 在持有写锁后重新打开目标并计算 revision；
- 不一致返回 Aborted，不做任何 Sidecar mutation；
- UpdateFile 在 temp file 完成并 fsync 后、rename 前再次 fstat 目标并比较 revision；
- revision 算法变化时修改 `r1_` 前缀，旧 revision 将安全地冲突。

revision 不读取整个大文件内容，避免目录 List 对大文件产生线性 I/O。非协作进程刻意恢复
全部 stat 字段不属于 MVP 保证。

## 10. 分页与 Cursor

### 10.1 排序

- Project 按 name 升序；
- Entry 先目录、后文件，同类型按 name 的 bytewise 升序；
- 过滤项不参与排序和 page size。

### 10.2 Cursor

Cursor 是 base64url 编码的 versioned protobuf/JSON payload：

```text
version
operation: projects | entries
project scope
project name
path
snapshot revision
last entry type
last entry name
```

Cursor 不签名，因为它不是授权凭证。服务完整校验 cursor 与当前请求的 scope、Project name
和 path；篡改只能得到 InvalidArgument/Aborted，不能改变 ProjectRef confinement。

每次 List 都扫描当前一层可见条目，排序后计算：

```text
d1_<sha256(type + name + entry revision ...)>
```

作为 snapshot revision。cursor snapshot 与当前值不同则返回 Aborted，调用方从第一页重新
加载。没有下一页时返回空 `next_cursor`。

## 11. 操作算法

### 11.1 List Projects

1. 持读锁；
2. 从 Projects Root FD 读取直接目录项；
3. 过滤 denylist、符号链接和非目录；
4. 对普通目录读取 stat；
5. 使用 `openat2(projectFD, ".git")` 检查 `.git` 是否是真实目录，不验证仓库；
6. 排序、验证 cursor snapshot、分页并返回。

`.git` 的内部检查绕过普通 denylist 入口，仅用于 `git_initialized`。

### 11.2 Create Project

1. 持写锁并校验名称；
2. `mkdirat(projectsRootFD, name, 0755)`，EEXIST 映射 AlreadyExists；
3. 在 Projects Root 作为 cwd 执行：

   ```text
   git init --initial-branch=main -- <name>
   ```

4. 不设置 remote、不创建初始文件、不 commit；
5. Git 成功后验证 `.git` 是真实目录并返回 Project；
6. Git 失败时使用安全 Project 整体删除算法回滚刚创建的目录；
7. 回滚也失败时返回同时包含两类原因的 Internal error，并记录高优先级日志，不声称
   Project 未创建。

Git command 继承 Sidecar UID/GID 和最小环境，不通过 shell 执行，超时跟随 RPC context。

### 11.3 Delete Project

1. 持写锁；
2. 校验 name、confirm_name 和 Project root 类型；
3. 从 Projects Root FD 开始执行不跟随 symlink 的 `removeTreeAt`；
4. `.git`、denylist 目录和符号链接作为 Project 内容一并删除，符号链接只 unlink；
5. 最后 `unlinkat(..., AT_REMOVEDIR)` 删除 Project root；
6. 中途失败返回映射后的错误。永久递归删除不承诺跨进程事务性，调用方必须刷新状态。

### 11.4 List Entries

1. 持读锁并解析 ProjectRef；
2. 安全打开目标目录；
3. 读取直接目录项；
4. 过滤 denylist、符号链接和特殊文件；
5. 为每项生成 Entry 和 revision；
6. 普通文件在 size 未超限时设置 editable=true，作为可读取候选；
7. 排序、计算 snapshot、校验 cursor 并分页。

List 不读取文件正文。小文件是否确实为 UTF-8 文本由 GetFile 最终判断；超限文件直接
返回 `editable=false`。

### 11.5 Get File

1. 持读锁并安全打开目标；
2. 要求目标是普通文件；
3. fstat 并计算 revision；
4. size 超限时不读取正文，返回 editable=false；
5. 使用 limit reader 读取至 `maxEditableFileSize + 1`，防止 stat 后文件增长；
6. 校验 UTF-8 且不包含 NUL；
7. 合法时返回完整 string，否则只返回 Entry metadata。

### 11.6 Create Entry

1. 持写锁，解析 ProjectRef 和 parent；
2. 执行 Global allowlist 和受保护根规则；
3. 安全打开 parent directory FD；
4. 文件使用 `openat(O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW, 0644)`；
5. 目录使用 `mkdirat(..., 0755)`；
6. fsync parent directory；
7. 重新读取并返回 Entry。

### 11.7 Update File

1. 持写锁并安全打开目标普通文件；
2. 校验 content 大小、UTF-8、NUL 和 expected revision；
3. 在同一 parent directory 使用 `O_TMPFILE|O_WRONLY` 创建 unnamed temp file；
4. 按原文件 permission bits 写入，循环处理 short write；
5. fsync temp file；
6. 再次 fstat 原目标并比较 expected revision；
7. revision 未变时，通过 `linkat(AT_EMPTY_PATH)` 将 unnamed temp 链接为
   `.agent-sidecar-tmp-<secure-random>`，随后立即用 `renameat` 原子覆盖目标；
8. fsync parent directory；
9. 读取并返回新 Entry/revision；
10. 任一步失败都关闭 unnamed temp FD，并在已经 link 后 unlink 临时目录项。

临时前缀始终被 List 和普通 API 过滤。正常错误路径会清理已链接 temp；若进程在 link 与
rename 的极短窗口崩溃，可能遗留隐藏 temp。启动时不递归清理用户目录，避免重启产生未
请求的文件 mutation。

### 11.8 Move Entry

1. 持写锁；
2. source/destination 使用同一个 ProjectHandle；
3. 校验受保护根、Global policy、denylist 和目录自包含关系；
4. 安全打开两个 parent FD；
5. 校验 source type 和 expected revision；
6. 使用 `renameat2(..., RENAME_NOREPLACE)`；
7. fsync 两个不同的 parent directory；
8. 返回 destination Entry。

不支持跨 Project、Project 与 Global 之间移动，也不提供 check-then-rename fallback。

### 11.9 Delete Entry

- 普通文件：expected revision 一致后 `unlinkat`；
- 空目录：expected revision 一致后 `unlinkat(..., AT_REMOVEDIR)`；
- 非空目录且 recursive=false：FailedPrecondition；
- recursive=true：先扫描完整 subtree；发现 denylist、符号链接或特殊文件则
  PermissionDenied，确认可删除后执行不跟随 symlink 的递归删除；
- Project root、Global root、skills root 永远拒绝；
- AGENTS.md 可作为普通文件删除。

Entry recursive delete 不是事务。写锁能排除其他 Sidecar mutation，但 Agent Codex 可在
预扫描后创建新内容；删除过程中发现新 denylist 或 symlink 时停止并返回错误。

## 12. 错误模型

filesystem 和 Project Service 使用可 `errors.Is` 匹配的领域错误，不在底层直接构造
gRPC status：

```text
ErrInvalidArgument
ErrNotFound
ErrAlreadyExists
ErrPermissionDenied
ErrConflict
ErrResourceExhausted
ErrFailedPrecondition
ErrUnsupportedPlatform
```

Handler 统一映射：

| 领域错误 | gRPC code |
| --- | --- |
| `ErrInvalidArgument` | InvalidArgument |
| `ErrNotFound` | NotFound |
| `ErrAlreadyExists` | AlreadyExists |
| `ErrPermissionDenied` | PermissionDenied |
| `ErrConflict` | Aborted |
| `ErrResourceExhausted` | ResourceExhausted |
| `ErrFailedPrecondition` | FailedPrecondition |
| context deadline/cancel | DeadlineExceeded/Cancelled |
| 其他错误 | Internal |

典型 errno 映射：

- ENOENT → NotFound；
- EEXIST → AlreadyExists；
- ELOOP、EXDEV confinement failure → PermissionDenied；
- ENOTEMPTY → FailedPrecondition；
- ENAMETOOLONG、EFBIG → ResourceExhausted；
- EACCES、EPERM → PermissionDenied；
- EINVAL 只在已知输入问题时映射 InvalidArgument，否则 Internal。

错误文本使用逻辑 ProjectRef 和相对路径，不回显物理 root、FD、文件正文或环境变量。

## 13. 日志

### 13.1 RPC 日志

复用 `rpcserver/middlewares.LogMetricMiddlewareBuilder`，固定：

```text
LogPayload = false
```

Health 请求从普通日志过滤。logger 从 context 获取 request/trace ID。

### 13.2 Mutation 日志

Project Service 在一次 mutation 完成时记录：

```text
request_id
operator
method
project_scope
project_name
source_path
destination_path
result
grpc_code
duration
```

不记录 content、expected revision、cursor、绝对路径或环境变量。第一阶段日志只用于诊断，
不把 Sidecar 日志定义为权威管理员审计记录。

### 13.3 Metrics

第一阶段不引入业务 Metrics，后续根据实际观测需求再设计和添加。

## 14. gRPC Server

- 使用共享 `rpcserver.NewRPCServer`；
- 注册 `ProjectService`、standard Health 和 reflection；
- 开启 tracing；
- 不注册 auth interceptor；
- 不注册 grpc-gateway；
- 最大接收和发送 message 固定为 8 MiB；
- server 不静默追加业务 deadline，由调用方提供；
- 缺少 deadline 的请求仍受文件大小、分页和路径限制保护。

## 15. 镜像与构建

### 15.1 Go module

`services/agent-sidecar/go.mod` 使用仓库当前 Go 版本，并依赖：

- shared contracts；
- shared infra；
- gRPC/Protobuf；
- `golang.org/x/sys/unix`；
- validator/defaults/YAML；
- `go.uber.org/dig`；
- testify 和 go.uber.org/mock（测试/生成）。

不引入数据库、AWS SDK、Kubernetes Client、Git library 或文件 watcher。

### 15.2 Makefile

提供：

```text
make build
make test
make generate
make generate-proto
make generate-mock
make docker-build-agent-sidecar
```

Proto 从 `packages/contracts/agent-sidecar` 生成；mock 使用仓库现有
`scripts/make/go.mk` 规则。

### 15.3 Dockerfile

运行镜像基于 `debian:bookworm-slim`：

- 安装 `ca-certificates` 和 `git`；
- COPY `output/bin/agent-sidecar` 到 `/agent-sidecar`；
- `USER 1000:1000`；
- `EXPOSE 9091`；
- ENTRYPOINT `/agent-sidecar`。

镜像不包含 Codex、Node.js、凭证或交互式管理工具。

## 16. dev 部署设计

当前 dev `agent-codex` 是单副本、RWO PVC、Recreate Deployment。第一阶段在同一 Pod
增加：

```yaml
- name: agent-sidecar
  image: agent-sidecar:latest
  args: ["--config", "/etc/agent-sidecar/config.yaml"]
  ports:
    - name: sidecar-grpc
      containerPort: 9091
  resources:
    requests:
      cpu: 25m
      memory: 64Mi
    limits:
      cpu: 250m
      memory: 256Mi
  securityContext:
    allowPrivilegeEscalation: false
    capabilities:
      drop: ["ALL"]
  volumeMounts:
    - name: projects
      mountPath: /data/projects
    - name: codex-home
      mountPath: /home/node/.codex
    - name: agent-sidecar-config
      mountPath: /etc/agent-sidecar/config.yaml
      subPath: config.yaml
      readOnly: true
```

Pod 级 `runAsUser/runAsGroup/fsGroup=1000` 与 Agent Codex 保持一致。

Service 为现有 `agent-codex` selector 增加端口：

```yaml
- name: sidecar-grpc
  port: 9091
  targetPort: sidecar-grpc
```

第一阶段内部地址为 `agent-codex.agents-dev:9091`。不创建 LoadBalancer、Ingress 或跨
namespace 暴露。

探针：

- Sidecar 使用 standard gRPC liveness probe；
- 不设置 Sidecar readiness probe，避免文件管理暂时失败使 Agent Codex app-server 从
  Service endpoints 中被移除；
- 调用方通过标准 Health RPC 判断 Sidecar 是否可用。

部署变更写入当前 app-delivery 的 agents dev manifest；aisupport 新增独立镜像构建与 dev
bump workflow，component 名称为 `agent-sidecar`。不在仓库中保存 Secret，本阶段不新增
Sidecar 调用 token。

## 17. CI 与验证

### 17.1 Contract gate

```text
buf lint
buf generate
git diff --exit-code on generated files
```

额外断言 contract 只生成 Go/gRPC 产物，不生成 HTTP/OpenAPI surface。

### 17.2 Go gate

```text
gofmt check
go vet ./...
go mod tidy + git diff --exit-code
go test ./...
```

文件系统测试使用 `t.TempDir()`，可并行用例调用 `t.Parallel()`；涉及 process cwd、环境或
真实 Git 的用例保持串行。Mock 由 `go.uber.org/mock` 生成，不手写。

### 17.3 Docker gate

- 构建 linux/amd64 image；
- 以 UID/GID 1000 运行；
- 挂载两个临时 volume；
- 启动后 gRPC Health 返回 SERVING；
- 容器中 `git --version` 和 CreateProject 可用；
- 容器不监听 9091 以外的新端口。

### 17.4 测试矩阵

#### 架构与生命周期

- deps 可以装配 Project Service、Handler、Service Manager 和 RPC server；
- deps 不直接装配 filesystem Roots 或 Git initializer；
- deps 不注册或关闭 Project Service 持有的 filesystem Roots；
- Project Service constructor 校验失败时 RPC server 不启动且已打开 FD 被关闭；
- Git binary 查找失败时不打开 root FD，也不创建 `CODEX_HOME/skills`；
- shutdown 顺序先停止 server，再通过 `StopServices` 结束 Project Service 并关闭 root FD；
- 新增一个测试 Service 可通过 group 注册，不修改 cmd 装配逻辑；
- Handler 只依赖 `project/api.Service` mock。

#### Project

- 合法名称创建、`.git` 存在、List 不展示 `.git`；
- 无 Git 的既有目录返回 `git_initialized=false`；
- 非法名称、denylist、同名文件/目录；
- Git binary 不存在和 Git init error 的回滚；
- Project 删除包括 `.git`、隐藏目录和 symlink 本身，不跟随 symlink；
- confirm_name 不匹配时零 mutation。

#### Global

- 启动自动创建 skills；
- skills 已是文件或 symlink 时启动失败；
- AGENTS.md create/read/update/delete；
- skills 内普通 CRUD；
- auth.json、config.toml 和其他根项全部 PermissionDenied；
- skills root delete/move 被拒绝。

#### 路径安全

- absolute、`.`、`..`、double slash、trailing slash、NUL、超长、超深；
- source、parent、destination 任一层 symlink；
- symlink 指向 root 内和 root 外均拒绝；
- FIFO/socket/device 不展示且不可访问；
- 并发把普通目录替换为 symlink 时不能逃逸 root；
- 内核或文件系统不支持 openat2、O_TMPFILE、RENAME_NOREPLACE 时 fail closed。

#### 文件与并发

- 空文本、普通 UTF-8、非法 UTF-8、NUL、恰好 1 MiB、超过 1 MiB；
- 原子 save 成功、short write/error 清理 temp；
- 相同 expected revision 首次成功、第二次 Aborted；
- Move destination exists 不覆盖；
- Move directory 到自身后代被拒绝；
- recursive=false 删除非空目录失败；
- recursive delete 遇到 denylist/symlink 时预扫描失败；
- 多个 Sidecar mutation 被串行执行。

#### 分页

- Project/Entry 排序；
- 默认、最大和非法 page size；
- cursor 与不同 ProjectRef/path 组合被拒绝；
- 目录未变化时连续翻页无重复无遗漏；
- 目录变化后旧 cursor 返回 Aborted。

#### gRPC

- 每个 RPC 成功和领域错误 code；
- Health 与 reflection；
- 不需要 token 即可从内部连接调用；
- metadata 缺失不失败，存在时进入日志上下文；
- `LogPayload=false`，日志不含文件 content。

## 18. 实施顺序

1. 新增 Project entity、`ProjectService` 和 Buf 配置；
2. 生成 Go/gRPC 代码并加入 contract gate；
3. 按 `app + deps + services` 建立 `services/agent-sidecar` module 和空 gRPC server；
4. 建立 Project Service API、生命周期与 Handler；
5. 在 Project Service 下实现 filesystem、路径/Global policy 和 revision/cursor；
6. 在 Project Service 下实现 Git initializer；
7. 实现 Project 与 Entry 操作；
8. 接入错误映射、metadata 和日志；
9. 补齐单元、集成、gRPC 和真实 Git 测试；
10. 新增 binary/image Make target、Dockerfile 和 CI；
11. 构建并发布 dev image；
12. 更新 app-delivery dev Agent Codex Pod、Service 和 ConfigMap；
13. 在 dev 使用 gRPC Client 完成第一阶段验收并记录结果。

## 19. 第一阶段验收标准

- contract 可重复生成且生成后 worktree 无额外 drift；
- Sidecar module 通过 gofmt、vet、tidy 和全部测试；
- 代码结构符合 `app + deps + services`，Project 管理位于独立 Service；
- filesystem 与 git 位于 `internal/services/project` 内，不成为跨 Service 的隐式公共层；
- image 以非 root UID 1000 启动，Health 为 SERVING；
- dev Agent Codex 和 Sidecar 同时访问原两个 PVC；
- `grpcurl` 能完成 Project create/list/delete 和 Project/Global Entry CRUD；
- `.git`、denylist、CODEX_HOME 其他文件和 symlink 无法通过 API 访问；
- revision 冲突不会覆盖已保存内容；
- 写失败不产生半写目标文件，正常错误路径清理临时文件；
- Sidecar restart 不改变已有 Project、Skills 或 AGENTS.md；
- Sidecar 无 token 可从受信任集群内调用，但没有外部 Service/Ingress；
- 停止 Sidecar container 不删除 PVC 内容，Agent Codex container 不被主动停止；
- 本阶段没有引入上游管理 contract、HTTP route 或 OpenAPI 产物。

## 20. 已知限制与后续阶段边界

| 限制 | 第一阶段处理 | 后续阶段 |
| --- | --- | --- |
| 只有一个 dev 实例 | 验证单 Pod/PVC 正确性 | 验证多实例路由与隔离 |
| Sidecar 无认证 | 仅 ClusterIP/内部可信网络 | 跨信任域时评估 NetworkPolicy、JWT 或 mTLS |
| Agent 写入不参与锁 | 最终 revision 检查，记录边界 | 需要时增加 Task/Project 协调 |
| 永久删除非事务 | 安全递归、不跟随 symlink、明确错误 | 回收站或卷快照 |
| 未定义上游管理 contract | Sidecar contract 独立可测试 | 第二阶段单独设计 |
| 无 Portal | 使用 gRPC Client 验收 | 管理 API 完成后接入 |

## 21. 最终决策摘要

- 第一阶段新增独立 `services/agent-sidecar` 和 `agent-sidecar` image；
- Sidecar 采用与 `agent-gateway` 一致的 `app + deps + services` 框架和 dig 装配方式；
- Project 管理作为 `internal/services/project` Service 实现；
- filesystem 与 git 是 Project Service 的内核子模块；
- 产品和代码统一使用 Project 语义，不再定义 Workspace Service、WorkspaceRef 或
  WorkspaceScope；
- Global 是 Project Service 管理的受限视图，不是可创建、列举或删除的真实 Project；
- Project 配置放在 `services.project`，为后续同级 Service 保留扩展位置；
- Sidecar 只提供内部 gRPC，不提供 HTTP，不验证 token；
- 第一阶段 contract 只包含 Sidecar entity 和 `ProjectService`，不设计上游 contract；
- 以 Linux root directory FD 和 openat2 confinement 实现路径安全，不提供不安全 fallback；
- `.git` 强制 deny，Global 只映射 skills 和 AGENTS.md；
- 全部 mutation 使用单写锁，revision 防止 Sidecar 客户端静默覆盖；
- Update 使用同目录 temp + fsync + rename 原子提交；
- Create Project 使用真实 `git init --initial-branch=main`，失败回滚；
- Sidecar 不保存数据库、Project metadata 或 applied state；
- dev 将 Sidecar 挂入现有 Agent Codex Pod，共享两个 PVC 并通过 ClusterIP 9091 访问。
