@ryubyte/dsh-a2a 0.1.3 → 0.1.5

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
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir, networkInterfaces } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
- import { homedir } from "node:os";
4
4
  //#region src/protocol.ts
5
5
  /**
6
6
  * A2A (Agent2Agent) Protocol v1.0 — TypeScript type definitions.
@@ -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)"
@@ -1191,6 +1479,30 @@ function profileNameFromArgv() {
1191
1479
  }
1192
1480
  }
1193
1481
  /**
1482
+ * Best-effort profile-name fallback for launchers that resolve a profile
1483
+ * WITHOUT writing `--profile` back into argv.
1484
+ *
1485
+ * The dsh `web` subcommand is a hardcoded alias for `--profile web`: the
1486
+ * launcher resolves the name internally and boots the web profile, but the
1487
+ * process argv keeps the original tokens (`node …/dsh web [flags]`) — there
1488
+ * is no `--profile` flag and no environment variable naming the profile. So
1489
+ * when {@link profileNameFromArgv} finds nothing, check whether the first
1490
+ * non-flag token after argv[0] is a known dsh subcommand alias:
1491
+ *
1492
+ * - `web` → boot profile `web` (alias, never followed by a name)
1493
+ * - `--profile <n>` → already handled above
1494
+ * - anything else → unknown/not a profile alias (e.g. `plugin`, which
1495
+ * is a management mode, not a boot; returns undefined)
1496
+ *
1497
+ * Returns undefined when argv carries no recognizable alias, letting the
1498
+ * caller fall through to the cwd-based resolution steps.
1499
+ */
1500
+ function profileNameFromDshAlias() {
1501
+ const argv = process.argv ?? [];
1502
+ if (argv.includes("--profile") || argv.some((a) => a.startsWith("--profile="))) return void 0;
1503
+ if (argv[2] === "web") return "web";
1504
+ }
1505
+ /**
1194
1506
  * Resolve the a2a.json path for the current process, without depending on
1195
1507
  * the launch directory. The plugin must work no matter where `dsh` was
1196
1508
  * started from (dsh does NOT chdir into the profile directory), so this
@@ -1200,7 +1512,9 @@ function profileNameFromArgv() {
1200
1512
  *
1201
1513
  * Order:
1202
1514
  * 0. `$DSH_HOME/profiles/<argv --profile>/a2a.json` — the active profile
1203
- * as named on the command line (most authoritative; works from ANY cwd)
1515
+ * as named on the command line (most authoritative; works from ANY cwd).
1516
+ * Also matches the `dsh web` alias, which boots profile `web` without
1517
+ * writing `--profile` into argv.
1204
1518
  * 1. `cwd/a2a.json` — explicit when launched inside a profile dir
1205
1519
  * 2. outward from cwd: the nearest ancestor holding `a2a.json` — covers
1206
1520
  * launching from e.g. a repo checkout whose parent chain includes the
@@ -1218,7 +1532,7 @@ function resolveConfigPath() {
1218
1532
  const cwd = process.cwd();
1219
1533
  const home = process.env.DSH_HOME ?? join(homedir(), ".dsh");
1220
1534
  const base = cwd.split(/[\\/]/).pop() ?? "";
1221
- const profileName = profileNameFromArgv();
1535
+ const profileName = profileNameFromArgv() ?? profileNameFromDshAlias();
1222
1536
  if (profileName && profileName !== "." && profileName !== ".." && !profileName.includes("/") && !profileName.includes("\\")) {
1223
1537
  const argvFile = join(home, "profiles", profileName, A2A_CONFIG_FILENAME);
1224
1538
  if (existsSync(argvFile)) return argvFile;
@@ -1291,6 +1605,7 @@ function mergePersisted(base, persisted) {
1291
1605
  if (pServer.agentDescription) merged.agentDescription = pServer.agentDescription;
1292
1606
  if (pServer.agentVersion) merged.agentVersion = pServer.agentVersion;
1293
1607
  if (pServer.endpointPath) merged.endpointPath = pServer.endpointPath;
1608
+ if (pServer.authToken !== void 0) merged.authToken = pServer.authToken;
1294
1609
  if (pServer.skills) merged.skills = pServer.skills;
1295
1610
  }
1296
1611
  return merged;
@@ -1599,8 +1914,9 @@ function apply(ctx, config = {}) {
1599
1914
  message: `connection ${connectionId} reconnected as ${newId}`
1600
1915
  };
1601
1916
  },
1602
- addAgent: async (name, agentCardUrl) => {
1603
- const cid = await connect(name, agentCardUrl, true);
1917
+ addAgent: async (name, agentCardUrl, opts) => {
1918
+ const bearerToken = opts?.bearerToken;
1919
+ const cid = await connect(name, agentCardUrl, true, { bearerToken });
1604
1920
  if (!cid) return {
1605
1921
  ok: false,
1606
1922
  message: `failed to connect to ${agentCardUrl}`
@@ -1609,7 +1925,8 @@ function apply(ctx, config = {}) {
1609
1925
  name,
1610
1926
  agentCardUrl,
1611
1927
  configured: true,
1612
- enabled: true
1928
+ enabled: true,
1929
+ bearerToken
1613
1930
  });
1614
1931
  persistAgents();
1615
1932
  return {
@@ -1617,11 +1934,11 @@ function apply(ctx, config = {}) {
1617
1934
  message: `agent "${name}" connected as ${cid} (saved to ${configPath})`
1618
1935
  };
1619
1936
  },
1620
- discoverAgent: async (agentCardUrl) => {
1937
+ discoverAgent: async (agentCardUrl, opts) => {
1621
1938
  try {
1622
1939
  const card = await fetchAgentCard(agentCardUrl, {
1623
1940
  timeoutMs: mergedConfig.timeoutMs ?? 8e3,
1624
- bearerToken: mergedConfig.bearerToken
1941
+ bearerToken: opts?.bearerToken ?? mergedConfig.bearerToken
1625
1942
  });
1626
1943
  const iface = pickInterface(card);
1627
1944
  return {
@@ -1724,8 +2041,37 @@ function apply(ctx, config = {}) {
1724
2041
  ctx.inject(["webServer"], (ctx) => {
1725
2042
  ctx.effect(() => {
1726
2043
  const webServer = ctx.webServer;
1727
- const baseUrl = mergedConfig.baseUrl ?? `http://${webServer.host === "0.0.0.0" ? "127.0.0.1" : webServer.host}:${webServer.port}`;
2044
+ const baseUrl = mergedConfig.baseUrl ?? inferBaseUrl(webServer.host, webServer.port);
1728
2045
  const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
2046
+ let execute = mergedConfig.execute;
2047
+ let executorKind = execute ? "custom" : "none";
2048
+ let disposeExecutor;
2049
+ if (!execute) {
2050
+ const agents = probeAgentRegistry(ctx);
2051
+ if (agents) {
2052
+ const inboundCwd = inboundSessionCwd(configPath);
2053
+ const dshExec = createDshAgentExecutor(agents, {
2054
+ cwd: inboundCwd,
2055
+ plugin: "a2a",
2056
+ agentPresets: probeAgentPresets(ctx),
2057
+ resolveAgentOptions: () => {
2058
+ const sel = probeAgentDefaultModel(ctx)?.currentSelection();
2059
+ if (!sel?.model && !sel?.provider) return void 0;
2060
+ return {
2061
+ provider: sel.provider,
2062
+ model: sel.model
2063
+ };
2064
+ },
2065
+ onSessionOpened: ({ session, firstPrompt }) => {
2066
+ probeSessionTitle(ctx)?.rename(session, `A2A: ${summarize(firstPrompt)}`);
2067
+ }
2068
+ });
2069
+ execute = dshExec;
2070
+ disposeExecutor = () => dshExec.disposeAll();
2071
+ executorKind = "dsh-agent";
2072
+ logger?.info?.(`[a2a] server executor: DSH agent session per A2A context (cwd ${inboundCwd})`);
2073
+ }
2074
+ }
1729
2075
  const server = new A2AServer({
1730
2076
  baseUrl,
1731
2077
  agentName: mergedConfig.agentName ?? "DSH Agent",
@@ -1733,7 +2079,8 @@ function apply(ctx, config = {}) {
1733
2079
  agentVersion: mergedConfig.agentVersion ?? "0.1.0",
1734
2080
  skills: mergedConfig.skills,
1735
2081
  endpointPath,
1736
- execute: mergedConfig.execute,
2082
+ execute,
2083
+ authToken: mergedConfig.authToken,
1737
2084
  onInbound: (facts) => {
1738
2085
  const peerId = dashboard.touchInbound({
1739
2086
  headers: facts.headers,
@@ -1780,17 +2127,23 @@ function apply(ctx, config = {}) {
1780
2127
  }
1781
2128
  const body = await readBody(req);
1782
2129
  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));
2130
+ const stream = await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
2131
+ if (stream.status !== 200) {
2132
+ res.writeHead(stream.status, {
2133
+ "content-type": "text/plain",
2134
+ ...stream.headers ?? {}
2135
+ });
2136
+ res.end("Unauthorized");
2137
+ return;
2138
+ }
1789
2139
  res.end();
1790
2140
  return;
1791
2141
  }
1792
2142
  const out = await server.handle(toServerReq(req), body);
1793
- res.writeHead(out.status, { "content-type": out.contentType });
2143
+ res.writeHead(out.status, {
2144
+ "content-type": out.contentType,
2145
+ ...out.headers ?? {}
2146
+ });
1794
2147
  res.end(out.body);
1795
2148
  }
1796
2149
  }));
@@ -1813,7 +2166,9 @@ function apply(ctx, config = {}) {
1813
2166
  name: s.name,
1814
2167
  description: s.description
1815
2168
  })),
1816
- customExecutor: Boolean(mergedConfig.execute)
2169
+ customExecutor: Boolean(mergedConfig.execute),
2170
+ executor: executorKind,
2171
+ authConfigured: server.authConfigured
1817
2172
  });
1818
2173
  dashboard.setHooks({
1819
2174
  setServerEnabled: async (enable) => {
@@ -1838,6 +2193,27 @@ function apply(ctx, config = {}) {
1838
2193
  message: "A2A server disabled"
1839
2194
  };
1840
2195
  },
2196
+ setServerAuthToken: async (token) => {
2197
+ server.setAuthToken(token);
2198
+ const next = loadConfig();
2199
+ next.server = {
2200
+ ...next.server ?? {},
2201
+ authToken: token && token.length > 0 ? token : void 0
2202
+ };
2203
+ const res = saveConfig(next);
2204
+ if (!res.ok) {
2205
+ logger?.error?.(`[a2a] failed to persist authToken to ${res.path}: ${res.message}`);
2206
+ return {
2207
+ ok: false,
2208
+ message: `applied in-memory but failed to save: ${res.message}`
2209
+ };
2210
+ }
2211
+ logger?.info?.(`[a2a] inbound authToken ${token ? "set" : "cleared"} (saved to ${res.path})`);
2212
+ return {
2213
+ ok: true,
2214
+ message: token ? "已设置入站鉴权 token" : "已清除入站鉴权 token"
2215
+ };
2216
+ },
1841
2217
  serverStatus: () => ({
1842
2218
  ok: true,
1843
2219
  message: JSON.stringify({
@@ -1848,11 +2224,140 @@ function apply(ctx, config = {}) {
1848
2224
  });
1849
2225
  return () => {
1850
2226
  unregisterRoutes();
2227
+ disposeExecutor?.().catch((err) => logger?.error?.(`[a2a] executor dispose failed: ${err.message}`));
1851
2228
  };
1852
2229
  }, "a2a: server routes");
1853
2230
  });
1854
2231
  }
1855
2232
  }
2233
+ /**
2234
+ * Probe for a usable agent registry WITHOUT taking an inject dependency on it.
2235
+ *
2236
+ * `ctx.agents` (the registry) can be present while `create()` still rejects —
2237
+ * that happens on a bare dsh-base profile that has the agent layer but no
2238
+ * agent-loop plugin to register the creation factory. We can't cheaply tell
2239
+ * "factory registered" apart from "registry present" without attempting a
2240
+ * create, so this returns the registry when it's structurally usable and lets
2241
+ * {@link createDshAgentExecutor} degrade to a readable error if a later
2242
+ * `create()` rejects.
2243
+ */
2244
+ function probeAgentRegistry(ctx) {
2245
+ return probeService(ctx, "agents", "create");
2246
+ }
2247
+ /**
2248
+ * Probe for the agent-preset roster service (`ctx.agentPresets`, owned by
2249
+ * `@deepseek-ai/dsh-agent-presets`) without an inject dependency. When the
2250
+ * deployment composes presets (`dsh-web-app` mounts the roster with a
2251
+ * `default: standard`), inbound sessions must be composed from the same
2252
+ * preset or they publish against the empty global layer — tools, prompt
2253
+ * sections, and skills all absent. Absent rosters keep legacy behavior.
2254
+ */
2255
+ function probeAgentPresets(ctx) {
2256
+ return probeService(ctx, "agentPresets", "resolve");
2257
+ }
2258
+ /**
2259
+ * Probe for the deployment default-model service without an inject dependency.
2260
+ * Returns undefined when the service is absent, so the executor simply creates
2261
+ * the agent without a seeded selection — fine for compositions that seed the
2262
+ * model another way.
2263
+ */
2264
+ function probeAgentDefaultModel(ctx) {
2265
+ return probeService(ctx, "agentDefaultModel", "currentSelection");
2266
+ }
2267
+ /** Probe `ctx.sessionTitle` (optional). Used to name inbound sessions. */
2268
+ function probeSessionTitle(ctx) {
2269
+ return probeService(ctx, "sessionTitle", "rename");
2270
+ }
2271
+ /**
2272
+ * Read a strict service off the reflection layer and confirm it exposes the
2273
+ * named method, without taking an inject dependency. Returns undefined when the
2274
+ * service is absent, its fiber inactive, or the method missing.
2275
+ */
2276
+ function probeService(ctx, name, method) {
2277
+ try {
2278
+ const svc = ctx.reflect?.get(name, true);
2279
+ if (svc && typeof svc[method] === "function") return svc;
2280
+ } catch {}
2281
+ }
2282
+ /**
2283
+ * One-line title summary from a prompt: first line, whitespace-collapsed, cut
2284
+ * to a display-friendly length on a word boundary when possible.
2285
+ */
2286
+ function summarize(prompt, maxChars = 40) {
2287
+ const text = prompt.replace(/\s+/g, " ").trim();
2288
+ if (text.length <= maxChars) return text || "(empty)";
2289
+ const cut = text.slice(0, maxChars);
2290
+ const sp = cut.lastIndexOf(" ");
2291
+ return `${(sp > maxChars * .6 ? cut.slice(0, sp) : cut).trimEnd()}…`;
2292
+ }
2293
+ /**
2294
+ * Absolute cwd for inbound per-context sessions: a `.a2a-sessions` subdir of
2295
+ * the profile directory (the dir holding `a2a.json`), kept separate from the
2296
+ * profile root so inbound sessions don't crowd the user's interactive
2297
+ * conversations there. They surface in the session list's "Ungrouped" bucket,
2298
+ * named `A2A: <summary>`. Created if missing (persistence writes under it);
2299
+ * falls back to cwd on any failure.
2300
+ */
2301
+ function inboundSessionCwd(configPath) {
2302
+ try {
2303
+ const dir = join(dirname(configPath), ".a2a-sessions");
2304
+ mkdirSync(dir, { recursive: true });
2305
+ return dir;
2306
+ } catch {
2307
+ return process.cwd();
2308
+ }
2309
+ }
2310
+ /**
2311
+ * Derive the public base URL advertised in the AgentCard from the real wire
2312
+ * listen address, when the operator did not pin `baseUrl` in config.
2313
+ *
2314
+ * The A2A AgentCard must carry a URL a REMOTE caller can reach. When the web
2315
+ * server binds only loopback (`127.0.0.1`), that address is correct. But when
2316
+ * it binds every interface (`0.0.0.0`), advertising `127.0.0.1` is wrong for
2317
+ * every non-local caller — it points at the caller's own loopback. In that
2318
+ * case pick the machine's best-known LAN address (the primary non-loopback
2319
+ * IPv4 of the interface that routes to the default gateway), and only fall
2320
+ * back to loopback when no such address exists. IPv6 link-local is skipped
2321
+ * (not reachable without a scope id).
2322
+ *
2323
+ * @param host - the web server's configured bind host ('127.0.0.1' | '0.0.0.0').
2324
+ * @param port - the OS-assigned (or configured) listen port.
2325
+ * @returns a scheme+host+port URL for Agents to call back into this DSH.
2326
+ */
2327
+ function inferBaseUrl(host, port) {
2328
+ return `http://${host === "0.0.0.0" ? primaryLanAddress() ?? "127.0.0.1" : host || "127.0.0.1"}:${port}`;
2329
+ }
2330
+ /**
2331
+ * Best single LAN IPv4 for this machine: the address of the non-loopback
2332
+ * interface whose route reaches the default gateway (the address a remote
2333
+ * peer on the same subnet would use to reach us). Returns undefined when the
2334
+ * machine has no non-loopback IPv4 (e.g. offline / containers).
2335
+ *
2336
+ * Prefers IPv4 (A2A URLs here are v4-style), skips link-local (169.254.0.0/16)
2337
+ * and CGNAT (100.64.0.0/10) ranges, which are useless to advertise.
2338
+ */
2339
+ function primaryLanAddress() {
2340
+ try {
2341
+ const ifaces = networkInterfaces();
2342
+ let candidate;
2343
+ for (const name of Object.keys(ifaces)) {
2344
+ const addrs = ifaces[name] ?? [];
2345
+ for (const a of addrs) {
2346
+ if (a.family !== "IPv4" || a.internal) continue;
2347
+ const ip = a.address;
2348
+ if (ip.startsWith("169.254.")) continue;
2349
+ const first = Number(ip.split(".")[0]);
2350
+ const second = Number(ip.split(".")[1]);
2351
+ if (first === 100 && second >= 64 && second <= 127) continue;
2352
+ if (!candidate) candidate = ip;
2353
+ if (name === "en0" || name === "eth0" || name === "ens3" || name === "ens5") return ip;
2354
+ }
2355
+ }
2356
+ return candidate;
2357
+ } catch {
2358
+ return;
2359
+ }
2360
+ }
1856
2361
  /** Drain the request body from a Node IncomingMessage. */
1857
2362
  async function readBody(req) {
1858
2363
  return new Promise((resolve, reject) => {
@@ -1893,4 +2398,4 @@ function toServerReq(req) {
1893
2398
  };
1894
2399
  }
1895
2400
  //#endregion
1896
- export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inject, isTerminal, name, registerAgentTools };
2401
+ export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, createDshAgentExecutor, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inferBaseUrl, inject, isTerminal, name, notConfiguredExecutor, registerAgentTools, shellExecutor };