@primusclaw/connector 0.1.3 → 0.1.4

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 (3) hide show
  1. package/README.md +3 -2
  2. package/dist/cli.js +106 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -94,7 +94,8 @@ In Clawy (or any chat client built on Claw), send `/agents`. The agents of this
94
94
  A session started there runs on this machine, inside one of your `roots`, and each permission request comes back as a card in the chat.
95
95
 
96
96
  To disconnect: `claw-connector pause` (stay offline until `resume`), `claw-connector stop`, or `claw-connector logout` (also
97
- asks the identity service to revoke the device sign-in; a saved key is deleted from this machine, not revoked at Claw).
97
+ revokes this machine's connector at Claw, so it can be sent nothing more, and asks the identity service to revoke the device
98
+ sign-in; a saved key is deleted from this machine, not revoked at Claw).
98
99
 
99
100
  ## agents.toml
100
101
 
@@ -336,7 +337,7 @@ session stops the agent process; it never deletes the conversation.
336
337
  | `status [--json]` | Running or not, paused, signed in, agents, adapter processes, the last disconnect and its reason (`last down:`), a `problem:` line when a new sign-in is needed, and a `warning:` line for each agent whose sessions will be refused because Claude's own settings default to `bypassPermissions` (see [Running without per-step approval](#running-without-per-step-approval)). |
337
338
  | `pause` / `resume` | Stop every agent within a second and stay offline, or undo that. |
338
339
  | `stop` | Stop the running connector and every agent it started. |
339
- | `logout` | Stop, ask the identity service to revoke the sign-in, delete it from this machine. The stored connector id stays, so the next sign-in reuses the same connector. |
340
+ | `logout` | Stop, revoke this machine's connector at Claw (with a key, `DELETE /v1/remote-agents/connectors/:cid`; with a device sign-in, the connector-side `POST .../connectors/:cid/revoke`), ask the identity service to revoke the sign-in, and delete it from this machine. The next sign-in registers a new connector. When Claw cannot be reached or refuses (a Claw from before the connector-side route refuses a device sign-in), the sign-in is still deleted, but `logout` says the connector was not revoked, prints the `curl` that revokes it with a Claw API key of yours, keeps its id (so a later sign-in reuses it), and exits 1. |
340
341
 
341
342
  **Kill switch.** Every adapter is started in its own process group and recorded (pid and start time) in
342
343
  `children.json`. `stop`, `pause`, a revocation and Ctrl-C signal exactly those groups: a process that is not recorded, or
package/dist/cli.js CHANGED
@@ -406,6 +406,17 @@ var ClawClient = class {
406
406
  const d = await this.json("POST", `/v1/remote-agents/connectors/${cid}/events`, { batch_id: batchId, events });
407
407
  return { acked: isObject(d.acked) ? d.acked : {}, rejected: Array.isArray(d.rejected) ? d.rejected : [] };
408
408
  }
409
+ /**
410
+ * Revoke connector `cid` at Claw, for `logout`: Claw sends it nothing more and closes its sessions. A key is a user's
411
+ * credential and uses the user-side DELETE; a device sign-in's access token is refused there, and uses the
412
+ * connector-side route made for this. Throws {@link HttpError} 404 `not_found` when Claw has no live row by that id
413
+ * for this sign-in (already revoked, or another account's).
414
+ */
415
+ async revoke(cid, kind) {
416
+ const id = encodeURIComponent(cid);
417
+ if (kind === "key") await this.json("DELETE", `/v1/remote-agents/connectors/${id}`);
418
+ else await this.json("POST", `/v1/remote-agents/connectors/${id}/revoke`);
419
+ }
409
420
  /**
410
421
  * Hold the command stream. Yields every frame (a `ping` too, since it is
411
422
  * proof of life). Ends normally when the server closes it; throws
@@ -2600,16 +2611,16 @@ var Session = class {
2600
2611
  * delivers it again instead of losing it.
2601
2612
  */
