Compare commits

..

4 Commits

Author SHA1 Message Date
Jiayuan Zhang
f52db9e96c feat(server): install_token mint+exchange + daemon_token revoke (MUL-2305)
Phase 1 of RFC MUL-2297 — DB and credential contract for the new runtime
install flow. CLI/daemon/UI plumbing lands in later phases.

Schema (migration 091):
- daemon_token.revoked_at — explicit revoke replaces TTL-based expiry; the
  exchange path now mints daemon_token rows with a ~100y expires_at so the
  cleanup query stays intact while the credential is effectively long-lived
  until revoked.
- install_token — short-lived (15m) single-use credential. used_at IS NULL
  is the atomic gate enforced inside the UPDATE so a concurrent second
  exchange returns zero rows.

API:
- POST /api/workspaces/{id}/install-tokens — admin-only mint, returns mit_
  once; only the hash is stored.
- POST /api/install-tokens/exchange — public (the mit_ is the credential);
  atomically consumes the install_token and returns a fresh mdt_.

Error contract for Phase 2 daemon installer:
- 401 invalid_install_token — unknown hash OR expired
- 401 install_token_already_used — hash exists but used_at IS NOT NULL

Co-authored-by: multica-agent <github@multica.ai>
2026-05-17 00:09:48 +08:00
Jiayuan Zhang
9bd17058f8 fix(daemon): bump idle watchdog default 5m → 30m (MUL-2300) (#2728)
* fix(daemon): bump idle watchdog default 5m → 30m (MUL-2300)

The previous 5 min default killed legitimate long assistant outputs (e.g.
RFC-length writeups) where the model streams a single message for many
minutes without any daemon-visible activity. 30 min keeps the safety net
for truly stuck runs (dockerd hang) while leaving headroom for long
writes.

runIdleWatchdog tick interval is window/2, with a 30 s floor that only
applies when interval < 30 s — at window=30 min the natural tick is 15
min, so no sync needed.

Co-authored-by: multica-agent <github@multica.ai>

* docs(daemon): drop stale 5-minute mention from idle watchdog comment

Refers to DefaultAgentIdleWatchdog so the comment stays in sync if the
default shifts again. Follow-up to Emacs review on PR #2728.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 17:20:10 +02:00
Jiayuan Zhang
e00b94b0f9 fix(realtime): invalidate per-issue token usage on task events (MUL-2298) (#2723)
The issue-detail right-rail Token usage card is fed by useQuery(issueUsageOptions(id)),
but the realtime task: handler only invalidated ["issues","tasks"]. As a result the
card only refreshed on remount, so consecutive runs on the same issue left the
numbers stuck until the user navigated away and back. Mirror the existing tasks
invalidation with a prefix invalidation of ["issues","usage"] so any task
lifecycle event refreshes the aggregated usage numbers.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 12:45:27 +02:00
Jiayuan Zhang
4c7a990a25 fix(autopilot): attribute autopilot-created issue to assignee agent (MUL-2293) (#2719)
Before: dispatchCreateIssue copied autopilot.created_by_type/id onto the
new issue's creator_type/creator_id, and the same fields were used as the
ActorType/ActorID of the issue:created event. Result: any issue spawned by
an autopilot was reported as created by the human who first configured
the autopilot, not by the agent that actually owns the work. Downstream
subscriber/activity/notification listeners inherited the same wrong actor.

After: creator and actor are both the autopilot's assignee agent
(creator_type=agent, creator_id=ap.assignee_id). The human owner is still
recoverable via origin_type=autopilot + origin_id.

Audited the other ap.created_by_* usages: analytics attribution
(autopilotActorID, task.go user-id), and the private-agent visibility
gate in shouldSkipDispatch — all correctly read the autopilot's owner,
not the executor, so they stay as-is.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 09:32:15 +02:00
15 changed files with 799 additions and 10 deletions

View File

@@ -262,6 +262,10 @@ export function useRealtimeSync(
// every list-of-tasks query stale" so cache stays fresh even
// when the relevant component isn't currently mounted.
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
// Per-issue token usage card (issue-detail right rail). Same
// shape as the tasks invalidation above — any task lifecycle
// event shifts the aggregated usage numbers.
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
},
};

View File

@@ -219,6 +219,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Post("/api/webhooks/github", h.HandleGitHubWebhook)
r.Get("/api/github/setup", h.GitHubSetupCallback)
// Install-token exchange is public — the `mit_` token in the request
// body IS the credential. A daemon installer hasn't been issued an
// `mdt_` yet, and we don't want to require a user PAT for the
// install-time hand-off. See handler.ExchangeInstallToken for the
// single-use semantics. RFC MUL-2297 Phase 1.
r.Post("/api/install-tokens/exchange", h.ExchangeInstallToken)
// Daemon API routes (require daemon token or valid user token)
r.Route("/api/daemon", func(r chi.Router) {
r.Use(middleware.DaemonAuth(queries, patCache, daemonTokenCache))
@@ -294,6 +301,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Delete("/", h.DeleteMember)
})
r.Delete("/invitations/{invitationId}", h.RevokeInvitation)
// Install-token mint — workspace admin presses "Add
// computer", server hands back a one-shot `mit_` to paste
// into a daemon installer. RFC MUL-2297 Phase 1.
r.Post("/install-tokens", h.CreateInstallToken)
})
// Owner-only access
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)

