diff --git a/README.md b/README.md index 1442bba..4e4e30a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DotControl +本地生命周期重建与内部配对基础见 [macOS 生命周期与配对说明](docs/MACOS_LIFECYCLE_PAIRING.md)。当前仍以官方私有插件/隧道为主路径;一次性设备配对接口仅是内部准备,不代表已实现无插件控制。签名公证、Mac 真机、源 Dot 图片与用户 Mac 执行仍待验收。 + 让官方 Dot 通过每台设备自己的私有 MCP 插件与安全隧道,操作另一台 Windows 或 Mac。无需自建云服务器,不复制官方账号凭据或私有记忆;本地会话只在用户启用共享后按已保存范围只读返回。 **0.2 系列:跨电脑终端任务与只读会话共享。** 页面分为概览、连接设置、Codex 和会话共享。内置 SSH/实验助手和旧审批转发已下线;用户原有配置、密钥及记录保留。[alpha.7](https://github.com/VeraPyuyi/DotControl/releases/tag/v0.2.0-alpha.7) 已发布,包含[复盘中的七项修复](docs/REVIEW_0_2.md)和用户级后台恢复。安装包及实际验证范围见[发布支持矩阵](docs/RELEASE_SUPPORT.md)。 diff --git a/cmd/dotcontrol/runtime.go b/cmd/dotcontrol/runtime.go index 35acd95..6e0ca72 100644 --- a/cmd/dotcontrol/runtime.go +++ b/cmd/dotcontrol/runtime.go @@ -597,7 +597,7 @@ func (o *runtimeOwner) Diagnostics(ctx context.Context) (any, error) { d := o.eng.Device() caps := o.bridge.Capabilities() caps.CodexHistory = historyCap - return map[string]any{"schema": 1, "version": model.Version, "background": desktop.CachedStartupStatus(), "os": runtime.GOOS, "arch": runtime.GOARCH, "time": time.Now().UTC(), "paused": d.Paused, "history_enabled": o.cfg.HistoryEnabled, "terminal_only": o.cfg.TerminalOnly, "terminal_enabled": o.cfg.TerminalEnabled, "revoked": o.revoked, "activity_available": d.Activity.Available, "locked": d.Activity.Locked, "driver": ds, "execution_progress": o.eng.Progress(), "failure_layers": map[string]string{"driver": diagnosticFailureLayer(o.driverError), "tunnel": diagnosticFailureLayer(o.sup.Status().Error), "source_dot": "unconfirmed"}, "driver_error": o.driverError, "permissions": o.permissions, "tunnel": o.sup.Status(), "tunnel_error": o.tunnelError, "network_mode": o.cfg.Network.Mode, "plugin": o.proof, "codex_capabilities": caps}, nil + return map[string]any{"schema": 1, "version": model.Version, "background": desktop.CachedStartupStatus(), "os": runtime.GOOS, "arch": runtime.GOARCH, "time": time.Now().UTC(), "paused": d.Paused, "history_enabled": o.cfg.HistoryEnabled, "terminal_only": o.cfg.TerminalOnly, "terminal_enabled": o.cfg.TerminalEnabled, "revoked": o.revoked, "activity_available": d.Activity.Available, "activity_status": o.monitor.Diagnostic(), "locked": d.Activity.Locked, "driver": ds, "execution_progress": o.eng.Progress(), "failure_layers": map[string]string{"driver": diagnosticFailureLayer(o.driverError), "tunnel": diagnosticFailureLayer(o.sup.Status().Error), "source_dot": "unconfirmed"}, "driver_error": o.driverError, "permissions": o.permissions, "tunnel": o.sup.Status(), "tunnel_error": o.tunnelError, "network_mode": o.cfg.Network.Mode, "plugin": o.proof, "codex_capabilities": caps}, nil } func (o *runtimeOwner) maintain() { ticker := time.NewTicker(10 * time.Second) diff --git a/docs/MACOS_LIFECYCLE_PAIRING.md b/docs/MACOS_LIFECYCLE_PAIRING.md new file mode 100644 index 0000000..e286efa --- /dev/null +++ b/docs/MACOS_LIFECYCLE_PAIRING.md @@ -0,0 +1,32 @@ +# macOS lifecycle and internal pairing preparation + +This work builds on main `c90155c`; it is a local source change, not a new release or signed installer. The official private plugin and tunnel remain the supported connection path. There is no plugin-free control mode, public relay, cross-network transport, controller UI, or new ChatGPT login requirement. + +## Startup and permissions + +A saved LaunchAgent definition, a registered service, and a running process are separate evidence. A matching loaded service needs a positive PID before the cached runtime status can say it is managed. An explicitly disabled service is rejected before any kickstart, even if it remains registered. Conflicting executable/state paths or an unknown loaded definition are refused. + +For `managed_startup_disabled`, inspect the current user's DotControl login/background setting and enable it only if you want background operation, then reopen the installed app. For `managed_startup_unavailable` or `launch_agent_loaded_definition_unknown`, use the installer to repair the selected installation; do not manually overwrite another instance's plist. A saved login plist describes the login preference; it does not prove that launchd currently runs the service. + +Diagnostic downloads now include `activity_status`. `helper_unavailable` means the helper did not start or its output ended; `awaiting_sample`, `sample_stale`, `invalid_sample`, and `no_foreground_app` describe sampling failures. Only explicit helper permission evidence reports `permission_missing`. An unavailable activity stream must not be interpreted as user inactivity. macOS Input Monitoring belongs to the installed DotControlActivity helper; Cua Accessibility and Screen Recording are separate permissions. + +## Installation recovery + +The existing installation transaction remains authoritative. It suspends automatic restart before waiting for the old process, restores configuration/startup state on failed activation, and verifies health before committing. Old-version cleanup checks recorded ownership, file hashes, and process/startup references. Unknown or modified files remain untouched. + +Device identity, pause/revocation state, history sharing scope, and unrelated configuration survive reinstall and interrupted upgrades. A genuinely missing old executable cannot be restarted: recovery explicitly reports `rollback_previous_program_unavailable`, preserves configuration, and allows a subsequent repair. This is not a successful rollback-to-running claim. + +## Internal one-time invitation API + +The bridge service has internal `CreateInvitation(deviceID, ttl)`, `ConsumeInvitation(token, deviceID)`, and `RevokeInvitation(token)` methods. They are not exposed through HTTP, CLI, or the onboarding UI. + +- Invitations use 32 cryptographically random bytes, returned as 64 hexadecimal characters. The private state file stores only their hashes. +- The device ID is bound at creation. TTL must be positive and at most 15 minutes. A new invitation replaces an outstanding invitation for the same device; at most 128 active invitations are retained. +- Consumption checks device and expiry under the service mutex, creates an initially unprivileged client identity, and removes the invitation in one private state-file replacement. Reuse and concurrent second consumption fail. +- Pending invitations and paired identities survive service restart. Explicit revocation deletes pending authorization; existing `RevokeClient` revokes a paired bearer credential. +- The consumer receives the durable client's bearer credential once and must retain it securely. If the response is lost after persistence, create a new device identity through a future operator flow; retrying an already consumed invitation never returns the secret again. +- This follows the existing single-service-owner model. Independent concurrent processes writing the same bridge state are not supported. Local same-user malicious processes are outside the private-file isolation boundary. + +## Acceptance limits + +Windows fixtures and Go race tests do not prove macOS launchd/TCC behavior. macOS Swift compilation and real startup/permission behavior, Developer ID signing/notarization, original source Dot image display, and execution on the user's Mac remain pending. No driver is installed or launched and no system login item is changed by this implementation session. diff --git a/docs/validation/round2-installation-fixtures.md b/docs/validation/round2-installation-fixtures.md new file mode 100644 index 0000000..804884c --- /dev/null +++ b/docs/validation/round2-installation-fixtures.md @@ -0,0 +1,15 @@ +# Round 2 installation audit + +Baseline `c90155c52e7069540619cd4766bccbcd6a3dbcce` already implements T2. No installer behavior is replaced from the older PR #2 branch. + +| Requirement | Existing fixture evidence | +|---|---| +| Suspend before stopping; registered startup | `TestUpgradeSuspendsBeforeExitAndStartsRegisteredRuntime` | +| Failure rollback startup/config consistency | `TestFailedHealthSuspendsCandidateBeforeRollbackExit`, `TestUnmanagedActivationFailureRestoresExactConfigStartupAndLegacy` | +| Interrupted activation recovery | `TestMissingExecutableRepairRecoversInterruptedActivation`, `TestInterruptedUnmanagedActivationRecoversFromDurableSnapshot` | +| Missing old program; repair retry | `TestManagedMissingExecutableRepairAndFailedHealthRetry`, `TestManagedMissingExecutableStillRequiresExitEvidence` | +| Preserve device config, pause, revocation, sharing scope | `TestManagedUninstallAndReinstallPreservesState` | +| Cleanup only recorded owned payloads; preserve unknown/changed files | `internal/install/cleanup_test.go` | +| Retain state binding after reinstall/interruption | `TestRetainedReceiptSurvivesActivationBeforeCommitRecovery`, `TestManagedStateBindingSurvivesCleanupAndRejectsRebinding` | + +The external round 2 implementation report records actual command results, test counts and platform skips. These are filesystem/command fixtures; they do not establish real macOS installer or launchd acceptance. When the old executable is absent, recovery reports that it cannot restart that old executable and allows repair; it does not claim successful runtime restoration. diff --git a/internal/activity/activity.go b/internal/activity/activity.go index 4285502..d566990 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -8,10 +8,12 @@ import ( ) type Monitor struct { - mu sync.Mutex - state model.Activity - sampled time.Time - close func() + mu sync.Mutex + state model.Activity + sampled time.Time + close func() + diagnostic Diagnostic + helperFailed bool } func (m *Monitor) Snapshot() model.Activity { @@ -19,7 +21,7 @@ func (m *Monitor) Snapshot() model.Activity { defer m.mu.Unlock() s := m.state // A stopped/crashed helper is never evidence that the user is idle. - if m.sampled.IsZero() || time.Since(m.sampled) > time.Second { + if m.helperFailed || m.sampled.IsZero() || time.Since(m.sampled) > time.Second { s.Available = false } return s diff --git a/internal/activity/darwin.go b/internal/activity/darwin.go index 15eb27b..0fcb6d5 100644 --- a/internal/activity/darwin.go +++ b/internal/activity/darwin.go @@ -16,7 +16,7 @@ import ( // Start launches the installed, stable-identity activity helper. It never // requests or works around TCC grants. Setup owns the app bundle location. func Start(ctx context.Context, helper string) (*Monitor, error) { - m := &Monitor{} + m := &Monitor{helperFailed: true} if helper == "" { return m, errors.New("activity helper is not installed") } @@ -33,17 +33,23 @@ func Start(ctx context.Context, helper string) (*Monitor, error) { cancel() return m, err } + m.helperFailed = false done := make(chan struct{}) m.close = func() { cancel(); <-done } go func() { defer close(done) defer cmd.Wait() + defer m.markHelperFailed() s := bufio.NewScanner(stdout) s.Buffer(make([]byte, 4096), 8192) for s.Scan() { - var state model.Activity + var state struct { + model.Activity + Reason string `json:"availability_reason"` + Permission *bool `json:"input_monitoring_granted"` + } if json.Unmarshal(s.Bytes(), &state) == nil { - m.update(state) + m.updateSample(state.Activity, state.Reason, state.Permission) } } }() diff --git a/internal/activity/diagnostic.go b/internal/activity/diagnostic.go new file mode 100644 index 0000000..b53f37e --- /dev/null +++ b/internal/activity/diagnostic.go @@ -0,0 +1,58 @@ +package activity + +import ( + "github.com/dotcontrol/dotcontrol/internal/model" + "time" +) + +type Diagnostic struct { + Reason string `json:"reason"` + InputMonitoringKnown bool `json:"input_monitoring_known"` + InputMonitoringGranted bool `json:"input_monitoring_granted"` +} + +func (m *Monitor) Diagnostic() Diagnostic { + m.mu.Lock() + defer m.mu.Unlock() + d := m.diagnostic + switch { + case m.helperFailed: + d.Reason = "helper_unavailable" + case m.sampled.IsZero(): + d.Reason = "awaiting_sample" + case time.Since(m.sampled) > time.Second: + d.Reason = "sample_stale" + case m.state.Available: + d.Reason = "ready" + case d.Reason == "": + d.Reason = "sample_unavailable" + } + return d +} +func (m *Monitor) updateSample(s model.Activity, reason string, permission *bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.state = s + m.sampled = time.Now() + d := Diagnostic{Reason: "sample_unavailable", InputMonitoringKnown: permission != nil} + switch reason { + case "permission_missing", "invalid_sample", "no_foreground_app", "ready": + d.Reason = reason + } + if permission != nil { + d.InputMonitoringGranted = *permission + } + m.diagnostic = d +} +func (m *Monitor) markHelperFailed() { m.mu.Lock(); m.helperFailed = true; m.mu.Unlock() } +func (m *Managed) Diagnostic() Diagnostic { + if m == nil { + return Diagnostic{Reason: "helper_unavailable"} + } + m.mu.Lock() + defer m.mu.Unlock() + if m.current == nil { + return Diagnostic{Reason: "helper_unavailable"} + } + return m.current.Diagnostic() +} diff --git a/internal/activity/diagnostic_test.go b/internal/activity/diagnostic_test.go new file mode 100644 index 0000000..c3474f3 --- /dev/null +++ b/internal/activity/diagnostic_test.go @@ -0,0 +1,50 @@ +package activity + +import ( + "github.com/dotcontrol/dotcontrol/internal/model" + "testing" + "time" +) + +func TestActivityFailureEvidence(t *testing.T) { + m := &Monitor{} + if m.Diagnostic().Reason != "awaiting_sample" { + t.Fatal(m.Diagnostic()) + } + for _, tc := range []struct { + reason string + permission *bool + available bool + want string + }{ + {"permission_missing", boolValue(false), false, "permission_missing"}, + {"invalid_sample", boolValue(true), false, "invalid_sample"}, + {"no_foreground_app", boolValue(true), false, "no_foreground_app"}, + {"private title", nil, false, "sample_unavailable"}, + {"ready", boolValue(true), true, "ready"}, + } { + t.Run(tc.want, func(t *testing.T) { + m.updateSample(model.Activity{Available: tc.available}, tc.reason, tc.permission) + d := m.Diagnostic() + if d.Reason != tc.want || d.InputMonitoringKnown != (tc.permission != nil) { + t.Fatalf("%+v", d) + } + }) + } + m.sampled = time.Now().Add(-2 * time.Second) + if m.Diagnostic().Reason != "sample_stale" || m.Snapshot().Available { + t.Fatal("stale sample trusted") + } + m.update(model.Activity{Available: true}) + m.markHelperFailed() + if m.Diagnostic().Reason != "helper_unavailable" || m.Snapshot().Available { + t.Fatal("exited helper trusted") + } +} +func boolValue(v bool) *bool { return &v } +func TestInactiveActivityIsNotPermissionDenial(t *testing.T) { + m := NewInactive(nil, "") + if m.Diagnostic().Reason != "helper_unavailable" || m.Diagnostic().InputMonitoringKnown { + t.Fatal(m.Diagnostic()) + } +} diff --git a/internal/desktop/launch_policy.go b/internal/desktop/launch_policy.go new file mode 100644 index 0000000..a2c11e2 --- /dev/null +++ b/internal/desktop/launch_policy.go @@ -0,0 +1,41 @@ +package desktop + +import ( + "errors" + "strconv" + "strings" +) + +func launchField(data, key string) string { + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, key+" = ") { + return strings.Trim(strings.TrimPrefix(line, key+" = "), `"`) + } + } + return "" +} + +func (s StartupSnapshot) launchRunning() bool { + pid, err := strconv.Atoi(launchField(s.LoadedText, "pid")) + return s.Loaded && err == nil && pid > 0 +} + +func (s StartupSnapshot) launchStartAction(exe, state string) (string, error) { + if !s.BelongsToState(state) { + return "", errors.New("managed_startup_unavailable") + } + if !s.matchesExecutable(exe) { + return "", errors.New("conflicting_definition_paths") + } + if s.Disabled { + return "", errors.New("managed_startup_disabled") + } + if s.Loaded { + if s.launchRunning() { + return "", nil + } + return "kickstart", nil + } + return "bootstrap", nil +} diff --git a/internal/desktop/launch_policy_test.go b/internal/desktop/launch_policy_test.go new file mode 100644 index 0000000..44c9d13 --- /dev/null +++ b/internal/desktop/launch_policy_test.go @@ -0,0 +1,78 @@ +package desktop + +import "testing" + +func TestLaunchPolicyRejectsDisabledAndForeignDefinitions(t *testing.T) { + data := launchAgentXML("org.dotcontrol.agent", "/agent", "/state") + for _, tc := range []struct { + name string + snapshot StartupSnapshot + want string + }{ + {"unregistered", StartupSnapshot{ManagedData: data}, "bootstrap"}, + {"registered-stopped", StartupSnapshot{ManagedData: data, Loaded: true}, "kickstart"}, + {"running", StartupSnapshot{ManagedData: data, Loaded: true, LoadedText: "pid = 42\n"}, ""}, + {"disabled-loaded", StartupSnapshot{ManagedData: data, Loaded: true, Disabled: true}, "managed_startup_disabled"}, + {"disabled-running", StartupSnapshot{ManagedData: data, Loaded: true, Disabled: true, LoadedText: "pid = 42\n"}, "managed_startup_disabled"}, + {"path-mismatch", StartupSnapshot{ManagedData: launchAgentXML("org.dotcontrol.agent", "/other", "/state")}, "conflicting_definition_paths"}, + {"old-definition", StartupSnapshot{ManagedData: data, Data: launchAgentXML("org.dotcontrol.agent", "/old", "/foreign"), Exists: true}, "managed_startup_unavailable"}, + {"missing-definition", StartupSnapshot{Loaded: true}, "managed_startup_unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + action, err := tc.snapshot.launchStartAction("/agent", "/state") + got := action + if err != nil { + got = err.Error() + } + if got != tc.want { + t.Fatalf("got %q want %q", got, tc.want) + } + }) + } +} +func TestLaunchPolicyChecksAllDefinitionPaths(t *testing.T) { + current := launchAgentXML("org.dotcontrol.agent", "/agent", "/state") + old := launchAgentXML("org.dotcontrol.agent", "/old", "/state") + for _, tc := range []struct { + name string + login, managed []byte + loaded string + want string + }{ + {"different-paths-loaded-login", old, current, "path = /login.plist\nprogram = /old\npid = 42\n", "conflicting_definition_paths"}, + {"different-paths-loaded-background", current, old, "path = /background.plist\nprogram = /old\npid = 42\n", "conflicting_definition_paths"}, + {"same-path-loaded-login", current, current, "path = /login.plist\nprogram = /agent\npid = 42\n", ""}, + {"same-path-loaded-background", current, current, "path = /background.plist\nprogram = /agent\npid = 42\n", ""}, + {"stale-login-unloaded", old, current, "", "conflicting_definition_paths"}, + {"stale-background-unloaded", current, old, "", "conflicting_definition_paths"}, + {"stale-loaded-program", current, current, "path = /login.plist\nprogram = /old\npid = 42\n", "conflicting_definition_paths"}, + {"same-path-unloaded", current, current, "", "bootstrap"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := StartupSnapshot{Exists: true, Data: tc.login, ManagedData: tc.managed, Loaded: tc.loaded != "", LoadedText: tc.loaded} + action, err := s.launchStartAction("/agent", "/state") + got := action + if err != nil { + got = err.Error() + } + if got != tc.want { + t.Fatalf("got %q want %q (action %q)", got, tc.want, action) + } + if tc.want == "conflicting_definition_paths" && s.matchesExecutable("/agent") { + t.Fatal("conflicting snapshot matches target executable") + } + }) + } +} + +func TestLaunchRegistrationIsNotRunningEvidence(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{{"", false}, {"pid = 0\n", false}, {"pid = invalid\n", false}, {"pid = 42\n", true}} { + s := StartupSnapshot{Loaded: true, LoadedText: tc.text} + if s.launchRunning() != tc.want { + t.Fatalf("running for %q", tc.text) + } + } +} diff --git a/internal/desktop/startup.go b/internal/desktop/startup.go index 35b0850..ab69c75 100644 --- a/internal/desktop/startup.go +++ b/internal/desktop/startup.go @@ -76,7 +76,7 @@ func RefreshStartupStatus(executable, state, managedMechanism string) { result.Reason = "manual_start" if s.Present() && !s.BelongsToState(state) { result.Reason = "startup_other_instance" - } else if managedMechanism != "" && managedMechanism == result.Mechanism && s.matchesExecutable(executable) && (s.TaskRunning || s.Loaded) && managedParentCheck(executable) { + } else if managedMechanism != "" && managedMechanism == result.Mechanism && s.matchesExecutable(executable) && (s.TaskRunning || (!s.Disabled && s.launchRunning())) && managedParentCheck(executable) { result.Managed = true result.Reason = "" } @@ -198,12 +198,26 @@ func (s StartupSnapshot) matchesExecutable(exe string) bool { t, e := parseTask(s.TaskXML) return e == nil && strings.EqualFold(t.Actions.Exec[0].Command, exe) } - data := s.ManagedData - if len(data) == 0 { - data = s.Data + // Neither the background nor the login definition may hide a stale path. + found := false + for _, data := range [][]byte{s.ManagedData, s.Data} { + if len(data) == 0 { + continue + } + a, e := plistArguments(data) + if e != nil || a[0] != exe { + return false + } + found = true } - a, e := plistArguments(data) - return e == nil && a[0] == exe + // SnapshotStartup validates the loaded source and arguments against its + // plist; also compare the actual loaded program with the requested target. + if s.Loaded { + if program := launchField(s.LoadedText, "program"); program != "" && program != exe { + return false + } + } + return found } func (s StartupSnapshot) AutostartConfigured() bool { if s.Value != "" { diff --git a/internal/desktop/startup_darwin.go b/internal/desktop/startup_darwin.go index 4f10f36..c0f0a19 100644 --- a/internal/desktop/startup_darwin.go +++ b/internal/desktop/startup_darwin.go @@ -44,15 +44,6 @@ func launchMissing(data []byte) bool { s := strings.ToLower(string(data)) return strings.Contains(s, "could not find service") || strings.Contains(s, "service not found") } -func launchField(data, key string) string { - for _, line := range strings.Split(data, "\n") { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, key+" = ") { - return strings.Trim(strings.TrimPrefix(line, key+" = "), `"`) - } - } - return "" -} func launchArguments(data string) []string { var args []string inside := false @@ -227,17 +218,12 @@ func StartManaged(exe, state string) error { if e != nil { return e } - if !s.BelongsToState(state) || !s.matchesExecutable(exe) { - return errors.New("managed_startup_unavailable") - } - if s.Loaded { - if launchField(s.LoadedText, "pid") != "" { - return nil - } - return launchChange("kickstart", launchService()) + action, e := s.launchStartAction(exe, state) + if e != nil || action == "" { + return e } - if s.Disabled { - return errors.New("managed_startup_disabled") + if action == "kickstart" { + return launchChange(action, launchService()) } p := managedPlistPath() if len(s.ManagedData) == 0 { diff --git a/internal/dotbridge/invitation.go b/internal/dotbridge/invitation.go new file mode 100644 index 0000000..9dab3a2 --- /dev/null +++ b/internal/dotbridge/invitation.go @@ -0,0 +1,94 @@ +package dotbridge + +import ( + "errors" + "maps" + "time" +) + +// Invitations are internal preparation only: no network endpoint exposes them. +// Only hashes are persisted; deletion and identity creation share one commit. +type deviceInvitation struct { + DeviceID string `json:"device_id"` + Expires time.Time `json:"expires"` +} + +func (s *Service) CreateInvitation(device string, ttl time.Duration) (string, error) { + if !pairingID.MatchString(device) || ttl <= 0 || ttl > 15*time.Minute { + return "", errors.New("pairing_invitation_invalid") + } + token, e := randomToken() + if e != nil { + return "", e + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.state.Clients[device]; exists { + return "", errors.New("client_exists") + } + previous := s.state.Invitations + next := maps.Clone(previous) + if next == nil { + next = map[string]deviceInvitation{} + } + now := time.Now().UTC() + for key, inv := range next { + if !now.Before(inv.Expires) || inv.DeviceID == device { + delete(next, key) + } + } + if len(next) >= 128 { + return "", errors.New("pairing_invitation_capacity") + } + next[hashToken(token)] = deviceInvitation{DeviceID: device, Expires: now.Add(ttl)} + s.state.Invitations = next + if e = s.saveState(); e != nil { + s.state.Invitations = previous + return "", e + } + return token, nil +} + +func (s *Service) ConsumeInvitation(token, device string) (PairingFile, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := hashToken(token) + inv, exists := s.state.Invitations[key] + if !exists || device != inv.DeviceID || !time.Now().Before(inv.Expires) { + return PairingFile{}, errors.New("pairing_invitation_rejected") + } + if _, exists = s.state.Clients[device]; exists { + return PairingFile{}, errors.New("client_exists") + } + credential, e := randomToken() + if e != nil { + return PairingFile{}, e + } + now := time.Now().UTC() + s.state.Clients[device] = ClientPolicy{ClientID: device, QueueID: device, TokenHash: hashToken(credential), CreatedAt: now} + delete(s.state.Invitations, key) + if e = s.saveState(); e != nil { + delete(s.state.Clients, device) + s.state.Invitations[key] = inv + return PairingFile{}, e + } + // The recipient is responsible for retaining the returned credential. No + // second file write can turn a durable consumption into a retryable error. + return PairingFile{Version: APIVersion, ClientID: device, QueueID: device, Token: credential, Listen: s.listen, CreatedAt: now}, nil +} + +func (s *Service) RevokeInvitation(token string) error { + s.mu.Lock() + defer s.mu.Unlock() + key := hashToken(token) + previous, exists := s.state.Invitations[key] + if !exists { + return errors.New("pairing_invitation_rejected") + } + delete(s.state.Invitations, key) + if e := s.saveState(); e != nil { + s.state.Invitations[key] = previous + return e + } + return nil +} diff --git a/internal/dotbridge/invitation_test.go b/internal/dotbridge/invitation_test.go new file mode 100644 index 0000000..c63e6c9 --- /dev/null +++ b/internal/dotbridge/invitation_test.go @@ -0,0 +1,158 @@ +package dotbridge + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func invitationFixture(t *testing.T) *Service { + t.Helper() + return &Service{root: physicalTemp(t), listen: "127.0.0.1:9317", state: PersistedState{Version: APIVersion, Clients: map[string]ClientPolicy{}}} +} + +func TestDeviceInvitationLifecycle(t *testing.T) { + for _, kind := range []string{"consume-restart", "wrong-device", "expired", "revoked", "reused", "concurrent", "invalid"} { + t.Run(kind, func(t *testing.T) { + s := invitationFixture(t) + if kind == "invalid" { + for _, device := range []string{"", "../escape"} { + if _, e := s.CreateInvitation(device, time.Minute); e == nil { + t.Fatal("invalid device accepted") + } + } + if _, e := s.CreateInvitation("device.a", 0); e == nil { + t.Fatal("zero expiry accepted") + } + return + } + token, e := s.CreateInvitation("device.a", time.Minute) + if e != nil { + t.Fatal(e) + } + if len(token) != 64 { + t.Fatal("credential lacks 256-bit random encoding") + } + bytes, e := os.ReadFile(filepath.Join(s.root, "bridge.state.json")) + if e != nil || strings.Contains(string(bytes), token) { + t.Fatal("raw invitation persisted", e) + } + switch kind { + case "wrong-device": + if _, e = s.ConsumeInvitation(token, "device.b"); e == nil { + t.Fatal("wrong device accepted") + } + case "expired": + s.state.Invitations[hashToken(token)] = deviceInvitation{DeviceID: "device.a", Expires: time.Now().Add(-time.Second)} + if _, e = s.ConsumeInvitation(token, "device.a"); e == nil { + t.Fatal("expired invitation accepted") + } + return + case "revoked": + if e = s.RevokeInvitation(token); e != nil { + t.Fatal(e) + } + if _, e = s.ConsumeInvitation(token, "device.a"); e == nil { + t.Fatal("revoked invitation accepted") + } + return + case "concurrent": + var wg sync.WaitGroup + successes := make(chan bool, 8) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { defer wg.Done(); _, e := s.ConsumeInvitation(token, "device.a"); successes <- e == nil }() + } + wg.Wait() + close(successes) + n := 0 + for ok := range successes { + if ok { + n++ + } + } + if n != 1 { + t.Fatalf("consumed %d times", n) + } + return + } + paired, e := s.ConsumeInvitation(token, "device.a") + if e != nil || paired.ClientID != "device.a" || paired.Token == "" { + t.Fatalf("%+v %v", paired, e) + } + if _, e = s.ConsumeInvitation(token, "device.a"); e == nil { + t.Fatal("credential reused") + } + reopened := invitationFixture(t) + reopened.root = s.root + if e = reopened.loadState(); e != nil { + t.Fatal(e) + } + if _, e = reopened.clientFromToken(paired.Token); e != nil { + t.Fatal("identity lost on restart", e) + } + if _, e = reopened.ConsumeInvitation(token, "device.a"); e == nil { + t.Fatal("reuse after restart") + } + if e = reopened.RevokeClient("device.a"); e != nil { + t.Fatal(e) + } + if _, e = reopened.clientFromToken(paired.Token); e == nil { + t.Fatal("revoked device accepted") + } + }) + } +} +func TestInvitationPersistenceFailureDoesNotConsume(t *testing.T) { + s := invitationFixture(t) + token, e := s.CreateInvitation("device.a", time.Minute) + if e != nil { + t.Fatal(e) + } + path := filepath.Join(s.root, "bridge.state.json") + before, _ := os.ReadFile(path) + if e = os.Remove(path); e != nil { + t.Fatal(e) + } + if e = os.Mkdir(path, 0700); e != nil { + t.Fatal(e) + } + if _, e = s.ConsumeInvitation(token, "device.a"); e == nil { + t.Fatal("persistence failure ignored") + } + if len(s.state.Clients) != 0 { + t.Fatal("uncommitted client remains authorized") + } + if e = os.Remove(path); e != nil { + t.Fatal(e) + } + if e = os.WriteFile(path, before, 0600); e != nil { + t.Fatal(e) + } + if _, e = s.ConsumeInvitation(token, "device.a"); e != nil { + t.Fatal("retry blocked", e) + } +} +func TestPendingInvitationSurvivesRestart(t *testing.T) { + s := invitationFixture(t) + token, e := s.CreateInvitation("device.a", time.Minute) + if e != nil { + t.Fatal(e) + } + data, _ := json.Marshal(s.state) + if strings.Contains(string(data), token) { + t.Fatal("raw token leaked") + } + other := invitationFixture(t) + other.root = s.root + if e = other.loadState(); e != nil { + t.Fatal(e) + } + if _, e = other.ConsumeInvitation(token, "device.a"); e != nil { + t.Fatal(e) + } +} diff --git a/internal/dotbridge/types.go b/internal/dotbridge/types.go index 7c7f55a..ca0c7c9 100644 --- a/internal/dotbridge/types.go +++ b/internal/dotbridge/types.go @@ -62,6 +62,7 @@ type CapPolicy struct { } type PersistedState struct { + Invitations map[string]deviceInvitation `json:"device_invitations,omitempty"` Version int `json:"version"` Enabled bool `json:"enabled"` Clients map[string]ClientPolicy `json:"clients"` diff --git a/native/macos/Activity.swift b/native/macos/Activity.swift index 5e12700..b0fab90 100644 --- a/native/macos/Activity.swift +++ b/native/macos/Activity.swift @@ -15,6 +15,8 @@ struct Activity: Encodable { let generation: UInt64 let foreground_pid: Int32 let foreground_window: String + let availability_reason: String + let input_monitoring_granted: Bool } var generation: UInt64 = 0 var previousIdle: Double = 0 @@ -27,10 +29,12 @@ let timer = Timer(timeInterval: 0.02, repeats: true) { _ in let session = CGSessionCopyCurrentDictionary() as? [String: Any] let locked = session == nil || (session?["CGSSessionScreenIsLocked"] as? Bool ?? false) let app = NSWorkspace.shared.frontmostApplication - let state = Activity(available: idle.isFinite && idle >= 0 && app != nil && CGPreflightListenEventAccess(), locked: locked, + let granted = CGPreflightListenEventAccess() + let reason = !granted ? "permission_missing" : (!idle.isFinite || idle < 0) ? "invalid_sample" : app == nil ? "no_foreground_app" : "ready" + let state = Activity(available: reason == "ready", locked: locked, idle_ms: idle.isFinite ? Int64(min(idle * 1000, Double(Int64.max / 2))) : 0, generation: generation, foreground_pid: app?.processIdentifier ?? 0, - foreground_window: "") + foreground_window: "", availability_reason: reason, input_monitoring_granted: granted) if let data = try? JSONEncoder().encode(state) { FileHandle.standardOutput.write(data) FileHandle.standardOutput.write(Data([10])) diff --git a/research/dotcontrol-round2-implementation.md b/research/dotcontrol-round2-implementation.md new file mode 100644 index 0000000..6178e92 --- /dev/null +++ b/research/dotcontrol-round2-implementation.md @@ -0,0 +1,54 @@ +# DotControl round 2 implementation + +## 2026-10-23: reject conflicting launch definition executables + +Base: `cec0e0848f375277a420b44e10e5cc4630de545c`, branch `codex/macos-lifecycle-rebuild`. + +The requested research file was absent in this checkout; this entry creates it. + +`matchesExecutable` previously preferred ManagedData and ignored a remaining login plist. A same-state login definition pointing at an old executable could therefore pass ownership validation and its positive loaded PID could make StartManaged return success for the wrong installation. + +The shared executable check now validates every present background/login plist and, when present, the loaded program against the requested executable. SnapshotStartup's existing source, program, and complete argument checks remain in effect before StartManaged receives a snapshot. launchStartAction returns `conflicting_definition_paths` before inspecting the PID whenever the executable check fails. State ownership failures retain their existing reason. The shared check also prevents a conflicting snapshot from being classified as managed in startup status. + +### RED / GREEN evidence + +Added `TestLaunchPolicyChecksAllDefinitionPaths` with eight pure snapshot fixtures: + +- different-paths-loaded-login +- different-paths-loaded-background +- same-path-loaded-login +- same-path-loaded-background +- stale-login-unloaded +- stale-background-unloaded +- stale-loaded-program +- same-path-unloaded + +Before the implementation change, `go test -count=1 ./internal/desktop -run '^TestLaunchPolicyChecksAllDefinitionPaths$'` exited 1: five failing and three passing subtests. The old loaded login and stale loaded program returned success, and the stale unloaded login returned bootstrap. After the fix, `go test -count=1 ./internal/desktop -run '^TestLaunch(Policy|Registration)'` exited 0. Existing path-mismatch coverage now expects the explicit conflict reason. + +### Full verification + +Executed on Windows with Go and MinGW added only to each child shell's PATH; CGO_ENABLED=1 applied only to the race child shell. No persistent user environment changes. + +| Command | Exit | Passed test events (including subtests) | Passed top-level tests | Passed packages | Skipped test events | +| --- | --- | --- | --- | --- | --- | +| `go test -json -count=1 ./...` | 0 | 1320 | 635 | 22 | 10 | +| `CGO_ENABLED=1 go test -race -json -count=1 ./...` | 0 | 1320 | 635 | 22 | 10 | +| `go vet ./...` | 0 | n/a | n/a | n/a | n/a | +| `go build ./...` | 0 | n/a | n/a | n/a | n/a | + +Both test runs had zero failures and one package with no tests. Counts use actual JSON event lines; PowerShell's native stdout decoding damaged one Chinese subtest name, so whole-line JSON parsing would undercount by one. Event-line counting includes that passing event. Logs: `C:\Users\13678\AppData\Local\Temp\opencode\dotcontrol-paths-test.json` and `dotcontrol-paths-race.json`. + +Both runs skipped the same ten test events: + +- TestOfficialHistoryReadOnlyAcceptance +- TestCrashDispatchSubprocess +- TestLiveFixtureObservation +- TestLocalCodexDiscovery +- TestStoreRefusesSymlinkState +- TestCleanupRejectsUnsafeOwnedPaths/symlink +- TestRejectsLinks/symlink +- TestNativeWindowsStartupLifecycle +- TestOwnershipDoesNotCaseFoldDistinctPaths +- TestOwnedFileRejectsRecordedSymlinkAlias + +No system service was installed or started and no login item was changed. Native macOS launchd execution was not tested on this Windows host; the regression fixtures exercise the platform-neutral policy used by the Darwin implementation. Missing loaded-program evidence in synthetic policy snapshots remains accepted for compatibility with existing tests; production SnapshotStartup already rejects missing or mismatched loaded programs before invoking this policy.