Skip to content

Commit befe3b1

Browse files
rowantrollopeclaude
andcommitted
Unify API keys around workspace scope with per-mount permissions
- Collapse MCP and CLI tokens into one workspace-composition-scoped API key. Auth dispatch is now by token prefix so the same key works for both the MCP server and the CLI HTTP API. - Add per-mount capabilities on workspace keys so /skills can be read-only while /memory is read-write on the same key. Validated against the composition manifest at create time. - New POST /v2/workspaces/:id/api-keys endpoint; MCP server resolves the composition manifest on auth and routes file_* tool calls by path prefix to the matching volume, enforcing per-mount capability. - Frontend: rename MCP -> API Keys (sidebar, route alias at /api-keys), drop the Access Type radio, single workspace composition picker, per- volume permission picker. Drop the Type column; scope = Workspace or Control plane. Filter via dropdown next to Local/Create. Workspace profile gets a Settings-tab API keys summary; volume Settings keys panel removed. - MountsSection Add Volumes dialog now has one permission dropdown per row instead of a single global setting. - Read-only mount enforcement: fullReconciler.execUpload rejects writes for readonly daemons (the real source of the silent-import bug); mount reconcile planner downgrades local-side ops to Skipped on readonly and resolves remote-wins conflicts as redownloads; daemon chmods the mount root to 0o555 after initial sync so shell writes fail with EACCES. - Misc: remove "Agent search tools are built in" notice + MCP Endpoint panel from API Keys page (endpoint lives at /mcp/connect); drop the "Mounted" tag from the Filesystem table; update empty-state hero copy. - Plan doc at plans/api-keys-unification.md captures the design pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b25dc59 commit befe3b1

36 files changed

Lines changed: 2865 additions & 1578 deletions

