diff --git a/.playwright-mcp/console-2026-09-05T11-45-08-455Z.log b/.playwright-mcp/console-2026-09-05T11-45-08-455Z.log new file mode 100644 index 0000000..ba7adc1 --- /dev/null +++ b/.playwright-mcp/console-2026-09-05T11-45-08-455Z.log @@ -0,0 +1,7 @@ +[26994805ms] Uncaught (in promise) TypeError: this.commitBySessionCache is not a function + at _commitBySession (https://res-access.dwservice.net/app/framework/29/mod_ui_datasource.js:0:9424) + at commit (https://res-access.dwservice.net/app/framework/29/mod_ui_datasource.js:0:11090) + at eval (:7:9) + at _fire_callback (https://access.dwservice.net/app/framework/29/core.js:0:43398) + at (https://access.dwservice.net/app/framework/29/core.js:0:43703) + at (https://access.dwservice.net/app/framework/29/core.js:0:16839) diff --git a/.playwright-mcp/page-2026-09-05T19-13-08-913Z.yml b/.playwright-mcp/page-2026-09-05T19-13-08-913Z.yml new file mode 100644 index 0000000..a576f8e --- /dev/null +++ b/.playwright-mcp/page-2026-09-05T19-13-08-913Z.yml @@ -0,0 +1,66 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - generic [ref=f1e7] [cursor=pointer] + - generic [ref=f1e9]: + - generic [active] [ref=f1e100] [cursor=pointer]: Agenti + - generic [ref=f1e329] [cursor=pointer]: Gruppi + - generic [ref=f1e11]: + - generic [ref=f1e12]: + - generic [ref=f1e13] [cursor=pointer] + - generic [ref=f1e14] [cursor=pointer]: "3" + - generic [ref=f1e16] [cursor=pointer] + - generic [ref=f1e104]: + - generic [ref=f1e106]: + - generic [ref=f1e107]: Agenti + - generic [ref=f1e111]: + - generic [ref=f1e112]: Tutti + - generic [ref=f1e114]: Disponibile + - generic [ref=f1e116]: Non disponibile + - generic [ref=f1e118]: Da installare + - generic [ref=f1e120]: Disabilitato + - generic [ref=f1e123]: + - generic [ref=f1e124]: + - generic [ref=f1e125] [cursor=pointer] + - generic [ref=f1e127] [cursor=pointer] + - generic [ref=f1e129]: + - generic: Cerca + - textbox [ref=f1e130] + - generic [ref=f1e131]: + - generic: Gruppo + - textbox [ref=f1e132] + - generic [ref=f1e133] [cursor=pointer] + - generic [ref=f1e136]: + - generic [ref=f1e138]: + - generic [ref=f1e139]: + - generic [ref=f1e140]: BoxRally Acer 1 + - generic [ref=f1e150] [cursor=pointer] + - generic [ref=f1e158]: Non disponibile + - generic [ref=f1e164]: + - generic [ref=f1e165]: + - generic [ref=f1e166]: BoxRally Acer 2 + - generic [ref=f1e176] [cursor=pointer] + - generic [ref=f1e184]: Non disponibile + - generic [ref=f1e190]: + - generic [ref=f1e191]: + - generic [ref=f1e192]: Chromebox + - generic [ref=f1e202] [cursor=pointer] + - generic [ref=f1e210]: Non disponibile + - generic [ref=f1e216]: + - generic [ref=f1e217]: + - generic [ref=f1e218]: Fisso Poscante + - generic [ref=f1e228] [cursor=pointer] + - generic [ref=f1e236]: Non disponibile + - generic [ref=f1e242]: + - generic [ref=f1e243] [cursor=pointer]: GHE + - generic [ref=f1e255] [cursor=pointer] + - generic [ref=f1e262]: Disponibile + - generic [ref=f1e268]: + - generic [ref=f1e269]: + - generic [ref=f1e270]: Legion + - generic [ref=f1e280] [cursor=pointer] + - generic [ref=f1e288]: Non disponibile + - generic [ref=f1e294]: + - generic [ref=f1e295]: + - generic [ref=f1e296]: Lenovo + - generic [ref=f1e306] [cursor=pointer] + - generic [ref=f1e314]: Non disponibile \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-05T19-13-43-045Z.yml b/.playwright-mcp/page-2026-09-05T19-13-43-045Z.yml new file mode 100644 index 0000000..61ae3db --- /dev/null +++ b/.playwright-mcp/page-2026-09-05T19-13-43-045Z.yml @@ -0,0 +1,66 @@ +- generic [ref=f1e3]: + - generic [ref=f1e4]: + - generic [ref=f1e7] [cursor=pointer] + - generic [ref=f1e9]: + - generic [ref=f1e100] [cursor=pointer]: Agenti + - generic [ref=f1e329] [cursor=pointer]: Gruppi + - generic [ref=f1e11]: + - generic [ref=f1e12]: + - generic [ref=f1e13] [cursor=pointer] + - generic [ref=f1e14] [cursor=pointer]: "3" + - generic [ref=f1e16] [cursor=pointer] + - generic [ref=f1e104]: + - generic [ref=f1e106]: + - generic [ref=f1e107]: Agenti + - generic [ref=f1e111]: + - generic [ref=f1e112]: Tutti + - generic [ref=f1e114]: Disponibile + - generic [ref=f1e116]: Non disponibile + - generic [ref=f1e118]: Da installare + - generic [ref=f1e120]: Disabilitato + - generic [ref=f1e123]: + - generic [ref=f1e124]: + - generic [active] [ref=f1e125] [cursor=pointer] + - generic [ref=f1e127] [cursor=pointer] + - generic [ref=f1e129]: + - generic: Cerca + - textbox [ref=f1e130] + - generic [ref=f1e131]: + - generic: Gruppo + - textbox [ref=f1e132] + - generic [ref=f1e133] [cursor=pointer] + - generic [ref=f1e136]: + - generic [ref=f1e138]: + - generic [ref=f1e139]: + - generic [ref=f1e140]: BoxRally Acer 1 + - generic [ref=f1e150] [cursor=pointer] + - generic [ref=f1e158]: Non disponibile + - generic [ref=f1e164]: + - generic [ref=f1e165]: + - generic [ref=f1e166]: BoxRally Acer 2 + - generic [ref=f1e176] [cursor=pointer] + - generic [ref=f1e184]: Non disponibile + - generic [ref=f1e190]: + - generic [ref=f1e191]: + - generic [ref=f1e192]: Chromebox + - generic [ref=f1e202] [cursor=pointer] + - generic [ref=f1e210]: Non disponibile + - generic [ref=f1e216]: + - generic [ref=f1e217]: + - generic [ref=f1e218]: Fisso Poscante + - generic [ref=f1e228] [cursor=pointer] + - generic [ref=f1e236]: Non disponibile + - generic [ref=f1e242]: + - generic [ref=f1e243] [cursor=pointer]: GHE + - generic [ref=f1e255] [cursor=pointer] + - generic [ref=f1e262]: Disponibile + - generic [ref=f1e268]: + - generic [ref=f1e269]: + - generic [ref=f1e270]: Legion + - generic [ref=f1e280] [cursor=pointer] + - generic [ref=f1e288]: Non disponibile + - generic [ref=f1e294]: + - generic [ref=f1e295]: + - generic [ref=f1e296]: Lenovo + - generic [ref=f1e306] [cursor=pointer] + - generic [ref=f1e314]: Non disponibile \ No newline at end of file diff --git a/README.md b/README.md index ddf25f8..28d9d99 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,11 @@ the session without any of this. To supply a code non-interactively, see | `dwshell put [-r] :` | Upload a file (or directory with `-r`). | | `dwshell rm [-r] : [...]` | Remove remote file(s) (directories with `-r`). | | `dwshell sync [flags] ` | One-way sync (size+mtime or `--checksum`); one side is `agent:path`. | +| `dwshell agent create ` | Register a new agent and print its installation code. | +| `dwshell agent code ` | Print the code of an agent still awaiting installation. | +| `dwshell agent reinstall ` | Mint a fresh code, invalidating the current one. | +| `dwshell agent rm ` | Delete an agent from the account. | +| `dwshell agent group ` | Move an agent into a group (`--none` to remove it). | | `dwshell version` | Print the version and exit. | | `dwshell help` | Show usage. | @@ -208,6 +213,40 @@ address the same directory, and an omitted path means the root. On Windows `/` i the root and lists the drives; address a drive as `agent:/C:/dir` or `agent:C:/dir` (`/` and `\` are interchangeable, and a bare `agent:C:` means the drive root). +### Creating agents + +`dwshell agent create` registers a machine on your account and prints the code +that binds an installation to it: + +```sh +$ dwshell agent create web-01 +Agent "web-01" created. + +Installation code: 281-407-902 + +Download the agent on the target machine and enter this code when the installer +asks for it: + https://www.dwservice.net/download.html +``` + +The code is then read back with `dwshell agent code web-01` for as long as the +machine has not been installed — `dwshell list` shows such an agent as +`pending`. `dwshell agent reinstall` mints a new one and invalidates the old. + +Deleting an agent and regenerating a code both ask for confirmation, and refuse +outright when there is no terminal unless you pass `--yes`, so a script cannot +remove a machine by accident. Every subcommand takes `--json`. + +Groups have to exist already: `dwshell agent group web-01 prod` fails and lists +the groups you do have rather than creating one from a typo. These commands work +only on agents you own — a share is someone else's agent, and is refused. + +**Installing is still a manual step.** dwshell does not download the installer: +the download page is where the licence is accepted. The agent's unattended mode +(`-silent`) is not documented here either, because the service refuses to serve +it — an install attempted that way answers *"Silent installation forbidden. +Please contact the support."* + #### Agent name vs subcommand `dwshell ` is a convenience shortcut: the first argument is treated as an diff --git a/cmd/dwshell/agent.go b/cmd/dwshell/agent.go new file mode 100644 index 0000000..9b02531 --- /dev/null +++ b/cmd/dwshell/agent.go @@ -0,0 +1,365 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/porech/dwshell/internal/client" + "github.com/porech/dwshell/internal/manage" + "github.com/porech/dwshell/internal/remote" + "github.com/porech/dwshell/internal/session" + "github.com/porech/dwshell/internal/term" +) + +// downloadPage is where the DWService agent is obtained. It is deliberately the +// page and not the installer file: the page carries the licence acceptance +// ("By selecting the 'Download' button I accept the Terms and Conditions and +// the Restrictive Terms and Conditions"), and a download-and-run one-liner — +// convenient as it would be — would route around that. The direct file URLs are +// known and stay unused. +const downloadPage = "https://www.dwservice.net/download.html" + +// formatCode renders an installation code the way the web client shows it and +// the way the installer expects it: three groups of three, dash-separated. The +// code arrives as a JSON number, so it is padded back to nine digits first — a +// leading zero could not have survived the wire. +func formatCode(code int) string { + s := fmt.Sprintf("%09d", code) + return s[0:3] + "-" + s[3:6] + "-" + s[6:] +} + +// installInstructions renders what to do with a fresh installation code. +// +// It gives the code and the page, and nothing else. Two things are deliberately +// absent: any line that downloads the installer, because the page is where the +// licence is accepted, and any mention of the installer's unattended mode, +// because the service refuses it — an install run with -silent answers "Silent +// installation forbidden. Please contact the support." Documenting it would +// send people down a path that does not work. +func installInstructions(code int) string { + return fmt.Sprintf(`Installation code: %s + +Download the agent on the target machine and enter this code when the installer +asks for it: + %s +`, formatCode(code), downloadPage) +} + +// agentJSON is the --json shape of an agent and, when it has one, its code. +type agentJSON struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + InstallCode string `json:"installCode,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` +} + +func printAgentJSON(a agentJSON) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(a) +} + +// agentValueFlags are the `agent` subcommand flags that consume a following +// value token, so a positional can be pulled out from among them. +var agentValueFlags = map[string]bool{"config": true, "description": true, "group": true} + +// extractPositional pulls the first non-flag token out of a subcommand's args, +// returning it with the flags that surrounded it. Go's flag package stops at +// the first positional, so `agent create NAME --json` would otherwise silently +// drop --json; this mirrors what extractAgent does for the shell shortcut. +func extractPositional(args []string) (pos string, flagArgs []string) { + i := 0 + for i < len(args) { + a := args[i] + if len(a) > 0 && a[0] == '-' { + flagArgs = append(flagArgs, a) + if !containsEq(a) && agentValueFlags[trimDashes(a)] && i+1 < len(args) { + i++ + flagArgs = append(flagArgs, args[i]) + } + i++ + continue + } + return a, append(flagArgs, args[i+1:]...) + } + return "", flagArgs +} + +// cmdAgentManage dispatches the `dwshell agent ` family. It is separate +// from cmdAgent, which is the shell shortcut for `dwshell `. +func cmdAgentManage(ctx context.Context, args []string) int { + if len(args) == 0 { + return fail("usage: dwshell agent …") + } + switch args[0] { + case "create": + return cmdAgentCreate(ctx, args[1:]) + case "code": + return cmdAgentCode(ctx, args[1:]) + case "rm": + return cmdAgentLifecycle(ctx, args[1:], "rm") + case "reinstall": + return cmdAgentLifecycle(ctx, args[1:], "reinstall") + case "group": + return cmdAgentGroup(ctx, args[1:]) + default: + return fail("unknown agent subcommand %q", args[0]) + } +} + +func cmdAgentCreate(ctx context.Context, args []string) int { + fs := newFlags("agent create") + var configPath, description, group string + asJSON := false + fs.StringVar(&configPath, "config", "", "config file path") + fs.StringVar(&description, "description", "", "free-text description") + fs.StringVar(&group, "group", "", "existing group to place the agent in") + fs.BoolVar(&asJSON, "json", false, "machine-readable output") + name, flagArgs := extractPositional(args) + if err := fs.Parse(flagArgs); err != nil { + return 2 + } + if name == "" || fs.NArg() != 0 { + return fail("usage: dwshell agent create [--group G] [--description D] [--json]") + } + + c, err := client.New(configPath) + if err != nil { + return fail("%v", err) + } + sess, err := c.Session(ctx) + if err != nil { + return fail("%v", err) + } + + idGroup := "" + if group != "" { + groups, err := manage.ListGroups(ctx, sess) + if err != nil { + return fail("%v", err) + } + g, err := manage.ResolveGroup(groups, group) + if err != nil { + return fail("%v", err) + } + idGroup = g.ID + } + + a, err := manage.CreateAgent(ctx, sess, name, description, idGroup) + if err != nil { + return fail("create agent %q: %v", name, err) + } + if asJSON { + printAgentJSON(agentJSON{ + ID: a.ID, Name: a.Name, State: a.State, + InstallCode: formatCode(a.InstallCode), DownloadURL: downloadPage, + }) + return 0 + } + fmt.Printf("Agent %q created.\n\n%s", a.Name, installInstructions(a.InstallCode)) + return 0 +} + +// confirm gates a change that cannot be undone. With a terminal it asks; with +// none — a script, a CI job — it refuses unless the caller passed --yes, so +// automation cannot delete a machine or invalidate a code by accident. +func confirm(prompt string, assumeYes, interactive bool) error { + if assumeYes { + return nil + } + if !interactive { + return fmt.Errorf("%s refusing without a terminal; pass --yes to proceed", prompt) + } + fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) + var answer string + _, _ = fmt.Fscanln(os.Stdin, &answer) + if answer != "y" && answer != "Y" { + return fmt.Errorf("cancelled") + } + return nil +} + +// resolveOwnAgent finds an agent by name or id and refuses anything these +// commands cannot act on: a share is someone else's agent. +func resolveOwnAgent(machines []remote.Machine, query string) (*remote.Machine, error) { + m, err := remote.Resolve(machines, query, remote.Any) + if err != nil { + return nil, err + } + if m.Shared { + return nil, fmt.Errorf("%s is a share — someone else's agent — so it cannot be managed from this account", m.Name) + } + return m, nil +} + +// agentCodeFor reports why an agent has no installation code to show, or nil +// when it does. A code exists only between creation and installation. +func agentCodeFor(m *remote.Machine) error { + if m.Shared { + return fmt.Errorf("%s is a share — someone else's agent — so it has no installation code here", m.Name) + } + if !m.Pending { + return fmt.Errorf("%s is already installed; `dwshell agent reinstall %s` mints a new code", m.Name, m.Name) + } + return nil +} + +// agentSession resolves an owned agent and hands back a session for acting on +// the account, which every subcommand below needs. +func agentSession(ctx context.Context, configPath, query string) (*remote.Machine, *client.Client, *session.Session, int) { + c, err := client.New(configPath) + if err != nil { + return nil, nil, nil, fail("%v", err) + } + machines, err := c.List(ctx) + if err != nil { + return nil, nil, nil, fail("%v", err) + } + m, err := resolveOwnAgent(machines, query) + if err != nil { + return nil, nil, nil, fail("%v", err) + } + sess, err := c.Session(ctx) + if err != nil { + return nil, nil, nil, fail("%v", err) + } + return m, c, sess, 0 +} + +func cmdAgentCode(ctx context.Context, args []string) int { + fs := newFlags("agent code") + var configPath string + asJSON := false + fs.StringVar(&configPath, "config", "", "config file path") + fs.BoolVar(&asJSON, "json", false, "machine-readable output") + name, flagArgs := extractPositional(args) + if err := fs.Parse(flagArgs); err != nil { + return 2 + } + if name == "" { + return fail("usage: dwshell agent code [--json]") + } + m, _, _, code := agentSession(ctx, configPath, name) + if m == nil { + return code + } + if err := agentCodeFor(m); err != nil { + return fail("%v", err) + } + if asJSON { + printAgentJSON(agentJSON{ + ID: m.ID, Name: m.Name, State: "W", + InstallCode: formatCode(m.InstallCode), DownloadURL: downloadPage, + }) + return 0 + } + fmt.Print(installInstructions(m.InstallCode)) + return 0 +} + +// cmdAgentLifecycle serves rm and reinstall, which differ only in what they do +// and what they warn about: both are irreversible and both confirm first. +func cmdAgentLifecycle(ctx context.Context, args []string, verb string) int { + fs := newFlags("agent " + verb) + var configPath string + assumeYes := false + fs.StringVar(&configPath, "config", "", "config file path") + fs.BoolVar(&assumeYes, "yes", false, "do not ask for confirmation") + name, flagArgs := extractPositional(args) + if err := fs.Parse(flagArgs); err != nil { + return 2 + } + if name == "" { + return fail("usage: dwshell agent %s [--yes]", verb) + } + m, _, sess, code := agentSession(ctx, configPath, name) + if m == nil { + return code + } + + prompt := fmt.Sprintf("delete agent %q from the account?", m.Name) + if verb == "reinstall" { + prompt = fmt.Sprintf("mint a new installation code for %q, invalidating the current one?", m.Name) + } + if err := confirm(prompt, assumeYes, term.IsTTY()); err != nil { + return fail("%v", err) + } + + if verb == "rm" { + if err := manage.DeleteAgent(ctx, sess, m.ID); err != nil { + return fail("delete agent %q: %v", m.Name, err) + } + fmt.Fprintf(os.Stderr, "agent %q deleted\n", m.Name) + return 0 + } + if err := manage.ReinstallAgent(ctx, sess, m.ID); err != nil { + return fail("reinstall agent %q: %v", m.Name, err) + } + // The command acknowledges without echoing the record, so the new code is + // read back from the listing. + c, err := client.New(configPath) + if err != nil { + return fail("%v", err) + } + machines, err := c.List(ctx) + if err != nil { + return fail("%v", err) + } + fresh, err := resolveOwnAgent(machines, m.ID) + if err != nil { + return fail("%v", err) + } + fmt.Print(installInstructions(fresh.InstallCode)) + return 0 +} + +func cmdAgentGroup(ctx context.Context, args []string) int { + fs := newFlags("agent group") + var configPath string + none := false + fs.StringVar(&configPath, "config", "", "config file path") + fs.BoolVar(&none, "none", false, "remove the agent from its group") + name, flagArgs := extractPositional(args) + if err := fs.Parse(flagArgs); err != nil { + return 2 + } + groupName := "" + if !none { + if fs.NArg() != 1 { + return fail("usage: dwshell agent group (or --none)") + } + groupName = fs.Arg(0) + } + if name == "" { + return fail("usage: dwshell agent group (or --none)") + } + + m, _, sess, code := agentSession(ctx, configPath, name) + if m == nil { + return code + } + idGroup := "" + if groupName != "" { + groups, err := manage.ListGroups(ctx, sess) + if err != nil { + return fail("%v", err) + } + g, err := manage.ResolveGroup(groups, groupName) + if err != nil { + return fail("%v", err) + } + idGroup = g.ID + } + if err := manage.SetAgentGroup(ctx, sess, m.ID, idGroup); err != nil { + return fail("set group for %q: %v", m.Name, err) + } + if idGroup == "" { + fmt.Fprintf(os.Stderr, "agent %q removed from its group\n", m.Name) + } else { + fmt.Fprintf(os.Stderr, "agent %q moved to group %q\n", m.Name, groupName) + } + return 0 +} diff --git a/cmd/dwshell/agent_test.go b/cmd/dwshell/agent_test.go new file mode 100644 index 0000000..e5b0df8 --- /dev/null +++ b/cmd/dwshell/agent_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "strings" + "testing" +) + +// The code is shown and typed in groups of three, the way the web client +// renders it and the way the installer forwards it. +func TestFormatCodeGroupsInThrees(t *testing.T) { + if got := formatCode(281407902); got != "281-407-902" { + t.Fatalf("formatCode = %q, want 281-407-902", got) + } +} + +// tempCode travels as a JSON number, so a leading zero is already gone by the +// time we see it; padding restores the nine digits the grouping assumes. +func TestFormatCodePadsToNineDigits(t *testing.T) { + if got := formatCode(12345678); got != "012-345-678" { + t.Fatalf("formatCode = %q, want 012-345-678", got) + } +} + +// What the output may contain is narrow, and each exclusion has a reason. +func TestInstallInstructionsGiveOnlyTheCodeAndThePage(t *testing.T) { + out := installInstructions(281407902) + + if !strings.Contains(out, "281-407-902") { + t.Error("must show the code, dashed as the installer expects it") + } + if strings.Contains(out, "281407902") { + t.Error("must never show the undashed code") + } + if !strings.Contains(out, "https://www.dwservice.net/download.html") { + t.Error("must point at the download page, where the licence is accepted") + } + + // The download page carries the licence acceptance, so nothing here may + // fetch the installer or run it for the user. + for _, forbidden := range []string{"curl", "wget", "download/dwagent", "| sh", "|sh"} { + if strings.Contains(out, forbidden) { + t.Errorf("must not hand out a download-and-run line, found %q", forbidden) + } + } + + // Silent installation is refused by the service ("Silent installation + // forbidden. Please contact the support."), so promising it would send + // people down a path that does not work. + for _, forbidden := range []string{"-silent", "dwagent.sh", "dwagent.exe"} { + if strings.Contains(out, forbidden) { + t.Errorf("must not describe an unattended install, found %q", forbidden) + } + } +} + +// Automation must not be able to delete a machine by accident. +func TestConfirmRefusesWithoutATerminal(t *testing.T) { + err := confirm("delete agent \"x\"?", false, false) + if err == nil { + t.Fatal("with no terminal and no --yes it must refuse") + } + if !strings.Contains(err.Error(), "--yes") { + t.Errorf("the error should name --yes, got %q", err) + } +} + +func TestConfirmPassesWithYes(t *testing.T) { + if err := confirm("delete agent \"x\"?", true, false); err != nil { + t.Fatalf("--yes must proceed without a terminal: %v", err) + } +} diff --git a/cmd/dwshell/main.go b/cmd/dwshell/main.go index ed98e04..d60f5ab 100644 --- a/cmd/dwshell/main.go +++ b/cmd/dwshell/main.go @@ -94,6 +94,8 @@ Usage: dwshell put [-r] : Upload a file or directory dwshell rm [-r] : [...] Remove remote file(s)/dir(s) dwshell sync [-n] [--delete] [--checksum] One-way sync + dwshell agent create [--group G] [--json] Register an agent, print its install code + dwshell agent code|reinstall|rm|group Manage an agent you own Agent flags: -c string Run command non-interactively, capture output, exit @@ -155,6 +157,8 @@ func run() int { return cmdRm(ctx, os.Args[2:]) case "sync": return cmdSync(ctx, os.Args[2:]) + case "agent": + return cmdAgentManage(ctx, os.Args[2:]) case "shell": // Explicit form: the next argument is always an agent, even if it happens // to be named like a subcommand (e.g. `dwshell shell version`). @@ -277,14 +281,20 @@ func cmdList(ctx context.Context, args []string) int { } for _, m := range machines { state := "offline" - if m.Online { + os := m.OS.String() + switch { + case m.Online: state = "online" + case m.Pending: + // Created but never installed: it is waiting for the installer, and + // the service has not reported an OS for it yet. + state, os = "pending", "" } kind := "own" if m.Shared { kind = "shared" } - fmt.Printf("%-24s %-8s %-7s %-7s %s\n", m.Name, m.OS, state, kind, m.ID) + fmt.Printf("%-24s %-8s %-7s %-7s %s\n", m.Name, os, state, kind, m.ID) } return 0 } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ad00f69..33f9eee 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -487,3 +487,71 @@ header), and `key` is a client-generated id (no handshake): The filesystem app has **no checksum** in its metadata and **no set-mtime** command; dwshell's sync plans work around both using the shell (see DESIGN.md). + +## 9. Account datasources (agents, groups) + +Agents and groups are "datasources" on the account command channel, edited by +submitting a batch of pending changes. Verified live against the service on +2026-09-05. + +### Reading + +``` +module=agent command=datasource parameter operation=load +→ {"allowAdd":true,"allowDelete":true,"allowUpdate":true,"items":[…],"status":"ok"} +``` + +An agent record carries, among others: + +| field | meaning | +|---|---| +| `id` / `_id` | agent id (both forms appear; writes want both) | +| `name`, `description`, `displayName`, `fullName` | naming | +| `state` | `N` online, `F` offline, `W` awaiting installation, `D` disabled | +| `tempCode` | installation code, **a JSON number**, non-null only while `state` is `W` | +| `idGroup`, `group` | group membership | +| `osType` | **null until the agent is installed** | + +`module=group` has the same shape with `{name, description, _id}`. + +### Writing + +``` +module= command=datasource + parameter operation=commit + parameter changes=[{"operation":"add"|"update"|"delete","index":N,"item":{…}}] +→ {"status":"ok","itemsChanged":[{"index":N,"item":{…}}]} +``` + +- **add** takes `{name, description, idGroup}` and echoes back the created + record — including `tempCode`, so creating an agent and learning its code is + one round trip. +- **update** must carry the **whole record** with the field altered. Sending + only the changed keys is answered with `java.lang.NullPointerException`; the + browser client merges its edit into the loaded item and sends that. +- **delete** needs only `{_id, id}`. + +A rejection arrives inside a successful HTTP response as `"status":"error"` with +a `message` — for instance `L'agente 'x' già esiste.`, localized to the account. + +### Regenerating an installation code + +``` +module=agent command=reinstall parameter id= +``` + +Returns a bare acknowledgement, not the record, so the new code is read back +from the listing. + +### The installation code + +`tempCode` is a number on the wire (`281407902`) but is never shown or typed +that way: the client renders it in groups of three (`281-407-902`) and the +installer forwards the code stripping only whitespace, keeping the dashes. Being +a number, a leading zero cannot survive the wire, so it is padded back to nine +digits before grouping. + +The agent installer accepts `key=` alongside `-silent`, but **the service +refuses to serve a silent installation**: it answers `_download_files` with a +`#SILENTFORBIDDEN` marker, which the installer reports as "Silent installation +forbidden. Please contact the support." What lifts that was not established. diff --git a/docs/superpowers/plans/2026-09-05-agent-management.md b/docs/superpowers/plans/2026-09-05-agent-management.md new file mode 100644 index 0000000..f9191b3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-agent-management.md @@ -0,0 +1,1064 @@ +# Agent management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a DWService agent from the terminal, get the installation code that drives an unattended setup, and manage the lifecycle around it (read the code, regenerate it, delete, move between groups). + +**Architecture:** A new `internal/manage` package owns account mutation over the existing account command channel; `internal/remote` keeps its present job (list, resolve, connect) and only grows the fields it already receives. The CLI family lives in `cmd/dwshell/agent.go`, as file commands live in `files.go`. + +**Tech Stack:** Go, standard library only. No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-09-05-agent-management-design.md` + +## Global Constraints + +- **Agents only, never shares.** Every subcommand refuses a share, naming the reason. +- **Never print a download-and-run one-liner.** The DWService download page carries the licence acceptance ("By selecting the 'Download' button I accept the Terms and Conditions…"). Print the page `https://www.dwservice.net/download.html` and a run line that assumes the installer is already there. The direct file URLs are known and deliberately unused. +- **`tempCode` is a JSON number, not a string** — verified live: `"tempCode":281407902`. Decode it as an integer. +- **Never print or pass the code undashed.** The client renders it in groups of three (`S.substring(0,3)+"-"+S.substring(3,6)+"-"+S.substring(6)` → `281-407-902`) and the installer forwards the code with only whitespace stripped, keeping the dashes, so the dashed form is what the service expects. Pad to nine digits before grouping: a leading zero cannot survive a JSON number. +- **`osType` is `null` until an agent is installed**, so a pending agent has no meaningful OS. +- `--json` on every subcommand, matching `dwshell list --json`. +- Destructive operations (`rm`, `reinstall`) confirm interactively naming the agent; with no terminal they refuse unless `--yes`. +- Groups must already exist; an unknown group is an error listing the ones that do. + +## Verified protocol + +Confirmed against the live service on 2026-09-05 (an agent was created and deleted): + +``` +create module=agent command=datasource + operation=commit + changes=[{"operation":"add","index":0,"item":{"idGroup":null,"name":"N","description":"D"}}] + → {"itemsChanged":[{"item":{…,"id":"…","state":"W","tempCode":281407902},"index":0}],"status":"ok"} + +delete changes=[{"operation":"delete","index":0,"item":{"_id":"ID","id":"ID"}}] + → {"status":"ok"} + +load operation=load (already used by `list`) + → {"allowAdd":true,"allowDelete":true,"items":[…]} +``` + +Derived from the client but **not yet exercised** — each is confirmed live by the task that implements it: + +``` +update changes=[{"operation":"update","index":0,"item":{…,"idGroup":"GID"}}] +group module=group command=datasource operation=load +reinst. module=agent command=reinstall parameter id= +``` + +## File Structure + +| File | Responsibility | +|---|---| +| `internal/manage/datasource.go` (new) | the commit envelope: add/update/delete + response decoding, shared by agent and group | +| `internal/manage/agent.go` (new) | create, delete, reinstall, set group | +| `internal/manage/group.go` (new) | list groups, resolve a group name to its id | +| `internal/remote/remote.go` (modify) | decode `tempCode`/`idGroup`; `Machine.InstallCode`, `Machine.Pending` | +| `cmd/dwshell/agent.go` (new) | the `agent` subcommand family and its output | +| `cmd/dwshell/main.go` (modify) | dispatch `agent`; help text | +| `README.md`, `docs/PROTOCOL.md` (modify) | document the commands and the protocol | + +--- + +### Task 1: Datasource commit envelope + +**Files:** +- Create: `internal/manage/datasource.go` +- Test: `internal/manage/datasource_test.go` + +**Interfaces:** +- Consumes: `session.Session.Execute(ctx, module, command string, params map[string]string) ([]byte, error)` +- Produces: + ```go + type Executor interface { + Execute(ctx context.Context, module, command string, params map[string]string) ([]byte, error) + } + type item map[string]any + func commit(ctx context.Context, ex Executor, module string, changes []change) ([]item, error) + type change struct { + Operation string `json:"operation"` // "add" | "update" | "delete" + Index int `json:"index"` + Item item `json:"item"` + } + ``` + +- [ ] **Step 1: Write the failing test** + +```go +package manage + +import ( + "context" + "encoding/json" + "testing" +) + +type fakeExec struct { + gotModule, gotCommand string + gotParams map[string]string + resp string +} + +func (f *fakeExec) Execute(_ context.Context, module, command string, p map[string]string) ([]byte, error) { + f.gotModule, f.gotCommand, f.gotParams = module, command, p + return []byte(f.resp), nil +} + +func TestCommitSendsTheChangesEnvelope(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[{"index":0,"item":{"id":"A1","tempCode":281407902}}]}`} + items, err := commit(context.Background(), f, "agent", + []change{{Operation: "add", Index: 0, Item: item{"name": "n1"}}}) + if err != nil { + t.Fatalf("commit: %v", err) + } + if f.gotModule != "agent" || f.gotCommand != "datasource" { + t.Fatalf("sent to %s/%s", f.gotModule, f.gotCommand) + } + if f.gotParams["operation"] != "commit" { + t.Fatalf("operation = %q", f.gotParams["operation"]) + } + var sent []change + if err := json.Unmarshal([]byte(f.gotParams["changes"]), &sent); err != nil { + t.Fatalf("changes is not JSON: %v", err) + } + if len(sent) != 1 || sent[0].Operation != "add" || sent[0].Item["name"] != "n1" { + t.Fatalf("changes = %+v", sent) + } + if len(items) != 1 || items[0]["id"] != "A1" { + t.Fatalf("items = %+v", items) + } +} + +// The service reports failure in the body, with status 200. +func TestCommitSurfacesTheServiceMessage(t *testing.T) { + f := &fakeExec{resp: `{"status":"error","message":"The agent x already exists."}`} + _, err := commit(context.Background(), f, "agent", []change{{Operation: "add"}}) + if err == nil { + t.Fatal("expected an error") + } + if got := err.Error(); !contains(got, "already exists") { + t.Fatalf("error %q does not carry the service message", got) + } +} + +func contains(s, sub string) bool { return len(s) >= len(sub) && (len(sub) == 0 || indexOf(s, sub) >= 0) } +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `go test ./internal/manage/ -run TestCommit -v` +Expected: build failure, `undefined: commit` + +- [ ] **Step 3: Write the implementation** + +```go +// Package manage changes what an account owns: creating agents, deleting them, +// regenerating their installation code, and moving them between groups. Reading +// and connecting stay in internal/remote, which every command depends on. +package manage + +import ( + "context" + "encoding/json" + "fmt" +) + +// Executor is the slice of a session this package needs (satisfied by +// *session.Session; a fake stands in for it in tests). +type Executor interface { + Execute(ctx context.Context, module, command string, params map[string]string) ([]byte, error) +} + +// item is one datasource record. It stays untyped because a change carries only +// the fields it touches, and the service echoes back the whole record. +type item map[string]any + +// change is one pending edit in a commit. +type change struct { + Operation string `json:"operation"` // add | update | delete + Index int `json:"index"` + Item item `json:"item"` +} + +// commitResponse is what the service returns for operation=commit. A failure +// arrives as status != "ok" with a message, not as an HTTP error. +type commitResponse struct { + Status string `json:"status"` + Message string `json:"message"` + ItemsChanged []struct { + Index int `json:"index"` + Item item `json:"item"` + } `json:"itemsChanged"` +} + +// commit applies changes to a datasource module ("agent" or "group") and +// returns the records the service echoed back — for an add, that is the created +// record, which is where the installation code arrives. +func commit(ctx context.Context, ex Executor, module string, changes []change) ([]item, error) { + body, err := json.Marshal(changes) + if err != nil { + return nil, err + } + raw, err := ex.Execute(ctx, module, "datasource", map[string]string{ + "operation": "commit", + "changes": string(body), + }) + if err != nil { + return nil, err + } + var res commitResponse + if err := json.Unmarshal(raw, &res); err != nil { + return nil, fmt.Errorf("parse %s commit response: %w", module, err) + } + if res.Status != "ok" { + if res.Message != "" { + return nil, fmt.Errorf("%s", res.Message) + } + return nil, fmt.Errorf("%s commit rejected", module) + } + out := make([]item, 0, len(res.ItemsChanged)) + for _, c := range res.ItemsChanged { + out = append(out, c.Item) + } + return out, nil +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./internal/manage/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/manage/datasource.go internal/manage/datasource_test.go +git commit -m "manage: the datasource commit envelope" +``` + +--- + +### Task 2: `list` tells a pending agent from an offline one + +**Files:** +- Modify: `internal/remote/remote.go` (the `dsItem` struct, `Machine`, and the loop in `List`) +- Modify: `cmd/dwshell/main.go` (the `list` output column) +- Test: `internal/remote/remote_test.go` + +**Interfaces:** +- Produces: `Machine.InstallCode int` (0 when there is none) and `Machine.Pending bool` + +Verified live: a created-but-uninstalled agent arrives as `"state":"W"`, `"tempCode":281407902`, `"osType":null`. dwshell currently renders it as "Linux offline", which is wrong twice. + +- [ ] **Step 1: Write the failing test** + +```go +func TestPendingAgentIsNotJustOffline(t *testing.T) { + it := dsItem{Name: "probe", ID: "A1", State: "W", TempCode: 281407902} + m := machineFromAgent(it) + if !m.Pending { + t.Error("an agent in state W is pending installation") + } + if m.Online { + t.Error("a pending agent is not online") + } + if m.InstallCode != 281407902 { + t.Errorf("InstallCode = %d", m.InstallCode) + } +} + +func TestInstalledAgentIsNotPending(t *testing.T) { + m := machineFromAgent(dsItem{Name: "GHE", ID: "A2", State: "N", OsType: 0}) + if m.Pending || m.InstallCode != 0 { + t.Errorf("got %+v", m) + } + if !m.Online { + t.Error("state N is online") + } +} +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `go test ./internal/remote/ -run Pending -v` +Expected: build failure, `undefined: machineFromAgent`, unknown fields `TempCode`, `Pending`, `InstallCode` + +- [ ] **Step 3: Write the implementation** + +Add to `dsItem`: + +```go + TempCode int `json:"tempCode"` // installation code; set only while state is "W" + IDGroup string `json:"idGroup"` +``` + +Add to `Machine`: + +```go + // Pending is an agent created but never installed: it has an InstallCode + // and no meaningful OS yet (the service sends osType null). + Pending bool + InstallCode int + IDGroup string +``` + +Extract the mapping the `List` loop does today into a function, and use it there: + +```go +// machineFromAgent maps one owned-agent record. State "N" is online and "W" is +// created but not yet installed — the state this package's callers show as +// "pending" rather than as an ordinary offline machine. +func machineFromAgent(it dsItem) Machine { + return Machine{ + Name: it.Name, + ID: it.ID, + OS: OS(it.OsType), + Online: it.State == "N", + Pending: it.State == "W", + InstallCode: it.TempCode, + Group: it.Group, + IDGroup: it.IDGroup, + Apps: splitApps(it.SupportedApplications), + } +} +``` + +In `cmd/dwshell/main.go`, the `list` state column prints `pending` when `m.Pending`, before the online/offline choice, and leaves the OS column empty for a pending agent since the service has not reported one. + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./internal/remote/ ./cmd/dwshell/ -v` +Expected: PASS + +- [ ] **Step 5: Verify against the live account** + +Run: `go run ./cmd/dwshell list` +Expected: unchanged output for the real agents (no pending agent exists yet; Task 3 rechecks this with one). + +- [ ] **Step 6: Commit** + +```bash +git add internal/remote/remote.go internal/remote/remote_test.go cmd/dwshell/main.go +git commit -m "remote: tell an agent pending installation from an offline one" +``` + +--- + +### Task 3: `dwshell agent create` + +**Files:** +- Create: `internal/manage/agent.go`, `cmd/dwshell/agent.go` +- Modify: `cmd/dwshell/main.go` (dispatch + help) +- Test: `internal/manage/agent_test.go`, `cmd/dwshell/agent_test.go` + +**Interfaces:** +- Consumes: `commit`, `Executor`, `item`, `change` from Task 1 +- Produces: + ```go + type Agent struct { + ID string + Name string + Description string + InstallCode int + State string + } + func CreateAgent(ctx context.Context, ex Executor, name, description, idGroup string) (*Agent, error) + func FormatCode(code int) string // cmd/dwshell/agent.go — 281407902 → "281-407-902" + func InstallInstructions(code int) string // cmd/dwshell/agent.go + ``` + +- [ ] **Step 1: Write the failing tests** + +```go +// internal/manage/agent_test.go +func TestCreateAgentReturnsTheInstallationCode(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[{"index":0,"item":{ + "id":"IXVlyraPmHUOxqNeHzYv","name":"probe","state":"W","tempCode":281407902}}]}`} + a, err := CreateAgent(context.Background(), f, "probe", "d", "") + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + if a.InstallCode != 281407902 || a.ID != "IXVlyraPmHUOxqNeHzYv" || a.State != "W" { + t.Fatalf("got %+v", a) + } + var sent []change + json.Unmarshal([]byte(f.gotParams["changes"]), &sent) + if sent[0].Operation != "add" || sent[0].Item["name"] != "probe" { + t.Fatalf("changes = %+v", sent) + } + if _, ok := sent[0].Item["idGroup"]; !ok { + t.Error("idGroup must be present, null when there is no group") + } +} +``` + +```go +// cmd/dwshell/agent_test.go +// The code is shown and typed in groups of three, the way the web client shows +// it and the way the installer forwards it. +func TestFormatCodeGroupsInThrees(t *testing.T) { + if got := FormatCode(281407902); got != "281-407-902" { + t.Fatalf("FormatCode = %q, want 281-407-902", got) + } +} + +// tempCode travels as a JSON number, so a leading zero would already be gone; +// padding restores the nine digits the grouping assumes. +func TestFormatCodePadsToNineDigits(t *testing.T) { + if got := FormatCode(12345678); got != "012-345-678" { + t.Fatalf("FormatCode = %q, want 012-345-678", got) + } +} + +// the licence constraint is a test, not a habit +func TestInstallInstructionsNeverGiveADownloadAndRunLine(t *testing.T) { + out := InstallInstructions(281407902) + if !strings.Contains(out, "https://www.dwservice.net/download.html") { + t.Error("must point at the download page, where the licence is accepted") + } + for _, forbidden := range []string{"curl", "wget", "download/dwagent", "| sh", "|sh"} { + if strings.Contains(out, forbidden) { + t.Errorf("must not hand out a download-and-run line, found %q", forbidden) + } + } + if !strings.Contains(out, "-silent key=281-407-902") { + t.Error("must show the silent-install line with the dashed code") + } + if strings.Contains(out, "key=281407902") { + t.Error("must never hand out the undashed code") + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `go test ./internal/manage/ ./cmd/dwshell/ -run 'Create|InstallInstructions' -v` +Expected: `undefined: CreateAgent`, `undefined: InstallInstructions` + +- [ ] **Step 3: Write the implementation** + +```go +// internal/manage/agent.go +package manage + +import ( + "context" + "fmt" +) + +// Agent is an agent record as the service echoes it back after a change. +type Agent struct { + ID string + Name string + Description string + InstallCode int // the installation code; set only while State is "W" + State string +} + +func agentFromItem(it item) *Agent { + a := &Agent{} + if s, ok := it["id"].(string); ok { + a.ID = s + } + if s, ok := it["name"].(string); ok { + a.Name = s + } + if s, ok := it["description"].(string); ok { + a.Description = s + } + if s, ok := it["state"].(string); ok { + a.State = s + } + // JSON numbers decode into float64 through an untyped map; the code is an + // integer on the wire ("tempCode":281407902). + if f, ok := it["tempCode"].(float64); ok { + a.InstallCode = int(f) + } + return a +} + +// CreateAgent registers a new agent and returns it with its installation code. +// The service mints the code on creation, so this is one round trip. idGroup +// may be empty, which sends a null group. +func CreateAgent(ctx context.Context, ex Executor, name, description, idGroup string) (*Agent, error) { + it := item{"name": name, "description": description, "idGroup": nil} + if idGroup != "" { + it["idGroup"] = idGroup + } + items, err := commit(ctx, ex, "agent", []change{{Operation: "add", Index: 0, Item: it}}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, fmt.Errorf("the service accepted the agent but returned no record") + } + return agentFromItem(items[0]), nil +} +``` + +```go +// cmd/dwshell/agent.go +// InstallInstructions renders what to do with a fresh installation code. +// +// It deliberately does not hand out a download-and-run one-liner. The DWService +// download page is where the licence is accepted ("By selecting the 'Download' +// button I accept the Terms and Conditions…"), and piping the installer +// straight into a shell would route around that. The direct file URLs are known +// and stay unused. +func InstallInstructions(code int) string { + c := FormatCode(code) + return fmt.Sprintf(`Installation code: %s + +1. Download the agent on the target machine (this is where you accept the licence): + https://www.dwservice.net/download.html + +2. Run the unattended setup there: + Linux / macOS sudo sh dwagent.sh -silent key=%s + Windows dwagent.exe -silent key=%s +`, c, c, c) +} + +// FormatCode renders an installation code the way the web client does and the +// way the installer expects it: three groups of three, dash-separated. The code +// arrives as a JSON number, so it is padded back to nine digits first — a +// leading zero could not have survived the wire. +func FormatCode(code int) string { + s := fmt.Sprintf("%09d", code) + return s[0:3] + "-" + s[3:6] + "-" + s[6:] +} +``` + +The CLI subcommand parses `dwshell agent create [--group G] [--description D] [--json]`, resolves `--group` through Task 7's lookup when given, calls `CreateAgent`, and prints either `InstallInstructions` or, with `--json`, `{"id","name","state","installCode"}`. + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./... ` +Expected: PASS + +- [ ] **Step 5: Verify against the live account** + +```bash +go run ./cmd/dwshell agent create dwshell-probe-2 +go run ./cmd/dwshell list | grep probe # expect: pending, no OS +``` + +Expected: a code is printed; `list` shows the agent as pending. Leave it in place — Task 4 and Task 8 use it. + +- [ ] **Step 6: Commit** + +```bash +git add internal/manage cmd/dwshell +git commit -m "agent: create an agent and print its installation code" +``` + +--- + +### Task 4: `dwshell agent code` + +**Files:** +- Modify: `cmd/dwshell/agent.go` +- Test: `cmd/dwshell/agent_test.go` + +**Interfaces:** +- Consumes: `remote.List`, `remote.Resolve`, `Machine.Pending`, `Machine.InstallCode` (Task 2), `InstallInstructions` (Task 3) +- Produces: nothing later tasks depend on + +Reading a pending agent's code needs no new protocol: it arrives in the listing `list` already fetches. + +- [ ] **Step 1: Write the failing test** + +```go +func TestAgentCodeRefusesAnInstalledAgent(t *testing.T) { + m := remote.Machine{Name: "GHE", Online: true} + err := agentCodeFor(&m) + if err == nil { + t.Fatal("an installed agent has no installation code") + } + if !strings.Contains(err.Error(), "reinstall") { + t.Errorf("the error should point at `agent reinstall`, got %q", err) + } +} + +func TestAgentCodeRefusesAShare(t *testing.T) { + m := remote.Machine{Name: "Regia", Shared: true, Pending: true, InstallCode: 1} + if err := agentCodeFor(&m); err == nil || !strings.Contains(err.Error(), "share") { + t.Fatalf("a share is someone else's agent, got %v", err) + } +} +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `go test ./cmd/dwshell/ -run AgentCode -v` +Expected: `undefined: agentCodeFor` + +- [ ] **Step 3: Write the implementation** + +```go +// agentCodeFor reports why an agent has no installation code to show, or nil +// when it does. A code exists only between creation and installation. +func agentCodeFor(m *remote.Machine) error { + if m.Shared { + return fmt.Errorf("%s is a share — someone else's agent — so it has no installation code here", m.Name) + } + if !m.Pending { + return fmt.Errorf("%s is already installed; `dwshell agent reinstall %s` mints a new code", m.Name, m.Name) + } + return nil +} +``` + +The subcommand resolves the agent, calls `agentCodeFor`, and prints `InstallInstructions(m.InstallCode)` or the `--json` object. + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./cmd/dwshell/ -v` +Expected: PASS + +- [ ] **Step 5: Verify against the live account** + +```bash +go run ./cmd/dwshell agent code dwshell-probe-2 # same code as Task 3 printed +go run ./cmd/dwshell agent code GHE # refuses, points at reinstall +``` + +- [ ] **Step 6: Commit** + +```bash +git add cmd/dwshell +git commit -m "agent: read back the code of an agent pending installation" +``` + +--- + +### Task 5: `dwshell agent rm`, with its confirmation + +**Files:** +- Modify: `internal/manage/agent.go`, `cmd/dwshell/agent.go` +- Test: `internal/manage/agent_test.go`, `cmd/dwshell/agent_test.go` + +**Interfaces:** +- Produces: + ```go + func DeleteAgent(ctx context.Context, ex Executor, id string) error + func confirm(prompt string, assumeYes, interactive bool) error // cmd/dwshell + ``` + +Verified live: `changes=[{"operation":"delete","index":0,"item":{"_id":ID,"id":ID}}]`. + +- [ ] **Step 1: Write the failing tests** + +```go +// internal/manage/agent_test.go +func TestDeleteAgentSendsBothIDForms(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[]}`} + if err := DeleteAgent(context.Background(), f, "A1"); err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + var sent []change + json.Unmarshal([]byte(f.gotParams["changes"]), &sent) + if sent[0].Operation != "delete" || sent[0].Item["_id"] != "A1" || sent[0].Item["id"] != "A1" { + t.Fatalf("changes = %+v", sent) + } +} +``` + +```go +// cmd/dwshell/agent_test.go — automation must not delete a machine by accident +func TestConfirmRefusesWithoutATerminal(t *testing.T) { + if err := confirm("delete agent x?", false, false); err == nil { + t.Fatal("with no terminal and no --yes it must refuse") + } else if !strings.Contains(err.Error(), "--yes") { + t.Errorf("the error should name --yes, got %q", err) + } +} + +func TestConfirmPassesWithYes(t *testing.T) { + if err := confirm("delete agent x?", true, false); err != nil { + t.Fatalf("--yes must proceed without a terminal: %v", err) + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `go test ./internal/manage/ ./cmd/dwshell/ -run 'Delete|Confirm' -v` +Expected: `undefined: DeleteAgent`, `undefined: confirm` + +- [ ] **Step 3: Write the implementation** + +```go +// DeleteAgent removes an agent from the account. The service wants the record's +// id under both keys it uses internally. +func DeleteAgent(ctx context.Context, ex Executor, id string) error { + _, err := commit(ctx, ex, "agent", []change{{ + Operation: "delete", Index: 0, Item: item{"_id": id, "id": id}, + }}) + return err +} +``` + +```go +// confirm gates an irreversible change. With a terminal it asks; without one — +// a script, CI — it refuses unless the caller passed --yes, so automation +// cannot delete a machine by accident. +func confirm(prompt string, assumeYes, interactive bool) error { + if assumeYes { + return nil + } + if !interactive { + return fmt.Errorf("%s: refusing without a terminal; pass --yes to proceed", prompt) + } + fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt) + var answer string + fmt.Fscanln(os.Stdin, &answer) + if answer != "y" && answer != "Y" { + return fmt.Errorf("cancelled") + } + return nil +} +``` + +The subcommand resolves the agent, refuses a share, confirms with `delete agent ""?`, then calls `DeleteAgent`. Interactivity comes from the same terminal check `term` already uses. + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./... ` +Expected: PASS + +- [ ] **Step 5: Verify against the live account** + +```bash +go run ./cmd/dwshell agent rm dwshell-probe-2 < /dev/null # refuses: no terminal, no --yes +go run ./cmd/dwshell agent create dwshell-probe-3 +go run ./cmd/dwshell agent rm dwshell-probe-3 --yes +go run ./cmd/dwshell list | grep -c probe-3 # expect 0 +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/manage cmd/dwshell +git commit -m "agent: delete an agent, behind a confirmation" +``` + +--- + +### Task 6: `dwshell agent reinstall` + +**Files:** +- Modify: `internal/manage/agent.go`, `cmd/dwshell/agent.go` +- Test: `internal/manage/agent_test.go` + +**Interfaces:** +- Produces: `func ReinstallAgent(ctx context.Context, ex Executor, id string) error` + +`module=agent command=reinstall parameter id=` is read from the client but not yet exercised; Step 5 confirms it live. + +- [ ] **Step 1: Write the failing test** + +```go +func TestReinstallAgentCallsTheReinstallCommand(t *testing.T) { + f := &fakeExec{resp: `K`} + if err := ReinstallAgent(context.Background(), f, "A1"); err != nil { + t.Fatalf("ReinstallAgent: %v", err) + } + if f.gotModule != "agent" || f.gotCommand != "reinstall" || f.gotParams["id"] != "A1" { + t.Fatalf("sent %s/%s %v", f.gotModule, f.gotCommand, f.gotParams) + } +} +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `go test ./internal/manage/ -run Reinstall -v` +Expected: `undefined: ReinstallAgent` + +- [ ] **Step 3: Write the implementation** + +```go +// ReinstallAgent puts an installed agent back into "pending installation" with +// a fresh code, invalidating the previous one. Read the new code with +// remote.List afterwards: this command answers with a bare acknowledgement. +func ReinstallAgent(ctx context.Context, ex Executor, id string) error { + _, err := ex.Execute(ctx, "agent", "reinstall", map[string]string{"id": id}) + return err +} +``` + +The subcommand confirms (it invalidates the existing code) exactly as `rm` does, then re-reads the listing and prints the new code through `InstallInstructions`. + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./... ` +Expected: PASS + +- [ ] **Step 5: Confirm the call against the live service** + +```bash +go run ./cmd/dwshell agent create dwshell-probe-4 +go run ./cmd/dwshell agent reinstall dwshell-probe-4 --yes +go run ./cmd/dwshell agent code dwshell-probe-4 # expect a different code +go run ./cmd/dwshell agent rm dwshell-probe-4 --yes +``` + +If `reinstall` rejects an agent that is already pending, note it and confirm instead against a throwaway agent that was installed in Task 8; do not run it against GHE, which is a working machine. + +- [ ] **Step 6: Commit** + +```bash +git add internal/manage cmd/dwshell +git commit -m "agent: regenerate an installation code" +``` + +--- + +### Task 7: Groups + +**Files:** +- Create: `internal/manage/group.go` +- Modify: `internal/manage/agent.go`, `cmd/dwshell/agent.go` +- Test: `internal/manage/group_test.go` + +**Interfaces:** +- Produces: + ```go + type Group struct{ ID, Name string } + func ListGroups(ctx context.Context, ex Executor) ([]Group, error) + func ResolveGroup(groups []Group, name string) (*Group, error) + func SetAgentGroup(ctx context.Context, ex Executor, agentID, idGroup string) error + ``` + +Groups must already exist: a typo should be an error, not a new object on the account. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestResolveGroupIsExactAndListsOnMiss(t *testing.T) { + gs := []Group{{ID: "G1", Name: "prod"}, {ID: "G2", Name: "lab"}} + g, err := ResolveGroup(gs, "lab") + if err != nil || g.ID != "G2" { + t.Fatalf("got %+v err=%v", g, err) + } + _, err = ResolveGroup(gs, "nope") + if err == nil { + t.Fatal("an unknown group must fail") + } + for _, want := range []string{"prod", "lab"} { + if !contains(err.Error(), want) { + t.Errorf("the error should list existing groups, missing %q in %q", want, err) + } + } +} + +func TestSetAgentGroupUpdatesOnlyTheGroup(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[]}`} + if err := SetAgentGroup(context.Background(), f, "A1", "G2"); err != nil { + t.Fatalf("SetAgentGroup: %v", err) + } + var sent []change + json.Unmarshal([]byte(f.gotParams["changes"]), &sent) + if sent[0].Operation != "update" || sent[0].Item["idGroup"] != "G2" || sent[0].Item["id"] != "A1" { + t.Fatalf("changes = %+v", sent) + } +} + +func TestSetAgentGroupClearsWithAnEmptyID(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[]}`} + SetAgentGroup(context.Background(), f, "A1", "") + var sent []change + json.Unmarshal([]byte(f.gotParams["changes"]), &sent) + if sent[0].Item["idGroup"] != nil { + t.Fatalf("removing from a group sends null, got %#v", sent[0].Item["idGroup"]) + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `go test ./internal/manage/ -run Group -v` +Expected: `undefined: ResolveGroup`, `undefined: SetAgentGroup` + +- [ ] **Step 3: Write the implementation** + +```go +// internal/manage/group.go +package manage + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Group is an agent group on the account. +type Group struct{ ID, Name string } + +// ListGroups reads the account's groups. Same datasource shape as agents. +func ListGroups(ctx context.Context, ex Executor) ([]Group, error) { + raw, err := ex.Execute(ctx, "group", "datasource", map[string]string{"operation": "load"}) + if err != nil { + return nil, fmt.Errorf("list groups: %w", err) + } + var res struct { + Items []struct { + ID string `json:"_id"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &res); err != nil { + return nil, fmt.Errorf("parse groups: %w", err) + } + out := make([]Group, 0, len(res.Items)) + for _, it := range res.Items { + out = append(out, Group{ID: it.ID, Name: it.Name}) + } + return out, nil +} + +// ResolveGroup matches a group by exact name. Creating groups is out of scope, +// so an unknown name is an error that shows what does exist — a typo should not +// quietly become a new group. +func ResolveGroup(groups []Group, name string) (*Group, error) { + for i := range groups { + if groups[i].Name == name { + return &groups[i], nil + } + } + names := make([]string, 0, len(groups)) + for _, g := range groups { + names = append(names, g.Name) + } + sort.Strings(names) + if len(names) == 0 { + return nil, fmt.Errorf("no group named %q; this account has no groups", name) + } + return nil, fmt.Errorf("no group named %q; existing groups: %s", name, strings.Join(names, ", ")) +} +``` + +```go +// internal/manage/agent.go +// SetAgentGroup moves an agent into a group, or out of every group when idGroup +// is empty. Only the group field is sent; the service keeps the rest. +func SetAgentGroup(ctx context.Context, ex Executor, agentID, idGroup string) error { + it := item{"_id": agentID, "id": agentID, "idGroup": nil} + if idGroup != "" { + it["idGroup"] = idGroup + } + _, err := commit(ctx, ex, "agent", []change{{Operation: "update", Index: 0, Item: it}}) + return err +} +``` + +The subcommand is `dwshell agent group ` and `dwshell agent group --none`. + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `go test ./... ` +Expected: PASS + +- [ ] **Step 5: Verify against the live account** + +```bash +go run ./cmd/dwshell agent create dwshell-probe-5 +go run ./cmd/dwshell agent group dwshell-probe-5 nonexistent-group # lists real groups +go run ./cmd/dwshell agent group dwshell-probe-5 +go run ./cmd/dwshell list | grep probe-5 +go run ./cmd/dwshell agent group dwshell-probe-5 --none +go run ./cmd/dwshell agent rm dwshell-probe-5 --yes +``` + +If the account has no group, create one in the web client first; `manage` does not create groups. + +- [ ] **Step 6: Commit** + +```bash +git add internal/manage cmd/dwshell +git commit -m "agent: move an agent between groups" +``` + +--- + +### Task 8: Live validation of the unattended install, then docs + +**Files:** +- Modify: `README.md`, `docs/PROTOCOL.md` + +The point of the feature is an installation that really works, so the docs are written after it has been seen to work. + +- [ ] **Step 1: Linux, in an ephemeral container** + +```bash +CODE=$(go run ./cmd/dwshell agent create dwshell-probe-linux --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["installCode"])') +docker run --rm -it debian:12 bash -c "apt-get update -qq && apt-get install -y -qq curl >/dev/null && + curl -fsSL -o /tmp/dwagent.sh https://www.dwservice.net/download/dwagent.sh && + sh /tmp/dwagent.sh -silent key=$CODE; sleep 30; echo done" +``` + +Fetching the installer here is a test of the documented run line, not the line dwshell prints — dwshell still points a user at the download page. + +Expected: `dwshell list` shows `dwshell-probe-linux` leaving `pending`. Record what the state becomes. Then `go run ./cmd/dwshell agent rm dwshell-probe-linux --yes`. + +- [ ] **Step 2: macOS, on this workstation** + +```bash +CODE=$(go run ./cmd/dwshell agent create dwshell-probe-mac --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["installCode"])') +# download the agent from https://www.dwservice.net/download.html by hand, accepting the licence +sudo sh ~/Downloads/dwagent.sh -silent key=$CODE +go run ./cmd/dwshell list | grep probe-mac +``` + +Then uninstall and clean up: + +```bash +sudo sh ~/Downloads/dwagent.sh uninstall +go run ./cmd/dwshell agent rm dwshell-probe-mac --yes +``` + +Expected: the agent appears installed while it runs, and nothing is left on the machine or the account afterwards. + +- [ ] **Step 3: Write the docs from what happened** + +`README.md` gains an `agent` section: the subcommands, the two-step installation (page, then run line), the note that groups must already exist, and the confirmation behaviour. `docs/PROTOCOL.md` gains the datasource commit envelope, the `tempCode`/`state` fields, and `agent/reinstall`, marked with what was verified live and what was read from the client. + +The Windows run line is documented as derived from the installer's source and **not** executed — see §9 of the spec. + +- [ ] **Step 4: Full verification** + +Run: `gofmt -l cmd internal && go vet ./... && go test -race ./...` +Expected: no output from gofmt, PASS from the rest. + +- [ ] **Step 5: Confirm the account is clean** + +Run: `go run ./cmd/dwshell list | grep -c probe` +Expected: `0` + +- [ ] **Step 6: Commit** + +```bash +git add README.md docs/PROTOCOL.md +git commit -m "docs: agent management and the datasource protocol" +``` + +--- + +## Self-review + +**Spec coverage.** §3 protocol → Tasks 1, 3, 5, 6, 7. §4 installation and the licence constraint → Task 3 (with a test that forbids a download-and-run line) and Task 8. §5 command surface → Tasks 3–7; `--json` appears in Task 3's CLI step and is carried by each subcommand. §5 pending state in `list` → Task 2. §6 structure → the file table. §7 errors: share refused → Task 4; unknown group → Task 7; already installed → Task 4; duplicate name → Task 1 surfaces the service's message. §8 testing → each task's Step 5, plus Task 8. + +**Placeholders.** None: every step carries the code or the command it needs. + +**Type consistency.** `Executor`, `item`, `change`, `commit` are defined in Task 1 and used unchanged after. `Agent.InstallCode`, `Machine.InstallCode`, `FormatCode(code int)` and `InstallInstructions(code int)` are all `int`, matching the wire, where `tempCode` is a number; the dashed string exists only at the edge, where it is printed or passed to `key=`. `ResolveGroup` returns `*Group` whose `ID` feeds `SetAgentGroup`'s `idGroup`. + +**Gap found and closed while reviewing:** the spec says `agent create` accepts `--group`, which needs Task 7's `ResolveGroup`. Task 3 is implemented before Task 7, so its `--group` flag is wired in Task 7's CLI step; Task 3's own live check uses no group. diff --git a/docs/superpowers/specs/2026-09-05-agent-management-design.md b/docs/superpowers/specs/2026-09-05-agent-management-design.md new file mode 100644 index 0000000..a0c227e --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-agent-management-design.md @@ -0,0 +1,214 @@ +# Agent management (`dwshell agent …`) — design + +Date: 2026-09-05 +Status: approved for implementation pending the open questions in §9 + +## 1. Purpose + +Create a DWService agent from the terminal and get back the installation code +that drives an unattended setup on the target machine, plus the lifecycle around +it: read the code again later, regenerate it, delete an agent, and move an agent +between groups. + +Today the only way to obtain that code is the browser client. For provisioning — +the case that motivated this — that means a human clicking through a web UI in +the middle of an otherwise scripted flow. + +## 2. Terminology + +DWService calls these **agents**, not hosts: its own English strings shipped +with the agent (`ui/messages/default.py`) use "agent" 39 times against one +"host", which is `proxyHost`, a network proxy. The protocol modules are `agent`, +`share` and `group`. dwshell's CLI vocabulary was renamed to match before this +work started. + +**Agents only, never shares.** Creating, deleting, regenerating and grouping +apply to agents you own. A share is someone else's agent; these commands refuse +one rather than failing obscurely against the service. + +## 3. Protocol (reverse-engineered, to be added to PROTOCOL.md) + +All of it rides the existing account command channel, which dwshell already +speaks — `session.Execute`. No new transport. + +### Reading + +``` +module=agent command=datasource parameter operation=load +→ {"allowAdd":true,"allowDelete":true,"items":[ {…}, … ]} +``` + +dwshell already makes this exact call for `list`. Each item: + +| field | meaning | +|---|---| +| `id` / `_id` | agent id | +| `name`, `description`, `displayName`, `fullName` | naming | +| `state` | `N` online, `F` offline, `W` **to install**, `D` disabled | +| `tempCode` | **the installation code**, non-null only while `state` is `W` | +| `idGroup`, `group` | group membership | +| `osType`, `supportedApplications`, `hwName`, `countOutgoingShares`, `dateCreation` | as today | + +So reading a pending agent's code needs no new protocol at all — only a field +dwshell currently discards. + +### Writing + +``` +module= command=datasource + parameter operation=commit + parameter changes=[{"operation":"add"|"update"|"delete","item":{…},"index":N}] +→ {"status":"ok","itemsChanged":[{"index":N,"item":{…}}]} +``` + +The create payload is `{idGroup, name, description}`, matching the browser +client. **The commit response returns the created item, `tempCode` included**, +so creation and code retrieval are one round trip. + +`tempCode` is a JSON **number** (`281407902`), but it is never shown or typed +that way. The client renders it in groups of three — `281-407-902` — and the +installer passes the code through with only whitespace removed, keeping the +dashes, so the dashed form is what the service expects. Being a number on the +wire, a leading zero could not survive, so dwshell pads to nine digits before +grouping. + +Groups use the identical shape on `module=group` with `{name, description}`. + +### Regenerating a code + +``` +module=agent command=reinstall parameter id= +``` + +Puts an installed agent back into state `W` with a fresh `tempCode`. + +## 4. What `agent create` hands you + +The code, and the page to download the agent from. Nothing else. + +``` +Installation code: 281-407-902 + +Download the agent on the target machine and enter this code when the installer +asks for it: + https://www.dwservice.net/download.html +``` + +Two omissions, each deliberate. + +**No download-and-run line.** The download page carries the licence acceptance: + +> By selecting the 'Download' button I accept the Terms and Conditions and the +> Restrictive Terms and Conditions. + +A `curl … | sh` one-liner would be more convenient and would route around that. +The direct file URLs are known and verified (`download/dwagent.sh`, +`download/dwagent.exe`, both HTTP 200) and stay unused. + +**No unattended install.** The installer does accept `-silent key=` — its +argument parser reads both — but **the service refuses to serve it.** Tested +live in a Debian container with a real code: + +``` +Downloading file distr.json... +Silent installation forbidden. Please contact the support. +``` + +The refusal comes from the server, which answers `_download_files` with a +`#SILENTFORBIDDEN` marker that the installer turns into that message +(`ui/installer.py:2587`). What gates it was not established; this account has no +subscription, which is a plausible but unverified explanation. Since it does not +work, it is not documented: promising an unattended setup would send people down +a path that fails. + +The feature is therefore narrower than it set out to be, and still worth having: +the code is obtained, read back, regenerated and managed from the terminal +instead of from a browser. Only the last step — typing the code into the +installer — stays manual. + +## 5. Command surface + +``` +dwshell agent create [--group G] [--description D] [--json] +dwshell agent code [--json] # code of an agent still pending install +dwshell agent reinstall [--yes] # regenerate the code +dwshell agent rm [--yes] +dwshell agent group # move into a group +dwshell agent group --none # remove from its group +``` + +`--json` is available on every subcommand from the start, matching +`dwshell list --json`. + +### Groups must already exist + +`agent group` against an unknown group fails and lists the groups that do exist. +Creating groups is out of scope: a typo should be an error, not a new object on +the account. + +### Destructive operations + +`rm` and `reinstall` (which invalidates the existing code) confirm interactively, +naming the agent. With no terminal — a script, CI — they refuse unless `--yes` +is given, so automation cannot delete a machine by accident. + +### `list` gains a pending state + +An agent in state `W` shows today as merely offline. It becomes a distinct +`pending` state, since "created but never installed" is exactly what this feature +produces. + +## 6. Structure + +A new `internal/manage` package owns account mutation. `internal/remote` keeps +its present job — list, resolve a name, connect — because every command depends +on it and it should not also carry the code that deletes machines. + +``` +internal/manage/ + datasource.go commit envelope (add/update/delete) shared by agent and group + agent.go create, delete, reinstall, set group + group.go resolve a group name to its id; list groups +cmd/dwshell/agent.go the subcommand family, as files.go does for file commands +``` + +`remote.dsItem` grows `tempCode` and `idGroup`, and `remote.Machine` exposes the +code and the pending state, so `list` and `agent code` share one read path. + +The datasource envelope is written once and serves both `agent` and `group`; +the generic "model every DWService datasource" version was rejected as +speculative generality for two consumers. + +## 7. Errors + +- an agent name that matches a share → refuse, naming the reason +- an unknown group → refuse, listing existing groups +- `agent code` on an agent that is not pending → say it is already installed and + point at `agent reinstall` +- a duplicate name → the service already rejects it (`The agent {0} already + exists`); surface its message rather than inventing one + +## 8. Testing + +Unit tests, against the existing fake-session pattern in `internal/`: +the commit envelope for each operation, group resolution including the +not-found path, the pending/installed distinction, and confirmation behaviour +with and without a terminal. + +Live verification, since the whole point is an installation that really works: + +- **Linux** — an ephemeral Docker container established that the service refuses + a silent install, which is why §4 documents none. +- Nothing is installed on a real machine: with no unattended path to verify, + there is nothing an installation would prove that the code itself does not. + +## 9. Open questions + +1. **Why silent installation is forbidden is unknown.** It is refused by the + service, not by the installer. Whether a subscription, a support request or + something else lifts it was not established. If it is ever enabled, the run + lines can be added — the installer already accepts `-silent key=`. +2. **The commit wire format is derived from the client's code, not observed.** + Every field is read from `mod_ui_datasource.js` and the manager packages, and + no agent was created while establishing it. First implementation step is to + confirm it against the live service with a throwaway agent. diff --git a/internal/manage/agent.go b/internal/manage/agent.go new file mode 100644 index 0000000..a147ccd --- /dev/null +++ b/internal/manage/agent.go @@ -0,0 +1,118 @@ +package manage + +import ( + "context" + "encoding/json" + "fmt" +) + +// Agent is an agent record as the service echoes it back after a change. +type Agent struct { + ID string + Name string + Description string + // InstallCode is the code that binds an installation to this agent. The + // service mints it on creation and clears it once the agent is installed, + // so it is set only while State is "W". + InstallCode int + State string +} + +// agentFromItem reads the fields we care about out of an echoed record. +func agentFromItem(it item) *Agent { + a := &Agent{} + if s, ok := it["id"].(string); ok { + a.ID = s + } + if s, ok := it["name"].(string); ok { + a.Name = s + } + if s, ok := it["description"].(string); ok { + a.Description = s + } + if s, ok := it["state"].(string); ok { + a.State = s + } + // The code is a JSON number on the wire ("tempCode":281407902), which + // through an untyped map lands as a float64. + if f, ok := it["tempCode"].(float64); ok { + a.InstallCode = int(f) + } + return a +} + +// CreateAgent registers a new agent and returns it with its installation code. +// The service mints the code as part of creating the record, so this is one +// round trip rather than a create followed by a read. idGroup may be empty, +// which puts the agent in no group. +func CreateAgent(ctx context.Context, ex Executor, name, description, idGroup string) (*Agent, error) { + it := item{"name": name, "description": description, "idGroup": nil} + if idGroup != "" { + it["idGroup"] = idGroup + } + items, err := commit(ctx, ex, "agent", []change{{Operation: "add", Index: 0, Item: it}}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, fmt.Errorf("the service accepted the agent but returned no record, so there is no installation code to show") + } + return agentFromItem(items[0]), nil +} + +// DeleteAgent removes an agent from the account. The service wants the record's +// id under both keys it uses internally. +func DeleteAgent(ctx context.Context, ex Executor, id string) error { + _, err := commit(ctx, ex, "agent", []change{{ + Operation: "delete", Index: 0, Item: item{"_id": id, "id": id}, + }}) + return err +} + +// ReinstallAgent puts an installed agent back into "pending installation" with +// a fresh code, invalidating the previous one. The command answers with a bare +// acknowledgement, so read the new code from the listing afterwards. +func ReinstallAgent(ctx context.Context, ex Executor, id string) error { + _, err := ex.Execute(ctx, "agent", "reinstall", map[string]string{"id": id}) + return err +} + +// loadAgentItem reads one agent's full record, with its position in the +// listing. Both are needed to update it: the service rejects a change carrying +// only the altered fields, answering java.lang.NullPointerException, so the +// whole record has to travel — which is what the browser client sends, since it +// merges the edit into the item it loaded. +func loadAgentItem(ctx context.Context, ex Executor, agentID string) (item, int, error) { + raw, err := ex.Execute(ctx, "agent", "datasource", map[string]string{"operation": "load"}) + if err != nil { + return nil, 0, fmt.Errorf("load agents: %w", err) + } + var res struct { + Items []item `json:"items"` + } + if err := json.Unmarshal(raw, &res); err != nil { + return nil, 0, fmt.Errorf("parse agents: %w", err) + } + for i, it := range res.Items { + if it["id"] == agentID || it["_id"] == agentID { + return it, i, nil + } + } + return nil, 0, fmt.Errorf("agent %s is not in the account listing", agentID) +} + +// SetAgentGroup moves an agent into a group, or out of every group when idGroup +// is empty. The record is read back first because the service wants the whole +// item, not just the field being changed. +func SetAgentGroup(ctx context.Context, ex Executor, agentID, idGroup string) error { + it, idx, err := loadAgentItem(ctx, ex, agentID) + if err != nil { + return err + } + it["idGroup"] = nil + if idGroup != "" { + it["idGroup"] = idGroup + } + _, err = commit(ctx, ex, "agent", []change{{Operation: "update", Index: idx, Item: it}}) + return err +} diff --git a/internal/manage/agent_test.go b/internal/manage/agent_test.go new file mode 100644 index 0000000..8cf6c8e --- /dev/null +++ b/internal/manage/agent_test.go @@ -0,0 +1,96 @@ +package manage + +import ( + "context" + "testing" +) + +func TestCreateAgentReturnsTheInstallationCode(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[{"index":0,"item":{ + "id":"IXVlyraPmHUOxqNeHzYv","name":"probe","state":"W","tempCode":281407902}}]}`} + a, err := CreateAgent(context.Background(), f, "probe", "d", "") + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + if a.InstallCode != 281407902 || a.ID != "IXVlyraPmHUOxqNeHzYv" || a.State != "W" { + t.Fatalf("got %+v", a) + } + sent := sentChanges(t, f) + if sent[0].Operation != "add" || sent[0].Item["name"] != "probe" { + t.Fatalf("changes = %+v", sent) + } + if _, ok := sent[0].Item["idGroup"]; !ok { + t.Error("idGroup must be present, null when there is no group") + } +} + +func TestCreateAgentSendsTheGroupWhenGiven(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[{"index":0,"item":{"id":"A1"}}]}`} + if _, err := CreateAgent(context.Background(), f, "n", "", "G2"); err != nil { + t.Fatalf("CreateAgent: %v", err) + } + if got := sentChanges(t, f)[0].Item["idGroup"]; got != "G2" { + t.Fatalf("idGroup = %#v, want G2", got) + } +} + +// A creation the service accepted but answered with nothing would leave the +// caller with no code to show, which is worse than an error. +func TestCreateAgentFailsWhenNoRecordComesBack(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[]}`} + if _, err := CreateAgent(context.Background(), f, "n", "", ""); err == nil { + t.Fatal("expected an error when the service returns no record") + } +} + +func TestDeleteAgentSendsBothIDForms(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[]}`} + if err := DeleteAgent(context.Background(), f, "A1"); err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + sent := sentChanges(t, f) + if sent[0].Operation != "delete" || sent[0].Item["_id"] != "A1" || sent[0].Item["id"] != "A1" { + t.Fatalf("changes = %+v", sent) + } +} + +// The service rejects a partial update with a NullPointerException: a change +// has to carry the whole record with the one field altered, which is what the +// browser client sends. So the record is read back before it is written. +func TestSetAgentGroupSendsTheWholeRecord(t *testing.T) { + f := &fakeExec{resp: `{"items":[ + {"id":"A0","_id":"A0","name":"other","idGroup":null}, + {"id":"A1","_id":"A1","name":"probe","description":"d","state":"W","idGroup":null}]}`} + // the load answers first; the commit reuses the same canned body, which is + // enough to inspect what was sent + _ = SetAgentGroup(context.Background(), f, "A1", "G2") + + sent := sentChanges(t, f) + if sent[0].Operation != "update" { + t.Fatalf("operation = %q", sent[0].Operation) + } + if sent[0].Item["idGroup"] != "G2" { + t.Errorf("idGroup = %#v, want G2", sent[0].Item["idGroup"]) + } + if sent[0].Item["name"] != "probe" { + t.Errorf("the whole record must travel, name = %#v", sent[0].Item["name"]) + } + if sent[0].Index != 1 { + t.Errorf("index = %d, want the record's position 1", sent[0].Index) + } +} + +func TestSetAgentGroupClearsWithAnEmptyID(t *testing.T) { + f := &fakeExec{resp: `{"items":[{"id":"A1","_id":"A1","name":"probe","idGroup":"G2"}]}`} + _ = SetAgentGroup(context.Background(), f, "A1", "") + if got := sentChanges(t, f)[0].Item["idGroup"]; got != nil { + t.Fatalf("removing from a group sends null, got %#v", got) + } +} + +func TestSetAgentGroupFailsOnAnUnknownAgent(t *testing.T) { + f := &fakeExec{resp: `{"items":[{"id":"A1","_id":"A1","name":"probe"}]}`} + if err := SetAgentGroup(context.Background(), f, "NOPE", "G2"); err == nil { + t.Fatal("expected an error for an id that is not in the listing") + } +} diff --git a/internal/manage/datasource.go b/internal/manage/datasource.go new file mode 100644 index 0000000..bb06d99 --- /dev/null +++ b/internal/manage/datasource.go @@ -0,0 +1,77 @@ +// Package manage changes what an account owns: creating agents, deleting them, +// regenerating their installation code, and moving them between groups. +// +// Reading and connecting stay in internal/remote, which every command depends +// on: the path used to resolve a name should not also carry the code that +// deletes machines. +// +// DWService exposes these as "datasources" — one per module, edited by +// submitting a batch of pending changes (PROTOCOL.md §9). +package manage + +import ( + "context" + "encoding/json" + "fmt" +) + +// Executor is the slice of a session this package needs, satisfied by +// *session.Session; a fake stands in for it in tests. +type Executor interface { + Execute(ctx context.Context, module, command string, params map[string]string) ([]byte, error) +} + +// item is one datasource record. It stays untyped because a change carries only +// the fields it touches, while the service echoes back the whole record. +type item map[string]any + +// change is one pending edit within a commit. +type change struct { + Operation string `json:"operation"` // add | update | delete + Index int `json:"index"` + Item item `json:"item"` +} + +// commitResponse is the reply to operation=commit. A rejection arrives here, +// as a status other than "ok" with a message — not as a transport error. +type commitResponse struct { + Status string `json:"status"` + Message string `json:"message"` + ItemsChanged []struct { + Index int `json:"index"` + Item item `json:"item"` + } `json:"itemsChanged"` +} + +// commit applies changes to a datasource module ("agent" or "group") and returns +// the records the service echoed back. For an add that is the created record, +// which is where a new agent's installation code arrives — so creating an agent +// and learning its code is a single round trip. +func commit(ctx context.Context, ex Executor, module string, changes []change) ([]item, error) { + body, err := json.Marshal(changes) + if err != nil { + return nil, err + } + raw, err := ex.Execute(ctx, module, "datasource", map[string]string{ + "operation": "commit", + "changes": string(body), + }) + if err != nil { + return nil, err + } + var res commitResponse + if err := json.Unmarshal(raw, &res); err != nil { + return nil, fmt.Errorf("parse %s commit response: %w", module, err) + } + if res.Status != "ok" { + if res.Message != "" { + return nil, fmt.Errorf("%s", res.Message) + } + return nil, fmt.Errorf("the service rejected the %s change", module) + } + out := make([]item, 0, len(res.ItemsChanged)) + for _, c := range res.ItemsChanged { + out = append(out, c.Item) + } + return out, nil +} diff --git a/internal/manage/datasource_test.go b/internal/manage/datasource_test.go new file mode 100644 index 0000000..d541f39 --- /dev/null +++ b/internal/manage/datasource_test.go @@ -0,0 +1,73 @@ +package manage + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// fakeExec records what was sent and replays a canned response. +type fakeExec struct { + gotModule, gotCommand string + gotParams map[string]string + resp string + err error +} + +func (f *fakeExec) Execute(_ context.Context, module, command string, p map[string]string) ([]byte, error) { + f.gotModule, f.gotCommand, f.gotParams = module, command, p + return []byte(f.resp), f.err +} + +// sentChanges decodes the changes envelope the fake received. +func sentChanges(t *testing.T, f *fakeExec) []change { + t.Helper() + var sent []change + if err := json.Unmarshal([]byte(f.gotParams["changes"]), &sent); err != nil { + t.Fatalf("changes is not JSON: %v", err) + } + return sent +} + +func TestCommitSendsTheChangesEnvelope(t *testing.T) { + f := &fakeExec{resp: `{"status":"ok","itemsChanged":[{"index":0,"item":{"id":"A1","tempCode":281407902}}]}`} + items, err := commit(context.Background(), f, "agent", + []change{{Operation: "add", Index: 0, Item: item{"name": "n1"}}}) + if err != nil { + t.Fatalf("commit: %v", err) + } + if f.gotModule != "agent" || f.gotCommand != "datasource" { + t.Fatalf("sent to %s/%s, want agent/datasource", f.gotModule, f.gotCommand) + } + if f.gotParams["operation"] != "commit" { + t.Fatalf("operation = %q, want commit", f.gotParams["operation"]) + } + sent := sentChanges(t, f) + if len(sent) != 1 || sent[0].Operation != "add" || sent[0].Item["name"] != "n1" { + t.Fatalf("changes = %+v", sent) + } + if len(items) != 1 || items[0]["id"] != "A1" { + t.Fatalf("items = %+v", items) + } +} + +// The service reports a rejection in the body, with a successful HTTP status, +// so the message has to be dug out rather than surfaced as a transport error. +func TestCommitSurfacesTheServiceMessage(t *testing.T) { + f := &fakeExec{resp: `{"status":"error","message":"The agent x already exists."}`} + _, err := commit(context.Background(), f, "agent", []change{{Operation: "add"}}) + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("error %q does not carry the service message", err) + } +} + +func TestCommitReportsARejectionWithNoMessage(t *testing.T) { + f := &fakeExec{resp: `{"status":"error"}`} + if _, err := commit(context.Background(), f, "group", []change{{Operation: "add"}}); err == nil { + t.Fatal("a non-ok status must be an error even with no message") + } +} diff --git a/internal/manage/group.go b/internal/manage/group.go new file mode 100644 index 0000000..c07e466 --- /dev/null +++ b/internal/manage/group.go @@ -0,0 +1,58 @@ +package manage + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Group is an agent group on the account. +type Group struct { + ID string + Name string +} + +// ListGroups reads the account's groups, which use the same datasource shape as +// agents on their own module. +func ListGroups(ctx context.Context, ex Executor) ([]Group, error) { + raw, err := ex.Execute(ctx, "group", "datasource", map[string]string{"operation": "load"}) + if err != nil { + return nil, fmt.Errorf("list groups: %w", err) + } + var res struct { + Items []struct { + ID string `json:"_id"` + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(raw, &res); err != nil { + return nil, fmt.Errorf("parse groups: %w", err) + } + out := make([]Group, 0, len(res.Items)) + for _, it := range res.Items { + out = append(out, Group{ID: it.ID, Name: it.Name}) + } + return out, nil +} + +// ResolveGroup matches a group by exact name. Creating groups is out of scope, +// so an unknown name is an error that shows what does exist: a typo should not +// quietly become a new group on the account. +func ResolveGroup(groups []Group, name string) (*Group, error) { + for i := range groups { + if groups[i].Name == name { + return &groups[i], nil + } + } + names := make([]string, 0, len(groups)) + for _, g := range groups { + names = append(names, g.Name) + } + sort.Strings(names) + if len(names) == 0 { + return nil, fmt.Errorf("no group named %q; this account has no groups", name) + } + return nil, fmt.Errorf("no group named %q; existing groups: %s", name, strings.Join(names, ", ")) +} diff --git a/internal/manage/group_test.go b/internal/manage/group_test.go new file mode 100644 index 0000000..6923aba --- /dev/null +++ b/internal/manage/group_test.go @@ -0,0 +1,45 @@ +package manage + +import ( + "context" + "strings" + "testing" +) + +func TestListGroupsReadsIDAndName(t *testing.T) { + f := &fakeExec{resp: `{"items":[{"_id":"G1","name":"prod"},{"_id":"G2","name":"lab"}]}`} + gs, err := ListGroups(context.Background(), f) + if err != nil { + t.Fatalf("ListGroups: %v", err) + } + if f.gotModule != "group" || f.gotParams["operation"] != "load" { + t.Fatalf("sent %s %v", f.gotModule, f.gotParams) + } + if len(gs) != 2 || gs[1].ID != "G2" || gs[1].Name != "lab" { + t.Fatalf("groups = %+v", gs) + } +} + +func TestResolveGroupIsExactAndListsOnMiss(t *testing.T) { + gs := []Group{{ID: "G1", Name: "prod"}, {ID: "G2", Name: "lab"}} + g, err := ResolveGroup(gs, "lab") + if err != nil || g.ID != "G2" { + t.Fatalf("got %+v err=%v", g, err) + } + _, err = ResolveGroup(gs, "nope") + if err == nil { + t.Fatal("an unknown group must fail rather than be created") + } + for _, want := range []string{"prod", "lab"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the error should list existing groups, missing %q in %q", want, err) + } + } +} + +func TestResolveGroupSaysWhenThereAreNone(t *testing.T) { + _, err := ResolveGroup(nil, "prod") + if err == nil || !strings.Contains(err.Error(), "no groups") { + t.Fatalf("got %v", err) + } +} diff --git a/internal/remote/remote.go b/internal/remote/remote.go index 966a0fc..dcdfadf 100644 --- a/internal/remote/remote.go +++ b/internal/remote/remote.go @@ -52,6 +52,13 @@ type Machine struct { Owner string // share owner display name Group string // optional group label Apps []string // supported applications + + // Pending is an agent created but never installed. The service reports it + // as state "W" with a null osType, so it is neither online nor meaningfully + // typed — it is waiting for someone to run the installer with InstallCode. + Pending bool + InstallCode int // installation code; set only while Pending + IDGroup string // group id, for moving the agent between groups } // Supports reports whether the machine offers the given app (e.g. "shell", @@ -82,6 +89,8 @@ type dsItem struct { OsType int `json:"osType"` AgentOsType int `json:"agentOsType"` State string `json:"state"` + TempCode int `json:"tempCode"` // installation code, only while state is "W" + IDGroup string `json:"idGroup"` SupportedApplications string `json:"supportedApplications"` Group string `json:"group"` // share-only @@ -94,6 +103,24 @@ type dsItem struct { } `json:"permissions"` } +// machineFromAgent maps one owned-agent record. State "N" is online; "W" is +// created but not yet installed, which callers show as pending rather than as +// an ordinary offline machine, since such an agent carries a code and has no +// reported OS yet. +func machineFromAgent(it dsItem) Machine { + return Machine{ + Name: it.Name, + ID: it.ID, + OS: OS(it.OsType), + Online: it.State == "N", + Pending: it.State == "W", + InstallCode: it.TempCode, + Group: it.Group, + IDGroup: it.IDGroup, + Apps: splitApps(it.SupportedApplications), + } +} + // List returns owned agents followed by incoming shares. func List(ctx context.Context, s *session.Session) ([]Machine, error) { var machines []Machine @@ -107,14 +134,7 @@ func List(ctx context.Context, s *session.Session) ([]Machine, error) { return nil, fmt.Errorf("parse agents: %w", err) } for _, it := range agents.Items { - machines = append(machines, Machine{ - Name: it.Name, - ID: it.ID, - OS: OS(it.OsType), - Online: it.State == "N", - Group: it.Group, - Apps: splitApps(it.SupportedApplications), - }) + machines = append(machines, machineFromAgent(it)) } sharesRaw, err := s.Execute(ctx, "share", "datasource", map[string]string{ diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 1ba8705..31fc130 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -70,3 +70,28 @@ func TestSupportsShell(t *testing.T) { t.Fatal("empty apps (share full access) should allow shell") } } + +// A created-but-uninstalled agent arrives as state "W" with a code and a null +// osType; dwshell rendered that as "Linux offline", wrong on both counts. +func TestPendingAgentIsNotJustOffline(t *testing.T) { + m := machineFromAgent(dsItem{Name: "probe", ID: "A1", State: "W", TempCode: 281407902}) + if !m.Pending { + t.Error("an agent in state W is pending installation") + } + if m.Online { + t.Error("a pending agent is not online") + } + if m.InstallCode != 281407902 { + t.Errorf("InstallCode = %d, want 281407902", m.InstallCode) + } +} + +func TestInstalledAgentIsNotPending(t *testing.T) { + m := machineFromAgent(dsItem{Name: "GHE", ID: "A2", State: "N", OsType: 0}) + if m.Pending || m.InstallCode != 0 { + t.Errorf("got Pending=%v InstallCode=%d", m.Pending, m.InstallCode) + } + if !m.Online { + t.Error("state N is online") + } +}