Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,32 @@ Notes:
- `relates_to.rel_type` currently supports `m.thread`; the root must be a
top-level message in the same room
- A thread reply also publishes `thread.updated`
- To send attachments, use `multipart/form-data` with a `payload` JSON part containing the same fields and one or more `files` parts.
- Attachment-only messages are valid when at least one file is present.
- Each returned message can include `attachments` with `id`, `name`, `kind`, `media_type`, `size_bytes`, `sha256`, `created_at`, `download_url`, optional `preview_url`, optional image dimensions, and optional `workspace_path` for agent-facing deliveries.

Multipart example:

```text
payload={"room_id":"room-1","sender_id":"manager","content":""}
files=@diagram.png;type=image/png
```

### `GET /api/v1/attachments/{id}`

Downloads a stored chat attachment by attachment ID.

The `download_url` returned in attachment metadata includes an attachment-scoped capability token and can be used directly by browsers and agents.

Callers may instead request the bare path with the configured server Bearer token.

The endpoint serves the original bytes with the stored media type and `X-Content-Type-Options: nosniff`.

Treat the capability URL as a secret and avoid sharing it outside the room context.

Image attachments are served inline.

Other file attachments are served with attachment disposition.

### `POST /api/v1/rooms/{id}/threads`

Expand Down Expand Up @@ -1112,6 +1138,10 @@ thread was started. Runtime/LLM bridges use it as prompt context; it is not a li
of thread replies. PicoClaw-native clients can use `context.topic_id` as the
same thread/session identifier.

Events can include an `attachments` array with the same message attachment metadata returned by the message APIs.

For CSGClaw agents, the server also attempts to copy each attachment into the target agent workspace and sets `workspace_path` when that copy succeeds.

### `POST /api/v1/channels/csgclaw/participants/{id}/messages`

Sends a message as the specified local CSGClaw participant.
Expand All @@ -1132,6 +1162,10 @@ that IM thread. When all are omitted, the response is sent as a top-level room/D
message; the server does not infer a thread from the participant's most recent room
event.

This endpoint also accepts the same multipart attachment format as `POST /api/v1/messages`.

Use a `payload` JSON part for the participant message fields and one or more `files` parts for generated files.

PicoClaw outbound message shape is also accepted:

```json
Expand Down
34 changes: 34 additions & 0 deletions docs/api.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,32 @@ room 消息列表默认不包含 thread reply;当 thread 存在时,root mess
- 发送 thread reply 时传入 `relates_to: {"rel_type":"m.thread","event_id":"<root_message_id>"}`
- `relates_to.rel_type` 当前支持 `m.thread`;root 必须是同一 room 内的顶层消息
- thread reply 还会发布 `thread.updated`
- 发送附件时使用 `multipart/form-data`,其中 `payload` JSON part 包含同样字段,`files` part 可以出现一次或多次。
- 至少带有一个文件时允许发送纯附件消息。
- 返回的 message 可以包含 `attachments`,字段包括 `id`、`name`、`kind`、`media_type`、`size_bytes`、`sha256`、`created_at`、`download_url`、可选的 `preview_url`、可选图片尺寸,以及面向 agent 投递时可选的 `workspace_path`。

Multipart 示例:

```text
payload={"room_id":"room-1","sender_id":"manager","content":""}
files=@diagram.png;type=image/png
```

### `GET /api/v1/attachments/{id}`

按 attachment ID 下载已存储的聊天附件。

附件元数据中的 `download_url` 包含附件级 capability token,浏览器和 agent 可以直接使用。

调用方也可以使用配置的服务端 Bearer token 请求不带 capability token 的基础路径。

该接口会使用存储的 media type 返回原始字节,并设置 `X-Content-Type-Options: nosniff`。

请将 capability URL 视为敏感信息,不要在 room 上下文之外共享。

图片附件以内联方式返回。

其他文件附件以下载方式返回。

### `POST /api/v1/rooms/{id}/threads`

Expand Down Expand Up @@ -1087,6 +1113,10 @@ data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"ro
prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 可以把
`context.topic_id` 当作同一个 thread/session 标识。

事件可以包含 `attachments` 数组,字段与 message API 返回的附件元数据一致。

对于 CSGClaw agents,服务端还会尝试把每个附件复制到目标 agent workspace,并在复制成功时设置 `workspace_path`。

### `POST /api/v1/channels/csgclaw/participants/{id}/messages`

以指定本地 CSGClaw participant 身份发送消息。
Expand All @@ -1106,6 +1136,10 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client
响应会作为 room/DM 顶层消息发送;服务端不会根据 participant 在房间中最近收到的
事件推断 thread。

该接口也接受与 `POST /api/v1/messages` 相同的 multipart 附件格式。

使用 `payload` JSON part 传 participant 消息字段,并用一个或多个 `files` part 传生成的文件。

也接受 PicoClaw outbound message 形态:

```json
Expand Down
19 changes: 18 additions & 1 deletion internal/agent/agents_instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,24 @@ const managerRuntimeConnectorInstructions = `### GitHub Connector Access
- Never print, echo, log, write, persist, or include the token value in prompts, messages, UI text, state files, snapshots, or ` + "`AGENTS.md`" + ` edits.
- Do not rely on connector tokens from environment variables such as ` + "`GITHUB_TOKEN`" + `; connector credentials are intentionally fetched on demand so reconnects and refreshes work without restarting the Manager.
- Do not treat an empty result from an external Codex GitHub app connector as proof that the CSGClaw GitHub connector has no repository access.
- If the credential API returns ` + "`400`" + `, ` + "`401`" + `, or ` + "`403`" + `, tell the user to reconnect the CSGClaw GitHub OAuth connector or check connector access policy.`
- If the credential API returns ` + "`400`" + `, ` + "`401`" + `, or ` + "`403`" + `, tell the user to reconnect the CSGClaw GitHub OAuth connector or check connector access policy.

