auto-harness-client 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +467 -0
  2. package/package.json +5 -1
  3. package/src/cli/admin-login.js +71 -0
  4. package/src/cli/allowlist.js +34 -0
  5. package/src/cli/args.js +57 -0
  6. package/src/cli/cli-errors.js +16 -0
  7. package/src/cli/commands/api.js +91 -0
  8. package/src/cli/commands/attach-repository.js +117 -0
  9. package/src/cli/commands/dependency-conflict.js +15 -0
  10. package/src/cli/commands/detach-repository.js +109 -0
  11. package/src/cli/commands/doctor.js +164 -0
  12. package/src/cli/commands/host-drain.js +22 -0
  13. package/src/cli/commands/host-inventory-get.js +40 -0
  14. package/src/cli/commands/host-inventory-set.js +66 -0
  15. package/src/cli/commands/host-inventory.js +15 -0
  16. package/src/cli/commands/host-list.js +87 -0
  17. package/src/cli/commands/host-post-action.js +38 -0
  18. package/src/cli/commands/host-repo-add.js +81 -0
  19. package/src/cli/commands/host-repo-rm.js +51 -0
  20. package/src/cli/commands/host-repo.js +16 -0
  21. package/src/cli/commands/host-resume.js +17 -0
  22. package/src/cli/commands/host-smoke-format.js +30 -0
  23. package/src/cli/commands/host-smoke-poll.js +77 -0
  24. package/src/cli/commands/host-smoke-provider.js +172 -0
  25. package/src/cli/commands/host-smoke-repository.js +49 -0
  26. package/src/cli/commands/host-smoke-session-attempt.js +104 -0
  27. package/src/cli/commands/host-smoke-teardown.js +164 -0
  28. package/src/cli/commands/host-smoke.js +151 -0
  29. package/src/cli/commands/host.js +31 -0
  30. package/src/cli/commands/parse-worktree-flag.js +30 -0
  31. package/src/cli/commands/repo-add.js +38 -0
  32. package/src/cli/commands/repo-list.js +84 -0
  33. package/src/cli/commands/repo-rm.js +83 -0
  34. package/src/cli/commands/repo.js +18 -0
  35. package/src/cli/commands/service-account-create.js +114 -0
  36. package/src/cli/commands/service-account-list.js +84 -0
  37. package/src/cli/commands/service-account-rm.js +48 -0
  38. package/src/cli/commands/service-account.js +19 -0
  39. package/src/cli/commands/session-cancel.js +28 -0
  40. package/src/cli/commands/session-create.js +117 -0
  41. package/src/cli/commands/session-get.js +26 -0
  42. package/src/cli/commands/session-logs.js +65 -0
  43. package/src/cli/commands/session-target.js +29 -0
  44. package/src/cli/commands/session.js +22 -0
  45. package/src/cli/commands/whoami.js +22 -0
  46. package/src/cli/config.js +96 -0
  47. package/src/cli/index.js +21 -0
  48. package/src/cli/main.js +71 -0
  49. package/src/cli/path-segment.js +20 -0
  50. package/src/cli/read-stdin.js +9 -0
  51. package/src/cli/report-error.js +34 -0
  52. package/src/cli/service-account-format.js +24 -0
  53. package/src/cli/session-format.js +15 -0
  54. package/src/cli/usage.js +83 -0
  55. package/src/cli/wait-for-session.js +46 -0
  56. package/src/errors.js +1 -0
  57. package/src/index.d.ts +64 -4
  58. package/src/index.js +76 -30
  59. package/src/resolve-target.js +29 -13