View File

@@ -46,6 +46,18 @@ func GenerateDaemonToken() (string, error) {
return "mdt_" + hex.EncodeToString(b), nil
}
// GenerateInstallToken creates a new single-use install token: "mit_" + 40
// random hex chars. The raw token is shown to the user once at mint time
// and exchanged by the daemon installer for a long-lived daemon_token.
// See RFC MUL-2297.
func GenerateInstallToken() (string, error) {
b := make([]byte, 20) // 20 bytes = 40 hex chars
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate install token: %w", err)
}
return "mit_" + hex.EncodeToString(b), nil
}
// HashToken returns the hex-encoded SHA-256 hash of a token string.
func HashToken(token string) string {
h := sha256.Sum256([]byte(token))

View File

@@ -25,11 +25,13 @@ const (
// message queue is empty. Backends like Claude Code can hang indefinitely
// on a stuck child process (e.g. `docker ps` against a frozen dockerd),
// in which case `cmd.Wait()` never returns and the task sits at "running"
// for its full DefaultAgentTimeout (2 h). 5 min is conservative enough to
// avoid false positives during long tool calls but tight enough to keep
// stuck runs out of the operator's hair. Set MULTICA_AGENT_IDLE_WATCHDOG=0
// to disable.
DefaultAgentIdleWatchdog = 5 * time.Minute
// for its full DefaultAgentTimeout (2 h). The previous 5 min default
// killed legitimate long assistant outputs (e.g. RFC-length writeups)
// where the model streams a single message for many minutes without any
// daemon-visible activity — see MUL-2300. 30 min keeps the safety net for
// truly stuck runs (dockerd hang) while leaving headroom for long writes.
// Set MULTICA_AGENT_IDLE_WATCHDOG=0 to disable.
DefaultAgentIdleWatchdog = 30 * time.Minute
DefaultRuntimeName = "Local Agent"
DefaultWorkspaceSyncInterval = 30 * time.Second
DefaultHealthPort = 19514
@@ -253,7 +255,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
// MULTICA_AGENT_IDLE_WATCHDOG=0 disables the per-task idle watchdog. We
// route 0 through durationFromEnv so the operator can opt out without
// patching the binary; any positive duration overrides the 5-minute default.
// patching the binary; any positive duration overrides DefaultAgentIdleWatchdog.
agentIdleWatchdog, err := durationFromEnv("MULTICA_AGENT_IDLE_WATCHDOG", DefaultAgentIdleWatchdog)
if err != nil {
return Config{}, err

View File

@@ -0,0 +1,184 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// installTokenTTL bounds how long an install_token stays valid between mint
// and exchange. The RFC (MUL-2297 Phase 1) suggests 15 minutes — long enough
// for a user to copy the snippet, switch to their machine, paste it into an
// installer, and complete a network round-trip; short enough that a token
// leaked onto a chat log or screenshot doesn't stay armed for long.
const installTokenTTL = 15 * time.Minute
// daemonTokenLongLivedExpiry is how far in the future an mdt_ token issued
// via the install_token exchange is dated. The point is "no automatic
// expiry" — explicit revoke via daemon_token.revoked_at is the only way to
// retire one. We keep expires_at NOT NULL (legacy schema constraint) and
// the DeleteExpiredDaemonTokens cleanup query intact by picking a stamp so
// far out that no rational deployment will reach it. See RFC MUL-2297.
const daemonTokenLongLivedExpiry = 100 * 365 * 24 * time.Hour
// CreateInstallTokenResponse is the workspace-admin-facing mint response.
// Token is shown once — the server only retains the hash.
type CreateInstallTokenResponse struct {
ID string `json:"id"`
Token string `json:"token"`
WorkspaceID string `json:"workspace_id"`
ExpiresAt string `json:"expires_at"`
CreatedAt string `json:"created_at"`
}
// CreateInstallToken mints a short-lived single-use install token for the
// authenticated admin's workspace. Routed under
// /api/workspaces/{id}/install-tokens so workspace membership/role is
// enforced by the RequireWorkspaceRoleFromURL middleware before this
// handler runs.
func (h *Handler) CreateInstallToken(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := middleware.WorkspaceIDFromContext(r.Context())
if workspaceID == "" {
writeError(w, http.StatusBadRequest, "workspace_id is required")
return
}
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
rawToken, err := auth.GenerateInstallToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
expiresAt := pgtype.Timestamptz{Time: time.Now().Add(installTokenTTL), Valid: true}
row, err := h.Queries.CreateInstallToken(r.Context(), db.CreateInstallTokenParams{
TokenHash: auth.HashToken(rawToken),
WorkspaceID: wsUUID,
CreatedBy: parseUUID(userID),
ExpiresAt: expiresAt,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create install token")
return
}
writeJSON(w, http.StatusCreated, CreateInstallTokenResponse{
ID: uuidToString(row.ID),
Token: rawToken,
WorkspaceID: uuidToString(row.WorkspaceID),
ExpiresAt: timestampToString(row.ExpiresAt),
CreatedAt: timestampToString(row.CreatedAt),
})
}
// ExchangeInstallTokenRequest is the public unauthenticated body posted by a
// daemon installer. The mit_ token itself is the credential.
type ExchangeInstallTokenRequest struct {
Token string `json:"token"`
DaemonID string `json:"daemon_id"`
}
// ExchangeInstallTokenResponse returns the long-lived daemon token. The
// caller stores `token` and authenticates subsequent /api/daemon/* requests
// with it.
type ExchangeInstallTokenResponse struct {
Token string `json:"token"`
WorkspaceID string `json:"workspace_id"`
DaemonID string `json:"daemon_id"`
ExpiresAt string `json:"expires_at"`
}
// ExchangeInstallToken validates a `mit_` install token, atomically burns
// it, and returns a fresh long-lived `mdt_` daemon token bound to the
// caller-supplied daemon_id. Public (no Authorization header) — the install
// token IS the credential.
//
// Error contract (Phase 2 daemon depends on these):
// - 400 `invalid_install_token` — malformed body or missing fields
// - 401 `invalid_install_token` — unknown hash OR expired
// - 401 `install_token_already_used` — hash exists but used_at IS NOT NULL
// - 500 any DB / token-gen failure
func (h *Handler) ExchangeInstallToken(w http.ResponseWriter, r *http.Request) {
var req ExchangeInstallTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_install_token")
return
}
req.Token = strings.TrimSpace(req.Token)
req.DaemonID = strings.TrimSpace(req.DaemonID)
if req.Token == "" || !strings.HasPrefix(req.Token, "mit_") {
writeError(w, http.StatusBadRequest, "invalid_install_token")
return
}
if req.DaemonID == "" {
writeError(w, http.StatusBadRequest, "daemon_id is required")
return
}
hash := auth.HashToken(req.Token)
// Try to consume the row atomically (single UPDATE flips used_at). A
// miss here means one of: unknown hash, expired, or already used. We
// disambiguate with a follow-up read so the "already used" path can
// return the dedicated error code the daemon installer surfaces to
// the user — that copy explicitly tells them to mint a fresh mit_.
consumed, err := h.Queries.ConsumeInstallToken(r.Context(), hash)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "failed to exchange install token")
return
}
existing, lookupErr := h.Queries.GetInstallTokenByHash(r.Context(), hash)
if lookupErr == nil && existing.UsedAt.Valid {
writeError(w, http.StatusUnauthorized, "install_token_already_used")
return
}
writeError(w, http.StatusUnauthorized, "invalid_install_token")
return
}
daemonToken, err := auth.GenerateDaemonToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate daemon token")
return
}
expiresAt := pgtype.Timestamptz{
Time: time.Now().Add(daemonTokenLongLivedExpiry),
Valid: true,
}
created, err := h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{
TokenHash: auth.HashToken(daemonToken),
WorkspaceID: consumed.WorkspaceID,
DaemonID: req.DaemonID,
ExpiresAt: expiresAt,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to issue daemon token")
return
}
writeJSON(w, http.StatusOK, ExchangeInstallTokenResponse{
Token: daemonToken,
WorkspaceID: uuidToString(created.WorkspaceID),
DaemonID: created.DaemonID,
ExpiresAt: timestampToString(created.ExpiresAt),
})
}