### Historical Attachment Recovery

- Treat files under ` + "`.csgclaw/attachments/`" + ` as runtime-local cache copies, not as the durable attachment index.
- When the user refers to a previously uploaded file that is absent from the current workspace, query CSGClaw message history before claiming the file is unavailable or asking the user to upload it again.
- Use the current ` + "`channel`" + ` and ` + "`room_id`" + ` from the hidden channel context with ` + "`csgclaw-cli message list --channel <current_channel> --room-id <target_room_id>`" + `.
- Filter the JSON locally to attachment-bearing messages and retain ` + "`id`" + `, ` + "`name`" + `, ` + "`media_type`" + `, ` + "`size_bytes`" + `, ` + "`sha256`" + `, ` + "`created_at`" + `, the originating message ID, and the originating message text.
- Use a structured pipeline that excludes capability-bearing download URLs, such as ` + "`csgclaw-cli message list --channel <current_channel> --room-id <target_room_id> | jq '[.[] as $message | ($message.attachments // [])[] | {id, name, kind, media_type, size_bytes, sha256, created_at, message_id: $message.id, message_text: $message.content}]'`" + `.
- Match candidates using the filename, the originating message text, and recency.
- If exactly one candidate matches, download it by stable attachment ID into ` + "`.csgclaw/retrieved/<attachment-id>-<safe-name>`" + ` with ` + "`GET $CSGCLAW_BASE_URL/api/v1/attachments/<attachment-id>`" + ` and ` + "`Authorization: Bearer $CSGCLAW_ACCESS_TOKEN`" + `.
- A safe download command is ` + "`curl -fsS -H \"Authorization: Bearer ${CSGCLAW_ACCESS_TOKEN:?}\" \"$CSGCLAW_BASE_URL/api/v1/attachments/<attachment-id>\" --output \".csgclaw/retrieved/<attachment-id>-<safe-name>\"`" + `.
- Use the stable attachment ID for authenticated downloads instead of copying a capability-bearing ` + "`download_url`" + ` into commands, logs, or responses.
- Verify the downloaded file against its ` + "`sha256`" + ` before reading it.
- If multiple candidates plausibly match, show the user a concise candidate list instead of guessing.
- If the current room has no match and the user clearly refers to an upload from another conversation, list rooms and inspect only the relevant candidate rooms.
- Do not search the web for a referenced upload, rely only on ` + "`find`" + ` in the current workspace, or request a re-upload until durable CSGClaw history has been checked.
- Never print, echo, or include ` + "`CSGCLAW_ACCESS_TOKEN`" + ` or a capability token in tool output, logs, prompts, or responses.`

func renderAgentsInstructionsBlock(instructions, managedInstructions string) string {
instructions = strings.TrimSpace(instructions)
Expand Down
12 changes: 11 additions & 1 deletion internal/agent/agents_instructions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,24 @@ func TestRenderRuntimeAgentsInstructionsBlockAddsManagerConnectorRulesOnlyForMan
"Do not rely on connector tokens from environment variables",
"Do not treat an empty result from an external Codex GitHub app connector as proof",
"reconnect the CSGClaw GitHub OAuth connector",
"Historical Attachment Recovery",
"csgclaw-cli message list --channel <current_channel> --room-id <target_room_id>",
"jq '[.[] as $message | ($message.attachments // [])[]",
"runtime-local cache copies, not as the durable attachment index",
"GET $CSGCLAW_BASE_URL/api/v1/attachments/<attachment-id>",
"curl -fsS -H \"Authorization: Bearer ${CSGCLAW_ACCESS_TOKEN:?}\"",
"Use the stable attachment ID for authenticated downloads",
"until durable CSGClaw history has been checked",
} {
if !strings.Contains(manager, want) {
t.Fatalf("manager runtime instructions missing %q in %q", want, manager)
}
}