cmd/afs/mount_reconcile.go

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func buildMountReconcilePlan(ctx context.Context, d *syncDaemon) (mountReconcile
9393
r, rok := remote[path]
9494
switch {
9595
case lok && !rok:
96-
plan.addLocalOnly(path, l, d.cfg.MaxFileBytes)
96+
plan.addLocalOnly(path, l, d.cfg.MaxFileBytes, d.cfg.Readonly)
9797
case !lok && rok:
9898
plan.DownloadCount++
9999
plan.Operations = append(plan.Operations, mountReconcileOperation{
@@ -138,7 +138,21 @@ func (p mountReconcilePlan) requiresConfirmation() bool {
138138
return p.ImportCount > 0 || p.UploadCount > 0 || p.DownloadCount > 0 || p.SkippedCount > 0
139139
}
140140

141-
func (p *mountReconcilePlan) addLocalOnly(path string, meta observedMeta, maxFileBytes int64) {
141+
func (p *mountReconcilePlan) addLocalOnly(path string, meta observedMeta, maxFileBytes int64, readonly bool) {
142+
if readonly {
143+
// Read-only mounts never push local changes upstream. Surface the
144+
// local file in the plan as a skip so the user knows it will stay
145+
// local-only (and can move it aside if they actually meant to add it
146+
// to the workspace).
147+
p.SkippedCount++
148+
p.Operations = append(p.Operations, mountReconcileOperation{
149+
Code: "S",
150+
Path: path,
151+
Kind: meta.kind,
152+
Details: "local only; mount is read-only — file stays local and is not uploaded",
153+
})
154+
return
155+
}
142156
if meta.kind == "file" && maxFileBytes > 0 && meta.size > maxFileBytes {
143157
p.SkippedCount++
144158
p.Operations = append(p.Operations, mountReconcileOperation{
@@ -177,7 +191,7 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
177191
})
178192
continue
179193
}
180-
plan.addKnownUpload(path, l, d.cfg.MaxFileBytes)
194+
plan.addKnownUpload(path, l, d.cfg.MaxFileBytes, d.cfg.Readonly)
181195
case !lok && rok:
182196
if hasStored && stored.Deleted {
183197
if r.kind == "dir" && mountRemoteDirHasLiveDescendants(path, remote, st.Entries) {
@@ -190,6 +204,16 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
190204
})
191205
continue
192206
}
207+
if d.cfg.Readonly {
208+
plan.SkippedCount++
209+
plan.Operations = append(plan.Operations, mountReconcileOperation{
210+
Code: "S",
211+
Path: path,
212+
Kind: r.kind,
213+
Details: "local deletion pending; mount is read-only — remote kept and will be redownloaded",
214+
})
215+
continue
216+
}
193217
plan.DeleteRemoteCount++
194218
plan.Operations = append(plan.Operations, mountReconcileOperation{
195219
Code: "DR",
@@ -200,6 +224,20 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
200224
continue
201225
}
202226
if hasStored {
227+
if d.cfg.Readonly {
228+
// Read-only mounts treat the remote as source of truth. Any
229+
// state where remote exists and local is gone — whether or
230+
// not the remote also changed — resolves to a redownload,
231+
// never a "local deleted" / conflict.
232+
plan.DownloadCount++
233+
plan.Operations = append(plan.Operations, mountReconcileOperation{
234+
Code: "D",
235+
Path: path,
236+
Kind: r.kind,
237+
Details: "local deleted while mount is read-only — redownload from workspace",
238+
})
239+
continue
240+
}
203241
if observedChangedFromStored(r, stored, false) {
204242
plan.addConflict(path, "local deleted while remote changed")
205243
continue
@@ -231,6 +269,19 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
231269
plan.Baseline[path] = entry
232270
continue
233271
}
272+
if d.cfg.Readonly {
273+
// Read-only mounts let the remote win without prompting; the
274+
// local divergence is preserved as a conflict-copy by the
275+
// downloader.
276+
plan.DownloadCount++
277+
plan.Operations = append(plan.Operations, mountReconcileOperation{
278+
Code: "D",
279+
Path: path,
280+
Kind: r.kind,
281+
Details: "mount is read-only — redownload remote, keep local as conflict-copy",
282+
})
283+
continue
284+
}
234285
plan.addConflict(path, detail)
235286
continue
236287
}
@@ -240,7 +291,7 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
240291
case !localChanged && !remoteChanged:
241292
plan.SameCount++
242293
case localChanged && !remoteChanged:
243-
plan.addKnownUpload(path, l, d.cfg.MaxFileBytes)
294+
plan.addKnownUpload(path, l, d.cfg.MaxFileBytes, d.cfg.Readonly)
244295
case !localChanged && remoteChanged:
245296
plan.DownloadCount++
246297
plan.Operations = append(plan.Operations, mountReconcileOperation{
@@ -250,6 +301,17 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
250301
Details: "remote changed while unmounted; download to local folder",
251302
})
252303
default:
304+
if d.cfg.Readonly {
305+
// Both diverged but read-only mounts always defer to remote.
306+
plan.DownloadCount++
307+
plan.Operations = append(plan.Operations, mountReconcileOperation{
308+
Code: "D",
309+
Path: path,
310+
Kind: r.kind,
311+
Details: "mount is read-only — redownload remote, keep local as conflict-copy",
312+
})
313+
continue
314+
}
253315
entry, same, _, err := mountBaselineEntry(ctx, d, path, l, r, now)
254316
if err != nil {
255317
return mountReconcilePlan{}, err
@@ -266,7 +328,20 @@ func buildKnownMountReconcilePlan(ctx context.Context, d *syncDaemon, local, rem
266328
return plan, nil
267329
}
268330

269-
func (p *mountReconcilePlan) addKnownUpload(path string, meta observedMeta, maxFileBytes int64) {
331+
func (p *mountReconcilePlan) addKnownUpload(path string, meta observedMeta, maxFileBytes int64, readonly bool) {
332+
if readonly {
333+
// Read-only mounts can't push their local edits back. Mark as skipped
334+
// so the user sees that the local divergence is intentional and the
335+
// remote stays the source of truth.
336+
p.SkippedCount++
337+
p.Operations = append(p.Operations, mountReconcileOperation{
338+
Code: "S",
339+
Path: path,
340+
Kind: meta.kind,
341+
Details: "local changed while unmounted; mount is read-only — local divergence kept, remote unchanged",
342+
})
343+
return
344+
}
270345
if meta.kind == "file" && maxFileBytes > 0 && meta.size > maxFileBytes {
271346
p.SkippedCount++
272347
p.Operations = append(p.Operations, mountReconcileOperation{

cmd/afs/sync_daemon.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@ func (d *syncDaemon) start(ctx context.Context, onProgress ProgressFunc, skipRec
177177
cancel()
178178
return fmt.Errorf("initial reconcile: %w", err)
179179
}
180+
if d.cfg.Readonly {
181+
// Lock down the mount root so user shells can't add new top-level
182+
// files into a read-only volume. We can't blanket-chmod the whole
183+
// tree without breaking subsequent steady-state downloads into
184+
// existing subdirs; locking the root catches the most common
185+
// "echo > file.txt in a readonly mount" mistake.
186+
if err := os.Chmod(d.cfg.LocalRoot, 0o555); err != nil {
187+
fmt.Fprintf(os.Stderr, "afs sync: chmod read-only mount root %s: %v\n", d.cfg.LocalRoot, err)
188+
}
189+
}
180190
}
181191

182192
d.startQueryIndexWorker(dctx)

cmd/afs/sync_full_reconciler.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,14 @@ func (f *fullReconciler) execDeleteRemote(ctx context.Context, a syncAction) err
926926
}
927927

928928
func (f *fullReconciler) execUpload(ctx context.Context, a syncAction) error {
929+
if f.r.readonly {
930+
// Read-only mounts must never push local changes back to the workspace.
931+
// The mount reconcile planner already downgrades imports/uploads to
932+
// "skipped" so users see this in the plan; this is the runtime guard
933+
// for any path that schedules an upload directly.
934+
fmt.Fprintf(os.Stderr, "afs sync: skipping upload of %s — mount is read-only\n", a.path)
935+
return nil
936+
}
929937
data, err := os.ReadFile(a.absPath)
930938
if err != nil {
931939
if os.IsNotExist(err) {

internal/controlplane/auth.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ type AuthIdentity struct {
7373
ScopedWorkspace string
7474
MCPProfile string
7575
Readonly bool
76+
// WorkspaceMountCapabilities is set for workspace-scoped MCP tokens. It
77+
// maps volume_id → capability (ro/rw/rw-checkpoint) for the volumes
78+
// mounted in the bound Agent Workspace composition. The MCP server and CLI
79+
// HTTP middleware use this map to enforce per-mount access.
80+
WorkspaceMountCapabilities map[string]string
7681
}
7782

7883
type authRuntimeConfigResponse struct {
@@ -355,7 +360,13 @@ func (a *AuthHandler) DeleteCurrentIdentity(ctx context.Context) error {
355360

356361
func (a *AuthHandler) authenticate(r *http.Request) (*AuthIdentity, error) {
357362
if bearer, ok := bearerTokenFromRequest(r); ok {
358-
if isMCPTokenAuthPath(r.URL.Path) {
363+
// Dispatch by token prefix so a single workspace-scoped key works for
364+
// both the MCP server and the CLI HTTP API. afs_mcp_* and afs_cp_*
365+
// are MCP/control-plane tokens; afs_cli_* is the legacy CLI token
366+
// kept for onboarding bootstrap.
367+
switch {
368+
case strings.HasPrefix(bearer, mcpAccessTokenPrefix+"_"),
369+
strings.HasPrefix(bearer, mcpControlPlaneTokenPrefix+"_"):
359370
if a.mcpAuthenticate == nil {
360371
return nil, ErrUnauthorized
361372
}
@@ -364,13 +375,28 @@ func (a *AuthHandler) authenticate(r *http.Request) (*AuthIdentity, error) {
364375
return nil, ErrUnauthorized
365376
}
366377
return identity, nil
367-
}
368-
if a.cliAuthenticate != nil {
378+
case strings.HasPrefix(bearer, cliAccessTokenPrefix+"_"):
379+
if a.cliAuthenticate == nil {
380+
return nil, ErrUnauthorized
381+
}
369382
identity, err := a.cliAuthenticate(r.Context(), bearer)
370383
if err != nil {
371384
return nil, ErrUnauthorized
372385
}
373386
return identity, nil
387+
default:
388+
// Unknown prefix: try MCP first, then CLI as a fallback.
389+
if a.mcpAuthenticate != nil {
390+
if identity, err := a.mcpAuthenticate(r.Context(), bearer); err == nil {
391+
return identity, nil
392+
}
393+
}
394+
if a.cliAuthenticate != nil {
395+
if identity, err := a.cliAuthenticate(r.Context(), bearer); err == nil {
396+
return identity, nil
397+
}
398+
}
399+
return nil, ErrUnauthorized
374400
}
375401
}
376402
if a == nil || a.mode() == AuthModeNone {

internal/controlplane/catalog.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ func (c *workspaceCatalog) migrate(ctx context.Context) error {
174174
profile TEXT NOT NULL DEFAULT '',
175175
template_slug TEXT NOT NULL DEFAULT '',
176176
readonly INTEGER NOT NULL DEFAULT 0,
177+
mount_capabilities TEXT NOT NULL DEFAULT '',
177178
secret_hash TEXT NOT NULL,
178179
secret TEXT NOT NULL DEFAULT '',
179180
created_at TEXT NOT NULL,
@@ -217,6 +218,7 @@ func (c *workspaceCatalog) migrate(ctx context.Context) error {
217218
`ALTER TABLE mcp_access_tokens ADD COLUMN IF NOT EXISTS secret TEXT NOT NULL DEFAULT ''`,
218219
`ALTER TABLE mcp_access_tokens ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT ''`,
219220
`ALTER TABLE mcp_access_tokens ADD COLUMN IF NOT EXISTS capability TEXT NOT NULL DEFAULT ''`,
221+
`ALTER TABLE mcp_access_tokens ADD COLUMN IF NOT EXISTS mount_capabilities TEXT NOT NULL DEFAULT ''`,
220222
`UPDATE cli_access_tokens SET scope = 'account' WHERE scope = ''`,
221223
`UPDATE cli_access_tokens SET capability = 'account' WHERE capability = ''`,
222224
`CREATE INDEX IF NOT EXISTS idx_cli_access_tokens_scope ON cli_access_tokens(scope)`,
@@ -255,6 +257,7 @@ func (c *workspaceCatalog) migrate(ctx context.Context) error {
255257
`ALTER TABLE mcp_access_tokens ADD COLUMN secret TEXT NOT NULL DEFAULT ''`,
256258
`ALTER TABLE mcp_access_tokens ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
257259
`ALTER TABLE mcp_access_tokens ADD COLUMN capability TEXT NOT NULL DEFAULT ''`,
260+
`ALTER TABLE mcp_access_tokens ADD COLUMN mount_capabilities TEXT NOT NULL DEFAULT ''`,
258261
}
259262
for _, statement := range sqliteAlterations {
260263
if _, err := c.execContext(ctx, statement); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {

internal/controlplane/catalog_store.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ type catalogStore interface {
5757

5858
CreateCLIAccessToken(ctx context.Context, item cliAccessTokenRecord) error
5959
GetCLIAccessToken(ctx context.Context, tokenID string) (cliAccessTokenRecord, error)
60+
ListAllCLIAccessTokens(ctx context.Context) ([]cliAccessTokenRecord, error)
6061
TouchCLIAccessToken(ctx context.Context, tokenID, lastUsedAt string) error
62+
RevokeCLIAccessTokenByID(ctx context.Context, tokenID, revokedAt string) error
6163
RevokeCLIAccessTokensByOwner(ctx context.Context, ownerSubject, revokedAt string) error
6264

6365
CreateMCPAccessToken(ctx context.Context, item mcpAccessTokenRecord) error

internal/controlplane/cli_token_catalog.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,49 @@ func (c *workspaceCatalog) GetCLIAccessToken(ctx context.Context, tokenID string
8888
return items[0], nil
8989
}
9090

91+
func (c *workspaceCatalog) ListAllCLIAccessTokens(ctx context.Context) ([]cliAccessTokenRecord, error) {
92+
rows, err := c.queryContext(ctx, c.rebind(`SELECT
93+
id,
94+
name,
95+
owner_subject,
96+
owner_label,
97+
database_id,
98+
workspace_id,
99+
workspace_name,
100+
scope,
101+
capability,
102+
secret_hash,
103+
created_at,
104+
last_used_at,
105+
expires_at,
106+
revoked_at
107+
FROM cli_access_tokens
108+
WHERE revoked_at = ''
109+
ORDER BY created_at DESC`))
110+
if err != nil {
111+
return nil, err
112+
}
113+
defer rows.Close()
114+
return scanCLIAccessTokenRows(rows)
115+
}
116+
91117
func (c *workspaceCatalog) TouchCLIAccessToken(ctx context.Context, tokenID, lastUsedAt string) error {
92118
_, err := c.execContext(ctx, c.rebind(`UPDATE cli_access_tokens
93119
SET last_used_at = ?
94120
WHERE id = ?`), strings.TrimSpace(lastUsedAt), strings.TrimSpace(tokenID))
95121
return err
96122
}
97123

124+
func (c *workspaceCatalog) RevokeCLIAccessTokenByID(ctx context.Context, tokenID, revokedAt string) error {
125+
_, err := c.execContext(ctx, c.rebind(`UPDATE cli_access_tokens
126+
SET revoked_at = ?
127+
WHERE id = ? AND revoked_at = ''`),
128+
strings.TrimSpace(revokedAt),
129+
strings.TrimSpace(tokenID),
130+
)
131+
return err
132+
}
133+
98134
func (c *workspaceCatalog) RevokeCLIAccessTokensByOwner(ctx context.Context, ownerSubject, revokedAt string) error {
99135
_, err := c.execContext(ctx, c.rebind(`UPDATE cli_access_tokens
100136
SET revoked_at = ?

internal/controlplane/cli_tokens.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,58 @@ func (m *DatabaseManager) createCLIAccessTokenRecordWithOptions(ctx context.Cont
199199
return response, nil
200200
}
201201

202+
// ListAllCLIAccessTokens returns every active CLI access token owned by the
203+
// caller's subject. Mirrors the MCP token list shape so the dashboard can
204+
// merge both into one view.
205+
func (m *DatabaseManager) ListAllCLIAccessTokens(ctx context.Context) ([]cliAccessTokenResponse, error) {
206+
if m == nil || m.catalog == nil {
207+
return nil, fmt.Errorf("cli token storage is unavailable")
208+
}
209+
subject := authSubjectFromContext(ctx)
210+
items, err := m.catalog.ListAllCLIAccessTokens(ctx)
211+
if err != nil {
212+
return nil, err
213+
}
214+
out := make([]cliAccessTokenResponse, 0, len(items))
215+
for _, item := range items {
216+
ownerSubject := strings.TrimSpace(item.OwnerSubject)
217+
if subject != "" && ownerSubject != "" && ownerSubject != subject {
218+
continue
219+
}
220+
// Bootstrap onboarding tokens (account scope, no workspace) are
221+
// internal credentials; hide them from the dashboard list.
222+
if isAccountCLIScope(item.Scope) {
223+
continue
224+
}
225+
out = append(out, cliAccessTokenResponseFromRecord(item))
226+
}
227+
return out, nil
228+
}
229+
230+
// RevokeCLIAccessToken marks a CLI token revoked. Scoped to the caller's
231+
// subject so users cannot revoke tokens belonging to others.
232+
func (m *DatabaseManager) RevokeCLIAccessToken(ctx context.Context, tokenID string) error {
233+
if m == nil || m.catalog == nil {
234+
return fmt.Errorf("cli token storage is unavailable")
235+
}
236+
tokenID = strings.TrimSpace(tokenID)
237+
if tokenID == "" {
238+
return os.ErrNotExist
239+
}
240+
record, err := m.catalog.GetCLIAccessToken(ctx, tokenID)
241+
if err != nil {
242+
return err
243+
}
244+
if strings.TrimSpace(record.RevokedAt) != "" {
245+
return os.ErrNotExist
246+
}
247+
subject := authSubjectFromContext(ctx)
248+
if subject != "" && strings.TrimSpace(record.OwnerSubject) != "" && strings.TrimSpace(record.OwnerSubject) != subject {
249+
return os.ErrNotExist
250+
}
251+
return m.catalog.RevokeCLIAccessTokenByID(ctx, tokenID, time.Now().UTC().Format(timeRFC3339))
252+
}
253+
202254
func (m *DatabaseManager) AuthenticateCLIAccessToken(ctx context.Context, rawToken string) (cliAccessTokenRecord, error) {
203255
if m == nil || m.catalog == nil {
204256
return cliAccessTokenRecord{}, ErrCLIAccessTokenInvalid

0 commit comments

Comments
 (0)