View File

@@ -0,0 +1,337 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/multica-ai/multica/server/internal/auth"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/internal/util"
)
// withWorkspaceContext primes the workspace ID into the request context the
// same way the RequireWorkspaceMember* middleware does in production. The
// handler tests bypass the real middleware chain.
func withWorkspaceContext(t *testing.T, req *http.Request, workspaceID string) *http.Request {
t.Helper()
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
UserID: util.MustParseUUID(testUserID),
WorkspaceID: util.MustParseUUID(workspaceID),
})
if err != nil {
t.Fatalf("load member row: %v", err)
}
return req.WithContext(middleware.SetMemberContext(req.Context(), workspaceID, memberRow))
}
func TestCreateInstallToken_ReturnsMitTokenAndPersistsHash(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/install-tokens", nil)
req = withWorkspaceContext(t, req, testWorkspaceID)
testHandler.CreateInstallToken(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateInstallToken: expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp CreateInstallTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !strings.HasPrefix(resp.Token, "mit_") {
t.Fatalf("expected mit_ prefix, got %q", resp.Token)
}
if resp.ID == "" || resp.WorkspaceID != testWorkspaceID {
t.Fatalf("expected workspace_id %q with non-empty id, got %+v", testWorkspaceID, resp)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, resp.ID)
})
// Server must store hash, not raw token. The raw token must not appear
// in any column.
sum := sha256.Sum256([]byte(resp.Token))
wantHash := hex.EncodeToString(sum[:])
var gotHash string
var usedAt *time.Time
var expiresAt time.Time
if err := testPool.QueryRow(context.Background(),
`SELECT token_hash, used_at, expires_at FROM install_token WHERE id = $1`, resp.ID,
).Scan(&gotHash, &usedAt, &expiresAt); err != nil {
t.Fatalf("read install_token row: %v", err)
}
if gotHash != wantHash {
t.Fatalf("expected token_hash %q, got %q", wantHash, gotHash)
}
if usedAt != nil {
t.Fatalf("expected used_at NULL on fresh token, got %v", usedAt)
}
// Expiry must land in (now, now + 30m]: TTL is 15m, but we allow slop
// for slow CI runs and clock drift between Go and Postgres.
if expiresAt.Before(time.Now()) || expiresAt.After(time.Now().Add(30*time.Minute)) {
t.Fatalf("expires_at %v not within (now, now+30m]", expiresAt)
}
}
func TestExchangeInstallToken_HappyPath(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
rawInstall, installID := seedInstallToken(t, testWorkspaceID, 5*time.Minute)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, installID)
})
const daemonID = "test-daemon-exchange-happy"
body := map[string]any{"token": rawInstall, "daemon_id": daemonID}
w := httptest.NewRecorder()
req := newRequest("POST", "/api/install-tokens/exchange", body)
testHandler.ExchangeInstallToken(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ExchangeInstallToken: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp ExchangeInstallTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !strings.HasPrefix(resp.Token, "mdt_") {
t.Fatalf("expected mdt_ prefix on returned daemon token, got %q", resp.Token)
}
if resp.WorkspaceID != testWorkspaceID || resp.DaemonID != daemonID {
t.Fatalf("unexpected response: %+v", resp)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM daemon_token WHERE token_hash = $1`, auth.HashToken(resp.Token))
})
// install_token row must be burned (used_at populated).
var usedAt *time.Time
if err := testPool.QueryRow(context.Background(),
`SELECT used_at FROM install_token WHERE id = $1`, installID,
).Scan(&usedAt); err != nil {
t.Fatalf("read install_token after exchange: %v", err)
}
if usedAt == nil {
t.Fatal("expected install_token.used_at to be set after exchange")
}
// daemon_token row must exist, unrevoked, with the ~100y long-lived
// expiry (well past anything a TTL-style mint would produce).
var dtExpiresAt time.Time
var revokedAt *time.Time
if err := testPool.QueryRow(context.Background(),
`SELECT expires_at, revoked_at FROM daemon_token WHERE token_hash = $1`,
auth.HashToken(resp.Token),
).Scan(&dtExpiresAt, &revokedAt); err != nil {
t.Fatalf("read daemon_token: %v", err)
}
if revokedAt != nil {
t.Fatalf("expected daemon_token.revoked_at NULL, got %v", revokedAt)
}
if dtExpiresAt.Before(time.Now().Add(50 * 365 * 24 * time.Hour)) {
t.Fatalf("expected long-lived expiry (>50y), got %v", dtExpiresAt)
}
}
func TestExchangeInstallToken_DoubleExchangeRejected(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
rawInstall, installID := seedInstallToken(t, testWorkspaceID, 5*time.Minute)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, installID)
})
const daemonID = "test-daemon-double-exchange"
body := map[string]any{"token": rawInstall, "daemon_id": daemonID}
// First exchange must succeed.
w := httptest.NewRecorder()
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
if w.Code != http.StatusOK {
t.Fatalf("first exchange: expected 200, got %d: %s", w.Code, w.Body.String())
}
var first ExchangeInstallTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &first); err != nil {
t.Fatalf("decode first response: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM daemon_token WHERE token_hash = $1`, auth.HashToken(first.Token))
})
// Second exchange must fail with the dedicated error code so the daemon
// installer can surface the "请重新生成 mit_" guidance from the RFC.
w = httptest.NewRecorder()
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
if w.Code != http.StatusUnauthorized {
t.Fatalf("second exchange: expected 401, got %d: %s", w.Code, w.Body.String())
}
var errBody map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
t.Fatalf("decode error body: %v", err)
}
if errBody["error"] != "install_token_already_used" {
t.Fatalf("expected install_token_already_used, got %q", errBody["error"])
}
}
func TestExchangeInstallToken_UnknownTokenRejected(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
w := httptest.NewRecorder()
body := map[string]any{
"token": "mit_unknown_never_minted_token_hex_padding_4",
"daemon_id": "test-daemon-unknown-mit",
}
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
var errBody map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
t.Fatalf("decode error body: %v", err)
}
if errBody["error"] != "invalid_install_token" {
t.Fatalf("expected invalid_install_token, got %q", errBody["error"])
}
}
func TestExchangeInstallToken_ExpiredTokenRejected(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
rawInstall, installID := seedInstallToken(t, testWorkspaceID, -1*time.Minute)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, installID)
})
body := map[string]any{"token": rawInstall, "daemon_id": "test-daemon-expired-mit"}
w := httptest.NewRecorder()
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
var errBody map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
t.Fatalf("decode error body: %v", err)
}
// An expired-but-never-used token reports invalid_install_token, not
// install_token_already_used — the daemon installer's recovery copy
// is "mint a fresh token", which is correct for both cases.
if errBody["error"] != "invalid_install_token" {
t.Fatalf("expected invalid_install_token for expired row, got %q", errBody["error"])
}
}
// TestGetDaemonTokenByHash_FiltersRevoked guards the D4 schema change: once a
// daemon_token row has revoked_at set, GetDaemonTokenByHash (which feeds the
// DaemonAuth middleware) must refuse to resolve it even though expires_at is
// still in the future. The bug we're preventing is a revoked daemon
// continuing to authenticate until the original short-TTL expiry.
func TestGetDaemonTokenByHash_FiltersRevoked(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
const daemonID = "test-daemon-revoked-filter"
rawToken, hash := seedDaemonToken(t, testWorkspaceID, daemonID, 100*365*24*time.Hour)
// Before revoke, the row resolves.
if _, err := testHandler.Queries.GetDaemonTokenByHash(context.Background(), hash); err != nil {
t.Fatalf("pre-revoke lookup: %v", err)
}
if _, err := testPool.Exec(context.Background(),
`UPDATE daemon_token SET revoked_at = now() WHERE token_hash = $1`, hash,
); err != nil {
t.Fatalf("revoke row: %v", err)
}
// After revoke, the row must vanish from the auth path even though
// expires_at is decades out.
_, err := testHandler.Queries.GetDaemonTokenByHash(context.Background(), hash)
if err == nil {
t.Fatal("expected GetDaemonTokenByHash to fail for revoked row")
}
// Tidy up: rawToken isn't an authentication concern (it was never
// returned to a client), but leaving the row around taints later runs
// of the same fixture.
_ = rawToken
testPool.Exec(context.Background(), `DELETE FROM daemon_token WHERE token_hash = $1`, hash)
}
// seedInstallToken inserts an install_token row directly so tests don't have
// to run the mint endpoint just to get a known-good raw token. ttl<=0 mints
// an already-expired row (ExpiresAt set in the past).
func seedInstallToken(t *testing.T, workspaceID string, ttl time.Duration) (string, string) {
t.Helper()
raw, err := auth.GenerateInstallToken()
if err != nil {
t.Fatalf("generate install token: %v", err)
}
hash := auth.HashToken(raw)
var id string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO install_token (token_hash, workspace_id, created_by, expires_at)
VALUES ($1, $2, $3, now() + $4::interval)
RETURNING id
`, hash, workspaceID, testUserID, formatInterval(ttl)).Scan(&id); err != nil {
t.Fatalf("seed install_token: %v", err)
}
return raw, id
}
func seedDaemonToken(t *testing.T, workspaceID, daemonID string, ttl time.Duration) (string, string) {
t.Helper()
raw, err := auth.GenerateDaemonToken()
if err != nil {
t.Fatalf("generate daemon token: %v", err)
}
hash := auth.HashToken(raw)
if _, err := testPool.Exec(context.Background(), `
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
VALUES ($1, $2, $3, now() + $4::interval)
`, hash, workspaceID, daemonID, formatInterval(ttl)); err != nil {
t.Fatalf("seed daemon_token: %v", err)
}
return raw, hash
}
// formatInterval renders a Go duration as a Postgres interval literal.
// Sub-second precision isn't needed for these tests; we clamp to whole
// seconds (with sign) so negative TTLs still produce a valid interval like
// "-60 seconds" for the expired-token case.
func formatInterval(d time.Duration) string {
secs := int64(d / time.Second)
if secs == 0 && d != 0 {
if d > 0 {
secs = 1
} else {
secs = -1
}
}
return fmt.Sprintf("%d seconds", secs)
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE daemon_token
DROP COLUMN revoked_at;

View File

@@ -0,0 +1,10 @@
-- daemon_token.revoked_at flips the credential from "short-lived auto-expiry"
-- to "long-lived until explicitly revoked". Phase 1 of the runtime UX
-- refactor (RFC MUL-2297): once the install_token / mdt_ flow is wired up
-- (Phase 2+), an issued daemon token sticks until the workspace owner
-- revokes its runtime — so users no longer need to re-paste a token on a
-- schedule. expires_at stays NOT NULL and is set to ~now()+100y at mint
-- time, which is functionally equivalent to "no expiry" while keeping the
-- DeleteExpiredDaemonTokens cleanup path intact.
ALTER TABLE daemon_token
ADD COLUMN revoked_at TIMESTAMPTZ NULL;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS install_token;

View File

@@ -0,0 +1,26 @@
-- install_token is a single-use, short-lived bearer credential that an
-- authorized workspace member (or autopilot, eventually) mints and pastes
-- into a daemon installer. The installer POSTs the raw token to the
-- exchange endpoint, which atomically marks it used and returns a
-- long-lived daemon_token (mdt_). Phase 1 of RFC MUL-2297 — DB +
-- credential contract only; Phase 2 wires the daemon/CLI side.
--
-- Why a dedicated table instead of reusing daemon_token / PAT:
-- * single-use semantics — used_at + UPDATE ... WHERE used_at IS NULL
-- atomically rejects replay. daemon_token has no such gate.
-- * short TTL — install tokens live ~15 minutes; daemon tokens live
-- 100 years. Sharing one row would conflate the two lifecycles.
-- * different prefix (mit_ vs mdt_) so DaemonAuth never accidentally
-- accepts a not-yet-exchanged install token on /api/daemon/*.
CREATE TABLE install_token (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT NOT NULL,
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX idx_install_token_hash ON install_token(token_hash);
CREATE INDEX idx_install_token_workspace ON install_token(workspace_id);

View File

@@ -14,7 +14,7 @@ import (
const createDaemonToken = `-- name: CreateDaemonToken :one
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at
RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at, revoked_at
`
type CreateDaemonTokenParams struct {
@@ -39,6 +39,7 @@ func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenPa
&i.DaemonID,
&i.ExpiresAt,
&i.CreatedAt,
&i.RevokedAt,
)
return i, err
}
@@ -93,10 +94,16 @@ func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error {
}
const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one
SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token
WHERE token_hash = $1 AND expires_at > now()
SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at, revoked_at FROM daemon_token
WHERE token_hash = $1
AND revoked_at IS NULL
AND expires_at > now()
`
// revoked_at IS NULL filters out tokens explicitly revoked via the runtime
// UX flow (RFC MUL-2297). expires_at > now() stays for defense in depth even
// though Phase 1 mints tokens with a ~100-year expiry — the legacy short-TTL
// behavior is still legal and the cleanup query depends on it.
func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) {
row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash)
var i DaemonToken
@@ -107,6 +114,31 @@ func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (D
&i.DaemonID,
&i.ExpiresAt,
&i.CreatedAt,
&i.RevokedAt,
)
return i, err
}
const revokeDaemonTokenByID = `-- name: RevokeDaemonTokenByID :one
UPDATE daemon_token
SET revoked_at = now()
WHERE id = $1
AND workspace_id = $2
AND revoked_at IS NULL
RETURNING token_hash
`
type RevokeDaemonTokenByIDParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Marks a single daemon_token revoked. Returns token_hash so the caller can
// invalidate auth.DaemonTokenCache before the 10-minute TTL would otherwise
// let a revoked token keep authenticating on a cached lookup.
func (q *Queries) RevokeDaemonTokenByID(ctx context.Context, arg RevokeDaemonTokenByIDParams) (string, error) {
row := q.db.QueryRow(ctx, revokeDaemonTokenByID, arg.ID, arg.WorkspaceID)
var token_hash string
err := row.Scan(&token_hash)
return token_hash, err
}

