Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)。
Expand Down
2 changes: 1 addition & 1 deletion cmd/dotcontrol/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions docs/MACOS_LIFECYCLE_PAIRING.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/validation/round2-installation-fixtures.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 7 additions & 5 deletions internal/activity/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,20 @@ 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 {
m.mu.Lock()
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
Expand Down
12 changes: 9 additions & 3 deletions internal/activity/darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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)
}
}
}()
Expand Down
58 changes: 58 additions & 0 deletions internal/activity/diagnostic.go
Original file line number Diff line number Diff line change
@@ -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()
}
50 changes: 50 additions & 0 deletions internal/activity/diagnostic_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
41 changes: 41 additions & 0 deletions internal/desktop/launch_policy.go
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions internal/desktop/launch_policy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
26 changes: 20 additions & 6 deletions internal/desktop/startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
}
Expand Down Expand Up @@ -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 != "" {
Expand Down
Loading
Loading