From 6956e00d2716c75da5295604c531b33bf2bb669f Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 21:20:48 +0200 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20design=20for=20agent=20management?= =?UTF-8?q?=20(dwshell=20agent=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a DWService agent from the terminal and getting the code that drives an unattended installation, plus the lifecycle around it: read the code again, regenerate it, delete an agent, move it between groups. The protocol was established without creating anything on the account: the read path is a call dwshell already makes for `list`, and the write path was derived from the client's own datasource module. Two facts shape the design — `tempCode` already arrives in the listing dwshell reads today, and a commit returns the created item, so creation and code retrieval are a single round trip. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- .../2026-09-05-agent-management-design.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-05-agent-management-design.md 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..d45a98d --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-agent-management-design.md @@ -0,0 +1,197 @@ +# 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. + +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. Unattended installation + +The installer parses its arguments in `fmain` (`ui/installer.py`), which accepts +`-silent`, `key=`, `name=`, `group=` and `uninstall`. Only the first two +matter here: the agent already exists server-side and the code binds the +installation to it. The `user=`/`password=` path, which creates the agent during +installation, is deliberately unused — it would put account credentials on the +target machine, which is exactly what a single-use code avoids. + +Download URLs, verified reachable (HTTP 200): + +| target | installer | +|---|---| +| Linux, macOS | `https://www.dwservice.net/download/dwagent.sh` (1.78 MB) | +| Windows | `https://www.dwservice.net/download/dwagent.exe` (2.0 MB) — one binary; there is no `_x64` variant | + +So the unattended line is, in substance: + +``` +# Linux / macOS, as root +curl -fsSL -o dwagent.sh https://www.dwservice.net/download/dwagent.sh && sh dwagent.sh -silent key= +``` + +Silent mode forces a real installation — it disables the installer's +"run without installing" path — so the command installs and registers a service. +`uninstall` is the reverse, which the macOS verification in §8 relies on. + +`dwshell agent create` prints these lines for Linux and Windows alongside the +raw code. Their exact final form is settled by the live verification in §8: +nothing is printed that has not been run, except where §9 says otherwise. + +## 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** — ephemeral Docker container, silent install with a real code, agent + confirmed online, container destroyed. +- **macOS** — this workstation, installed then uninstalled. +- **Windows** — see §9. + +## 9. Open questions + +1. **Windows verification.** No Windows machine is available: the owned Windows + agents are offline and the online ones are shares. A QEMU VM is possible in + principle — the host has the aarch64 UEFI firmware and Windows 11 Arm64 ISOs + are published, so it would be HVF-accelerated rather than emulated — but the + workstation's disk is 98% full, with 25.7 GB free against an ISO plus an + installation. Either free roughly 40 GB, supply a machine, or ship the + Windows line marked as verified from the installer's source only. +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. From 5758915ec5d5e41c1cbae46e07e536e4d74af308 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 21:37:53 +0200 Subject: [PATCH 2/7] docs: implementation plan for agent management Eight tasks, each ending in something testable, with the protocol now confirmed live rather than derived: an agent was created and deleted against the account to fix the wire form of the commit envelope. Two facts that only the live call could give, both of which would have shipped broken: - tempCode is a JSON number, not a string, so a string field would have failed to unmarshal; - it is never shown or typed as a bare number. The client groups it in threes (281-407-902) and the installer forwards the code stripping only whitespace, keeping the dashes, so the dashed form is what the service expects. It is padded to nine digits first, since a leading zero cannot survive a JSON number. The plan also carries the licence constraint as a test rather than as a habit: the download page is where the terms are accepted, so nothing may print a download-and-run one-liner, and the direct file URLs stay unused even though they are known. Windows is deferred: Linux and macOS are validated live, and the Windows run line ships marked as read from the installer's source, not executed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- .../plans/2026-09-05-agent-management.md | 1064 +++++++++++++++++ .../2026-09-05-agent-management-design.md | 55 +- 2 files changed, 1101 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-05-agent-management.md 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 index d45a98d..a229e19 100644 --- a/docs/superpowers/specs/2026-09-05-agent-management-design.md +++ b/docs/superpowers/specs/2026-09-05-agent-management-design.md @@ -65,6 +65,13 @@ 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 @@ -84,27 +91,38 @@ installation to it. The `user=`/`password=` path, which creates the agent during installation, is deliberately unused — it would put account credentials on the target machine, which is exactly what a single-use code avoids. -Download URLs, verified reachable (HTTP 200): +### dwshell does not download the installer, and does not tell you to -| target | installer | -|---|---| -| Linux, macOS | `https://www.dwservice.net/download/dwagent.sh` (1.78 MB) | -| Windows | `https://www.dwservice.net/download/dwagent.exe` (2.0 MB) — one binary; there is no `_x64` variant | +The download page carries the acceptance of the licence: + +> By selecting the 'Download' button I accept the Terms and Conditions and the +> Restrictive Terms and Conditions. -So the unattended line is, in substance: +A `curl … | sh` one-liner would be more convenient and would route around that +acceptance. So `agent create` prints **the download page**, not a direct file +URL, and a run line that assumes the installer is already on the machine: ``` -# Linux / macOS, as root -curl -fsSL -o dwagent.sh https://www.dwservice.net/download/dwagent.sh && sh dwagent.sh -silent key= +Installation code: 281-407-902 + +1. Download the agent on the target machine (accepting the licence): + https://www.dwservice.net/download.html + +2. Run the unattended setup there: + Linux / macOS: sudo sh dwagent.sh -silent key=281-407-902 + Windows: dwagent.exe -silent key=281-407-902 ``` +The direct file URLs are known and verified (`download/dwagent.sh`, +`download/dwagent.exe`, both HTTP 200; there is no `_x64` variant) and are +deliberately **not** printed. + Silent mode forces a real installation — it disables the installer's "run without installing" path — so the command installs and registers a service. `uninstall` is the reverse, which the macOS verification in §8 relies on. -`dwshell agent create` prints these lines for Linux and Windows alongside the -raw code. Their exact final form is settled by the live verification in §8: -nothing is printed that has not been run, except where §9 says otherwise. +The exact run lines are settled by the live verification in §8: nothing is +printed that has not been run, except where §9 says otherwise. ## 5. Command surface @@ -180,17 +198,18 @@ Live verification, since the whole point is an installation that really works: - **Linux** — ephemeral Docker container, silent install with a real code, agent confirmed online, container destroyed. - **macOS** — this workstation, installed then uninstalled. -- **Windows** — see §9. +- **Windows** — deferred; see §9. ## 9. Open questions -1. **Windows verification.** No Windows machine is available: the owned Windows - agents are offline and the online ones are shares. A QEMU VM is possible in - principle — the host has the aarch64 UEFI firmware and Windows 11 Arm64 ISOs - are published, so it would be HVF-accelerated rather than emulated — but the +1. **Windows verification is deferred.** Linux and macOS are validated first and + the Windows run line ships marked as derived from the installer's source, not + executed. No Windows machine is available: the owned Windows agents are + offline and the online ones are shares. A QEMU VM is possible in principle — + the host has the aarch64 UEFI firmware and Windows 11 Arm64 ISOs are + published, so it would be HVF-accelerated rather than emulated — but the workstation's disk is 98% full, with 25.7 GB free against an ISO plus an - installation. Either free roughly 40 GB, supply a machine, or ship the - Windows line marked as verified from the installer's source only. + installation. Freeing roughly 40 GB, or supplying a machine, would close it. 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 From 5b1df05346525473a189273b788b0e2dc1843792 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 21:39:29 +0200 Subject: [PATCH 3/7] manage: the datasource commit envelope DWService edits a datasource by submitting a batch of pending changes, one module at a time. This is that envelope, written once for both the agent and the group module. A rejection arrives inside a successful HTTP response, as a status other than "ok" with a message, so the service's own wording is dug out and surfaced rather than replaced with a generic failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- internal/manage/datasource.go | 77 ++++++++++++++++++++++++++++++ internal/manage/datasource_test.go | 73 ++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 internal/manage/datasource.go create mode 100644 internal/manage/datasource_test.go 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") + } +} From f48f76a22a595088ee3ad8a6d0aad0214890c6e4 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 21:40:28 +0200 Subject: [PATCH 4/7] remote: tell an agent pending installation from an offline one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent created but never installed arrives as state "W" with an installation code and a null osType. dwshell mapped state to online/ offline only, so such an agent showed up as "Linux offline" — wrong twice over: it is waiting for the installer, and the service has not reported an OS for it at all. Machine now carries Pending, InstallCode and IDGroup, all of which already arrive in the listing `list` fetches; only the decoding was missing. `list` shows them as pending with an empty OS column. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- cmd/dwshell/main.go | 10 ++++++++-- internal/remote/remote.go | 36 ++++++++++++++++++++++++++-------- internal/remote/remote_test.go | 25 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/cmd/dwshell/main.go b/cmd/dwshell/main.go index ed98e04..393beec 100644 --- a/cmd/dwshell/main.go +++ b/cmd/dwshell/main.go @@ -277,14 +277,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/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") + } +} From 096696e22d8cc1c5efc9e632e07420645bc6f302 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 21:45:00 +0200 Subject: [PATCH 5/7] agent: create an agent and print its installation code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dwshell agent create ` registers the agent and prints the code that binds an installation to it. The service mints the code as part of creating the record, so this is one round trip. The code is rendered the way the web client shows it and the way the installer expects it — three groups of three, dash-separated, padded to nine digits because a leading zero could not have survived a JSON number. What is deliberately absent is a download-and-run one-liner. The DWService download page is where the licence is accepted, and piping the installer into a shell would route around that, so the output names the page and gives a run line that assumes the installer is already there. The direct file URLs are known and stay unused; a test enforces it rather than a comment. Group lookup comes along because --group needs it: reading and resolving groups, not creating them, so a typo is an error listing the groups that exist rather than a new group on the account. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- cmd/dwshell/agent.go | 165 ++++++++++++++++++++++++++++++++++ cmd/dwshell/agent_test.go | 42 +++++++++ cmd/dwshell/main.go | 3 + internal/manage/agent.go | 60 +++++++++++++ internal/manage/agent_test.go | 44 +++++++++ internal/manage/group.go | 58 ++++++++++++ internal/manage/group_test.go | 45 ++++++++++ 7 files changed, 417 insertions(+) create mode 100644 cmd/dwshell/agent.go create mode 100644 cmd/dwshell/agent_test.go create mode 100644 internal/manage/agent.go create mode 100644 internal/manage/agent_test.go create mode 100644 internal/manage/group.go create mode 100644 internal/manage/group_test.go diff --git a/cmd/dwshell/agent.go b/cmd/dwshell/agent.go new file mode 100644 index 0000000..c32735d --- /dev/null +++ b/cmd/dwshell/agent.go @@ -0,0 +1,165 @@ +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" +) + +// 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: fetch +// the agent by hand from the download page, then run the silent install there. +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): + %s + +2. Run the unattended setup there: + Linux / macOS sudo sh dwagent.sh -silent key=%s + Windows dwagent.exe -silent key=%s +`, c, downloadPage, c, c) +} + +// 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:]) + 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 +} + +// 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 +} diff --git a/cmd/dwshell/agent_test.go b/cmd/dwshell/agent_test.go new file mode 100644 index 0000000..a376398 --- /dev/null +++ b/cmd/dwshell/agent_test.go @@ -0,0 +1,42 @@ +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) + } +} + +// The download page is where the licence is accepted, so dwshell must never +// hand out a line that fetches the installer and runs it. +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") + } +} diff --git a/cmd/dwshell/main.go b/cmd/dwshell/main.go index 393beec..bddf6c7 100644 --- a/cmd/dwshell/main.go +++ b/cmd/dwshell/main.go @@ -94,6 +94,7 @@ 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] Create an agent, print its install code Agent flags: -c string Run command non-interactively, capture output, exit @@ -155,6 +156,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`). diff --git a/internal/manage/agent.go b/internal/manage/agent.go new file mode 100644 index 0000000..693d137 --- /dev/null +++ b/internal/manage/agent.go @@ -0,0 +1,60 @@ +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 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 +} diff --git a/internal/manage/agent_test.go b/internal/manage/agent_test.go new file mode 100644 index 0000000..400d321 --- /dev/null +++ b/internal/manage/agent_test.go @@ -0,0 +1,44 @@ +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") + } +} 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) + } +} From 6d95122124c09246f1944cd7e97489f4d35b9933 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 22:05:16 +0200 Subject: [PATCH 6/7] agent: code, rm, reinstall and group membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the lifecycle: read back the code of an agent still pending installation, delete an agent, mint a fresh code, and move an agent between groups. Both irreversible operations confirm first, naming the agent, and refuse outright when there is no terminal unless --yes was passed, so a script cannot delete a machine or invalidate a code by accident. Two things live testing settled that reading the client could not: - agent/reinstall works as derived, and does not echo the record, so the new code is read back from the listing; - an update must carry the whole record. Sending only the changed field earned a java.lang.NullPointerException from the service — the browser client merges its edit into the loaded item and sends that, so SetAgentGroup reads the record back before writing it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- cmd/dwshell/agent.go | 198 ++++++++++++++++++++++++++++++++++ cmd/dwshell/agent_test.go | 17 +++ internal/manage/agent.go | 58 ++++++++++ internal/manage/agent_test.go | 52 +++++++++ 4 files changed, 325 insertions(+) diff --git a/cmd/dwshell/agent.go b/cmd/dwshell/agent.go index c32735d..adb5e37 100644 --- a/cmd/dwshell/agent.go +++ b/cmd/dwshell/agent.go @@ -9,6 +9,8 @@ import ( "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 @@ -93,6 +95,14 @@ func cmdAgentManage(ctx context.Context, args []string) int { 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]) } @@ -151,6 +161,25 @@ func cmdAgentCreate(ctx context.Context, args []string) int { 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) { @@ -163,3 +192,172 @@ func resolveOwnAgent(machines []remote.Machine, query string) (*remote.Machine, } 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 index a376398..d896466 100644 --- a/cmd/dwshell/agent_test.go +++ b/cmd/dwshell/agent_test.go @@ -40,3 +40,20 @@ func TestInstallInstructionsNeverGiveADownloadAndRunLine(t *testing.T) { t.Error("must never hand out the undashed code") } } + +// 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/internal/manage/agent.go b/internal/manage/agent.go index 693d137..a147ccd 100644 --- a/internal/manage/agent.go +++ b/internal/manage/agent.go @@ -2,6 +2,7 @@ package manage import ( "context" + "encoding/json" "fmt" ) @@ -58,3 +59,60 @@ func CreateAgent(ctx context.Context, ex Executor, name, description, idGroup st } 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 index 400d321..8cf6c8e 100644 --- a/internal/manage/agent_test.go +++ b/internal/manage/agent_test.go @@ -42,3 +42,55 @@ func TestCreateAgentFailsWhenNoRecordComesBack(t *testing.T) { 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") + } +} From dc26bc57a5aae8f48e21aa9b391e3ac0dc669a91 Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 22:49:25 +0200 Subject: [PATCH 7/7] agent: give the code and the download page, nothing more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent installation is refused by the service, not by the installer. Tested live in a Debian container with a real code: the installer takes -silent key=, downloads, and the server answers with a #SILENTFORBIDDEN marker that surfaces as "Silent installation forbidden. Please contact the support." (ui/installer.py:2587). What gates it was not established — this account has no subscription, which is a plausible but unverified explanation. So the run lines are gone, from the output and from the documentation both. Promising an unattended setup that the service declines would send people down a path that fails, and a comment saying "this may not work" in the middle of a copy-paste command is worse than not printing it. What is left is still the point of the feature: the code is obtained, read back, regenerated and managed from the terminal instead of from a browser. Only entering it into the installer stays manual. The test now enforces both exclusions — no download-and-run line, because the page is where the licence is accepted, and no mention of the unattended mode, because it does not work. README and PROTOCOL.md document the commands and the datasource protocol, including the two things live testing corrected: tempCode is a number rendered in dashed groups of three, and an update must carry the whole record or the service answers NullPointerException. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- .../console-2026-09-05T11-45-08-455Z.log | 7 ++ .../page-2026-09-05T19-13-08-913Z.yml | 66 +++++++++++++++ .../page-2026-09-05T19-13-43-045Z.yml | 66 +++++++++++++++ README.md | 39 +++++++++ cmd/dwshell/agent.go | 20 ++--- cmd/dwshell/agent_test.go | 28 +++++-- cmd/dwshell/main.go | 3 +- docs/PROTOCOL.md | 68 ++++++++++++++++ .../2026-09-05-agent-management-design.md | 80 +++++++++---------- 9 files changed, 318 insertions(+), 59 deletions(-) create mode 100644 .playwright-mcp/console-2026-09-05T11-45-08-455Z.log create mode 100644 .playwright-mcp/page-2026-09-05T19-13-08-913Z.yml create mode 100644 .playwright-mcp/page-2026-09-05T19-13-43-045Z.yml 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 index adb5e37..9b02531 100644 --- a/cmd/dwshell/agent.go +++ b/cmd/dwshell/agent.go @@ -30,19 +30,21 @@ func formatCode(code int) string { return s[0:3] + "-" + s[3:6] + "-" + s[6:] } -// installInstructions renders what to do with a fresh installation code: fetch -// the agent by hand from the download page, then run the silent install there. +// 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 { - c := formatCode(code) return fmt.Sprintf(`Installation code: %s -1. Download the agent on the target machine (this is where you accept the licence): +Download the agent on the target machine and enter this code when the installer +asks for it: %s - -2. Run the unattended setup there: - Linux / macOS sudo sh dwagent.sh -silent key=%s - Windows dwagent.exe -silent key=%s -`, c, downloadPage, c, c) +`, formatCode(code), downloadPage) } // agentJSON is the --json shape of an agent and, when it has one, its code. diff --git a/cmd/dwshell/agent_test.go b/cmd/dwshell/agent_test.go index d896466..e5b0df8 100644 --- a/cmd/dwshell/agent_test.go +++ b/cmd/dwshell/agent_test.go @@ -21,23 +21,35 @@ func TestFormatCodePadsToNineDigits(t *testing.T) { } } -// The download page is where the licence is accepted, so dwshell must never -// hand out a line that fetches the installer and runs it. -func TestInstallInstructionsNeverGiveADownloadAndRunLine(t *testing.T) { +// 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) } } - 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") + + // 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) + } } } diff --git a/cmd/dwshell/main.go b/cmd/dwshell/main.go index bddf6c7..d60f5ab 100644 --- a/cmd/dwshell/main.go +++ b/cmd/dwshell/main.go @@ -94,7 +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] Create an agent, print its install code + 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 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/specs/2026-09-05-agent-management-design.md b/docs/superpowers/specs/2026-09-05-agent-management-design.md index a229e19..a0c227e 100644 --- a/docs/superpowers/specs/2026-09-05-agent-management-design.md +++ b/docs/superpowers/specs/2026-09-05-agent-management-design.md @@ -82,47 +82,49 @@ module=agent command=reinstall parameter id= Puts an installed agent back into state `W` with a fresh `tempCode`. -## 4. Unattended installation +## 4. What `agent create` hands you -The installer parses its arguments in `fmain` (`ui/installer.py`), which accepts -`-silent`, `key=`, `name=`, `group=` and `uninstall`. Only the first two -matter here: the agent already exists server-side and the code binds the -installation to it. The `user=`/`password=` path, which creates the agent during -installation, is deliberately unused — it would put account credentials on the -target machine, which is exactly what a single-use code avoids. +The code, and the page to download the agent from. Nothing else. -### dwshell does not download the installer, and does not tell you to +``` +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. -The download page carries the acceptance of the licence: +**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 -acceptance. So `agent create` prints **the download page**, not a direct file -URL, and a run line that assumes the installer is already on the machine: - -``` -Installation code: 281-407-902 +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. -1. Download the agent on the target machine (accepting the licence): - https://www.dwservice.net/download.html +**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: -2. Run the unattended setup there: - Linux / macOS: sudo sh dwagent.sh -silent key=281-407-902 - Windows: dwagent.exe -silent key=281-407-902 +``` +Downloading file distr.json... +Silent installation forbidden. Please contact the support. ``` -The direct file URLs are known and verified (`download/dwagent.sh`, -`download/dwagent.exe`, both HTTP 200; there is no `_x64` variant) and are -deliberately **not** printed. - -Silent mode forces a real installation — it disables the installer's -"run without installing" path — so the command installs and registers a service. -`uninstall` is the reverse, which the macOS verification in §8 relies on. +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 exact run lines are settled by the live verification in §8: nothing is -printed that has not been run, except where §9 says otherwise. +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 @@ -195,21 +197,17 @@ with and without a terminal. Live verification, since the whole point is an installation that really works: -- **Linux** — ephemeral Docker container, silent install with a real code, agent - confirmed online, container destroyed. -- **macOS** — this workstation, installed then uninstalled. -- **Windows** — deferred; see §9. +- **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. **Windows verification is deferred.** Linux and macOS are validated first and - the Windows run line ships marked as derived from the installer's source, not - executed. No Windows machine is available: the owned Windows agents are - offline and the online ones are shares. A QEMU VM is possible in principle — - the host has the aarch64 UEFI firmware and Windows 11 Arm64 ISOs are - published, so it would be HVF-accelerated rather than emulated — but the - workstation's disk is 98% full, with 25.7 GB free against an ISO plus an - installation. Freeing roughly 40 GB, or supplying a machine, would close it. +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