2602
2613
  enqueuePrompt(text, cmdId, started = () => {
2603
- }) {
2614
+ }, cmdSeq = null) {
2604
2615
  if (this.closed) {
2605
- this.rejectCommand("prompt", cmdId, "session_closed");
2616
+ this.rejectCommand("prompt", cmdId, "session_closed", cmdSeq);
2606
2617
  return started();
2607
2618
  }
2608
2619
  if (this.queue.length >= MAX_QUEUED_PROMPTS) {
2609
- this.rejectCommand("prompt", cmdId, "too_many_queued_prompts");
2620
+ this.rejectCommand("prompt", cmdId, "too_many_queued_prompts", cmdSeq);
2610
2621
  return started();
2611
2622
  }
2612
- this.queue.push({ text, cmdId, started });
2623
+ this.queue.push({ text, cmdId, cmdSeq, started });
2613
2624
  if (!this.turnRunning) void this.pump();
2614
2625
  }
2615
2626
  /** Prompts that will now never run (the process is being stopped): they count as handled. */
@@ -2624,21 +2635,22 @@ var Session = class {
2624
2635
  try {
2625
2636
  while (this.queue.length && !this.closed) {
2626
2637
  const next = this.queue.shift();
2627
- await this.runTurn(next.text, next.started, next.cmdId);
2638
+ await this.runTurn(next.text, next.started, next.cmdId, next.cmdSeq);
2628
2639
  }
2629
2640
  } finally {
2630
2641
  this.turnRunning = false;
2631
2642
  this.touch();
2632
2643
  }
2633
2644
  }
