Skip to content

Commit 5c5c57c

Browse files
author
lxy
committed
feat(mail): add Bot-bound Agent Mail commands
Add Bot- and Space-bound Agent Mail authorization, encrypted mailbox credentials, guarded send and Draft workflows, JMAP polling support, and agent-facing skill guidance. Keep dry-run network-free, bind mailbox credentials to the authorizing Bot token and normalized API origin, and surface ambiguous send outcomes safely.\n\nRefs #128
1 parent 84655c9 commit 5c5c57c

35 files changed

Lines changed: 4322 additions & 47 deletions

CLAUDE.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,26 @@
2222
- **Credential resolution** (see `internal/credential`, `internal/authstore`): a token comes from a stored encrypted profile or, as a fallback, an env var — `OCTO_TOKEN` first, then `OCTO_BOT_TOKEN`. The credential's `Source` records which variable was used, so the envelope's `identity.source` cannot mislead. Stored profiles live in `~/.octo-cli` (override `OCTO_CONFIG_DIR`): metadata in plaintext `config.json`, tokens in AES-256-GCM `credentials.enc`. Manage them with `octo-cli auth`.
2323
- **Per-domain token gate and mount routing**: a spec may declare which token kinds it accepts and which server mount each kind uses. `drive` accepts all three and routes `uk_*` to `/v1/user/drive/*`, bots to `/v1/bot/drive/*`. An incompatible kind fails locally with `TOKEN_KIND_NOT_ALLOWED` (`validation`, exit 2 — switch credentials, don't re-auth), implemented once in `cmd/service/identity.go` and reused by the hand-written drive composites via `service.MountForOperation`. Generated leaves and composites alike resolve identity **before** `--dry-run` or any local success return, so a refused credential can never have a request described for it or a document link resolved under it.
2424
- **Lossless uint64 ids**: `drive` file ids are backend uint64s. Inputs are decimal-string flags validated in `[0, 2^64-1]` and sent as JSON integers; responses are emitted as decimal strings. Go's `int` cannot hold the upper half of the range and a `float64` would round above 2^53, so neither is used on this path. The rule holds for *every* output format, not just `json`: the row extraction behind `--format table|csv|ndjson` decodes with `UseNumber` too, so a large integer a spec did not declare as a lossless field still prints the digits the backend sent rather than a rounded float.
25-
- **Selecting a credential at runtime**: `--bot-id <robot_id>` (env `OCTO_BOT_ID`) is the agent's primary selector — robot ids are self-known; `--profile <name>` selects by friendly name. With exactly one profile, selection is implicit; with **two or more, a selector is required** (ambiguity is a hard error, never a silent guess). Precedence: selector > sole/implicit profile > `OCTO_BOT_TOKEN`. The success envelope's `identity` echoes the active `{profile, robot_id, bot_kind, source}` so misuse is visible.
25+
- **Selecting a credential at runtime**: `--bot-id <robot_id>` (env `OCTO_BOT_ID`) is the agent's primary selector — robot ids are self-known; `--profile <name>` selects by friendly name. With exactly one profile, selection is implicit; with **two or more, a selector is required** (ambiguity is a hard error, never a silent guess). Precedence: selector > sole/implicit profile > `OCTO_BOT_TOKEN`. An explicit `--bot-id` always selects a stored profile and fails closed when none exists; `OCTO_BOT_ID` may instead label an environment token when the profile store is empty. The success envelope reports that unverified environment value as `identity.robot_id_claimed`; `identity.robot_id` is reserved for a stored or verified binding.
2626
- **Isolation boundary = OS user**: the encryption key is machine-derived, so the store resists off-machine leakage (commit/backup/sync) but not a same-user process. Isolate mutually-distrusting bots with separate OS users or `OCTO_CONFIG_DIR` values.
2727
- **Daemon task isolation**: `OCTO_CREDENTIAL_MODE=task` selects the restricted Loop-only policy but cannot protect against a process rewriting its own environment. Daemon task processes must receive an isolated `OCTO_CONFIG_DIR` with no host profiles and the short-lived `OCTO_BOT_TOKEN`.
2828
- Each Bot has an **owner**; operations are attributed to the Bot identity. For LLM-backed paths (`matter extract`) the bot acts on behalf of its owner — pass `owner_uid` as `creator_uid`.
2929
- **Search subjects** (`message search` family): a `bf_` token searches as the bot, or as a real person with `--on-behalf-of <uid>` (OBO — requires an active grant); a `uk_` token searches as the real person it belongs to. An `app_` token cannot search — the CLI rejects it locally (`validation`, in `internal/client/search_route.go`) before any request, distinct from a server-side `FORBIDDEN`.
3030
- `OCTO_SPACE_ID` (or `--space`) supplies space context for platform-scoped bots. Space-scoped bots resolve their space server-side.
31-
32-
## Command Structure (11 active domains, 284 operations)
31+
- Agent Mail authorization is attached to the current Bot and Space.
32+
The Bot may come from a stored profile or the same runtime-provided
33+
`OCTO_BOT_TOKEN` used by every other service. A real authorization login
34+
resolves the authoritative RobotID through `/v1/bot/register`; `--dry-run`
35+
never performs that lookup. The mailbox token is kept in a dedicated
36+
encrypted file under `OCTO_CONFIG_DIR`, keyed by RobotID, SpaceID, the
37+
normalized Octo API origin, and a SHA-256 fingerprint of the authorizing Bot
38+
token. This prevents a replaced Bot token, another Space, or another gateway
39+
origin from unlocking the old mailbox credential without storing the Bot
40+
token again. Changing the API origin therefore requires a Mail authorization
41+
for that origin. Mail never uses a separate token or base-URL environment
42+
variable.
43+
44+
## Command Structure (12 active domains, 308 operations)
3345

3446
Service commands are auto-registered. The hand-written leaves are `schema`, `version`, `api` (generic passthrough), `config`, `auth`, and the cobra-generated `completion`.
3547

@@ -84,17 +96,28 @@ octo-cli html list | get | publish | versions | rm (octo-doc HTML docs;
8496
element get|replace
8597
reply
8698
87-
octo-cli auth login | status | logout | list
99+
octo-cli mail me
100+
auth login|status
101+
mailbox list
102+
address list
103+
thread get
104+
message list|read|raw|send|send-intent|reply|reply-draft
105+
reply-all|forward|flag|delete|delivery|auto-reply-context
106+
state|changes
107+
attachment download
108+
draft list|create|create-agent|update|send|delete
109+
110+
octo-cli auth login | status | update | logout | list
88111
octo-cli schema [--list [domain] | <operation-id>]
89112
octo-cli api <METHOD> <PATH> [--params ...] [--data ...] [--service ...]
90113
octo-cli config show
91114
octo-cli completion bash|zsh|fish|powershell
92115
octo-cli version
93116
```
94117

95-
`octo-cli auth login` stores a bot token (read from a hidden prompt, `--with-token` stdin, or `--token-file` — never argv) under a profile keyed by `--bot-id`/`--profile`. `status`/`list` show metadata only (tokens always masked); `logout` removes a profile.
118+
`octo-cli auth login` stores a bot token (read from a hidden prompt, `--with-token` stdin, or `--token-file` — never argv) under a profile keyed by `--bot-id`/`--profile`. `update` changes non-secret profile settings such as the API base URL without re-entering or rewriting the token. `status`/`list` show metadata only (tokens always masked); `logout` removes a profile.
96119

97-
Bot-type capability and per-command flags are in `docs/octo-cli-design.md`. Agent-facing usage lives under `skills/` (`octo-shared`, `octo-matter` (withheld — see above), `octo-summary` (withheld — see CHANGELOG "Currently withheld" note), `octo-messaging`, `octo-files`, `octo-drive`, `octo-docs`, `octo-html`, `octo-marketplace`) — keep those in sync when command shapes change.
120+
Bot-type capability and per-command flags are in `docs/octo-cli-design.md`. Agent-facing usage lives under `skills/` (`octo-shared`, `octo-matter` (withheld — see above), `octo-summary` (withheld — see CHANGELOG "Currently withheld" note), `octo-messaging`, `octo-files`, `octo-drive`, `octo-docs`, `octo-html`, `octo-marketplace`, `octo-mail`) — keep those in sync when command shapes change.
98121

99122
The hand-written drive leaves (`cmd/drive*.go`) are the exception to "everything is generated": `upload file` / `download file` / `share download` are multi-request transfers, `share create` branches on node type, and `share blob-create` / `share access` / `share download` take an argument shape (positional body field, whole share URL) the engine cannot express. They replace the generated leaf of the same name where one exists, so the spec still documents the endpoint for `octo-cli schema`.
100123

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ octo-cli thread create group-abc --name "design review"
128128
octo-cli file upload --file ./report.pdf
129129
octo-cli file download abc123 --jq '.data.url'
130130

131+
# Agent Mail — policy-aware send; the server may accept it or save a Draft
132+
# depending on the mailbox's current outbound mode.
133+
octo-cli mail message send-intent \
134+
--to recipient@example.com --subject "Status update" --text "Ready." \
135+
--idempotency-key "send-example-001"
136+
octo-cli mail thread get T123
137+
octo-cli mail draft list
138+
131139
# Docs — create/list/search, then read and incrementally edit the live body.
132140
octo-cli docs create --title "Design notes"
133141
octo-cli docs list --sort updatedAt:desc
@@ -181,6 +189,7 @@ octo-cli schema --list # all operations across all domains
181189
octo-cli schema --list message # operations in one domain
182190
octo-cli schema message.send # request/response schema for one op
183191
octo-cli config show # resolved config (token masked)
192+
octo-cli auth update --api-base-url https://octo.example # persist endpoint for the active profile
184193

185194
# Generic passthrough for ops that aren't auto-registered.
186195
octo-cli api GET /v1/messages --params '{"chat_id":"chat-1"}'
@@ -350,6 +359,8 @@ Machine-readable usage docs for AI Agents live under [`skills/`](./skills/):
350359
**separate backend** from `octo-docs`): publish immutable versions, drafts,
351360
share codes & per-uid grants, media assets, inline comments, agent element
352361
read/replace.
362+
- [`octo-mail`](./skills/octo-mail/SKILL.md) — Agent Mail authorization,
363+
mailbox access, message handling, drafts, attachments, and delivery status.
353364
- [`octo-summary`](./skills/octo-summary/SKILL.md) — create owner-only summaries
354365
from explicit sources, then discover, read, and cite summaries visible to the
355366
personal Agent's human owner. **Temporarily withheld** while the create
@@ -360,6 +371,7 @@ These docs are also **embedded in the binary**, so a released `octo-cli` ships t
360371

361372
```bash
362373
octo-cli skills # list embedded skills
374+
octo-cli skills octo-mail # load the official Agent Mail guide
363375
octo-cli skills octo-docs # print one skill (SKILL.md + its references)
364376
octo-cli skills --install ~/.config/octo/skills # write every skill (SKILL.md + references) to a dir
365377
```

cmd/api_secrets_test.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,13 @@ func TestAPI_AStringValueAtASecretPropertyStillWorks(t *testing.T) {
425425

426426
// TestSecrets_NoSpecDeclaresASecretApiCannotCollectOrMask is round-16 P2-2's tripwire.
427427
//
428-
// `api` recovers secrets by matching the concrete path and walking the body, which leaves
429-
// two declarations it would silently ignore: a secret in *query* or *header* position (
430-
// pathSegments drops the query component and headers are not read at all), and a secret on a
431-
// non-string schema, which the request side now refuses outright rather than masks.
428+
// `api` recovers secrets by matching the concrete path and walking the body, which leaves a
429+
// secret in *query* position unmasked because pathSegments drops the query component. A
430+
// header declaration is safe here: the generic command has no arbitrary-header input, so
431+
// there is no caller-supplied header value for it to collect; generated commands collect
432+
// those values from their bound flags. If `api` ever gains a header option, its own tests must
433+
// extend apiSecretsForRequest before that option ships. A secret on a non-string schema is
434+
// likewise unsupported because the request side refuses it outright rather than masking it.
432435
//
433436
// No embedded spec declares either today, which is why neither is a live leak — but nothing
434437
// pinned that, so adding one would produce an unmasked credential-equivalent value with no
@@ -448,10 +451,10 @@ func TestSecrets_NoSpecDeclaresASecretApiCannotCollectOrMask(t *testing.T) {
448451
if !p.Secret {
449452
continue
450453
}
451-
if p.In != "path" {
454+
if p.In != "path" && p.In != "header" {
452455
t.Errorf("%s declares x-octo-secret on a %s parameter %q, but `api` only "+
453-
"collects path and body secrets — teach apiSecretsForRequest to read %s "+
454-
"position before shipping this declaration", info.ID, p.In, p.Name, p.In)
456+
"collects path and body secrets and cannot safely recover a %s value",
457+
info.ID, p.In, p.Name, p.In)
455458
}
456459
if p.Type != "" && p.Type != "string" {
457460
t.Errorf("%s declares x-octo-secret on a %s-typed parameter %q; the masker's "+

cmd/auth.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,74 @@ func newAuthCmd(f *cmdutil.Factory) *cobra.Command {
3131
cmd.AddCommand(
3232
newAuthLoginCmd(f),
3333
newAuthStatusCmd(f),
34+
newAuthUpdateCmd(f),
3435
newAuthLogoutCmd(f),
3536
newAuthListCmd(f),
3637
)
3738
return cmd
3839
}
3940

41+
func newAuthUpdateCmd(f *cmdutil.Factory) *cobra.Command {
42+
var apiBaseURL string
43+
44+
cmd := &cobra.Command{
45+
Use: "update",
46+
Short: "Update non-secret settings for a stored bot profile",
47+
Args: cobra.NoArgs,
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
if strings.TrimSpace(apiBaseURL) == "" {
50+
return failErr(f, output.ErrValidation(
51+
"--api-base-url is required",
52+
"pass the public OCTO API origin for this Bot profile",
53+
))
54+
}
55+
normalized, err := config.NormalizeAPIBaseURL(apiBaseURL)
56+
if err != nil {
57+
return failErr(f, output.ErrValidation(
58+
err.Error(),
59+
"pass only the Octo gateway base URL, without a service path",
60+
))
61+
}
62+
apiBaseURL = normalized
63+
64+
store, err := f.AuthStore()
65+
if err != nil {
66+
return failErr(f, err)
67+
}
68+
name, meta, status, err := store.ActiveProfile(f.Globals.Profile, selectorBotID(f))
69+
if err != nil {
70+
return failErr(f, err)
71+
}
72+
switch status {
73+
case authstore.StatusFound:
74+
if err := store.UpdateProfileAPIBaseURL(name, apiBaseURL); err != nil {
75+
return failErr(f, err)
76+
}
77+
result := map[string]any{
78+
"profile": name, "api_base_url": apiBaseURL,
79+
}
80+
putIfSet(result, "robot_id", meta.RobotID)
81+
return emitJSON(f, result)
82+
case authstore.StatusAmbiguous:
83+
return failErr(f, output.ErrValidation(
84+
"multiple profiles configured; specify which Bot to update",
85+
"pass --bot-id <robot_id> or --profile <name>",
86+
))
87+
case authstore.StatusMissing:
88+
return failErr(f, output.ErrAuth(
89+
"no matching profile", "check `octo-cli auth list`",
90+
))
91+
default:
92+
return failErr(f, output.ErrValidation(
93+
"no stored profiles", "run `octo-cli auth login` first",
94+
))
95+
}
96+
},
97+
}
98+
cmd.Flags().StringVar(&apiBaseURL, "api-base-url", "", "set OCTO API base URL for this profile")
99+
return cmd
100+
}
101+
40102
func newAuthLoginCmd(f *cmdutil.Factory) *cobra.Command {
41103
var withToken bool
42104
var tokenFile string

cmd/auth_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,97 @@ func TestAuth_LoginListStatusLogout(t *testing.T) {
8989
}
9090
}
9191

92+
func TestAuth_UpdateAPIBaseURLPreservesCredentials(t *testing.T) {
93+
t.Setenv(authstore.EnvConfigDir, t.TempDir())
94+
loginToken(t, "cli_demo", "app_demo_token")
95+
96+
store, err := authstore.New()
97+
if err != nil {
98+
t.Fatalf("authstore.New: %v", err)
99+
}
100+
if err := store.SaveMailCredential("cli_demo", "omb_mail_secret"); err != nil {
101+
t.Fatalf("SaveMailCredential: %v", err)
102+
}
103+
104+
f := newTestFactoryWithReg()
105+
out, _, err := execRoot(t, f,
106+
"auth", "update", "--bot-id", "cli_demo",
107+
"--api-base-url", "http://127.0.0.1:28080/")
108+
if err != nil {
109+
t.Fatalf("auth update: %v", err)
110+
}
111+
data := dataOf(t, out)
112+
if data["api_base_url"] != "http://127.0.0.1:28080" {
113+
t.Fatalf("api_base_url = %v", data["api_base_url"])
114+
}
115+
116+
botToken, err := store.GetToken("cli_demo")
117+
if err != nil || botToken != "app_demo_token" {
118+
t.Fatalf("Bot token changed: %q, %v", botToken, err)
119+
}
120+
mailToken, err := store.GetMailCredential("cli_demo")
121+
if err != nil || mailToken != "omb_mail_secret" {
122+
t.Fatalf("mail token changed: %q, %v", mailToken, err)
123+
}
124+
125+
f = newTestFactoryWithReg()
126+
out, _, err = execRoot(t, f, "auth", "status", "--bot-id", "cli_demo")
127+
if err != nil {
128+
t.Fatalf("auth status: %v", err)
129+
}
130+
if activeOf(t, out)["api_base_url"] != "http://127.0.0.1:28080" {
131+
t.Fatalf("active profile = %#v", activeOf(t, out))
132+
}
133+
}
134+
135+
func TestAuth_UpdateAPIBaseURLRejectsInvalidOriginWithoutChangingProfile(t *testing.T) {
136+
t.Setenv(authstore.EnvConfigDir, t.TempDir())
137+
loginToken(t, "cli_demo", "app_demo_token")
138+
139+
store, err := authstore.New()
140+
if err != nil {
141+
t.Fatalf("authstore.New: %v", err)
142+
}
143+
if err := store.UpdateProfileAPIBaseURL("cli_demo", "https://octo.example"); err != nil {
144+
t.Fatalf("seed API base URL: %v", err)
145+
}
146+
147+
for _, invalid := range []string{
148+
"not a url at all",
149+
"https://evil.example/fleet/api?x=1",
150+
} {
151+
t.Run(invalid, func(t *testing.T) {
152+
f := newTestFactoryWithReg()
153+
_, _, err := execRoot(t, f,
154+
"auth", "update", "--bot-id", "cli_demo",
155+
"--api-base-url", invalid)
156+
ee := output.AsExitError(err)
157+
if ee == nil || ee.Type != "validation" {
158+
t.Fatalf("auth update error = %v, want validation", err)
159+
}
160+
161+
profiles, err := store.LoadProfiles()
162+
if err != nil {
163+
t.Fatalf("LoadProfiles: %v", err)
164+
}
165+
if got := profiles["cli_demo"].APIBaseURL; got != "https://octo.example" {
166+
t.Fatalf("invalid update changed API base URL to %q", got)
167+
}
168+
})
169+
}
170+
}
171+
172+
func TestAuth_UpdateAPIBaseURLRequiresStoredProfile(t *testing.T) {
173+
t.Setenv(authstore.EnvConfigDir, t.TempDir())
174+
f := newTestFactoryWithReg()
175+
_, _, err := execRoot(t, f,
176+
"auth", "update", "--api-base-url", "https://octo.example")
177+
ee := output.AsExitError(err)
178+
if ee == nil || ee.Type != "validation" || !strings.Contains(ee.Message, "no stored profiles") {
179+
t.Fatalf("auth update error = %v", err)
180+
}
181+
}
182+
92183
func TestAuth_DuplicateRobotIDRejected(t *testing.T) {
93184
t.Setenv(authstore.EnvConfigDir, t.TempDir())
94185
loginToken(t, "cli_x", "app_a") // profile name defaults to "cli_x", robot_id cli_x

cmd/cmd_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,12 @@ func TestSkipValidation(t *testing.T) {
474474
if !skipValidation(findCmd("config", "show")) {
475475
t.Error("config show should skip via parent")
476476
}
477+
if !skipValidation(findCmd("auth")) {
478+
t.Error("top-level auth should skip validation")
479+
}
480+
if skipValidation(findCmd("mail", "auth", "login")) {
481+
t.Error("nested mail auth login should NOT skip validation")
482+
}
477483

478484
// Pick any real service leaf — should NOT skip.
479485
svcLeaf := findCmd("event", "list")

0 commit comments

Comments
 (0)