View File

@@ -0,0 +1,109 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: install_token.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const consumeInstallToken = `-- name: ConsumeInstallToken :one
UPDATE install_token
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
RETURNING id, token_hash, workspace_id, created_by, created_at, expires_at, used_at
`
// Atomically validates and burns an install_token in a single statement. The
// caller passes the SHA-256 hash of the raw mit_ token; the row is selected
// only when it is unexpired AND not yet consumed, and the same UPDATE sets
// used_at = now() so a concurrent second exchange returns zero rows. Callers
// distinguish "wrong/expired" from "already used" by re-querying after a
// miss (see handler.ExchangeInstallToken).
func (q *Queries) ConsumeInstallToken(ctx context.Context, tokenHash string) (InstallToken, error) {
row := q.db.QueryRow(ctx, consumeInstallToken, tokenHash)
var i InstallToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.CreatedBy,
&i.CreatedAt,
&i.ExpiresAt,
&i.UsedAt,
)
return i, err
}
const createInstallToken = `-- name: CreateInstallToken :one
INSERT INTO install_token (token_hash, workspace_id, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, token_hash, workspace_id, created_by, created_at, expires_at, used_at
`
type CreateInstallTokenParams struct {
TokenHash string `json:"token_hash"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedBy pgtype.UUID `json:"created_by"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreateInstallToken(ctx context.Context, arg CreateInstallTokenParams) (InstallToken, error) {
row := q.db.QueryRow(ctx, createInstallToken,
arg.TokenHash,
arg.WorkspaceID,
arg.CreatedBy,
arg.ExpiresAt,
)
var i InstallToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.CreatedBy,
&i.CreatedAt,
&i.ExpiresAt,
&i.UsedAt,
)
return i, err
}
const deleteExpiredInstallTokens = `-- name: DeleteExpiredInstallTokens :exec
DELETE FROM install_token
WHERE expires_at <= now()
`
func (q *Queries) DeleteExpiredInstallTokens(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteExpiredInstallTokens)
return err
}
const getInstallTokenByHash = `-- name: GetInstallTokenByHash :one
SELECT id, token_hash, workspace_id, created_by, created_at, expires_at, used_at FROM install_token
WHERE token_hash = $1
`
// Read-only lookup used after ConsumeInstallToken returns zero rows, so the
// handler can tell "no such token / expired" apart from "already consumed"
// and return the install_token_already_used error code that Phase 2's
// daemon installer expects.
func (q *Queries) GetInstallTokenByHash(ctx context.Context, tokenHash string) (InstallToken, error) {
row := q.db.QueryRow(ctx, getInstallTokenByHash, tokenHash)
var i InstallToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.CreatedBy,
&i.CreatedAt,
&i.ExpiresAt,
&i.UsedAt,
)
return i, err
}

