privateer-agent 0.6.3 → 0.6.6

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.
@@ -182,6 +182,22 @@ export interface RelayCallbacks {
182
182
  // The app asked to delete a platform's channel config, by platform name. Signed
183
183
  // (H2) — a forged removal is a DoS (the bot stops until re-added).
184
184
  onChannelsRemove?: (platform: string, sig?: string, ts?: number) => void;
185
+ // The app opened the MCP connectors manager — reply with the current MCP config
186
+ // (a sendMcp frame). Owned by the daemon (the host that runs the adapter), so these
187
+ // only fire on its relay. Read-only, so unsigned.
188
+ onMcpList?: () => void;
189
+ // The app asked to create/edit an MCP connector. `draft` carries only NON-secret
190
+ // fields (name/transport/command/args/url/oauth). `sealedSecrets`, when present, is
191
+ // a base64 sealed-box the app sealed to THIS terminal's pinned pubkey, opening to
192
+ // `{ termId, env: {NAME: value} }` — the connector's credential env. `sig`+`ts`
193
+ // authenticate the WHOLE save with the pinned account key (same shape as
194
+ // channels_save), so a hostile relay can neither forge a token nor inject a command.
195
+ onMcpSave?: (draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number) => void;
196
+ // The app asked to enable/disable a connector by name. Signed (H2) — a forged toggle
197
+ // silently arms/disarms a tool surface. Idempotent (non-strict ts).
198
+ onMcpSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
199
+ // The app asked to delete a connector by name. Signed (H2) — a forged removal is a DoS.
200
+ onMcpRemove?: (name: string, sig?: string, ts?: number) => void;
185
201
  // The app opened the workflows manager — reply with the current workflow summaries
186
202
  // (a sendWorkflows frame). Owned by the daemon, so these only fire on its relay.
187
203
  onWorkflowsList?: () => void;
@@ -519,6 +535,27 @@ export class RelayClient {
519
535
  case "channels_remove":
520
536
  if (typeof frame.platform === "string") this.cb.onChannelsRemove?.(frame.platform, sig(frame), tsOf(frame));
521
537
  break;
538
+ case "mcp_list":
539
+ this.cb.onMcpList?.();
540
+ break;
541
+ case "mcp_save":
542
+ // The connector rides in `draft` (same slot as channels_save), untyped — the
543
+ // daemon strict-validates it via mcpControl.save after the signature check.
544
+ if (frame.draft && typeof frame.draft === "object") {
545
+ this.cb.onMcpSave?.(
546
+ frame.draft,
547
+ typeof frame.sealedSecrets === "string" ? frame.sealedSecrets : undefined,
548
+ sig(frame),
549
+ tsOf(frame),
550
+ );
551
+ }
552
+ break;
553
+ case "mcp_set_enabled":
554
+ if (typeof frame.name === "string") this.cb.onMcpSetEnabled?.(frame.name, frame.enabled === true, sig(frame), tsOf(frame));
555
+ break;
556
+ case "mcp_remove":
557
+ if (typeof frame.name === "string") this.cb.onMcpRemove?.(frame.name, sig(frame), tsOf(frame));
558
+ break;
522
559
  case "workflows_list":
523
560
  this.cb.onWorkflowsList?.();
524
561
  break;
@@ -875,6 +912,47 @@ export class RelayClient {
875
912
  });
876
913
  }
877
914
 
915
+ // Push the host's MCP connectors to the app's MCP manager. Sent on request and after
916
+ // each save/set_enabled/remove. Like sendChannels this is the user's OWN config echoed
917
+ // to their OWN app — but an env VALUE (a token) NEVER crosses this wire: only `envKeys`
918
+ // (names) and `secretsSet` (which of those are non-empty, by name) are sent, so a relay
919
+ // / server compromise can't lift a credential from this frame. List bounded like the
920
+ // other managers.
921
+ sendMcp(payload: {
922
+ items: {
923
+ name: string;
924
+ transport: string;
925
+ enabled: boolean;
926
+ command?: string;
927
+ argsPreview?: string;
928
+ url?: string;
929
+ host?: string;
930
+ oauth: boolean;
931
+ envKeys: string[];
932
+ secretsSet: string[];
933
+ }[];
934
+ busy?: boolean;
935
+ message?: string;
936
+ }): void {
937
+ this.rawSend({
938
+ type: "mcp",
939
+ items: payload.items.slice(0, 50).map((m) => ({
940
+ name: clip(String(m.name), 128),
941
+ transport: m.transport === "http" ? "http" : "stdio",
942
+ enabled: !!m.enabled,
943
+ command: m.command ? clip(m.command, 200) : undefined,
944
+ argsPreview: m.argsPreview ? clip(m.argsPreview, 500) : undefined,
945
+ url: m.url ? clip(m.url, 500) : undefined,
946
+ host: m.host ? clip(m.host, 200) : undefined,
947
+ oauth: !!m.oauth,
948
+ envKeys: (m.envKeys ?? []).slice(0, 30).map((k) => clip(String(k), 128)),
949
+ secretsSet: (m.secretsSet ?? []).slice(0, 30).map((k) => clip(String(k), 128)),
950
+ })),
951
+ busy: !!payload.busy,
952
+ message: payload.message ? clip(payload.message, 500) : undefined,
953
+ });
954
+ }
955
+
878
956
  // Push the daemon's saved workflows to the app's workflows manager as SUMMARIES (not
879
957
  // the full graphs — the editor fetches one at a time via sendWorkflow). Sent on request
880
958
  // and after each save/remove/run. The user's OWN config echoed to their OWN app, so
@@ -158,6 +158,13 @@ export class RemoteBridge {
158
158
  onChannelsList: () => {},
159
159
  onChannelsSave: () => {},
160
160
  onChannelsRemove: () => {},
161
+ // MCP connectors, like channels, are managed on the daemon (the host that runs the
162
+ // adapter) — the daemon's own relay handles mcp_*. These no-ops just satisfy Required;
163
+ // an interactive terminal manages MCP over IPC (desktop), never over this relay.
164
+ onMcpList: () => {},
165
+ onMcpSave: () => {},
166
+ onMcpSetEnabled: () => {},
167
+ onMcpRemove: () => {},
161
168
  // Workflows, like routines/channels, are daemon-owned — the daemon's own relay handles
162
169
  // workflows_*. These no-ops just satisfy Required; an interactive terminal never
163
170
  // surfaces workflows.
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { z } from "zod";
2
3
 
3
4
  // Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
@@ -79,6 +80,13 @@ export const RoutineFile = z.object({
79
80
  export type RoutineFile = z.infer<typeof RoutineFile>;
80
81
 
81
82
  // A time-ordered routine id minted once at creation.
83
+ //
84
+ // The random suffix is load-bearing, not decoration. This was `r-${Date.now()}` alone,
85
+ // so two routines created in the SAME MILLISECOND got the same id — and upsertRoutine
86
+ // keys on id, so the second silently overwrote the first. Creating routines in quick
87
+ // succession (an import, a scripted setup, a fast tap-tap in the app) could therefore
88
+ // lose one with no error anywhere. The timestamp prefix still sorts by creation order;
89
+ // the suffix just makes collisions vanishingly unlikely.
82
90
  export function newRoutineId(): string {
83
- return `r-${Date.now()}`;
91
+ return `r-${Date.now()}-${randomBytes(4).toString("hex")}`;
84
92
  }