package/README.md CHANGED
@@ -160,6 +160,20 @@ const resumed = await harness.resumeSession("session-1", { prompt: "Address the
160
160
  console.log(resumed.status);
161
161
  ```
162
162
 
163
+ Pass `target` (and, only alongside it, `fallbacks`) to **rebind** the resume onto a different
164
+ Command/Provider instead of continuing the source session's original route — the same
165
+ `providerId`/`commandId`/`providerName`/`commandName` shapes as `createSession()`, resolved to ids
166
+ the same way. This replaces the source session's whole target/fallback policy (a bare `fallbacks`
167
+ override with no `target` is rejected) and always resumes on a fresh assignment: there is no native
168
+ CLI resume onto a different Command, so it may also land on a different host.
169
+
170
+ ```js
171
+ const resumed = await harness.resumeSession("session-1", {
172
+ prompt: "Continue with the updated Command.",
173
+ target: { commandName: "claude-print-auto" },
174
+ });
175
+ ```
176
+
163
177
  ## List sessions
164
178
 
165
179
  Session listings are bounded pages with the same cursor shape as repository listings, plus
@@ -203,3 +217,456 @@ import { parseHarnessTarget, requiredEnvironmentValue } from "auto-harness-clien
203
217
  const target = parseHarnessTarget(process.env.HARNESS_TARGET);
204
218
  const apiKey = requiredEnvironmentValue(process.env, "HARNESS_API_KEY");
205
219
  ```
220
+
221
+ ## CLI: `auto-harness`
222
+
223
+ This package also ships a small operator CLI, `auto-harness`, for scripting and debugging
224
+ against a control plane from a shell:
225
+
226
+ ```sh
227
+ npx auto-harness-client whoami
228
+ # or, once installed:
229
+ auto-harness whoami
230
+ ```
231
+
232
+ ### Configuration
233
+
234
+ The CLI never touches your repository config or the host daemon's persisted env file — it only
235
+ reads what you pass it:
236
+
237
+ | Setting | Flag | Environment variable(s) |
238
+ | -------------------- | ----------------------- | --------------------------------------------- |
239
+ | Base URL | `--api-url <url>` | `HARNESS_API_URL` (alias: `HARNESS_API_HTTP`) |
240
+ | API key | — | `HARNESS_API_KEY` |
241
+ | API key, from a file | `--api-key-file <path>` | `HARNESS_API_KEY_FILE` |
242
+ | Allow plain HTTP | `--allow-insecure-http` | — |
243
+
244
+ A flag always wins over its environment variable. `HARNESS_API_URL`/`HARNESS_API_HTTP` are the
245
+ same two variables the host daemon itself already accepts, so operators typically have one set.
246
+ An `--api-key-file`/`HARNESS_API_KEY_FILE` value is read and trimmed of surrounding whitespace.
247
+
248
+ **There is no `--api-key` flag.** A key passed on the command line lands in `ps` output and shell
249
+ history for the life of the process and the life of the shell's history file. Use the
250
+ `HARNESS_API_KEY` environment variable, or point `--api-key-file` / `HARNESS_API_KEY_FILE` at a
251
+ file holding it; passing `--api-key` fails immediately with a usage error telling you so.
252
+
253
+ Exit codes: `0` success, `1` an API/HTTP failure (or a failed `doctor` check), `2` a usage or
254
+ configuration error.
255
+
256
+ #### Admin bootstrap: `--admin-password-stdin`
257
+
258
+ Every API key is minted by `service-account create`, which itself requires being authenticated —
259
+ so the very first maintainer or service-account key can only come from an admin, and admin exists
260
+ only as a username/password, not an API key. `--admin-password-stdin` (with `--admin-username
261
+ <name>`, default `admin`) logs in as that admin instead of using an API key: it reads the password
262
+ from stdin (one trailing newline stripped), `POST`s it once to `/auth/login`, and carries the
263
+ returned session cookie on every later request for the rest of the invocation. The password never
264
+ touches argv, shell history, or output, and the login response body — which carries the
265
+ principal — is deliberately never read or printed.
266
+
267
+ ```sh
268
+ aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \
269
+ --query Parameter.Value --output text \
270
+ | auto-harness --admin-password-stdin service-account create --name ci --role operator --print-key
271
+ ```
272
+
273
+ It cannot be combined with an API key (`--api-key-file`, `HARNESS_API_KEY`, or
274
+ `HARNESS_API_KEY_FILE` all make the identity ambiguous) or with a command that also reads stdin
275
+ for its own input (`api --body-file -`, `host inventory set --file -`) — both are usage errors
276
+ (exit 2) before any request is made. A rejected password is `error: admin login failed (HTTP 401)`
277
+ (exit 1), never anything about the password itself. `whoami` and `doctor` work in this mode too,
278
+ reporting the admin identity instead of an API key's role.
279
+
280
+ ### `auto-harness api <METHOD> <path>`
281
+
282
+ A generic escape hatch for any route the control plane exposes. Both `/hosts` and
283
+ `/api/v1/hosts` are accepted (a leading `/api/v1` is stripped). `--body` takes inline JSON;
284
+ `--body-file <path>` reads a file, and `--body-file -` reads stdin. A `204` or otherwise empty
285
+ response prints nothing; anything else is pretty-printed JSON on stdout.
286
+
287
+ ```sh
288
+ auto-harness api GET /hosts
289
+ auto-harness api POST /repositories --body '{"name":"org/repo","url":"https://github.com/org/repo"}'
290
+ echo '{"prompt":"Review the latest changes"}' | auto-harness api POST /sessions --body-file -
291
+ ```
292
+
293
+ ### `auto-harness whoami [--json]`
294
+
295
+ `GET /auth/me`, printing only an allowlist of fields (`id`, `kind`, `username`, `name`, `role`,
296
+ `capabilities`, `boundHostId`, `allowedRepositoryIds`) — never `passwordHash`, `apiKeyHash`, or
297
+ anything else the API response happens to carry.
298
+
299
+ ```sh
300
+ auto-harness whoami
301
+ auto-harness whoami --json
302
+ ```
303
+
304
+ ### `auto-harness doctor`
305
+
306
+ Runs a handful of independent checks and reports each as `ok`, `warn`, or `fail` with a one-line
307
+ reason, exiting `1` if any check `fail`s:
308
+
309
+ - **url** — `fail`s for a plain `http://` base URL unless `--allow-insecure-http` is set; `warn`s
310
+ for a raw `*.execute-api.*.amazonaws.com` URL, which bypasses CloudFront (and the ingress token
311
+ CloudFront injects), so requests against it will be rejected.
312
+ - **reachability** — `GET /health` (at the site root, not under `/api/v1`) expecting HTTP 200 and
313
+ `{"ok":true}`.
314
+ - **auth** — if an API key is configured, `GET /auth/me`; `warn`s instead, without making the
315
+ call, when no key is configured. A `401` reports "API key rejected".
316
+
317
+ ```sh
318
+ auto-harness doctor
319
+ ```
320
+
321
+ ### `auto-harness host <subcommand>`
322
+
323
+ Operator commands for the host fleet. Every call goes through `client.request()` against the
324
+ same routes `auto-harness api` would hit — nothing here is a special path.
325
+
326
+ #### `auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]`
327
+
328
+ `GET /hosts`, printing one line per host (`hostId`, `online`/`offline`, and `draining` when
329
+ true). `--online`/`--offline` filter server-side (mutually exclusive — passing both is a usage
330
+ error); `--limit`/`--cursor` page manually. If the page has a `nextCursor` and `--all` was not
331
+ passed, a final line prints it so you can continue.
332
+
333
+ `--all` follows `nextCursor` itself and prints every host across pages (or `{ items: [...all] }`
334
+ with `--json`). Per this repo's [list/history invariant](../../docs/plan.md#5-invariants), it
335
+ never collects pages without bound: it stops after 20 pages and warns on stderr if there was
336
+ still more, rather than silently truncating or looping forever.
337
+
338
+ ```sh
339
+ auto-harness host list --online
340
+ auto-harness host list --all --json
341
+ ```
342
+
343
+ #### `auto-harness host drain <hostId> [--json]`
344
+
345
+ `POST /hosts/drain` with `{"hostId": "<id>"}` in the JSON body — **the host id is never in the
346
+ path** (`POST /hosts/<id>/drain` does not exist). Prints how many sessions are still running and
347
+ their ids. A `409 CONFLICT` means the host's connection changed mid-request; the normal error is
348
+ printed, followed by a one-line hint that retrying is safe.
349
+
350
+ #### `auto-harness host resume <hostId> [--json]`
351
+
352
+ `POST /hosts/resume`, same body-param shape as `drain`. Idempotent — safe to run on a host that
353
+ is not currently draining, which returns 200 rather than an error.
354
+
355
+ #### `auto-harness host inventory get <hostId> [--json]`
356
+
357
+ `GET /hosts/<hostId>/inventory`. Human output shows the record's `version`, each attached
358
+ repository (`id`, `path`, worktree count), and the provider account count; `--json` prints the
359
+ raw record.
360
+
361
+ #### `auto-harness host inventory set <hostId> --file <path|->`
362
+
363
+ `PUT /hosts/<hostId>/inventory` from a JSON file (or `-` for stdin), sent **verbatim** — this is
364
+ an authoritative write that replaces the entire inventory record, not a merge. Two traps this
365
+ command guards against:
366
+
367
+ - **Omitting `version` silently disables optimistic concurrency**: the server falls back to
368
+ whatever version is currently stored, so a concurrent edit can be overwritten with no error.
369
+ This command refuses (exit 2) to send a document with no integer `version` field, and tells you
370
+ to start from `auto-harness host inventory get <hostId> --json`.
371
+ - A version that has moved since you read it comes back as `409 CONFLICT` — re-read and reapply
372
+ rather than retrying the same body.
373
+
374
+ Because the write replaces the whole record, omitting `providerAccounts` wipes the host's
375
+ provider routing — always build the new document from a fresh `inventory get --json`, editing
376
+ only what you mean to change.
377
+
378
+ #### `auto-harness host repo add <hostId> <repositoryId> --path <path> [--worktree <id>=<path>]... [--default-branch <branch>] [--dry-run] [--json]`
379
+
380
+ Attaches an already-registered repository (create it first with `auto-harness repo add`) to a
381
+ host's inventory — the counterpart of `host repo rm`. Same safe read-modify-write shape:
382
+
383
+ 1. `GET /repositories/<repositoryId>` — a 404 fails with a clear message naming the id.
384
+ `--default-branch` defaults to that repository's own `defaultBranch` when omitted.
385
+ 2. `GET` the host's inventory. If this repository id is already attached, it fails (exit 1)
386
+ naming the path it is attached at — this command never overwrites an existing attachment;
387
+ remove it first with `host repo rm`.
388
+ 3. `--dry-run` prints what would be attached and exits without writing.
389
+ 4. Otherwise builds the new document as the record exactly as read, with the new entry appended
390
+ to `repositories` and the read `version` kept — every other field, including
391
+ `providerAccounts`, is preserved untouched.
392
+ 5. On a `409` (someone else wrote first) it re-reads and re-applies, up to 3 attempts total. If
393
+ the repository is now attached at the same path, that is treated as convergence (another
394
+ writer already did what this call wanted) rather than an error; a different path fails,
395
+ naming what is actually attached. Any other error status is not retried.
396
+ 6. On success it prints what was attached and the version transition (e.g. `version 29 → 30`).
397
+
398
+ `--worktree <id>=<path>` is repeatable and adds one worktree entry per occurrence (split on the
399
+ first `=`, so a path containing `=` still parses); a malformed value or a repeated id is a usage
400
+ error (exit 2) before any request is made. Each becomes `{ id, name: id, path, labels: [] }` —
401
+ `name` mirrors `id`, and the worktree's own name/slug shape is validated server-side.
402
+
403
+ ```sh
404
+ auto-harness host repo add host-1 repo-1 --path /repos/repo-1 --dry-run
405
+ auto-harness host repo add host-1 repo-1 --path /repos/repo-1 --worktree wt-1=/repos/repo-1/wt-1
406
+ ```
407
+
408
+ #### `auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]`
409
+
410
+ Detaches one repository from a host. This exists because the only prior way to do it was to
411
+ hand-assemble a full `PUT` of the inventory record — easy to get wrong, especially for
412
+ `providerAccounts`, which a naive PUT can silently drop. This command does a safe
413
+ read-modify-write instead:
414
+
415
+ 1. `GET` the inventory.
416
+ 2. If no repository with that id is attached, it fails (exit 1) and lists the ids that _are_
417
+ attached — nothing is written.
418
+ 3. Builds the new document as the record exactly as read, with only that repository removed from
419
+ `repositories` and the read `version` kept — every other field, including
420
+ `providerAccounts`, is preserved untouched. (The control plane also re-adds any repository a
421
+ scoped API key can't see before persisting, so this read-modify-write is safe even when the
422
+ caller's key is scoped to a subset of repositories.)
423
+ 4. `--dry-run` prints what would be removed — the repository's id, path, and the ids of its
424
+ worktrees — and exits without writing.
425
+ 5. On a `409` (someone else wrote first) it re-reads and re-applies, up to 3 attempts total, then
426
+ fails saying the inventory kept changing. Any other error status is not retried.
427
+ 6. On success it prints what was removed and the version transition (e.g. `version 29 → 30`).
428
+
429
+ **Removing a repository from the inventory also removes its worktrees** — they are a projection
430
+ of the repository, not independent records. Deleting the repository record itself (as opposed to
431
+ detaching it from this host's inventory) is a separate operation.
432
+
433
+ ```sh
434
+ auto-harness host repo rm host-1 repo-1 --dry-run
435
+ auto-harness host repo rm host-1 repo-1
436
+ ```
437
+
438
+ #### `auto-harness host smoke <hostId> --repo-path <path> --provider <id|name> [--provider <id|name>]... [--timeout <seconds>] [--json]`
439
+
440
+ Proves a host can run a real provider-routed session end to end, then cleans up after itself —
441
+ useful after standing up a new host, or after touching its execution profiles, without having to
442
+ open the control plane UI. It never needs to be run against production to be trusted: the CI
443
+ end-to-end suite (`e2e/control/cli-host-smoke.spec.ts`) exercises this exact command against a
444
+ real API, a real in-process host daemon, and a real (`echo`-backed) provider on every change.
445
+
446
+ **Preconditions this command cannot check itself:** `--repo-path` names a directory on the
447
+ **host**, not on whatever machine runs this CLI — they may be different machines entirely — so
448
+ this command never calls `existsSync` or otherwise inspects it locally. That path must already
449
+ be a git repository with a clean `main` checkout, and its `.gitignore` must exclude
450
+ `.worktrees/`, since this command attaches one worktree at `<repo-path>/.worktrees/<name>`,
451
+ named after the throwaway repository. Worktree names are unique across the whole fleet, so each
452
+ run uses a fresh name and concurrent smokes on different hosts never collide.
453
+
454
+ What it does, in order, always tearing down in a `finally` no matter which step failed:
455
+
456
+ 1. **Create** a throwaway repository (`POST /repositories`) with a unique, valid (slug) name.
457
+ Its `url` is a syntactically valid but inert `https://example.test/<name>.git` placeholder —
458
+ the daemon dispatches sessions against the host-local path this same run attaches, never a
459
+ repository's `url`, so nothing ever needs to resolve or dial it.
460
+ 2. **Attach** it to `<hostId>`'s inventory via the same `attachRepository` read-modify-write
461
+ `host repo add` uses, with one worktree named after the repository.
462
+ 3. **For each `--provider`, in order** (accepts an id or a name, exactly like `session create`):
463
+ create a session targeting it with the prompt `Reply with exactly: <MARKER>` (`MARKER` is
464
+ random and unique per run), wait for it, then fetch one page of its logs. A provider `PASS`es
465
+ only if the session `completed` with `exitCode` `0` **and** its stdout contains `MARKER`.
466
+ - **The host racing its own inventory poll:** a host only learns about a newly attached
467
+ repository through its own periodic poll — there is no push-on-write — so the very first
468
+ session against the repository this command _just_ attached routinely loses that race in
469
+ any real deployment, not only here: the host rejects it with a `setup_failed` session whose
470
+ `errorMessage` is exactly `Unknown repository: <id>` (`services/host-daemon/src/
471
+ worktree-manager.ts`), which the control plane never retries on its own. This command
472
+ recognizes that one exact, unambiguous shape and retries with it doubling backoff (up to 5
473
+ attempts, capped at 16s between attempts) — bounded by the same `--timeout` deadline as
474
+ everything else — before giving up and reporting it like any other failure. Any other
475
+ `setup_failed` (a real checkout/setup problem) is never retried.
476
+ - **Usage limits:** the control plane does not fail a session whose provider account hit its
477
+ usage limit — it requeues the session (`errorCode: "usage_limit"`) and puts the account on
478
+ cooldown instead (see `services/api/src/session-transition-planner.ts`'s
479
+ `planUsageLimit()`). This command checks for that on every poll (not only a status change,
480
+ since a requeued session's status can go right back to `"queued"` with no visible
481
+ transition) and fails that one provider immediately with "provider account hit its usage
482
+ limit" — it never sits out the rest of `--timeout` waiting for a cooldown to end.
483
+ - **On timeout**, the session is cancelled (this command owns it) and the provider fails with
484
+ a hint keyed off its last status: stuck in `queued` usually means no online host advertises
485
+ a ready execution profile for that provider's account (`HARNESS_EXECUTION_PROFILES`), or
486
+ nothing is running the scheduler.
487
+ - **A genuine polling failure** (a `getSession` network error or a `5xx`, not the usage-limit
488
+ or setup-failure shapes above) fails only that one provider with `session_wait_failed` — it
489
+ never aborts the run, so every remaining `--provider` still gets its own attempt. The
490
+ session is left for teardown's own safety net to cancel.
491
+ 4. **Teardown**, always: cancel any session this run created that isn't already terminal, detach
492
+ the repository (only if it was actually attached), then `DELETE` it. The delete retries a `409`
493
+ (the worktree/host-inventory projection the delete guard reads can lag the detach write
494
+ teardown just made) and a transient failure (`5xx`, or a network/timeout error) a few times
495
+ with a short backoff before giving up; once a transient failure has actually happened, a later
496
+ `404` is treated as success (the delete most likely landed and the response never arrived). If
497
+ a session could not be cancelled (anything other than a `409`, which just means it was already
498
+ terminal), or the repository is left behind, this command exits `1` and prints the exact
499
+ `session cancel` and/or `host repo rm`/`repo rm` commands needed to finish cleanup by hand.
500
+
501
+ Exit `0` only when every provider passed **and** teardown itself succeeded; `1` otherwise (a
502
+ malformed invocation is the usual usage-error exit `2`, before any of this runs). Progress
503
+ (`ok`/`FAIL` per step) goes to stderr as it happens; stdout stays a clean final summary — one
504
+ `PASS`/`FAIL` line per provider plus an overall line — or, with `--json`, the full structured
505
+ result (`hostId`, `repositoryId`, `providers[]`, `teardown` — including any `uncancelledSessionIds`
506
+ — `ok`).
507
+
508
+ ```sh
509
+ auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude
510
+ auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude --provider codex --timeout 600
511
+ ```
512
+
513
+ ### `auto-harness repo <subcommand>`
514
+
515
+ Repository CRUD, straight against the same routes `auto-harness api` would hit.
516
+
517
+ #### `auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]`
518
+
519
+ `POST /repositories`. The response is the created repository record itself — there is no
520
+ `{ repository }` wrapper, matching the shape `GET /repositories/<id>` returns — so `--json`
521
+ prints it verbatim. Human output is one line, `<id> <name>`, mirroring `repo list`'s per-line
522
+ format. `--default-branch` defaults server-side to `main` when omitted. The server also enforces
523
+ uniqueness and URL/name format; a rejected value comes back as the normal `error:` line (exit 1)
524
+ rather than a client-side re-check.
525
+
526
+ ```sh
527
+ auto-harness repo add --name org/repo --url https://github.com/org/repo
528
+ ```
529
+
530
+ #### `auto-harness repo list [--limit N] [--cursor C] [--all] [--json]`
531
+
532
+ `GET /repositories`, printing one line per repository (id, name, and status/url when present).
533
+ Paging works exactly like `host list`: `--limit`/`--cursor` page manually, and `--all` follows
534
+ `nextCursor` itself, capped at 20 pages with a stderr warning if more remain.
535
+
536
+ ```sh
537
+ auto-harness repo list --all
538
+ ```
539
+
540
+ #### `auto-harness repo rm <repositoryId> [--json]`
541
+
542
+ `DELETE /repositories/<id>`. The point of this command is that a refusal explains itself: a `409`
543
+ means other records still reference the repository, and rather than dumping that as raw JSON,
544
+ each blocking dependency gets the concrete next step to actually clear it:
545
+
546
+ | Dependency kind | Next step |
547
+ | ----------------------------- | ------------------------------------------------------------------------------------ |
548
+ | `schedule` | `auto-harness api DELETE /schedules/<id>` |
549
+ | `session` (live) | wait for it, or `auto-harness api POST /sessions/<id>/cancel` |
550
+ | `session-drain` | `auto-harness api POST /repositories/<repositoryId>/session-drains/<id>/release` |
551
+ | `host-inventory` | `auto-harness host repo rm <hostId> <repositoryId>` |
552
+ | `worktree` | same as `host-inventory` — worktrees go with detaching the repository from that host |
553
+ | `integration: github-ingress` | remove this repository's binding from the GitHub ingress configuration |
554
+ | `integration` (other) | remove or retarget that integration |
555
+ | anything else | printed as `<kind> <id>`, so a new server-side kind still displays |
556
+
557
+ Exits `1` on a `409` (after printing the server's message and every hint). With `--json`, a `409`
558
+ prints `{ "deleted": false, "dependencies": [...], "hints": [...] }`; success prints
559
+ `{ "deleted": true, "id": "..." }`.
560
+
561
+ ```sh
562
+ auto-harness repo rm repo-1
563
+ ```
564
+
565
+ ### `auto-harness service-account <subcommand>`
566
+
567
+ Service-account lifecycle. Every account's real permissions are its `role` and grants
568
+ (`boundHostId`/`allowedRepositoryIds`) — the CLI never invents a route for anything else.
569
+
570
+ #### `auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]`
571
+
572
+ `GET /auth/service-accounts`, printing id, name, role, `boundHostId` (if set), and `createdAt`.
573
+ Items are already server-sanitized, but this still filters them through the same allowlist as
574
+ `whoami`/`doctor` as defense in depth — in **both** human and `--json` output, so a field outside
575
+ the allowlist can never leak through either mode. Paging matches `host list`/`repo list`.
576
+
577
+ #### `auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>] [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]`
578
+
579
+ `POST /auth/service-accounts`. The response's API key is shown **exactly once** — the server
580
+ stores only a hash — so this command forces a deliberate choice about where that one-time value
581
+ goes:
582
+
583
+ - **`--key-file <path>`** writes the key to a new file, created with `O_CREAT | O_EXCL` and mode
584
+ `0600` — it never overwrites an existing file (checked before the request is even made, and
585
+ enforced atomically when the file is actually written). Only the account id and
586
+ `key written to <path>` are printed; if the write fails _after_ the account was already
587
+ created, the error names the account id and tells you to run `service-account rm` on it, since
588
+ the key itself is unrecoverable at that point.
589
+ - **`--print-key`** writes _only_ the key to stdout, on its own line, so
590
+ `KEY=$(auto-harness service-account create … --print-key)` works; the human-readable account
591
+ summary goes to stderr instead. Combining this with `--json` is a usage error — the stdout
592
+ contract would be ambiguous.
593
+
594
+ Exactly one of `--key-file`/`--print-key` is required; neither/both is a usage error (exit 2,
595
+ no request made). `--repositories a,b,c` maps to `allowedRepositoryIds`. The server validates
596
+ `--role` and any grants; an invalid one surfaces as its own `400`, unchanged.
597
+
598
+ ```sh
599
+ auto-harness service-account create --name ci --role operator --key-file ./ci.key
600
+ KEY=$(auto-harness service-account create --name ci --role operator --print-key)
601
+ ```
602
+
603
+ #### `auto-harness service-account rm <id> [--json]`
604
+
605
+ `DELETE /auth/service-accounts/<id>`. A `409` prints each blocking dependency generically, as
606
+ `<kind> <id>` — unlike `repo rm`, there is no per-kind hint here.
607
+
608
+ ```sh
609
+ auto-harness service-account rm svc-1
610
+ ```
611
+
612
+ ### `auto-harness session <subcommand>`
613
+
614
+ Session lifecycle for the operator CLI. `--provider`/`--command` accept either a catalog id or a
615
+ name — see below.
616
+
617
+ #### `auto-harness session create --repo <repositoryId> (--provider <id|name> | --command <id|name>) --prompt <text> [--timeout <seconds>] [--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]`
618
+
619
+ `POST /sessions`. Exactly one of `--provider`/`--command` is required. Each accepts either a
620
+ catalog id or a name: the CLI lists the relevant catalog once and checks for an exact id match;
621
+ on a miss it sends the value as `providerName`/`commandName` and lets `createSession()`'s own
622
+ name resolution handle it — so an unresolvable or ambiguous name fails with the same
623
+ `AutoHarnessError` (`UNKNOWN_PROVIDER_NAME`, `AMBIGUOUS_PROVIDER_NAME`, ...) documented above,
624
+ never a separate "unknown id" error. `--timeout` defaults to `600` seconds (matching the
625
+ create-session form's own default) since the server requires it but sets no default itself; the
626
+ server's own ceiling (7 days) is enforced there, not duplicated here.
627
+
628
+ With `--wait`, polls the new session until it reaches a terminal status (`completed`, `failed`,
629
+ `cancelled`, or `timed_out`), printing each status change to **stderr** so stdout stays the final
630
+ session record. `--wait-timeout <seconds>` bounds the wait (default: the session's own
631
+ `--timeout`); on expiry the CLI prints that the session is still running and its id, then exits 1
632
+ — it never cancels the session. Exit 0 only when the session `completed` with `exitCode` exactly
633
+ `0`; every other terminal status, or a wait timeout, exits 1.
634
+
635
+ ```sh
636
+ auto-harness session create --repo repo-1 --command claude-print --prompt "Review the diff" --wait
637
+ ```
638
+
639
+ #### `auto-harness session get <sessionId> [--json]`
640
+
641
+ `GET /sessions/<id>`, via the library's `getSession()`. Prints one line: id, status, and — only
642
+ when present — `exitCode`, `errorCode`, `errorMessage` (session records use `errorCode`/
643
+ `errorMessage`, never a top-level `error`, and `completedAt`, never `finishedAt`). `--json` prints
644
+ the full record, including `result.summary` when the session set one.
645
+
646
+ ```sh
647
+ auto-harness session get session-1
648
+ ```
649
+
650
+ #### `auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]`
651
+
652
+ `GET /sessions/<id>/logs` — a bounded page, printed once; this command never loops over every
653
+ page (see `docs/plan.md` invariant 13). Unlike `repo list`/`host list`, the logs endpoint has no
654
+ `nextCursor`; its only continuation knob is `since`, a whole ISO-8601 timestamp (exclusive),
655
+ which this CLI exposes as `--cursor` for a pagination vocabulary consistent with the other list
656
+ commands. A full page (`items.length === limit`, default `1000`) prints a hint to pass the last
657
+ line's own timestamp as the next `--cursor` — which, because `since` excludes an entire
658
+ timestamp rather than one row, also skips any other record sharing that exact timestamp. This is
659
+ the bounded contract the endpoint offers today; there is no exact row cursor over REST.
660
+
661
+ ```sh
662
+ auto-harness session logs session-1 --limit 200
663
+ ```
664
+
665
+ #### `auto-harness session cancel <sessionId> [--json]`
666
+
667
+ `POST /sessions/<id>/cancel`, via the library's `cancelSession()`. Prints the same one-line
668
+ summary as `session get`.
669
+
670
+ ```sh
671
+ auto-harness session cancel session-1
672
+ ```
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "auto-harness-client",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Dependency-free client for the Auto Harness automation API",
5
+ "license": "MIT",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "git+https://github.com/jonathanong/auto-harness.git",
8
9
  "directory": "modules/client"
9
10
  },
11
+ "bin": {
12
+ "auto-harness": "./src/cli/index.js"
13
+ },
10
14
  "files": [
11
15
  "LICENSE",
12
16
  "README.md",
@@ -0,0 +1,71 @@
1
+ import { isLoopbackOrigin } from "../loopback.js";
2
+ import { CliConfigError, CliUsageError } from "./cli-errors.js";
3
+ import { readStdin } from "./read-stdin.js";
4
+
5
+ // Matches `AutoHarnessClient`'s default `requestTimeoutMs`.
6
+ const ADMIN_LOGIN_TIMEOUT_MS = 30_000;
7
+
8
+ /**
9
+ * Usage-error checks for `--admin-password-stdin`, run before any request. An admin login is an
10
+ * alternate *identity*, not an additional one, so it is ambiguous to also have an API key
11
+ * configured — and it needs stdin for the password, so it conflicts with any command that also
12
+ * reads stdin for its own input (pass that command's own description as `stdinClaimedBy`, e.g.
13
+ * `"api --body-file -"`).
14
+ */
15
+ export function checkAdminLoginUsage(flags, env, stdinClaimedBy) {
16
+ if (!flags["--admin-password-stdin"]) return;
17
+ // Presence, not truthiness: an empty `--api-key-file=` is still an explicit (broken) request
18
+ // for API-key identity, and must not slip past this ambiguity check.
19
+ if (Object.hasOwn(flags, "--api-key-file") || env.HARNESS_API_KEY || env.HARNESS_API_KEY_FILE) {
20
+ throw new CliUsageError(
21
+ "--admin-password-stdin cannot be combined with an API key (--api-key-file, " +
22
+ "HARNESS_API_KEY, or HARNESS_API_KEY_FILE); pick one identity",
23
+ );
24
+ }
25
+ if (stdinClaimedBy) {
26
+ throw new CliUsageError(
27
+ `--admin-password-stdin and ${stdinClaimedBy} both need stdin; pass one of them another way`,
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * `POST /auth/login` with the password read from stdin (one trailing newline stripped), then
34
+ * returns a `fetch` that carries the session cookie on every later request. The login response
35
+ * body carries the principal but is deliberately never read — see the CLI spec — so a non-200 is
36
+ * reported by status code alone, never by inspecting or printing the body.
37
+ */
38
+ export async function loginAsAdmin(io, baseUrl, username, allowInsecureHttp = false) {
39
+ // The password is a credential, so it gets the same transport rule the client applies to an
40
+ // API key (`assertSecureTransport` in loopback.js): https, or plain http only to a loopback
41
+ // host and only with --allow-insecure-http. Admin mode sets no API key, so the client's own
42
+ // check never fires here. Checked before stdin is read, so a refused URL never consumes the
43
+ // password, and the session cookie that comes back is bound to the same transport.
44
+ if (!baseUrl.startsWith("https://") && !(allowInsecureHttp && isLoopbackOrigin(baseUrl))) {
45
+ throw new CliConfigError(
46
+ "--admin-password-stdin requires an https API URL (--allow-insecure-http only permits " +
47
+ "plain HTTP to a loopback host, e.g. http://127.0.0.1 or http://localhost)",
48
+ );
49
+ }
50
+ const rawPassword = await readStdin(io.stdin);
51
+ const password = rawPassword.endsWith("\n") ? rawPassword.slice(0, -1) : rawPassword;
52
+ const origin = baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
53
+ // A raw fetch, so it needs its own bound — the same one `doctor`'s /health probe uses —
54
+ // or a stalled server hangs the command before `AutoHarnessClient`'s timeout ever applies.
55
+ const response = await io.fetch(`${origin}/api/v1/auth/login`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({ username, password }),
59
+ signal: (io.timeoutSignal ?? AbortSignal.timeout)(ADMIN_LOGIN_TIMEOUT_MS),
60
+ });
61
+ if (response.status !== 200) throw new Error(`admin login failed (HTTP ${response.status})`);
62
+ const cookieHeader = response.headers
63
+ .getSetCookie()
64
+ .map((header) => header.split(";")[0].trim())
65
+ .join("; ");
66
+ return {
67
+ username,
68
+ fetch: (url, init = {}) =>
69
+ io.fetch(url, { ...init, headers: { ...init.headers, cookie: cookieHeader } }),
70
+ };
71
+ }
@@ -0,0 +1,34 @@
1
+ // Defense in depth. `GET /auth/me` spreads the server's principal object into its response.
2
+ // That principal is sanitized today — verified live, it carries no password or API-key hash —
3
+ // but the stored records behind it do hold `passwordHash` / `apiKeyHash`, and the client cannot
4
+ // see a future server change that lets one through. So `whoami` and `doctor` print only these
5
+ // named fields rather than trusting the response shape.
6
+ const ALLOWED_PRINCIPAL_FIELDS = [
7
+ "id",
8
+ "kind",
9
+ "username",
10
+ "name",
11
+ "role",
12
+ "capabilities",
13
+ "boundHostId",
14
+ "allowedRepositoryIds",
15
+ ];
16
+
17
+ export function allowlistPrincipal(principal) {
18
+ const allowed = {};
19
+ if (!principal || typeof principal !== "object") return allowed;
20
+ for (const field of ALLOWED_PRINCIPAL_FIELDS) {
21
+ if (Object.hasOwn(principal, field)) allowed[field] = principal[field];
22
+ }
23
+ return allowed;
24
+ }
25
+
26
+ export function formatWhoami(principal) {
27
+ const lines = [];
28
+ for (const field of ALLOWED_PRINCIPAL_FIELDS) {
29
+ if (!Object.hasOwn(principal, field)) continue;
30
+ const value = principal[field];
31
+ lines.push(`${field}: ${Array.isArray(value) ? value.join(", ") : value}`);
32
+ }
33
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "(no fields returned)\n";
34
+ }
@@ -0,0 +1,57 @@
1
+ import { CliUsageError } from "./cli-errors.js";
2
+
3
+ const REJECTED_API_KEY_MESSAGE =
4
+ "--api-key is not accepted on the command line (it would leak into `ps` output and shell " +
5
+ "history); set HARNESS_API_KEY instead, or point --api-key-file / HARNESS_API_KEY_FILE at a file";
6
+
7
+ /**
8
+ * Splits `argv` into recognized `--flag`/`--flag value`/`--flag=value` entries and positional
9
+ * arguments. Every `--name=value` token is split into name and value *before* any matching, so
10
+ * every check below — the rejected `--api-key` flag, an unknown flag, a value flag missing its
11
+ * value — only ever sees the flag name. This is what keeps a mistyped `--api-key=<secret>` (or
12
+ * any other `--unknown=<secret>`) from echoing the secret back in a "unknown flag" error.
13
+ *
14
+ * `repeatableFlags` behave like `valueFlags` (same inline-`=`/next-argument value rule, same
15
+ * missing-value error) except every occurrence is collected into an array at `flags[name]`,
16
+ * rather than the last one winning — e.g. `--worktree a=/a --worktree b=/b` yields
17
+ * `flags["--worktree"] === ["a=/a", "b=/b"]`. A flag not given at all is still `undefined`, not
18
+ * an empty array, so callers can tell "none passed" from "passed with zero values" (impossible
19
+ * here, but keeps the convention consistent with `valueFlags`/`booleanFlags`).
20
+ */
21
+ export function parseFlags(
22
+ argv,
23
+ { valueFlags = [], booleanFlags = [], repeatableFlags = [] } = {},
24
+ ) {
25
+ const flags = {};
26
+ const positionals = [];
27
+ for (let i = 0; i < argv.length; i += 1) {
28
+ const arg = argv[i];
29
+ if (!arg.startsWith("--")) {
30
+ positionals.push(arg);
31
+ continue;
32
+ }
33
+ const equals = arg.indexOf("=");
34
+ const name = equals === -1 ? arg : arg.slice(0, equals);
35
+ const inlineValue = equals === -1 ? undefined : arg.slice(equals + 1);
36
+ if (name === "--api-key") throw new CliUsageError(REJECTED_API_KEY_MESSAGE);
37
+ if (valueFlags.includes(name)) {
38
+ const value = inlineValue !== undefined ? inlineValue : argv[(i += 1)];
39
+ if (value === undefined) throw new CliUsageError(`${name} requires a value`);
40
+ flags[name] = value;
41
+ continue;
42
+ }
43
+ if (repeatableFlags.includes(name)) {
44
+ const value = inlineValue !== undefined ? inlineValue : argv[(i += 1)];
45
+ if (value === undefined) throw new CliUsageError(`${name} requires a value`);
46
+ (flags[name] ??= []).push(value);
47
+ continue;
48
+ }
49
+ if (booleanFlags.includes(name)) {
50
+ if (inlineValue !== undefined) throw new CliUsageError(`${name} does not take a value`);
51
+ flags[name] = true;
52
+ continue;
53
+ }
54
+ throw new CliUsageError(`unknown flag: ${name}`);
55
+ }
56
+ return { flags, positionals };
57
+ }