2634
- async runTurn(text, started, cmdId) {
2645
+ /** Every `turn_end` of the turn names its prompt: `cmd_id`, and `cmd_seq` (a number, which no credential scrub ever masks). */
2646
+ async runTurn(text, started, cmdId, cmdSeq) {
2635
2647
  if (this.agent.attach) {
2636
2648
  const refused = await this.preflight();
2637
2649
  if (refused) {
2638
2650
  started();
2639
2651
  this.audit("prompt_refused", { reason: refused.reason, detail: refused.detail.slice(0, 300) });
2640
- this.rejectCommand("prompt", cmdId, refused.reason);
2641
- this.emit("turn_end", { stopReason: "error", error: refused.reason });
2652
+ this.rejectCommand("prompt", cmdId, refused.reason, cmdSeq);
2653
+ this.emit("turn_end", { stopReason: "error", error: refused.reason, cmd_id: cmdId, cmd_seq: cmdSeq });
2642
2654
  return;
2643
2655
  }
2644
2656
  }
@@ -2646,7 +2658,7 @@ var Session = class {
2646
2658
  await this.ensureAdapter();
2647
2659
  } catch (e) {
2648
2660
  started();
2649
- this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable" });
2661
+ this.emit("turn_end", { stopReason: "error", error: e instanceof SessionRefusal ? e.reason : "adapter_unavailable", cmd_id: cmdId, cmd_seq: cmdSeq });
2650
2662
  return this.refuse(e);
2651
2663
  }
2652
2664
  const a = this.adapter;
@@ -2658,10 +2670,10 @@ var Session = class {
2658
2670
  started();
2659
2671
  const res = await reply;
2660
2672
  const usage = isObject(res.usage) ? res.usage : void 0;
2661
- this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {} });
2673
+ this.emit("turn_end", { stopReason: typeof res.stopReason === "string" ? res.stopReason : "end_turn", ...usage ? { usage } : {}, cmd_id: cmdId, cmd_seq: cmdSeq });
2662
2674
  } catch (e) {
2663
2675
  this.audit("turn_failed", { error: errMessage(e) });
2664
- this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300) });
2676
+ this.emit("turn_end", { stopReason: "error", error: errMessage(e).slice(0, 300), cmd_id: cmdId, cmd_seq: cmdSeq });
2665
2677
  } finally {
2666
2678
  this.markTranscript();
2667
2679
  if (watch) {
@@ -2730,15 +2742,20 @@ var Session = class {
2730
2742
  this.emit("session_resumed", { acp_session_id: this.acpId, modes: this.shownModes(), currentMode: this.currentMode, unattended: this.unattended });
2731
2743
  this.touch();
2732
2744
  }
2733
- /** `cancel`: stop the turn, drop what was queued, answer every waiting permission request `cancelled`. */
2745
+ /**
2746
+ * `cancel`: stop the turn, drop what was queued, answer every waiting permission request `cancelled`. Each prompt dropped
2747
+ * unrun is refused by name (`command_rejected`, reason `cancelled`, with its `cmd_id` and `cmd_seq`): it never gets a `turn_end`.
2748
+ */
2734
2749
  async cancel() {
2735
- const queued = this.queue.length;
2750
+ const dropped = this.queue.map((q) => [q.cmdId, q.cmdSeq]);
2751
+ const queued = dropped.length;
2736
2752
  this.dropQueued();
2753
+ for (const [cmdId, cmdSeq] of dropped) this.rejectCommand("prompt", cmdId, "cancelled", cmdSeq);
2737
2754
  const wasRunning = this.turnRunning;
2738
2755
  if (wasRunning && this.adapter && this.acpId) await this.adapter.notify("session/cancel", { sessionId: this.acpId }).catch(() => void 0);
2739
2756
  for (const actionId of this.book.cancelAll()) this.emit("permission_resolved", { actionId, optionId: null, outcome: "cancelled", by: "cancel" });
2740
2757
  this.audit("cancelled", { turn_running: wasRunning, queued_dropped: queued });
2741
- if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled" });
2758
+ if (!wasRunning) this.emit("turn_end", { stopReason: "cancelled", cmd_id: null, cmd_seq: null });
2742
2759
  }
2743
2760
  // --- ending ----------------------------------------------------------------------------
2744
2761
  /** The adapter's process goes away; the session stays and resumes on demand. */
@@ -2773,9 +2790,10 @@ var Session = class {
2773
2790
  this.audit("session_refused", { reason: r.reason, message: r.message.slice(0, 300) });
2774
2791
  await this.close(r.reason);
2775
2792
  }
2776
- rejectCommand(type, cmdId, reason) {
2793
+ /** A command refused; a prompt's refusal names it by `cmd_seq` too, as its turn_end would. */
2794
+ rejectCommand(type, cmdId, reason, cmdSeq = null) {
2777
2795
  this.audit("command_rejected", { type, cmd_id: cmdId, reason });
2778
- this.emit("command_rejected", { type, cmd_id: cmdId, reason });
2796
+ this.emit("command_rejected", { type, cmd_id: cmdId, reason, ...cmdSeq !== null ? { cmd_seq: cmdSeq } : {} });
2779
2797
  }
2780
2798
  };
2781
2799
  function mapAdapterError(e) {
@@ -2801,6 +2819,7 @@ function limitStrings(value, max) {
2801
2819
 
2802
2820
  // src/manager.ts
2803
2821
  var MAX_ACTIVE_SESSIONS = 10;
2822
+ var cmdSeqOf = (cmd) => Number.isSafeInteger(cmd.cmd_seq) ? cmd.cmd_seq : null;
2804
2823
  var SessionManager = class {
2805
2824
  constructor(env) {
2806
2825
  this.env = env;
@@ -2869,10 +2888,10 @@ var SessionManager = class {
2869
2888
  switch (type) {
2870
2889
  case "prompt":
2871
2890
  if (typeof cmd.text !== "string" || cmd.text === "") {
2872
- s.rejectCommand("prompt", cmdId, "text_required");
2891
+ s.rejectCommand("prompt", cmdId, "text_required", cmdSeqOf(cmd));
2873
2892
  return false;
2874
2893
  }
2875
- s.enqueuePrompt(cmd.text, cmdId, handled);
2894
+ s.enqueuePrompt(cmd.text, cmdId, handled, cmdSeqOf(cmd));
2876
2895
  return true;
2877
2896
  case "cancel":
2878
2897
  await s.cancel();
@@ -3640,7 +3659,7 @@ var Connector = class {
3640
3659
  this.d.audit.record("command_dropped", { reason: "malformed_frame", id: f.id });
3641
3660
  return;
3642
3661
  }
3643
- const cmd = data;
3662
+ const cmd = { ...data, cmd_seq: seq };
3644
3663
  if (this.ledger.seen(seq, cmd.cmd_id)) return;
3645
3664
  this.ledger.begin(seq, cmd.cmd_id);
3646
3665
  void this.manager.handle(cmd, () => {
@@ -4181,7 +4200,7 @@ function status(ctx, d, h, json) {
4181
4200
  }
4182
4201
 
4183
4202
  // src/version.ts
4184
- var VERSION = "0.1.3";
4203
+ var VERSION = "0.1.4";
4185
4204
 
4186
4205
  // src/commands.ts
4187
4206
  var EXIT = { ok: 0, failure: 1, usage: 2, needsPerson: 78, revoked: 78 };
@@ -4210,7 +4229,7 @@ Usage: claw-connector <command> [options]
4210
4229
  pause stop every agent now and stay offline until "resume"
4211
4230
  resume undo "pause"
4212
4231
  stop stop the running connector and every agent it started
4213
- logout stop, revoke the sign-in and delete it
4232
+ logout stop, revoke this connector at Claw and the sign-in, and delete the sign-in
4214
4233
  --version, --help
4215
4234
 
4216
4235
  Files: ${"$"}XDG_CONFIG_HOME/claw-connector (agents.toml, credentials.json) and ${"$"}XDG_STATE_HOME/claw-connector
@@ -4607,21 +4626,74 @@ async function cmdStop(ctx, quiet = false) {
4607
4626
  if (!quiet) ctx.out(pid === null ? `no connector running${left.killed.length ? `; stopped ${left.killed.length} leftover agent process group(s)` : ""}` : `stopped the connector${left.killed.length ? ` and ${left.killed.length} leftover agent process group(s)` : ""}`);
4608
4627
  return EXIT.ok;
4609
4628
  }
4629
+ function storedConnector(ctx) {
4630
+ try {
4631
+ const raw = JSON.parse(fs16.readFileSync(identityFile(ctx.dirs), "utf8"));
4632
+ if (isObject(raw) && typeof raw.server === "string" && typeof raw.connector_id === "string") {
4633
+ return { server: raw.server, connector_id: raw.connector_id, revoked: raw.revoked === true };
4634
+ }
4635
+ } catch {
4636
+ }
4637
+ return null;
4638
+ }
4639
+ var revokeByHand = (server, cid) => `curl -fsS -X DELETE -H "Authorization: Bearer <your Claw API key>" ${server}/v1/remote-agents/connectors/${encodeURIComponent(cid)}`;
4610
4640
  async function cmdLogout(ctx) {
4611
4641
  await cmdStop(ctx, true);
4612
4642
  const file = credentialsFile(ctx.dirs);
4613
4643
  let creds = null;
4644
+ let credsProblem = null;
4614
4645
  try {
4615
4646
  creds = loadCredentials(file);
4616
- } catch {
4647
+ } catch (e) {
4648
+ credsProblem = errMessage(e);
4649
+ }
4650
+ const row = storedConnector(ctx);
4651
+ let latest = creds;
4652
+ let notRevoked = null;
4653
+ let rowGone = false;
4654
+ if (row && !row.revoked) {
4655
+ if (!creds) {
4656
+ notRevoked = credsProblem ? `the sign-in on this machine is unreadable (${credsProblem})` : "this machine is not signed in";
4657
+ } else {
4658
+ let server = null;
4659
+ try {
4660
+ let cfg = null;
4661
+ try {
4662
+ cfg = loadCfg(ctx);
4663
+ } catch (e) {
4664
+ if (!(e instanceof ConfigError)) throw e;
4665
+ }
4666
+ server = serverUrl(ctx, void 0, cfg, creds);
4667
+ } catch {
4668
+ }
4669
+ if (server !== row.server) {
4670
+ notRevoked = `it was registered at ${row.server}, but this sign-in is for ${server ?? "no usable server"}, so nothing was sent to either`;
4671
+ } else try {
4672
+ const tokens = providerFor(creds, (c) => {
4673
+ latest = c;
4674
+ saveCredentials(file, c);
4675
+ }, { fetch: ctx.fetch });
4676
+ const client2 = new ClawClient({ server: row.server, tokens, fetch: ctx.fetch, requestTimeoutMs: 15e3 });
4677
+ await client2.revoke(row.connector_id, creds.kind);
4678
+ rowGone = true;
4679
+ ctx.out(`revoked connector ${row.connector_id} at ${row.server}: Claw sends it nothing more`);
4680
+ } catch (e) {
4681
+ if (e instanceof RevokedError || e instanceof HttpError && e.status === 404 && e.code === "not_found") {
4682
+ rowGone = true;
4683
+ ctx.out(`Claw has no live connector ${row.connector_id} for this sign-in (already revoked, or registered by another account)`);
4684
+ } else {
4685
+ notRevoked = errMessage(e);
4686
+ }
4687
+ }
4688
+ }
4617
4689
  }
4618
- if (creds?.kind === "device" && creds.identity_url && creds.refresh_token) {
4690
+ if (latest?.kind === "device" && latest.identity_url && latest.refresh_token) {
4619
4691
  try {
4620
4692
  const f = ctx.fetch ?? fetch;
4621
- await f(`${creds.identity_url}/oauth/revoke`, {
4693
+ await f(`${latest.identity_url}/oauth/revoke`, {
4622
4694
  method: "POST",
4623
4695
  headers: { "content-type": "application/x-www-form-urlencoded" },
4624
- body: new URLSearchParams({ token: creds.refresh_token, token_type_hint: "refresh_token", client_id: creds.client_id ?? DEFAULT_CLIENT_ID }),
4696
+ body: new URLSearchParams({ token: latest.refresh_token, token_type_hint: "refresh_token", client_id: latest.client_id ?? DEFAULT_CLIENT_ID }),
4625
4697
  signal: AbortSignal.timeout(1e4)
4626
4698
  });
4627
4699
  } catch (e) {
@@ -4629,7 +4701,15 @@ async function cmdLogout(ctx) {
4629
4701
  }
4630
4702
  }
4631
4703
  fs16.rmSync(file, { force: true });
4632
- forgetRevokedIdentity(ctx);
4704
+ if (rowGone) fs16.rmSync(identityFile(ctx.dirs), { force: true });
4705
+ else forgetRevokedIdentity(ctx);
4706
+ if (row && notRevoked !== null) {
4707
+ ctx.err(`signed out on this machine, but connector ${row.connector_id} was NOT revoked at Claw (${notRevoked}).`);
4708
+ ctx.err("It is still your connector there, and instructions sent to it wait for the next sign-in on this machine. Revoke it with:");
4709
+ ctx.err(` ${revokeByHand(row.server, row.connector_id)}`);
4710
+ ctx.err("or sign in again and run logout once Claw is reachable.");
4711
+ return EXIT.failure;
4712
+ }
4633
4713
  ctx.out("signed out");
4634
4714
  return EXIT.ok;
4635
4715
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primusclaw/connector",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Connects the coding agents on your own machine to Claw, over outbound HTTPS only.",
5
5
  "license": "MIT",
6
6
  "repository": {