@ryubyte/dsh-a2a 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.
package/lib/index.js CHANGED
@@ -418,6 +418,17 @@ async function registerAgentTools(tools, options) {
418
418
  connectionId,
419
419
  card
420
420
  });
421
+ const contextByAgent = /* @__PURE__ */ new Map();
422
+ const fallbackContextKey = "\0fallback";
423
+ const contextFor = (agentId) => {
424
+ const key = agentId ?? fallbackContextKey;
425
+ let ctxId = contextByAgent.get(key);
426
+ if (ctxId === void 0) {
427
+ ctxId = `a2a-out-${crypto.randomUUID()}`;
428
+ contextByAgent.set(key, ctxId);
429
+ }
430
+ return ctxId;
431
+ };
421
432
  let disposers = [];
422
433
  if (options.mapSkills === false || skills.length === 0) {
423
434
  const name = normalizeToolName(`a2a__${options.name}__agent`);
@@ -425,7 +436,7 @@ async function registerAgentTools(tools, options) {
425
436
  id: "agent",
426
437
  name: options.name,
427
438
  description: `${card.name}: ${card.description} (no skills advertised)`
428
- }));
439
+ }, contextFor));
429
440
  if (dispose) disposers = [{
430
441
  name,
431
442
  dispose
@@ -434,7 +445,7 @@ async function registerAgentTools(tools, options) {
434
445
  const out = [];
435
446
  for (const skill of skills) {
436
447
  const name = skillToToolName(options.name, skill);
437
- const dispose = tools.register?.(makeTool(name, client, skill));
448
+ const dispose = tools.register?.(makeTool(name, client, skill, contextFor));
438
449
  if (dispose) out.push({
439
450
  name,
440
451
  dispose
@@ -451,7 +462,7 @@ async function registerAgentTools(tools, options) {
451
462
  }
452
463
  }));
453
464
  }
454
- function makeTool(name, client, skill) {
465
+ function makeTool(name, client, skill, contextFor) {
455
466
  return {
456
467
  name,
457
468
  description: [skill.description, ...skill.examples?.length ? [`Examples: ${skill.examples.join(" | ")}`] : []].filter(Boolean).join("\n"),
@@ -474,6 +485,7 @@ function makeTool(name, client, skill) {
474
485
  const message = {
475
486
  messageId: crypto.randomUUID(),
476
487
  role: "ROLE_USER",
488
+ contextId: contextFor(exec.agent?.id),
477
489
  parts: [{ text: args.prompt }]
478
490
  };
479
491
  try {
@@ -567,10 +579,12 @@ var A2AServer = class {
567
579
  execute;
568
580
  listeners = /* @__PURE__ */ new Set();
569
581
  baseUrl;
582
+ /** Abort controllers for in-flight tasks, so CancelTask can abort the executor. */
583
+ running = /* @__PURE__ */ new Map();
570
584
  constructor(options, store = new TaskStore()) {
571
585
  this.options = options;
572
586
  this.store = store;
573
- this.execute = options.execute ?? defaultExecutor;
587
+ this.execute = options.execute ?? notConfiguredExecutor;
574
588
  this.endpointPath = options.endpointPath ?? "/a2a";
575
589
  this.baseUrl = options.baseUrl;
576
590
  this.card = this.buildCard();
@@ -586,6 +600,25 @@ var A2AServer = class {
586
600
  url: `${this.baseUrl}${this.endpointPath}`
587
601
  }));
588
602
  }
603
+ /**
604
+ * Set or clear the shared bearer token that gates the inbound endpoint at
605
+ * runtime. Rebuilds the AgentCard so the `bearerAuth` security scheme is
606
+ * advertised (token set) or withdrawn (token cleared); `authorized()` reads
607
+ * `options.authToken` on each request, so the gate flips immediately. Pass
608
+ * `undefined` or an empty string to disable token gating.
609
+ */
610
+ setAuthToken(token) {
611
+ this.options.authToken = token && token.length > 0 ? token : void 0;
612
+ if (this.options.authToken) applyAuthScheme(this.card, this.options.authToken);
613
+ else {
614
+ delete this.card.securitySchemes;
615
+ delete this.card.securityRequirements;
616
+ }
617
+ }
618
+ /** Whether the inbound endpoint is currently token-gated. */
619
+ get authConfigured() {
620
+ return Boolean(this.options.authToken);
621
+ }
589
622
  buildCard() {
590
623
  const iface = {
591
624
  url: `${this.baseUrl.replace(/\/$/, "")}${this.endpointPath}`,
@@ -593,7 +626,8 @@ var A2AServer = class {
593
626
  protocolVersion: "1.0"
594
627
  };
595
628
  const skills = this.options.skills ?? [];
596
- return {
629
+ const token = this.options.authToken;
630
+ const card = {
597
631
  name: this.options.agentName,
598
632
  description: this.options.agentDescription,
599
633
  version: this.options.agentVersion,
@@ -612,6 +646,18 @@ var A2AServer = class {
612
646
  skills,
613
647
  ...this.options.iconUrl ? { iconUrl: this.options.iconUrl } : {}
614
648
  };
649
+ applyAuthScheme(card, token);
650
+ return card;
651
+ }
652
+ /** True when the request carries the configured bearer token. */
653
+ authorized(req) {
654
+ const token = this.options.authToken;
655
+ if (!token) return true;
656
+ const h = req.headers ?? {};
657
+ const auth = h["authorization"] ?? h["Authorization"];
658
+ if (!auth) return false;
659
+ const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
660
+ return !!m && m[1] === token;
615
661
  }
616
662
  emit(ev) {
617
663
  for (const l of this.listeners) l(ev);
@@ -625,6 +671,19 @@ var A2AServer = class {
625
671
  body: JSON.stringify(this.card)
626
672
  };
627
673
  if (req.method === "POST" && path === this.endpointPath) {
674
+ if (!this.authorized(req)) return {
675
+ status: 401,
676
+ contentType: "application/json",
677
+ body: JSON.stringify({
678
+ jsonrpc: "2.0",
679
+ id: null,
680
+ error: {
681
+ code: -32001,
682
+ message: "Unauthorized"
683
+ }
684
+ }),
685
+ headers: { "WWW-Authenticate": "Bearer" }
686
+ };
628
687
  let payload;
629
688
  try {
630
689
  payload = JSON.parse(body);
@@ -671,6 +730,10 @@ var A2AServer = class {
671
730
  })}\n\n`);
672
731
  return { status: 200 };
673
732
  }
733
+ if (!this.authorized(req)) return {
734
+ status: 401,
735
+ headers: { "WWW-Authenticate": "Bearer" }
736
+ };
674
737
  let payload;
675
738
  try {
676
739
  payload = JSON.parse(body);
@@ -748,14 +811,16 @@ var A2AServer = class {
748
811
  contextId,
749
812
  status: this.store.get(taskId).status
750
813
  } });
751
- const signal = new AbortController().signal;
814
+ const controller = new AbortController();
815
+ this.running.set(taskId, controller);
752
816
  try {
753
817
  const reply = await this.execute({
754
818
  message: msg,
755
819
  taskId,
756
820
  contextId,
757
- signal
821
+ signal: controller.signal
758
822
  });
823
+ if (controller.signal.aborted || isTerminal(this.store.get(taskId)?.status.state)) return;
759
824
  this.store.addArtifact(taskId, {
760
825
  artifactId: "result",
761
826
  parts: reply.parts,
@@ -772,6 +837,7 @@ var A2AServer = class {
772
837
  lastChunk: true
773
838
  } });
774
839
  } catch (err) {
840
+ if (controller.signal.aborted || isTerminal(this.store.get(taskId)?.status.state)) return;
775
841
  const message = {
776
842
  messageId: crypto.randomUUID(),
777
843
  role: "ROLE_AGENT",
@@ -784,6 +850,8 @@ var A2AServer = class {
784
850
  contextId,
785
851
  status: this.store.get(taskId).status
786
852
  } });
853
+ } finally {
854
+ this.running.delete(taskId);
787
855
  }
788
856
  }
789
857
  async dispatch(rpc) {
@@ -831,6 +899,7 @@ var A2AServer = class {
831
899
  code: A2A_ERROR_CODES.TASK_CANCEL_NOT_ALLOWED,
832
900
  message: `Task ${id} already in terminal state ${task.status.state}`
833
901
  };
902
+ this.running.get(id)?.abort();
834
903
  this.store.setStatus(id, "TASK_STATE_CANCELED");
835
904
  this.options.onTaskSettled?.(id);
836
905
  return this.store.get(id);
@@ -859,6 +928,20 @@ var A2AServer = class {
859
928
  };
860
929
  }
861
930
  };
931
+ /**
932
+ * Advertise the `bearerAuth` HTTP security scheme on an AgentCard when a token
933
+ * is configured. Mutates in place; a no-op when `token` is falsy. Shared by
934
+ * {@link A2AServer.buildCard} and {@link A2AServer.setAuthToken}.
935
+ */
936
+ function applyAuthScheme(card, token) {
937
+ if (!token) return;
938
+ card.securitySchemes = { bearerAuth: {
939
+ type: "http",
940
+ scheme: "bearer",
941
+ bearerFormat: "opaque"
942
+ } };
943
+ card.securityRequirements = [{ schemes: { bearerAuth: [] } }];
944
+ }
862
945
  /** Pull task ids out of a dispatch result for inbound peer tracking. */
863
946
  function extractTaskIds(result) {
864
947
  if (!result || typeof result !== "object") return [];
@@ -876,11 +959,34 @@ const defaultSkills = [{
876
959
  outputModes: ["text/plain"]
877
960
  }];
878
961
  /**
879
- * Default executor: run the incoming prompt through the system shell and
962
+ * Safe default executor: refuses to act, telling the caller to inject a real
963
+ * executor. Used when an `A2AServer` is constructed without an `execute`
964
+ * option, so an inbound message never triggers arbitrary action (in particular
965
+ * never a shell command) unless the operator explicitly configured one.
966
+ */
967
+ const notConfiguredExecutor = async ({ message }) => {
968
+ const text = message.parts.map((p) => "text" in p ? p.text : "").filter(Boolean).join("\n").slice(0, 120);
969
+ const detail = text ? ` (prompt: ${text}…)` : "";
970
+ return {
971
+ messageId: crypto.randomUUID(),
972
+ role: "ROLE_AGENT",
973
+ parts: [{ text: `no executor configured — inject one via the \`execute\` option${detail}` }]
974
+ };
975
+ };
976
+ /**
977
+ * Shell executor: run the incoming prompt through the system shell and
880
978
  * return the captured stdout/stderr as the task artifact. Honors the
881
979
  * AbortSignal (task cancellation / shutdown) by killing the child process.
980
+ *
981
+ * This is local-testing-only: arbitrary text from an inbound message becomes
982
+ * a `/bin/sh -c` command, so only use it against a trusted client. It is
983
+ * deliberately NOT the default — pass it explicitly to opt in:
984
+ *
985
+ * ```ts
986
+ * const server = new A2AServer({ ..., execute: shellExecutor }, store);
987
+ * ```
882
988
  */
883
- const defaultExecutor = async ({ message, signal }) => {
989
+ const shellExecutor = async ({ message, signal }) => {
884
990
  const text = message.parts.map((p) => "text" in p ? p.text : "").filter(Boolean).join("\n");
885
991
  if (!text.trim()) return {
886
992
  messageId: crypto.randomUUID(),
@@ -920,6 +1026,179 @@ const defaultExecutor = async ({ message, signal }) => {
920
1026
  };
921
1027
  }
922
1028
  };
1029
+ /**
1030
+ * Backwards-compatible alias for {@link shellExecutor}. Kept for code that
1031
+ * explicitly opted into the shell executor before it was renamed; the safe
1032
+ * default for `A2AServer` is now {@link notConfiguredExecutor}, so using
1033
+ * this alias still requires an explicit opt-in.
1034
+ */
1035
+ const defaultExecutor = shellExecutor;
1036
+ /** Flatten A2A message parts into a single prompt string (text parts only). */
1037
+ function partsToPrompt(parts) {
1038
+ return parts.map((p) => "text" in p && typeof p.text === "string" ? p.text : "").filter(Boolean).join("\n").trim();
1039
+ }
1040
+ /** Read the last assistant message's text off an agent's derived history. */
1041
+ function lastAssistantText(agent) {
1042
+ const history = agent.session.deriveMessages();
1043
+ for (let i = history.length - 1; i >= 0; i--) if (history[i].role === "assistant") return history[i].content.map((b) => b.type === "text" && typeof b.text === "string" ? b.text : "").filter(Boolean).join("\n").trim();
1044
+ return "";
1045
+ }
1046
+ /** Derive a stable session id for an A2A context id. */
1047
+ function sessionIdForContext(contextId) {
1048
+ return `a2a-${contextId}`;
1049
+ }
1050
+ /**
1051
+ * DSH agent executor: forward each inbound A2A task to this harness's own agent
1052
+ * loop and return the assistant reply as the task artifact.
1053
+ *
1054
+ * ONE A2A CONTEXT ⇒ ONE SESSION. A2A already scopes a conversation by
1055
+ * `contextId`, so this executor keeps one workspace-bound agent session per
1056
+ * context: the first task for a context `create()`s it (session id derived from
1057
+ * the context, `meta.cwd` from `opts.cwd`), and later tasks for the same
1058
+ * context reuse the SAME live agent so history accumulates. Distinct contexts
1059
+ * get distinct sessions. A fresh process that sees a known context first tries
1060
+ * `resume()` (load persisted history), falling back to `create()`.
1061
+ *
1062
+ * Sessions are NOT disposed per task — a disposed session couldn't accumulate
1063
+ * context. The returned executor exposes {@link DshAgentExecutor.disposeAll} so
1064
+ * the plugin disposes every live per-context session on unload/teardown.
1065
+ *
1066
+ * Per-context work is serialized on a promise chain so concurrent tasks for the
1067
+ * same context (and the shared `whenIdle()` — a whole-agent quiescence signal,
1068
+ * not a per-message one) can't interleave. Different contexts run in parallel.
1069
+ *
1070
+ * Enable this via the plugin's `execute` precedence in `index.ts`; it is not
1071
+ * `A2AServer`'s default (the default stays {@link notConfiguredExecutor}).
1072
+ */
1073
+ function createDshAgentExecutor(agents, opts) {
1074
+ const plugin = opts.plugin ?? "a2a";
1075
+ const handles = /* @__PURE__ */ new Map();
1076
+ const tails = /* @__PURE__ */ new Map();
1077
+ /**
1078
+ * Get or open the live agent for a context. Returns the agent and whether it
1079
+ * was JUST opened (so the caller fires the one-time onSessionOpened hook after
1080
+ * the first prompt lands, keeping "open" and "first message" atomic per tail).
1081
+ * `justOpened` is true only on the create/resume path — a cached handle returns
1082
+ * false — so it alone gates the once-per-context hook; serialization on the
1083
+ * per-context tail guarantees no two openings race.
1084
+ */
1085
+ const agentFor = async (contextId) => {
1086
+ const sessionId = sessionIdForContext(contextId);
1087
+ const existing = handles.get(contextId);
1088
+ if (existing) return {
1089
+ agent: existing.agent,
1090
+ sessionId,
1091
+ justOpened: false
1092
+ };
1093
+ const agentOptions = opts.resolveAgentOptions?.() ?? opts.agentOptions;
1094
+ const withModel = agentOptions ? { agentOptions } : {};
1095
+ const presets = opts.agentPresets;
1096
+ let presetId;
1097
+ let setup;
1098
+ if (presets) try {
1099
+ presetId = (await presets.resolve()).id;
1100
+ if (!presetId) throw new Error("agent preset roster resolved to no id");
1101
+ setup = async (agentCtx) => {
1102
+ await presets.mount(agentCtx, presetId);
1103
+ };
1104
+ } catch (err) {
1105
+ throw new Error(`a2a: failed to resolve agent preset: ${err.message}`);
1106
+ }
1107
+ const createOptions = {
1108
+ sessionId,
1109
+ ...presetId ? { meta: {
1110
+ cwd: opts.cwd,
1111
+ agentPreset: presetId
1112
+ } } : { meta: { cwd: opts.cwd } },
1113
+ ...withModel,
1114
+ ...setup ? { setup } : {}
1115
+ };
1116
+ let handle;
1117
+ try {
1118
+ handle = await agents.create(createOptions);
1119
+ } catch (err) {
1120
+ if (/already (has|owns)|persisted/i.test(err.message)) handle = await agents.resume({
1121
+ resumeSessionId: sessionId,
1122
+ ...withModel,
1123
+ ...setup ? { setup } : {}
1124
+ });
1125
+ else throw err;
1126
+ }
1127
+ handles.set(contextId, handle);
1128
+ return {
1129
+ agent: handle.agent,
1130
+ sessionId,
1131
+ justOpened: true
1132
+ };
1133
+ };
1134
+ /** Run one turn on a context's session, serialized behind its tail. */
1135
+ const runTurn = async (contextId, prompt, signal) => {
1136
+ const { agent, sessionId, justOpened } = await agentFor(contextId);
1137
+ const onAbort = () => agent.cancel("a2a-canceled");
1138
+ if (signal?.aborted) onAbort();
1139
+ else signal?.addEventListener("abort", onAbort, { once: true });
1140
+ const userMessage = Object.freeze({
1141
+ id: crypto.randomUUID(),
1142
+ role: "user",
1143
+ content: [{
1144
+ type: "text",
1145
+ text: prompt
1146
+ }],
1147
+ source: {
1148
+ kind: "plugin",
1149
+ plugin
1150
+ }
1151
+ });
1152
+ agent.send(userMessage, "next-turn", true);
1153
+ if (justOpened) try {
1154
+ await opts.onSessionOpened?.({
1155
+ sessionId,
1156
+ contextId,
1157
+ session: agent.session,
1158
+ firstPrompt: prompt
1159
+ });
1160
+ } catch {}
1161
+ await agent.whenIdle();
1162
+ return lastAssistantText(agent);
1163
+ };
1164
+ const executor = (async ({ message, contextId, signal }) => {
1165
+ const prompt = partsToPrompt(message.parts);
1166
+ if (!prompt) return {
1167
+ messageId: crypto.randomUUID(),
1168
+ role: "ROLE_AGENT",
1169
+ parts: [{ text: "No prompt provided." }]
1170
+ };
1171
+ const ctxKey = contextId;
1172
+ const run = (tails.get(ctxKey) ?? Promise.resolve()).catch(() => {}).then(() => runTurn(ctxKey, prompt, signal));
1173
+ tails.set(ctxKey, run);
1174
+ try {
1175
+ const replyText = await run;
1176
+ const text = signal?.aborted ? `(canceled) ${replyText}`.trim() : replyText || "(no reply)";
1177
+ return {
1178
+ messageId: crypto.randomUUID(),
1179
+ role: "ROLE_AGENT",
1180
+ parts: [{ text }]
1181
+ };
1182
+ } catch (err) {
1183
+ return {
1184
+ messageId: crypto.randomUUID(),
1185
+ role: "ROLE_AGENT",
1186
+ parts: [{ text: `DSH agent executor error: ${err.message}` }]
1187
+ };
1188
+ } finally {
1189
+ if (tails.get(ctxKey) === run) tails.delete(ctxKey);
1190
+ }
1191
+ });
1192
+ executor.disposeAll = async () => {
1193
+ const live = [...handles.values()];
1194
+ const pending = [...tails.values()];
1195
+ handles.clear();
1196
+ tails.clear();
1197
+ await Promise.allSettled(pending);
1198
+ await Promise.allSettled(live.map((h) => h.dispose()));
1199
+ };
1200
+ return executor;
1201
+ }
923
1202
  //#endregion
924
1203
  //#region src/dashboard.ts
925
1204
  /** Registry of live A2A connections, both directions. */
@@ -1077,7 +1356,8 @@ var DashboardRegistry = class {
1077
1356
  ok: false,
1078
1357
  message: "discover-agent requires agentCardUrl"
1079
1358
  };
1080
- return this.hooks.discoverAgent ? this.hooks.discoverAgent(url) : {
1359
+ const bearerToken = payload?.bearerToken || void 0;
1360
+ return this.hooks.discoverAgent ? this.hooks.discoverAgent(url, { bearerToken }) : {
1081
1361
  ok: false,
1082
1362
  message: "discover-agent is not wired"
1083
1363
  };
@@ -1089,7 +1369,8 @@ var DashboardRegistry = class {
1089
1369
  ok: false,
1090
1370
  message: "add-agent requires agentCardUrl"
1091
1371
  };
1092
- return this.hooks.addAgent ? this.hooks.addAgent(name, url) : {
1372
+ const bearerToken = payload?.bearerToken || void 0;
1373
+ return this.hooks.addAgent ? this.hooks.addAgent(name, url, { bearerToken }) : {
1093
1374
  ok: false,
1094
1375
  message: "add-agent is not wired (client mode not mounted)"
1095
1376
  };
@@ -1106,6 +1387,13 @@ var DashboardRegistry = class {
1106
1387
  ok: false,
1107
1388
  message: "server control is not wired (server mode not mounted)"
1108
1389
  };
1390
+ case "set-server-auth": {
1391
+ const token = payload?.authToken || void 0;
1392
+ return this.hooks.setServerAuthToken ? this.hooks.setServerAuthToken(token) : {
1393
+ ok: false,
1394
+ message: "set-server-auth is not wired (server mode not mounted)"
1395
+ };
1396
+ }
1109
1397
  case "server-status": return this.hooks.serverStatus ? Promise.resolve(this.hooks.serverStatus()) : {
1110
1398
  ok: false,
1111
1399
  message: "server-status is not wired (server mode not mounted)"
@@ -1291,6 +1579,7 @@ function mergePersisted(base, persisted) {
1291
1579
  if (pServer.agentDescription) merged.agentDescription = pServer.agentDescription;
1292
1580
  if (pServer.agentVersion) merged.agentVersion = pServer.agentVersion;
1293
1581
  if (pServer.endpointPath) merged.endpointPath = pServer.endpointPath;
1582
+ if (pServer.authToken !== void 0) merged.authToken = pServer.authToken;
1294
1583
  if (pServer.skills) merged.skills = pServer.skills;
1295
1584
  }
1296
1585
  return merged;
@@ -1599,8 +1888,9 @@ function apply(ctx, config = {}) {
1599
1888
  message: `connection ${connectionId} reconnected as ${newId}`
1600
1889
  };
1601
1890
  },
1602
- addAgent: async (name, agentCardUrl) => {
1603
- const cid = await connect(name, agentCardUrl, true);
1891
+ addAgent: async (name, agentCardUrl, opts) => {
1892
+ const bearerToken = opts?.bearerToken;
1893
+ const cid = await connect(name, agentCardUrl, true, { bearerToken });
1604
1894
  if (!cid) return {
1605
1895
  ok: false,
1606
1896
  message: `failed to connect to ${agentCardUrl}`
@@ -1609,7 +1899,8 @@ function apply(ctx, config = {}) {
1609
1899
  name,
1610
1900
  agentCardUrl,
1611
1901
  configured: true,
1612
- enabled: true
1902
+ enabled: true,
1903
+ bearerToken
1613
1904
  });
1614
1905
  persistAgents();
1615
1906
  return {
@@ -1617,11 +1908,11 @@ function apply(ctx, config = {}) {
1617
1908
  message: `agent "${name}" connected as ${cid} (saved to ${configPath})`
1618
1909
  };
1619
1910
  },
1620
- discoverAgent: async (agentCardUrl) => {
1911
+ discoverAgent: async (agentCardUrl, opts) => {
1621
1912
  try {
1622
1913
  const card = await fetchAgentCard(agentCardUrl, {
1623
1914
  timeoutMs: mergedConfig.timeoutMs ?? 8e3,
1624
- bearerToken: mergedConfig.bearerToken
1915
+ bearerToken: opts?.bearerToken ?? mergedConfig.bearerToken
1625
1916
  });
1626
1917
  const iface = pickInterface(card);
1627
1918
  return {
@@ -1726,6 +2017,35 @@ function apply(ctx, config = {}) {
1726
2017
  const webServer = ctx.webServer;
1727
2018
  const baseUrl = mergedConfig.baseUrl ?? `http://${webServer.host === "0.0.0.0" ? "127.0.0.1" : webServer.host}:${webServer.port}`;
1728
2019
  const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
2020
+ let execute = mergedConfig.execute;
2021
+ let executorKind = execute ? "custom" : "none";
2022
+ let disposeExecutor;
2023
+ if (!execute) {
2024
+ const agents = probeAgentRegistry(ctx);
2025
+ if (agents) {
2026
+ const inboundCwd = inboundSessionCwd(configPath);
2027
+ const dshExec = createDshAgentExecutor(agents, {
2028
+ cwd: inboundCwd,
2029
+ plugin: "a2a",
2030
+ agentPresets: probeAgentPresets(ctx),
2031
+ resolveAgentOptions: () => {
2032
+ const sel = probeAgentDefaultModel(ctx)?.currentSelection();
2033
+ if (!sel?.model && !sel?.provider) return void 0;
2034
+ return {
2035
+ provider: sel.provider,
2036
+ model: sel.model
2037
+ };
2038
+ },
2039
+ onSessionOpened: ({ session, firstPrompt }) => {
2040
+ probeSessionTitle(ctx)?.rename(session, `A2A: ${summarize(firstPrompt)}`);
2041
+ }
2042
+ });
2043
+ execute = dshExec;
2044
+ disposeExecutor = () => dshExec.disposeAll();
2045
+ executorKind = "dsh-agent";
2046
+ logger?.info?.(`[a2a] server executor: DSH agent session per A2A context (cwd ${inboundCwd})`);
2047
+ }
2048
+ }
1729
2049
  const server = new A2AServer({
1730
2050
  baseUrl,
1731
2051
  agentName: mergedConfig.agentName ?? "DSH Agent",
@@ -1733,7 +2053,8 @@ function apply(ctx, config = {}) {
1733
2053
  agentVersion: mergedConfig.agentVersion ?? "0.1.0",
1734
2054
  skills: mergedConfig.skills,
1735
2055
  endpointPath,
1736
- execute: mergedConfig.execute,
2056
+ execute,
2057
+ authToken: mergedConfig.authToken,
1737
2058
  onInbound: (facts) => {
1738
2059
  const peerId = dashboard.touchInbound({
1739
2060
  headers: facts.headers,
@@ -1780,17 +2101,23 @@ function apply(ctx, config = {}) {
1780
2101
  }
1781
2102
  const body = await readBody(req);
1782
2103
  if (isStream) {
1783
- res.writeHead(200, {
1784
- "content-type": "text/event-stream",
1785
- "cache-control": "no-cache",
1786
- connection: "keep-alive"
1787
- });
1788
- await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
2104
+ const stream = await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
2105
+ if (stream.status !== 200) {
2106
+ res.writeHead(stream.status, {
2107
+ "content-type": "text/plain",
2108
+ ...stream.headers ?? {}
2109
+ });
2110
+ res.end("Unauthorized");
2111
+ return;
2112
+ }
1789
2113
  res.end();
1790
2114
  return;
1791
2115
  }
1792
2116
  const out = await server.handle(toServerReq(req), body);
1793
- res.writeHead(out.status, { "content-type": out.contentType });
2117
+ res.writeHead(out.status, {
2118
+ "content-type": out.contentType,
2119
+ ...out.headers ?? {}
2120
+ });
1794
2121
  res.end(out.body);
1795
2122
  }
1796
2123
  }));
@@ -1813,7 +2140,9 @@ function apply(ctx, config = {}) {
1813
2140
  name: s.name,
1814
2141
  description: s.description
1815
2142
  })),
1816
- customExecutor: Boolean(mergedConfig.execute)
2143
+ customExecutor: Boolean(mergedConfig.execute),
2144
+ executor: executorKind,
2145
+ authConfigured: server.authConfigured
1817
2146
  });
1818
2147
  dashboard.setHooks({
1819
2148
  setServerEnabled: async (enable) => {
@@ -1838,6 +2167,27 @@ function apply(ctx, config = {}) {
1838
2167
  message: "A2A server disabled"
1839
2168
  };
1840
2169
  },
2170
+ setServerAuthToken: async (token) => {
2171
+ server.setAuthToken(token);
2172
+ const next = loadConfig();
2173
+ next.server = {
2174
+ ...next.server ?? {},
2175
+ authToken: token && token.length > 0 ? token : void 0
2176
+ };
2177
+ const res = saveConfig(next);
2178
+ if (!res.ok) {
2179
+ logger?.error?.(`[a2a] failed to persist authToken to ${res.path}: ${res.message}`);
2180
+ return {
2181
+ ok: false,
2182
+ message: `applied in-memory but failed to save: ${res.message}`
2183
+ };
2184
+ }
2185
+ logger?.info?.(`[a2a] inbound authToken ${token ? "set" : "cleared"} (saved to ${res.path})`);
2186
+ return {
2187
+ ok: true,
2188
+ message: token ? "已设置入站鉴权 token" : "已清除入站鉴权 token"
2189
+ };
2190
+ },
1841
2191
  serverStatus: () => ({
1842
2192
  ok: true,
1843
2193
  message: JSON.stringify({
@@ -1848,11 +2198,89 @@ function apply(ctx, config = {}) {
1848
2198
  });
1849
2199
  return () => {
1850
2200
  unregisterRoutes();
2201
+ disposeExecutor?.().catch((err) => logger?.error?.(`[a2a] executor dispose failed: ${err.message}`));
1851
2202
  };
1852
2203
  }, "a2a: server routes");
1853
2204
  });
1854
2205
  }
1855
2206
  }
2207
+ /**
2208
+ * Probe for a usable agent registry WITHOUT taking an inject dependency on it.
2209
+ *
2210
+ * `ctx.agents` (the registry) can be present while `create()` still rejects —
2211
+ * that happens on a bare dsh-base profile that has the agent layer but no
2212
+ * agent-loop plugin to register the creation factory. We can't cheaply tell
2213
+ * "factory registered" apart from "registry present" without attempting a
2214
+ * create, so this returns the registry when it's structurally usable and lets
2215
+ * {@link createDshAgentExecutor} degrade to a readable error if a later
2216
+ * `create()` rejects.
2217
+ */
2218
+ function probeAgentRegistry(ctx) {
2219
+ return probeService(ctx, "agents", "create");
2220
+ }
2221
+ /**
2222
+ * Probe for the agent-preset roster service (`ctx.agentPresets`, owned by
2223
+ * `@deepseek-ai/dsh-agent-presets`) without an inject dependency. When the
2224
+ * deployment composes presets (`dsh-web-app` mounts the roster with a
2225
+ * `default: standard`), inbound sessions must be composed from the same
2226
+ * preset or they publish against the empty global layer — tools, prompt
2227
+ * sections, and skills all absent. Absent rosters keep legacy behavior.
2228
+ */
2229
+ function probeAgentPresets(ctx) {
2230
+ return probeService(ctx, "agentPresets", "resolve");
2231
+ }
2232
+ /**
2233
+ * Probe for the deployment default-model service without an inject dependency.
2234
+ * Returns undefined when the service is absent, so the executor simply creates
2235
+ * the agent without a seeded selection — fine for compositions that seed the
2236
+ * model another way.
2237
+ */
2238
+ function probeAgentDefaultModel(ctx) {
2239
+ return probeService(ctx, "agentDefaultModel", "currentSelection");
2240
+ }
2241
+ /** Probe `ctx.sessionTitle` (optional). Used to name inbound sessions. */
2242
+ function probeSessionTitle(ctx) {
2243
+ return probeService(ctx, "sessionTitle", "rename");
2244
+ }
2245
+ /**
2246
+ * Read a strict service off the reflection layer and confirm it exposes the
2247
+ * named method, without taking an inject dependency. Returns undefined when the
2248
+ * service is absent, its fiber inactive, or the method missing.
2249
+ */
2250
+ function probeService(ctx, name, method) {
2251
+ try {
2252
+ const svc = ctx.reflect?.get(name, true);
2253
+ if (svc && typeof svc[method] === "function") return svc;
2254
+ } catch {}
2255
+ }
2256
+ /**
2257
+ * One-line title summary from a prompt: first line, whitespace-collapsed, cut
2258
+ * to a display-friendly length on a word boundary when possible.
2259
+ */
2260
+ function summarize(prompt, maxChars = 40) {
2261
+ const text = prompt.replace(/\s+/g, " ").trim();
2262
+ if (text.length <= maxChars) return text || "(empty)";
2263
+ const cut = text.slice(0, maxChars);
2264
+ const sp = cut.lastIndexOf(" ");
2265
+ return `${(sp > maxChars * .6 ? cut.slice(0, sp) : cut).trimEnd()}…`;
2266
+ }
2267
+ /**
2268
+ * Absolute cwd for inbound per-context sessions: a `.a2a-sessions` subdir of
2269
+ * the profile directory (the dir holding `a2a.json`), kept separate from the
2270
+ * profile root so inbound sessions don't crowd the user's interactive
2271
+ * conversations there. They surface in the session list's "Ungrouped" bucket,
2272
+ * named `A2A: <summary>`. Created if missing (persistence writes under it);
2273
+ * falls back to cwd on any failure.
2274
+ */
2275
+ function inboundSessionCwd(configPath) {
2276
+ try {
2277
+ const dir = join(dirname(configPath), ".a2a-sessions");
2278
+ mkdirSync(dir, { recursive: true });
2279
+ return dir;
2280
+ } catch {
2281
+ return process.cwd();
2282
+ }
2283
+ }
1856
2284
  /** Drain the request body from a Node IncomingMessage. */
1857
2285
  async function readBody(req) {
1858
2286
  return new Promise((resolve, reject) => {
@@ -1893,4 +2321,4 @@ function toServerReq(req) {
1893
2321
  };
1894
2322
  }
1895
2323
  //#endregion
1896
- export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inject, isTerminal, name, registerAgentTools };
2324
+ export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, createDshAgentExecutor, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inject, isTerminal, name, notConfiguredExecutor, registerAgentTools, shellExecutor };