worker := RenderRuntimeAgentsInstructionsBlock("agent-worker", "Stay concise.")
if strings.Contains(worker, "GitHub Connector Access") || strings.Contains(worker, "`GITHUB_TOKEN`") {
if strings.Contains(worker, "GitHub Connector Access") ||
strings.Contains(worker, "Historical Attachment Recovery") ||
strings.Contains(worker, "`GITHUB_TOKEN`") {
t.Fatalf("worker runtime instructions include manager connector guidance: %q", worker)
}
}
16 changes: 14 additions & 2 deletions internal/agent/service_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ func updateIncludesMCPServers(req UpdateRequest) bool {
return ok
}

func updateIncludesRuntimeOptions(req UpdateRequest) bool {
fieldMask := normalizeUpdateFieldMask(req.FieldMask)
if len(fieldMask) == 0 {
return req.RuntimeOptions != nil
}
_, ok := fieldMask["runtime_options"]
return ok
}

func (s *Service) AgentProfileView(id string) (AgentProfileView, error) {
id = strings.TrimSpace(id)
if id == "" {
Expand Down Expand Up @@ -268,7 +277,7 @@ func (s *Service) update(ctx context.Context, id string, req UpdateRequest) (Age
return Agent{}, fmt.Errorf("agent %q not found", id)
}
if isManagerAgent(current) {
if err := validateManagerUpdateRuntimeSpec(req); err != nil {
if err := validateManagerUpdateRuntimeConfig(req); err != nil {
s.mu.Unlock()
return Agent{}, err
}
Expand Down Expand Up @@ -672,7 +681,10 @@ func runtimeMCPServerConfigFromCatalog(name string, raw any) (map[string]any, er
return serverConfig, nil
}

func validateManagerUpdateRuntimeSpec(req UpdateRequest) error {
func validateManagerUpdateRuntimeConfig(req UpdateRequest) error {
if updateIncludesRuntimeOptions(req) {
return fmt.Errorf("manager runtime options are managed automatically")
}
if !req.RuntimeSelectionRequested {
return nil
}
Expand Down
42 changes: 42 additions & 0 deletions internal/agent/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,48 @@ func TestUpdateRuntimeOptionsSyncsCodexWorkspaceAgentsFile(t *testing.T) {
}
}

func TestUpdateManagerRejectsRuntimeOptions(t *testing.T) {
svc, err := NewService(
testModelConfig(),
config.ServerConfig{},
"manager-image:test",
"",
WithRuntime(fakeAgentRuntime{kind: RuntimeKindCodex}),
)
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
svc.agents[ManagerUserID] = Agent{
ID: ManagerUserID,
Name: ManagerName,
RuntimeID: runtimeIDForAgentID(ManagerUserID),
RuntimeKind: RuntimeKindCodex,
Role: RoleManager,
Status: string(agentruntime.StateStopped),
CreatedAt: time.Date(2026, 5, 18, 9, 0, 0, 0, time.UTC),
}

nextRuntimeOptions := map[string]any{"local_workspace_dir": "/tmp/manager"}
for name, req := range map[string]UpdateRequest{
"value": {RuntimeOptions: &nextRuntimeOptions},
"field mask": {FieldMask: []string{"runtime_options"}},
} {
t.Run(name, func(t *testing.T) {
_, err := svc.Update(context.Background(), ManagerUserID, req)
if err == nil || !strings.Contains(err.Error(), "manager runtime options are managed automatically") {
t.Fatalf("Update() error = %v, want managed runtime options error", err)
}
})
}
manager, ok := svc.Agent(ManagerUserID)
if !ok {
t.Fatal("Agent(manager) ok = false, want true")
}
if manager.RuntimeOptions != nil {
t.Fatalf("manager RuntimeOptions = %#v, want nil", manager.RuntimeOptions)
}
}

func TestUpdateCodexLocalWorkspaceDirMarksRunningRuntimeForRestart(t *testing.T) {
observer := &fakeLifecycleObserver{}
svc, err := NewService(
Expand Down
Loading
Loading