// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package app import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strings" "sync" "text/tabwriter" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/runtimecred" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) type commonConsumeOptions struct { EventTypes []string Filter string Compact bool FormatRaw string OutputDir string RoutesRaw []string MaxEvents int Duration time.Duration Quiet bool Force bool DryRun bool Foreground bool } type personalConsumeOptions struct { Common commonConsumeOptions EventKey string EventKeys []string Flatten bool DebugRawEvents bool SubscribeID string Rule string Name string FilterJSON string QueryCSV string TTL time.Duration Ephemeral bool UserID string OpenDingTalkID string GroupID string ControlBaseURL string StreamTicketMode string StreamTicketURL string StreamSourceID string ExplicitToken string ClientIDOverride string } type personalListOptions struct { Category string EnabledOnly bool IncludePending bool Format string } type personalStatusOptions struct { EventKey string Status string SubscribeID string Format string ControlBaseURL string StreamSourceID string ExplicitToken string ClientIDOverride string } type personalStopOptions struct { SubscribeID string All bool ControlBaseURL string StreamSourceID string ExplicitToken string ClientIDOverride string } type personalStreamSourceOptions struct { ConfigDir string Identity personal.Identity TicketMode string TicketURL string ClientIDOverride string CredentialBroker *runtimecred.Broker RuntimeTokenMode bool } var ( personalResolveEventIdentity = resolvePersonalEventIdentity personalLookupDefinition = personal.Lookup personalEnsureSubscription = ensurePersonalSubscription personalGetSubscription = (*personal.Client).GetSubscription personalCreateSubscription = (*personal.Client).CreateSubscription personalDeleteSubscription = (*personal.Client).DeleteSubscription personalListSubscriptions = (*personal.Client).ListSubscriptions personalUpsertRunState = personal.UpsertRunState personalRemoveRunStates = personal.RemoveRunStates personalLoadRunStates = personal.LoadRunStates personalConsumeRun = consume.Run personalConsumeRunMany = consume.RunMany personalValidateConsumeConfig = consume.ValidateConfig personalValidateNoOutputConflict = consume.ValidateNoOutputConflict personalNewStreamSource = newPersonalStreamSource personalBusRun = bus.Run personalFindBusByIdentity = busctl.FindBusByIdentity personalQueryEntry = busctl.QueryEntry personalQueryStatus = busctl.QueryStatus personalStopBus = busctl.Stop personalStopConsumers = busctl.StopConsumers personalFindProcess = os.FindProcess personalSignalProcess = (*os.Process).Signal personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken personalForceRefreshRejectedToken = forceRefreshRejectedAccessToken personalLoadTokenData = authpkg.LoadTokenData personalLoadProfiles = authpkg.LoadProfiles personalClientID = authpkg.ClientID personalRuntimeEventClientID = runtimePersonalEventClientID personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict ) func runtimePersonalEventClientID() string { if clientID := strings.TrimSpace(edition.Get().AuthClientID); clientID != "" { return clientID } return strings.TrimSpace(os.Getenv("DWS_CLIENT_ID")) } func newEventSchemaCommand() *cobra.Command { var asIdentity string var formatRaw string var flatten bool cmd := &cobra.Command{ Use: "schema ", Short: "显示事件 schema", Args: cobra.ExactArgs(1), DisableAutoGenTag: true, RunE: func(c *cobra.Command, args []string) error { _, err := normalizeEventAs(asIdentity) if err != nil { return err } def, ok := personal.Lookup(args[0]) if !ok { return fmt.Errorf("unknown personal event key %q", args[0]) } if !def.Public { return personal.PublicAvailabilityError(args[0]) } return renderPersonalSchema(c.OutOrStdout(), def, formatRaw, flatten) }, } cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json") cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema") hideEventInternalFlags(cmd, "as") cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{ Name: "event_key", Type: "string", Description: "要查询 payload 字段定义的个人事件码", Required: true, Index: 0, }) helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{ Safety: contract.SafetySpec{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: helpers.LeafContract{ Identity: contract.ToolIdentitySpec{ ProductID: "event", Name: "schema", CanonicalPath: "event.schema", CLIPath: "event schema", PrimaryCLIPath: "event schema", }, Description: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", Interface: &contract.InterfaceSpec{ Mode: "local", Availability: "available", Reason: "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC", }, Selection: contract.SelectionSpec{ AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式", UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"}, AvoidWhen: []string{ "查询 CLI 命令参数契约时用顶层 dws schema", "要实际收事件时用 event consume", }, Examples: []string{ "dws event schema user_im_message_receive_at --flatten --format json", "dws event schema user_oa_approval_task_created --flatten --format json", }, }, }, }) return cmd } func runPersonalEventList(c *cobra.Command, opts personalListOptions) error { items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending) if opts.Format == "json" { enc := json.NewEncoder(c.OutOrStdout()) enc.SetIndent("", " ") return enc.Encode(items) } tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION") for _, it := range items { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", it.EventKey, it.RuleType, it.Status, it.Description) } return tw.Flush() } func renderPersonalSchema(w io.Writer, def personal.Definition, format string, flatten bool) error { format = strings.ToLower(strings.TrimSpace(format)) if format == "" { format = "json" } if format != "json" { return fmt.Errorf("event schema only supports json output") } enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(personal.BuildSchemaDocumentForMode(def, flatten)) } func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error { keys := dedupePersonalEventKeys(opts.EventKeys) if len(keys) == 0 && strings.TrimSpace(opts.EventKey) != "" { keys = []string{strings.TrimSpace(opts.EventKey)} } if len(keys) <= 1 { if len(keys) == 1 { opts.EventKey = keys[0] } return runPersonalEventConsumeSingle(c, opts) } opts.EventKeys = keys return runPersonalEventConsumeMany(c, opts) } func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error { ctx := c.Context() if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { return personalSubscriptionValidationError(err) } if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } rawFormat := "" if f := c.Flags().Lookup("format"); f != nil && f.Changed { rawFormat = opts.Common.FormatRaw } normalised, fellback := consume.NormalizeFormat(rawFormat) if fellback && !opts.Common.Quiet { fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat) } if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten) configDir := defaultConfigDir() identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } identityHash := dwsevent.IdentityHash(identity.Key()) editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) spawnProfileSelector := "" if strings.TrimSpace(opts.ExplicitToken) == "" { spawnProfileSelector = personalBusProfileSelector(configDir, identity) } spawnArgs := personalBusSpawnArgsForToken( identity, identityHash, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector, opts.ExplicitToken, ) routes, err := consume.ParseRoutes(opts.Common.RoutesRaw) if err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken) if opts.Common.DryRun { if strings.TrimSpace(opts.SubscribeID) == "" { if err := validatePersonalSubscriptionOptions(opts); err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } } else { _, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } opts.EventKey = eventKey } cfg := consume.Config{ WorkDir: workDir, IPCEndpoint: ipcEndpoint, ClientID: identity.ClientID, SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken), Compact: opts.Common.Compact, MaxEvents: opts.Common.MaxEvents, Duration: opts.Common.Duration, EventKey: opts.EventKey, Format: normalised, Flatten: opts.Flatten, OutputDir: opts.Common.OutputDir, Routes: routes, Projector: projector, Stderr: c.ErrOrStderr(), Quiet: opts.Common.Quiet, Foreground: opts.Common.Foreground, Force: opts.Common.Force, DryRun: true, } applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey) if err := personalConsumeRun(ctx, cfg); err != nil { return personalSubscriptionValidationError(err) } return nil } cfg := consume.Config{ WorkDir: workDir, IPCEndpoint: ipcEndpoint, ClientID: identity.ClientID, SpawnExtraArgs: spawnArgs, RuntimeToken: strings.TrimSpace(opts.ExplicitToken), Compact: opts.Common.Compact, MaxEvents: opts.Common.MaxEvents, Duration: opts.Common.Duration, EventKey: opts.EventKey, Format: normalised, Flatten: opts.Flatten, OutputDir: opts.Common.OutputDir, Routes: routes, Projector: projector, Stdout: c.OutOrStdout(), Stderr: c.ErrOrStderr(), Quiet: opts.Common.Quiet, Foreground: opts.Common.Foreground, Force: opts.Common.Force, } // Complete all local validation before creating a remote subscription. // Otherwise an invalid output mode can repeatedly create and roll back a // valid subscription when an outer agent relaunches the command. applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin()) if err := personalValidateConsumeConfig(cfg); err != nil { return personalSubscriptionValidationError(err) } if o := c.Flags().Lookup("output"); o != nil && o.Changed { if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil { return personalSubscriptionValidationError(err) } } var ( foregroundSource *source.PersonalSource foregroundBroker *runtimecred.Broker ) if opts.Common.Foreground { explicitToken := strings.TrimSpace(opts.ExplicitToken) foregroundBroker = newPersonalCredentialBroker(configDir, explicitToken != "", false) if explicitToken != "" { if _, err := foregroundBroker.Update(0, explicitToken); err != nil { return personalSubscriptionValidationError(err) } } foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{ ConfigDir: configDir, Identity: identity, TicketMode: opts.StreamTicketMode, TicketURL: opts.StreamTicketURL, CredentialBroker: foregroundBroker, RuntimeTokenMode: explicitToken != "", }) if err != nil { return personalSubscriptionValidationError(err) } } var attempt *personalSubscriptionAttemptReservation if strings.TrimSpace(opts.SubscribeID) == "" { attempt, err = reservePersonalSubscriptionAttempts( workDir, client, identity, spawnProfileSelector, []personalConsumeOptions{opts}, ) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } } sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts) if err != nil { if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(err) { err = attempt.releaseRuntimeTokenFailure() return fmt.Errorf("event consume --as user: %w", err) } err = attempt.completeFailure(ctx, 0, 0, err, nil) return fmt.Errorf("event consume --as user: %w", err) } if sub == nil { err = attempt.completeFailure( ctx, 0, 0, errors.New("personal event: server returned an empty subscription"), nil, ) return fmt.Errorf("event consume --as user: %w", err) } if strings.TrimSpace(sub.SubscribeID) == "" { err = attempt.completeFailure( ctx, 0, 0, errors.New("personal event: server returned empty subscribe_id"), nil, ) return fmt.Errorf("event consume --as user: %w", err) } selfCreated := strings.TrimSpace(opts.SubscribeID) == "" ownsSubscription := selfCreated || opts.Ephemeral var cleanupOnce sync.Once cleanupOwnedSubscription := func(cleanupCtx context.Context) { if !ownsSubscription { return } cleanupOnce.Do(func() { _ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID) _ = personalRemoveRunStates(workDir, []string{sub.SubscribeID}) }) } if err := personalUpsertRunState(workDir, personal.RunState{ SubscribeID: sub.SubscribeID, EventKey: eventKey, RuleType: ruleType, ClientID: identity.ClientID, SourceID: identity.SourceID, IdentityHash: identityHash, }); err != nil { wrapped := fmt.Errorf("save run state: %w", err) cleanupCtx := context.Background() if personalSubscriptionCanceled(ctx, wrapped) { cleanupCtx = ctx } if attempt != nil { classification := personalSubscriptionLocalFailure() wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification) } cleanupOwnedSubscription(cleanupCtx) return fmt.Errorf("event consume --as user: %w", wrapped) } if err := attempt.completeSuccess(); err != nil { cleanupOwnedSubscription(context.Background()) return fmt.Errorf("event consume --as user: %w", err) } // Ownership-based cleanup: a subscription this run CREATED is // unsubscribed on exit // (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing // leaks server-side. A subscription REUSED via --subscribe-id is left // intact — the caller owns its lifecycle. --ephemeral forces cleanup // either way. if ownsSubscription { defer cleanupOwnedSubscription(context.Background()) } cfg.EventKey = eventKey cfg.ReadySubscribeID = sub.SubscribeID applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey) if opts.DebugRawEvents && !opts.Common.Quiet { fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n", workDir, filepath.Join(workDir, "bus.log")) } if opts.Common.Foreground { busCfg := bus.Config{ WorkDir: workDir, IPCEndpoint: ipcEndpoint, ClientID: identity.ClientID, Edition: editionName, SourceKind: dwsevent.SourceKindPersonalStream, IdentityHash: identityHash, SourceID: identity.SourceID, Source: foregroundSource, CredentialBroker: foregroundBroker, } bus.ApplyEnvTuning(&busCfg) return personalBusRun(ctx, busCfg) } return personalConsumeRun(ctx, cfg) } type personalMultiSubscription struct { Sub *personal.Subscription EventKey string RuleType string } func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error { plans, err := preparePersonalMultiOptions(opts) if err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } rawFormat := "" if f := c.Flags().Lookup("format"); f != nil && f.Changed { rawFormat = opts.Common.FormatRaw } normalised, fellback := consume.NormalizeFormat(rawFormat) if fellback && !opts.Common.Quiet { fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat) } if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } projector := personalEventProjector(false, opts.Flatten) ctx := c.Context() configDir := defaultConfigDir() identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } identityHash := dwsevent.IdentityHash(identity.Key()) editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) spawnProfileSelector := "" if strings.TrimSpace(opts.ExplicitToken) == "" { spawnProfileSelector = personalBusProfileSelector(configDir, identity) } routes, err := consume.ParseRoutes(opts.Common.RoutesRaw) if err != nil { return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err)) } baseCfg := consume.Config{ WorkDir: workDir, IPCEndpoint: ipcEndpoint, ClientID: identity.ClientID, SpawnExtraArgs: personalBusSpawnArgsForToken(identity, identityHash, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector, opts.ExplicitToken), Compact: opts.Common.Compact, MaxEvents: opts.Common.MaxEvents, Duration: opts.Common.Duration, Format: normalised, Flatten: opts.Flatten, OutputDir: opts.Common.OutputDir, Routes: routes, Projector: projector, Stdout: c.OutOrStdout(), Stderr: c.ErrOrStderr(), Quiet: opts.Common.Quiet, } applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin()) if err := personalValidateConsumeConfig(baseCfg); err != nil { return personalSubscriptionValidationError(err) } if o := c.Flags().Lookup("output"); o != nil && o.Changed { if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil { return personalSubscriptionValidationError(err) } } if opts.Common.DryRun { printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans) return nil } baseCfg.RuntimeToken = strings.TrimSpace(opts.ExplicitToken) client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken) attempt, err := reservePersonalSubscriptionAttempts( workDir, client, identity, spawnProfileSelector, plans, ) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } created := make([]personalMultiSubscription, 0, len(plans)) cleanup := func(cleanupCtx context.Context) { ids := make([]string, 0, len(created)) for i := len(created) - 1; i >= 0; i-- { id := strings.TrimSpace(created[i].Sub.SubscribeID) ids = append(ids, id) if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil { fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err) } } if len(ids) > 0 { if err := personalRemoveRunStates(workDir, ids); err != nil { fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal event run state: %v\n", err) } } } failAndCleanup := func( failedIndex int, succeededCount int, cause error, override *personalSubscriptionFailureClass, ) error { cleanupCtx := context.Background() if personalSubscriptionCanceled(ctx, cause) { cleanupCtx = ctx } if strings.TrimSpace(opts.ExplicitToken) != "" && personalRuntimeTokenControlRejection(cause) { cleanup(cleanupCtx) return attempt.releaseRuntimeTokenFailure() } completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override) // Persist the hold (or release a canceled claim) before any potentially // slow remote rollback. Otherwise the attempt lease can expire while // deleting earlier subscriptions and admit a duplicate create batch. cleanup(cleanupCtx) return completed } seenSubscribeIDs := make(map[string]struct{}, len(plans)) for i, plan := range plans { sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan) if err != nil { err = failAndCleanup(i, len(created), err, nil) return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err) } if sub == nil { cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey) cause = failAndCleanup(i, len(created), cause, nil) return fmt.Errorf("event consume --as user: %w", cause) } id := strings.TrimSpace(sub.SubscribeID) if id == "" { cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey) cause = failAndCleanup(i, len(created), cause, nil) return fmt.Errorf("event consume --as user: %w", cause) } if _, exists := seenSubscribeIDs[id]; exists { cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id) cause = failAndCleanup(i, len(created), cause, nil) return fmt.Errorf("event consume --as user: %w", cause) } seenSubscribeIDs[id] = struct{}{} item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType} created = append(created, item) if err := personalUpsertRunState(workDir, personal.RunState{ SubscribeID: id, EventKey: eventKey, RuleType: ruleType, ClientID: identity.ClientID, SourceID: identity.SourceID, IdentityHash: identityHash, }); err != nil { cause := fmt.Errorf("save run state for %s: %w", eventKey, err) classification := personalSubscriptionLocalFailure() cause = failAndCleanup(i, len(created)-1, cause, &classification) return fmt.Errorf("event consume --as user: %w", cause) } } if err := attempt.completeSuccess(); err != nil { cleanup(context.Background()) return fmt.Errorf("event consume --as user: %w", err) } defer cleanup(context.Background()) specs := make([]consume.ConsumerSpec, 0, len(created)) for _, item := range created { specs = append(specs, consume.ConsumerSpec{ EventKey: item.EventKey, EventTypes: []string{item.EventKey}, SubscribeID: item.Sub.SubscribeID, ReadySubscribeID: item.Sub.SubscribeID, }) } if err := personalConsumeRunMany(ctx, baseCfg, specs); err != nil { return err } return nil } func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsumeOptions, error) { if strings.TrimSpace(opts.SubscribeID) != "" { return nil, errors.New("--subscribe-id is not supported when consuming multiple events") } if strings.TrimSpace(opts.Rule) != "" { return nil, errors.New("--rule is not supported when consuming multiple events") } if len(opts.Common.EventTypes) > 0 { return nil, errors.New("--event-types is not supported when consuming multiple events; use event_key positionals") } if strings.TrimSpace(opts.Common.Filter) != "" { return nil, errors.New("--filter is not supported when consuming multiple events; use event_key positionals") } if opts.Common.Foreground || opts.Common.Force { return nil, errors.New("--foreground/--force are not supported when consuming multiple events") } if opts.DebugRawEvents { return nil, errors.New("--debug-raw-events is not supported when consuming multiple events") } keys := dedupePersonalEventKeys(opts.EventKeys) if len(keys) < 2 { return nil, errors.New("multiple event keys are required") } hasUserScope := false hasGroupScope := false for _, eventKey := range keys { def, ok := personalLookupDefinition(eventKey) if !ok { return nil, fmt.Errorf("unknown personal event key %q", eventKey) } if !def.Public { return nil, personal.PublicAvailabilityError(eventKey) } if err := validatePersonalOAOptions(eventKey, opts); err != nil { return nil, err } switch def.RuleType { case "singleChat", "sender": hasUserScope = true case "group": hasGroupScope = true } if (strings.TrimSpace(opts.QueryCSV) != "" || strings.TrimSpace(opts.FilterJSON) != "") && !personal.SupportsMessageFilter(eventKey) { return nil, fmt.Errorf("--query/--filter-json require all selected events to be message receive events; %s is not", eventKey) } } if hasUserScope && hasGroupScope { return nil, errors.New("user-scoped and group-scoped events cannot be consumed in one command") } userID := strings.TrimSpace(opts.UserID) openID := strings.TrimSpace(opts.OpenDingTalkID) groupID := strings.TrimSpace(opts.GroupID) if userID != "" && openID != "" { return nil, errors.New("--user and --open-dingtalk-id are mutually exclusive") } switch { case hasUserScope: if groupID != "" { return nil, errors.New("--group cannot be used with user-scoped events") } if userID == "" && openID == "" { return nil, errors.New("one of --user or --open-dingtalk-id is required for the selected events") } case hasGroupScope: if userID != "" || openID != "" { return nil, errors.New("--user/--open-dingtalk-id cannot be used with group-scoped events") } if groupID == "" { return nil, errors.New("--group is required for the selected events") } default: if userID != "" || openID != "" || groupID != "" { return nil, errors.New("the selected events do not use --user, --open-dingtalk-id, or --group") } } plans := make([]personalConsumeOptions, 0, len(keys)) for _, eventKey := range keys { def, _ := personalLookupDefinition(eventKey) plan := opts plan.EventKey = eventKey plan.EventKeys = nil switch def.RuleType { case "at", "all": plan.UserID = "" plan.OpenDingTalkID = "" plan.GroupID = "" case "singleChat", "sender": plan.GroupID = "" case "group": plan.UserID = "" plan.OpenDingTalkID = "" } if err := validatePersonalSubscriptionOptions(plan); err != nil { return nil, err } plans = append(plans, plan) } return plans, nil } func printPersonalMultiDryRun(w io.Writer, cfg consume.Config, plans []personalConsumeOptions) { preview := cfg preview.EventTypes = make([]string, 0, len(plans)) for _, plan := range plans { preview.EventTypes = append(preview.EventTypes, plan.EventKey) } consume.PrintDryRun(w, preview) for i, plan := range plans { ruleType, ruleParam, _ := personal.BuildRuleParam(plan.EventKey, personal.RuleOptions{ UserID: plan.UserID, OpenDingTalkID: plan.OpenDingTalkID, GroupID: plan.GroupID, }) _, filter, _ := personal.BuildFilter(plan.FilterJSON, plan.QueryCSV) ruleJSON, _ := personal.CanonicalJSON(ruleParam) fmt.Fprintf(w, " subscription[%d] : event_key=%s rule_type=%s rule_param=%s", i, plan.EventKey, ruleType, ruleJSON) if filter != "" { fmt.Fprintf(w, " filter=%s", filter) } fmt.Fprintln(w) } } func personalEventProjector(debugRawEvents, flatten bool) consume.Projector { if debugRawEvents { return func(ev transport.Event) (any, error) { return ev, nil } } if flatten { return personal.ProjectOutput } return nil } func validatePersonalEventOutputMode(flatten, debugRawEvents bool, format consume.Format) error { if !flatten { return nil } if debugRawEvents { return fmt.Errorf("--flatten and --debug-raw-events are mutually exclusive") } if format == consume.FormatRaw { return fmt.Errorf("--flatten and --format raw are mutually exclusive") } return nil } func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) { if cfg == nil { return } if opts.DebugRawEvents { cfg.EventTypes = nil cfg.Filter = "" cfg.SubscribeID = "" return } cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes) cfg.Filter = opts.Common.Filter cfg.SubscribeID = strings.TrimSpace(subscribeID) } func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error { if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil { return err } if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{ RuleType: opts.Rule, UserID: opts.UserID, OpenDingTalkID: opts.OpenDingTalkID, GroupID: opts.GroupID, }); err != nil { return err } _, _, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV) return err } func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error { changed := personalOAOptionNames(opts) if len(changed) == 0 { return nil } def, ok := personalLookupDefinition(strings.TrimSpace(eventKey)) if !ok || def.Category != "oa" { return nil } return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey) } func personalOAOptionNames(opts personalConsumeOptions) []string { var changed []string for _, item := range []struct { name string value string }{ {name: "--user", value: opts.UserID}, {name: "--open-dingtalk-id", value: opts.OpenDingTalkID}, {name: "--group", value: opts.GroupID}, {name: "--query", value: opts.QueryCSV}, {name: "--filter-json", value: opts.FilterJSON}, } { if strings.TrimSpace(item.value) != "" { changed = append(changed, item.name) } } return changed } type personalPreparedSubscription struct { EventKey string RuleType string Request personal.CreateSubscriptionRequest } func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) { if strings.TrimSpace(opts.EventKey) == "" { return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided") } if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { return personalPreparedSubscription{}, err } if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil { return personalPreparedSubscription{}, err } ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{ RuleType: opts.Rule, UserID: opts.UserID, OpenDingTalkID: opts.OpenDingTalkID, GroupID: opts.GroupID, }) if err != nil { return personalPreparedSubscription{}, err } filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV) if err != nil { return personalPreparedSubscription{}, err } req := personal.CreateSubscriptionRequest{ EventKey: opts.EventKey, RuleType: ruleType, Name: opts.Name, RuleParam: ruleParam, Filter: filter, Delivery: map[string]any{"mode": "stream"}, IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical), } if opts.TTL > 0 { req.TTLSeconds = int64(opts.TTL.Seconds()) } return personalPreparedSubscription{ EventKey: opts.EventKey, RuleType: ruleType, Request: req, }, nil } func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) { sub, err := personalCreateSubscription(client, ctx, plan.Request) if err != nil { return nil, "", "", err } return sub, plan.EventKey, plan.RuleType, nil } func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) { if strings.TrimSpace(opts.SubscribeID) != "" { sub, err := personalGetSubscription(client, ctx, opts.SubscribeID) if err != nil { return nil, "", "", err } if sub == nil { return nil, "", "", errors.New("personal event: server returned an empty subscription") } requestedEventKey := strings.TrimSpace(opts.EventKey) actualEventKey := strings.TrimSpace(sub.EventKey) if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey { return nil, "", "", fmt.Errorf( "event_key %q does not match reused subscription %q event_key %q", requestedEventKey, strings.TrimSpace(opts.SubscribeID), actualEventKey, ) } eventKey := actualEventKey if eventKey == "" { eventKey = requestedEventKey } if eventKey == "" { return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key") } if err := ensurePublicPersonalEvent(eventKey); err != nil { return nil, "", "", err } if err := validatePersonalOAOptions(eventKey, opts); err != nil { return nil, "", "", err } ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule) if ruleType == "" { if def, ok := personal.Lookup(eventKey); ok { ruleType = def.RuleType } } sub.SubscribeID = strings.TrimSpace(opts.SubscribeID) return sub, eventKey, ruleType, nil } plan, err := preparePersonalSubscription(identity, opts) if err != nil { return nil, "", "", err } return createPreparedPersonalSubscription(ctx, client, plan) } func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error { ctx := c.Context() if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { return err } configDir := defaultConfigDir() identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride) if err != nil { return fmt.Errorf("event status --as user: %w", err) } identityHash := dwsevent.IdentityHash(identity.Key()) editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) entry := personalFindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) var qs busctl.EntryStatus if entry != nil { qs = personalQueryEntry(*entry) } else { qs = busctl.EntryStatus{Entry: busctl.BusEntry{ WorkDir: workDir, Edition: editionName, SourceKind: dwsevent.SourceKindPersonalStream, ClientIDHash: identityHash, IdentityHash: identityHash, State: busctl.BusStateNotRunning, Meta: &bus.Meta{ ClientID: identity.ClientID, Edition: editionName, SourceKind: dwsevent.SourceKindPersonalStream, IdentityHash: identityHash, SourceID: identity.SourceID, }, }} } status := opts.Status if status == "" || status == "all" { status = "" } subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken), ctx, personal.ListOptions{ Status: status, EventKey: opts.EventKey, SubscribeID: opts.SubscribeID, }) if err != nil { return fmt.Errorf("event status --as user: %w", err) } if opts.Format == "json" { enc := json.NewEncoder(c.OutOrStdout()) enc.SetIndent("", " ") return enc.Encode(map[string]any{ "identity": redactedPersonalIdentity(identity, identityHash), "subscriptions": subs, "bus": qs, }) } renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs) return nil } func personalRuntimeTokenControlRejection(err error) bool { var apiErr *personal.APIError if !errors.As(err, &apiErr) || apiErr == nil { return false } return apiErr.HTTPStatus == http.StatusUnauthorized || strings.EqualFold(strings.TrimSpace(apiErr.Code), "RUNTIME_TOKEN_REJECTED") } func ensurePublicPersonalEvent(eventKey string) error { eventKey = strings.TrimSpace(eventKey) if eventKey == "" { return nil } if def, ok := personalLookupDefinition(eventKey); ok && !def.Public { return personal.PublicAvailabilityError(eventKey) } return nil } func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) { fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n", displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash) fmt.Fprintf(w, "Bus: %s", qs.Entry.State) if qs.Entry.HolderPID > 0 { fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID) } fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir) if len(subs) == 0 { fmt.Fprintln(w, "Subscriptions: none") } else { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE") for _, sub := range subs { fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID) } _ = tw.Flush() } renderPersonalConsumers(w, qs) } func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) { if qs.Entry.State != busctl.BusStateRunning { fmt.Fprintln(w, "Consumers: none") return } if qs.Live == nil { fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)") return } if len(qs.Live.Consumers) == 0 { fmt.Fprintln(w, "Consumers: none") return } fmt.Fprintln(w, "Consumers:") tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED") for _, cs := range qs.Live.Consumers { eventKeys := strings.Join(cs.EventTypes, ",") if eventKeys == "" { eventKeys = "(catch-all)" } subscribeID := displayPersonalStatusValue(cs.SubscribeID) filter := displayPersonalStatusValue(cs.Filter) fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n", cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped) } _ = tw.Flush() } func displayPersonalStatusValue(v string) string { v = strings.TrimSpace(v) if v == "" { return "-" } return v } func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { ctx := c.Context() explicitSubscribeID := strings.TrimSpace(opts.SubscribeID) isSingleTarget := explicitSubscribeID != "" if explicitSubscribeID != "" && opts.All { return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive") } if explicitSubscribeID == "" && !opts.All { return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set") } configDir := defaultConfigDir() identity, err := resolvePersonalEventIdentityForToken(ctx, configDir, opts.StreamSourceID, opts.ExplicitToken, opts.ClientIDOverride) if err != nil { return fmt.Errorf("event stop --as user: %w", err) } identityHash := dwsevent.IdentityHash(identity.Key()) editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All) if err != nil { return fmt.Errorf("event stop --as user: %w", err) } client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity, opts.ExplicitToken) for _, id := range subscribeIDs { if err := personalDeleteSubscription(client, ctx, id); err != nil { return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err) } } if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil { return fmt.Errorf("event stop --as user: update local state: %w", err) } if err := stopPersonalConsumers(c.ErrOrStderr(), ipcEndpoint, subscribeIDs); err != nil { fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err) } remaining, err := personalLoadRunStates(workDir) if err != nil { return fmt.Errorf("event stop --as user: load remaining local state: %w", err) } if len(remaining) > 0 { printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running") return nil } busState := "personal bus stopped" if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil { if errors.Is(err, busctl.ErrNotRunning) { busState = "personal bus is not running" } else { return err } } printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState) return nil } func personalStopTargets(workDir, explicit string, all bool) ([]string, error) { explicit = strings.TrimSpace(explicit) if explicit != "" && all { return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive") } if explicit != "" { return []string{explicit}, nil } if !all { return nil, fmt.Errorf("subscribe_id is required unless --all is set") } states, err := personalLoadRunStates(workDir) if err != nil { return nil, err } ids := make([]string, 0, len(states)) for _, st := range states { if st.SubscribeID != "" { ids = append(ids, st.SubscribeID) } } sort.Strings(ids) return ids, nil } func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error { targets := make(map[string]struct{}, len(subscribeIDs)) for _, id := range subscribeIDs { id = strings.TrimSpace(id) if id != "" { targets[id] = struct{}{} } } if ipcEndpoint == "" || len(targets) == 0 { return nil } status, err := personalQueryStatus(ipcEndpoint) if err != nil { return nil } signalled := make(map[int]struct{}) for _, consumer := range status.Consumers { if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok { continue } if consumer.PID <= 0 || consumer.PID == os.Getpid() { continue } if _, ok := signalled[consumer.PID]; ok { continue } proc, err := personalFindProcess(consumer.PID) if err != nil { return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err) } if err := personalSignalProcess(proc, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err) } signalled[consumer.PID] = struct{}{} } return nil } func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error { if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil { return nil } else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) { return err } else { fmt.Fprintf(w, "WARN: running bus does not support targeted consumer stop; falling back to process signal: %v\n", err) } return interruptPersonalConsumers(ipcEndpoint, subscribeIDs) } func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) { if single && len(subscribeIDs) == 1 { fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState) return } fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState) } func resolvePersonalEventIdentityForToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) { explicitToken = strings.TrimSpace(explicitToken) if explicitToken == "" { return personalResolveEventIdentity(ctx, configDir, sourceIDOverride) } clientIDOverride := "" if len(clientIDOverrides) > 0 { clientIDOverride = strings.TrimSpace(clientIDOverrides[0]) } return resolvePersonalEventIdentityWithToken(ctx, configDir, sourceIDOverride, explicitToken, clientIDOverride) } // resolvePersonalEventIdentityWithToken resolves only non-sensitive identity // metadata around a caller-supplied bearer token. It intentionally does not // call LoadTokenData or any refresh-capable token resolver: an explicit root // --token must never be replaced with, persisted into, or used to refresh a // local OAuth profile. func resolvePersonalEventIdentityWithToken(ctx context.Context, configDir, sourceIDOverride, explicitToken string, clientIDOverrides ...string) (personal.Identity, error) { explicitToken = strings.TrimSpace(explicitToken) if explicitToken == "" { return resolvePersonalEventIdentity(ctx, configDir, sourceIDOverride) } if strings.Contains(strings.TrimSpace(authpkg.RuntimeProfile()), ",") { return personal.Identity{}, fmt.Errorf("personal events require exactly one --profile") } corpID := resolveRuntimeDefault(ctx, "$corpId") userID := resolveRuntimeDefault(ctx, "$currentUserId") clientID := "" if len(clientIDOverrides) > 0 { clientID = strings.TrimSpace(clientIDOverrides[0]) } if clientID == "" { // An edition hook or explicit environment value is runtime identity, // not persisted app state. Resolve it before profiles.json so a complete // host context never depends on local OAuth metadata health. clientID = strings.TrimSpace(personalRuntimeEventClientID()) } explicitProfile := strings.TrimSpace(authpkg.RuntimeProfile()) != "" if explicitProfile || corpID == "" || userID == "" || clientID == "" { profile, err := personalEventProfileMetadata(configDir) if err != nil { // A user-selected --profile remains a strict contract. Without an // explicit selector, profiles.json is optional metadata for a // host-managed bearer: malformed or stale persisted state must not // override complete runtime defaults or prevent the later global // client-id fallback. if explicitProfile { return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err) } profile = nil } if profile != nil { if corpID == "" { corpID = strings.TrimSpace(profile.CorpID) } if userID == "" { userID = strings.TrimSpace(profile.UserID) } if clientID == "" { clientID = strings.TrimSpace(profile.ClientID) } } } if clientID == "" { // Persisted/global app credentials are only a fallback after the // selected profile, so an old app config cannot override profile.ClientID. clientID = strings.TrimSpace(personalClientID()) } if clientID == "" { if id, _, _, _, resolveErr := personalResolveAppCredentialsStrict(configDir); resolveErr == nil { clientID = strings.TrimSpace(id) } } if clientID == "" { return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events") } sourceID := strings.TrimSpace(sourceIDOverride) if sourceID == "" { sourceID = personalEventStreamSourceID("") } localSubject := "" if corpID == "" || userID == "" { localSubject = personalTokenSubject("access", explicitToken) } return personal.Identity{ LocalSubject: localSubject, CorpID: corpID, UserID: userID, ClientID: clientID, SourceID: sourceID, }, nil } func personalEventProfileMetadata(configDir string) (*authpkg.Profile, error) { cfg, err := personalLoadProfiles(configDir) if err != nil { return nil, err } selector := strings.TrimSpace(authpkg.RuntimeProfile()) explicitSelector := selector != "" if strings.Contains(selector, ",") { return nil, fmt.Errorf("personal events require exactly one --profile") } if cfg == nil || len(cfg.Profiles) == 0 { if explicitSelector { return nil, fmt.Errorf("profile %q not found", selector) } return nil, nil } if selector == "" { selector = strings.TrimSpace(cfg.CurrentProfile) } if selector == "" { return nil, nil } profile, err := selectPersonalEventProfileMetadata(cfg, selector, make(map[string]struct{})) if err != nil && !explicitSelector { // A stale persisted CurrentProfile must not make a host-provided bearer // unusable. Runtime defaults and the one-way local subject are sufficient // to isolate the event bus without consulting local OAuth credentials. return nil, nil } return profile, err } func selectPersonalEventProfileMetadata(cfg *authpkg.ProfilesConfig, selector string, visited map[string]struct{}) (*authpkg.Profile, error) { _ = visited // retained for the focused compatibility seam used by app tests. return authpkg.ResolveProfileMetadata(cfg, strings.TrimSpace(selector)) } func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) { accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "") if err != nil { return personal.Identity{}, err } tokenData, err := personalLoadTokenData(configDir) if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) { return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err) } var corpID, userID, clientID, refreshToken string if tokenData != nil { corpID = tokenData.CorpID userID = tokenData.UserID clientID = tokenData.ClientID refreshToken = tokenData.RefreshToken } if corpID == "" { corpID = resolveRuntimeDefault(ctx, "$corpId") } if userID == "" { userID = resolveRuntimeDefault(ctx, "$currentUserId") } if clientID == "" { clientID = personalClientID() } if clientID == "" { if id, _, _, _, err := personalResolveAppCredentialsStrict(configDir); err == nil { clientID = id } } if clientID == "" { return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events") } sourceID := strings.TrimSpace(sourceIDOverride) if sourceID == "" { sourceID = personalEventStreamSourceID("") } localSubject := "" if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" { localSubject = personalTokenSubject("refresh", refreshToken) if localSubject == "" { localSubject = personalTokenSubject("access", accessToken) } } return personal.Identity{ AccessToken: accessToken, LocalSubject: localSubject, CorpID: corpID, UserID: userID, ClientID: clientID, SourceID: sourceID, }, nil } func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity, explicitTokens ...string) *personal.Client { explicitToken := "" if len(explicitTokens) > 0 { explicitToken = strings.TrimSpace(explicitTokens[0]) } identity.AccessToken = "" client := personal.NewClient(baseURL, identity) version := strings.TrimSpace(RawVersion()) if version == "" { version = "unknown" } client.ClientVersion = version client.UserAgent = "dws-cli/" + version if explicitToken != "" { client.AccessTokenProvider = func(context.Context) (string, error) { return explicitToken, nil } client.HTTPClient.Transport = runtimeTokenControlTransport{base: http.DefaultTransport, token: explicitToken} client.HTTPClient.CheckRedirect = runtimeTokenRedirectPolicy } else { client.AccessTokenProvider = func(ctx context.Context) (string, error) { return personalResolveAuxiliaryAccessToken(ctx, configDir, "") } } return client } // runtimeTokenRedirectPolicy prevents Go's redirect machinery from copying // DWS's custom x-user-access-token header to another authority. Returning // ErrUseLastResponse keeps the 3xx response available to the caller without a // url.Error that could echo an attacker-controlled Location value. func runtimeTokenRedirectPolicy(req *http.Request, via []*http.Request) error { if len(via) == 0 || req == nil || req.URL == nil || via[0] == nil || via[0].URL == nil { return http.ErrUseLastResponse } origin := via[0].URL if !strings.EqualFold(strings.TrimSpace(req.URL.Host), strings.TrimSpace(origin.Host)) { return http.ErrUseLastResponse } if strings.EqualFold(origin.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") { return http.ErrUseLastResponse } return nil } const runtimeTokenControlErrorBody = `{"code":"RUNTIME_TOKEN_REJECTED","message":"event runtime token was rejected; retry with a fresh host credential"}` // runtimeTokenControlTransport scrubs an explicit bearer from every response // body and diagnostic header before the control client decodes or logs it. A // 401 is replaced with a fixed rejection envelope so untrusted response text // can never escape through stderr or debug logs. type runtimeTokenControlTransport struct { base http.RoundTripper token string } func (t runtimeTokenControlTransport) RoundTrip(req *http.Request) (*http.Response, error) { base := t.base if base == nil { base = http.DefaultTransport } resp, err := base.RoundTrip(req) if err != nil { if token := strings.TrimSpace(t.token); token != "" && strings.Contains(err.Error(), token) { return nil, errors.New("personal event: runtime-token control request failed") } return nil, err } if resp == nil { return resp, err } token := strings.TrimSpace(t.token) for key, values := range resp.Header { for i := range values { if token != "" { values[i] = strings.ReplaceAll(values[i], token, "") } } resp.Header[key] = values } var responseBody []byte if resp.Body != nil { responseBody, err = io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) _ = resp.Body.Close() if err != nil { return nil, errors.New("personal event: read runtime-token control response") } } if resp.StatusCode == http.StatusUnauthorized { responseBody = []byte(runtimeTokenControlErrorBody) } else if token != "" { responseBody = redactRuntimeTokenResponseBody(responseBody, token) } resp.Body = io.NopCloser(bytes.NewReader(responseBody)) resp.ContentLength = int64(len(responseBody)) if resp.Header == nil { resp.Header = make(http.Header) } resp.Header.Set("Content-Type", "application/json") resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(responseBody))) return resp, nil } func redactRuntimeTokenResponseBody(data []byte, token string) []byte { token = strings.TrimSpace(token) if len(data) == 0 || token == "" { return data } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() var decoded any if err := decoder.Decode(&decoded); err == nil { var trailing any if trailingErr := decoder.Decode(&trailing); errors.Is(trailingErr, io.EOF) { if redacted, changed := redactRuntimeTokenJSONValue(decoded, token); changed { if encoded, marshalErr := json.Marshal(redacted); marshalErr == nil { return encoded } } } } return bytes.ReplaceAll(data, []byte(token), []byte("")) } func redactRuntimeTokenJSONValue(value any, token string) (any, bool) { switch typed := value.(type) { case string: redacted := strings.ReplaceAll(typed, token, "") return redacted, redacted != typed case []any: changed := false for i := range typed { var itemChanged bool typed[i], itemChanged = redactRuntimeTokenJSONValue(typed[i], token) changed = changed || itemChanged } return typed, changed case map[string]any: changed := false redactedMap := make(map[string]any, len(typed)) for key, item := range typed { redactedKey := strings.ReplaceAll(key, token, "") redacted, itemChanged := redactRuntimeTokenJSONValue(item, token) redactedMap[redactedKey] = redacted changed = changed || itemChanged || redactedKey != key } if !changed { return typed, false } return redactedMap, true default: return value, false } } func personalTokenSubject(kind, token string) string { token = strings.TrimSpace(token) if token == "" { return "" } sum := sha256.Sum256([]byte(token)) return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:]) } func validPersonalIdentityHash(value string) bool { value = strings.TrimSpace(value) if len(value) != 16 { return false } _, err := hex.DecodeString(value) return err == nil } func resolveRuntimeDefault(ctx context.Context, key string) string { if fnMap := edition.Get().RuntimeDefaults; fnMap != nil { if fn := fnMap()[key]; fn != nil { if v, ok := fn(ctx); ok { return strings.TrimSpace(v) } } } return "" } func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) { mode := strings.TrimSpace(opts.TicketMode) if mode == "" { mode = "normal" } if mode != "normal" && mode != "custom" { return nil, fmt.Errorf("stream ticket mode must be normal or custom") } ticketURL := strings.TrimSpace(opts.TicketURL) if ticketURL == "" { ticketURL = personalEventStreamTicketURL("", opts.ConfigDir) } clientID := opts.Identity.ClientID clientSecret := "" if mode == "custom" { resolvedID, secret, _, _, err := personalResolveAppCredentialsStrict(opts.ConfigDir) if err != nil { return nil, err } if opts.ClientIDOverride != "" { clientID = opts.ClientIDOverride } else if clientID == "" { clientID = resolvedID } clientSecret = secret } credentialBroker := opts.CredentialBroker if credentialBroker == nil { credentialBroker = newPersonalCredentialBroker(opts.ConfigDir, false, false) } httpClient := &http.Client{Timeout: 30 * time.Second} if opts.RuntimeTokenMode { httpClient.CheckRedirect = runtimeTokenRedirectPolicy } _ = ctx return source.NewPersonal(source.PersonalConfig{ AccessTokenProvider: func(ctx context.Context) (string, error) { return credentialBroker.Resolve(ctx) }, ForceRefreshToken: func(ctx context.Context, rejectedToken string) (string, error) { return credentialBroker.RefreshRejected(ctx, rejectedToken) }, ClassifyRetryReject: credentialBroker.ClassifyRejectedAfterRetry, ClientID: clientID, ClientSecret: clientSecret, SourceID: opts.Identity.SourceID, TicketURL: ticketURL, TicketMode: mode, HTTPClient: httpClient, }) } func newPersonalCredentialBroker(configDir string, requireSeed, requireActivation bool) *runtimecred.Broker { return runtimecred.New(runtimecred.Config{ RequireSeed: requireSeed, RequireActivation: requireActivation, LocalResolve: func(ctx context.Context) (string, error) { return personalResolveAuxiliaryAccessToken(ctx, configDir, "") }, LocalRefresh: func(ctx context.Context, rejectedToken string) (string, error) { return personalForceRefreshRejectedToken(ctx, configDir, rejectedToken) }, }) } func personalBusProfileSelector(configDir string, identity personal.Identity) string { // The parent already resolved and loaded this selector. Preserve it before // consulting identity metadata: personal event discovery can fill an empty // token userId from runtime defaults, and that inferred value must not turn a // historical unresolved account into a different exact same-corp account in // the detached child. if selector := strings.TrimSpace(authpkg.RuntimeProfile()); selector != "" { return selector } if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil { // With no explicit process-local override, LoadTokenData selected the // persisted current profile. Prefer that selection over the enriched // identity: $currentUserId may describe an exact same-corp account even // though the token came from the historical unresolved profile. currentSelector := strings.TrimSpace(cfg.CurrentProfile) for i := range cfg.Profiles { profile := cfg.Profiles[i] selector := authpkg.ProfileSelectionSelector(profile, cfg) if selector == currentSelector && (strings.TrimSpace(identity.CorpID) == "" || strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID)) { return selector } } for i := range cfg.Profiles { profile := cfg.Profiles[i] if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) && strings.TrimSpace(profile.UserID) == strings.TrimSpace(identity.UserID) { return authpkg.ProfileSelectionSelector(profile, cfg) } } } return authpkg.ProfileSelector(authpkg.Profile{ CorpID: identity.CorpID, UserID: identity.UserID, }) } func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string, profileSelectors ...string) []string { args := []string{ "--source-kind", string(dwsevent.SourceKindPersonalStream), "--stream-source-id", identity.SourceID, } // Forward the exact account so the detached _bus child resolves the same // credentials as the parent, including when one organization has multiple // logged-in users. if cid := strings.TrimSpace(identity.CorpID); cid != "" { profileSelector := authpkg.ProfileSelector(authpkg.Profile{CorpID: identity.CorpID, UserID: identity.UserID}) if len(profileSelectors) > 0 && strings.TrimSpace(profileSelectors[0]) != "" { profileSelector = strings.TrimSpace(profileSelectors[0]) } args = append(args, "--profile", profileSelector) } if strings.TrimSpace(ticketMode) != "" { args = append(args, "--stream-ticket-mode", ticketMode) } if strings.TrimSpace(ticketURL) != "" { args = append(args, "--stream-ticket-url", ticketURL) } return args } func personalBusSpawnArgsForToken(identity personal.Identity, identityHash, ticketMode, ticketURL, profileSelector, explicitToken string) []string { if strings.TrimSpace(explicitToken) == "" { return personalBusSpawnArgs(identity, ticketMode, ticketURL, profileSelector) } args := []string{ "--source-kind", string(dwsevent.SourceKindPersonalStream), "--runtime-token-mode", "--identity-hash", strings.TrimSpace(identityHash), "--stream-source-id", strings.TrimSpace(identity.SourceID), } if strings.TrimSpace(ticketMode) != "" { args = append(args, "--stream-ticket-mode", strings.TrimSpace(ticketMode)) } if strings.TrimSpace(ticketURL) != "" { args = append(args, "--stream-ticket-url", strings.TrimSpace(ticketURL)) } return args } func personalEventTypes(eventKey string, explicit []string) []string { if len(explicit) > 0 { return explicit } if strings.TrimSpace(eventKey) == "" { return nil } return []string{eventKey} } func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string { return map[string]string{ "corp_id": displayIdentityPart(identity.CorpID), "user_id": displayIdentityPart(identity.UserID), "client_id": identity.ClientID, "source_id": identity.SourceID, "identity_hash": identityHash, } } func displayIdentityPart(v string) string { v = strings.TrimSpace(v) if v == "" { return "unknown" } return v } func firstNonEmptyPersonalString(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" } func dedupePersonalEventKeys(values []string) []string { out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" { continue } if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func personalEventControlBaseURL(raw, configDir string) string { if v := strings.TrimSpace(raw); v != "" { return strings.TrimRight(v, "/") } return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath } func personalEventStreamTicketURL(raw, configDir string) string { if v := strings.TrimSpace(raw); v != "" { return strings.TrimRight(v, "/") } return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket" } func personalEventStreamSourceID(raw string) string { if v := strings.TrimSpace(raw); v != "" { return v } return strings.TrimSpace(edition.PersonalEventSourceID()) } func personalEventMCPBaseURL(configDir string) string { if v := configuredMCPBaseURL(configDir); v != "" { return strings.TrimRight(v, "/") } return strings.TrimRight(config.DefaultMCPBaseURL, "/") } func configuredMCPBaseURL(configDir string) string { if strings.TrimSpace(configDir) == "" { configDir = defaultConfigDir() } data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) if err != nil { return "" } return strings.TrimSpace(string(data)) }