View File

@@ -230,6 +230,7 @@ type DaemonToken struct {
DaemonID string `json:"daemon_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
}
type Feedback struct {
@@ -292,6 +293,16 @@ type InboxItem struct {
Details []byte `json:"details"`
}
type InstallToken struct {
ID pgtype.UUID `json:"id"`
TokenHash string `json:"token_hash"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
UsedAt pgtype.Timestamptz `json:"used_at"`
}
type Issue struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`

View File

@@ -4,8 +4,25 @@ VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetDaemonTokenByHash :one
-- revoked_at IS NULL filters out tokens explicitly revoked via the runtime
-- UX flow (RFC MUL-2297). expires_at > now() stays for defense in depth even
-- though Phase 1 mints tokens with a ~100-year expiry — the legacy short-TTL
-- behavior is still legal and the cleanup query depends on it.
SELECT * FROM daemon_token
WHERE token_hash = $1 AND expires_at > now();
WHERE token_hash = $1
AND revoked_at IS NULL
AND expires_at > now();
-- name: RevokeDaemonTokenByID :one
-- Marks a single daemon_token revoked. Returns token_hash so the caller can
-- invalidate auth.DaemonTokenCache before the 10-minute TTL would otherwise
-- let a revoked token keep authenticating on a cached lookup.
UPDATE daemon_token
SET revoked_at = now()
WHERE id = $1
AND workspace_id = $2
AND revoked_at IS NULL
RETURNING token_hash;
-- name: DeleteDaemonTokensByWorkspaceAndDaemons :many
-- Deletes every daemon_token row matching the (workspace_id, daemon_id)

View File

@@ -0,0 +1,30 @@
-- name: CreateInstallToken :one
INSERT INTO install_token (token_hash, workspace_id, created_by, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: ConsumeInstallToken :one
-- Atomically validates and burns an install_token in a single statement. The
-- caller passes the SHA-256 hash of the raw mit_ token; the row is selected
-- only when it is unexpired AND not yet consumed, and the same UPDATE sets
-- used_at = now() so a concurrent second exchange returns zero rows. Callers
-- distinguish "wrong/expired" from "already used" by re-querying after a
-- miss (see handler.ExchangeInstallToken).
UPDATE install_token
SET used_at = now()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
RETURNING *;
-- name: GetInstallTokenByHash :one
-- Read-only lookup used after ConsumeInstallToken returns zero rows, so the
-- handler can tell "no such token / expired" apart from "already consumed"
-- and return the install_token_already_used error code that Phase 2's
-- daemon installer expects.
SELECT * FROM install_token
WHERE token_hash = $1;
-- name: DeleteExpiredInstallTokens :exec
DELETE FROM install_token
WHERE expires_at <= now();