mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
e40df6b31c
* feat(cli): pad session arm/disarm/status + consent config resolution (PLAN-2613 S2, TASK-2617) The S2 CLI contract S3's plugin skills and S4's web composer build against. S1 gated push delivery on a server-side armed bit declared at stream connect; nothing decided WHETHER to arm or sent the declaration. S2 adds both, defaulting off everywhere. - ResolveAutoArm (internal/cli/arm_consent.go): pure consent resolver. .pad.toml [push] auto_arm is the only per-repo enabler (D4); a per-user config auto_arm=false vetoes it (deny-wins); default off. Config surfaces: PadToml.Push.AutoArm + config.Config.Push.AutoArm (*bool, unset != false), both nil-safe. - Wire contract: StreamSessionIdentity.Armed sends ?armed=true on the event stream — S1's server gate finally has a sender. The monitor announces armed = live local arm OR resolved auto_arm, so a repo opt-in works end to end with a safe default-off skew. - Verbs pad session arm/disarm/status: arm/disarm manage a per-session local arm-state file; status reports the resolved local/auto decision plus the server's own armed/connected counts (new Client.ListSessions), degrading gracefully when padd is unreachable. - Arm-state file (session_arm_state.go): keyed per session by CLAUDE_CODE_MESSAGING_SOCKET (cwd fallback for headless, secondary to auto_arm). Mandatory liveness — a dead-owner file (socket vanished / pid gone) reads as disarmed and is reaped, so a crashed session can never arm a future monitor. Local client state only; the server's armed bit stays the sole delivery authority. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): address Codex R1 on push-consent (fail-closed config, owner-identity liveness) - HIGH-1: user config.toml read now fails CLOSED. config.LoadPushConfigAutoArm reads the [push] auto_arm value strictly — absent → no opinion, but present-but-unparseable → error — and ResolveAutoArmFromDisk refuses to auto-arm when it can't confirm the user's veto (was: swallowed by the lenient config.Load and treated as no-opinion). - HIGH-2: arm-state liveness now checks owner IDENTITY, not just presence. Socket-keyed files record the socket's mtime and require an exact match, so a reused socket path can't revive a stale file. Headless files record a Linux /proc start-time token (portable fallback documented) to reject a reused pid. - MED-1: arm-state writes are atomic (temp + rename) and reaping is non-destructive (re-checks staleness before removing) — a concurrent re-arm is never clobbered. - MED-2: pad session status applies the .pad.toml URL override, so it queries the same server the monitor connects to. - LOW: malformed arm-state files are now reaped (safe now that writes are atomic — a corrupt file can't be a torn in-progress write). Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(cli): address Codex R2 on push-consent (atomic config write, stronger owner identity) - HIGH-1: Config.Save() is now atomic (temp + rename), so a monitor reconnecting while `pad configure` rewrites config.toml can't read a truncated/partial file, miss a [push] auto_arm=false veto, and arm. - finding 2: socket owner identity now uses inode+device (unix) as the primary signal, with mtime as the non-unix fallback — a rebound socket or a lingering stale node at the same path gets a new inode and is rejected, closing the mtime-collision / reused-node gaps. - finding 3: headless liveness fails closed when a proc-start token was recorded but can't be re-verified (was: fell back to bare pid-liveness, which a reused pid passes); zombies (state 'Z') now report not-alive. - finding 5: `pad session status` applies an explicit --url override too, not just the .pad.toml one. - finding 4 (connect-time TOCTOU): documented as an accepted, bounded residual — a disarm racing an in-flight connect is corrected on the next reconnect; fully closing it needs S3's server-side disarm-on-open signal. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
131 lines
4.2 KiB
Go
131 lines
4.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// PadToml represents the per-project workspace link file.
|
|
type PadToml struct {
|
|
Workspace string `toml:"workspace"`
|
|
// URL is the base URL of the Pad server hosting this workspace (e.g.
|
|
// "https://app.getpad.dev" or a self-hosted remote). When set, it
|
|
// overrides the user's global ~/.pad/config.toml URL so the directory
|
|
// targets the right server regardless of which workspace the user's
|
|
// default config points at. Empty for local-mode workspaces (the
|
|
// default loopback server is implied). See BUG-1535.
|
|
URL string `toml:"url,omitempty"`
|
|
AgentName string `toml:"agent_name,omitempty"` // optional: identifies which AI agent is acting
|
|
// Push carries per-repository push/consent settings (PLAN-2613 S2).
|
|
// A pointer so an absent `[push]` table is distinguishable from one
|
|
// written with every field at its zero value — though for AutoArm the
|
|
// two mean the same thing (not opted in), the distinction keeps the
|
|
// door open for future push settings where it would matter, and lets
|
|
// PadTomlAutoArm answer without a second nil check leaking out.
|
|
Push *PadTomlPush `toml:"push,omitempty"`
|
|
}
|
|
|
|
// PadTomlPush is the `[push]` table in a repository's .pad.toml
|
|
// (PLAN-2613 S2, D4).
|
|
type PadTomlPush struct {
|
|
// AutoArm, when true, opts THIS repository's sessions into arming at
|
|
// connect — declaring consent to receive `pad push` notifications
|
|
// without an explicit in-session `pad session arm` (D4). It is the
|
|
// ONLY per-repo enabler, and it lives in a committed file on purpose:
|
|
// D4's "explicit file edit = deliberate act" is the whole consent
|
|
// story for the auto path. Default (absent/false) is not opted in —
|
|
// see ResolveAutoArm for how a per-user setting can veto a true here
|
|
// but nothing can turn arming on machine-wide.
|
|
AutoArm bool `toml:"auto_arm"`
|
|
}
|
|
|
|
// PadTomlAutoArm reports whether this .pad.toml opts the repository into
|
|
// auto-arm. Nil-safe: a nil *PadToml (no workspace linked) or a missing
|
|
// `[push]` table both read as "not opted in", so callers need no guard.
|
|
func (p *PadToml) PadTomlAutoArm() bool {
|
|
return p != nil && p.Push != nil && p.Push.AutoArm
|
|
}
|
|
|
|
// DetectWorkspace walks up the directory tree from cwd looking for .pad.toml.
|
|
func DetectWorkspace(flagOverride string) (string, error) {
|
|
if flagOverride != "" {
|
|
return flagOverride, nil
|
|
}
|
|
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
dir := cwd
|
|
for {
|
|
configPath := filepath.Join(dir, ".pad.toml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
var cfg PadToml
|
|
if _, err := toml.DecodeFile(configPath, &cfg); err != nil {
|
|
return "", fmt.Errorf("parse %s: %w", configPath, err)
|
|
}
|
|
if cfg.Workspace != "" {
|
|
return cfg.Workspace, nil
|
|
}
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
return "", fmt.Errorf("no workspace linked. Run 'pad workspace init' to create one")
|
|
}
|
|
|
|
// LoadPadToml finds and reads the nearest .pad.toml by walking up from cwd.
|
|
// Returns nil if no .pad.toml is found (not an error).
|
|
func LoadPadToml() (*PadToml, error) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dir := cwd
|
|
for {
|
|
configPath := filepath.Join(dir, ".pad.toml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
var cfg PadToml
|
|
if _, err := toml.DecodeFile(configPath, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", configPath, err)
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// WriteWorkspaceLink writes a .pad.toml in the given directory.
|
|
//
|
|
// serverURL is the base URL of the Pad server hosting this workspace. Pass
|
|
// the empty string for local-mode workspaces; pass cfg.BaseURL() (or
|
|
// equivalently cfg.URL) for any non-local mode (remote, cloud) so that the
|
|
// directory targets the right server even when the user's global config
|
|
// points at a different one. See BUG-1535.
|
|
func WriteWorkspaceLink(dir, slug, serverURL string) error {
|
|
path := filepath.Join(dir, ".pad.toml")
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
return toml.NewEncoder(f).Encode(PadToml{Workspace: slug, URL: serverURL})
|
|
}
|