@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.
@@ -115,6 +115,10 @@ export function DashboardSection(_props: SectionProps): ReactElement {
115
115
  const [tab, setTab] = useState<'out' | 'in' | 'serve'>('out');
116
116
  /** connection ids whose skill list is expanded in the outbound tab. */
117
117
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
118
+ /** Optional bearer token for the add-agent form (outbound). */
119
+ const [addToken, setAddToken] = useState('');
120
+ /** Inbound authToken input (serve tab); empty string means "clear". */
121
+ const [authInput, setAuthInput] = useState('');
118
122
 
119
123
  interface ServeStatus {
120
124
  enabled: boolean;
@@ -125,7 +129,12 @@ export function DashboardSection(_props: SectionProps): ReactElement {
125
129
  endpoint?: string;
126
130
  agentCardUrl?: string;
127
131
  skills?: Array<{ id: string; name: string; description?: string }>;
132
+ /** Legacy: true when a custom executor was injected (kept for old snapshots). */
128
133
  customExecutor?: boolean;
134
+ /** How inbound tasks are executed. */
135
+ executor?: 'custom' | 'dsh-agent' | 'none';
136
+ /** Whether the inbound endpoint is token-gated (never the token value). */
137
+ authConfigured?: boolean;
129
138
  }
130
139
 
131
140
  const refresh = async (): Promise<void> => {
@@ -191,7 +200,7 @@ const doDiscover = async (): Promise<void> => {
191
200
  const res = await fetch('/a2a/api', {
192
201
  method: 'POST',
193
202
  headers: { 'content-type': 'application/json' },
194
- body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u }),
203
+ body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u, bearerToken: addToken.trim() || undefined }),
195
204
  });
196
205
  const body = (await res.json()) as ControlResultApi;
197
206
  if (!body.ok || !body.message) {
@@ -209,9 +218,10 @@ const doDiscover = async (): Promise<void> => {
209
218
  const doConnect = async (): Promise<void> => {
210
219
  if (!discovered) return;
211
220
  const n = discovered.name.trim() || 'remote';
212
- await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl });
221
+ await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl, bearerToken: addToken.trim() || undefined });
213
222
  if (!busy) {
214
223
  setAddUrl('');
224
+ setAddToken('');
215
225
  setDiscovered(null);
216
226
  }
217
227
  };
@@ -284,6 +294,22 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
284
294
  }),
285
295
  actionBtn('导入', false, () => void doDiscover(), discovering || !addUrl.trim()),
286
296
  ),
297
+ createElement(
298
+ 'div',
299
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
300
+ createElement('input', {
301
+ type: 'password',
302
+ placeholder: 'Bearer Token(可选,发送为 Authorization: Bearer …)',
303
+ value: addToken,
304
+ onChange: (e: { target: { value: string } }) => setAddToken(e.target.value),
305
+ style: { ...inputStyle, flex: 1, minWidth: 260 },
306
+ }),
307
+ ),
308
+ createElement(
309
+ 'div',
310
+ { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)' } },
311
+ '若远程 Agent 需要鉴权,填入 token;导入与后续调用都会带上。token 会明文存入 a2a.json。',
312
+ ),
287
313
  discovering
288
314
  ? createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #999)' } }, '正在读取 Agent Card…')
289
315
  : discovered
@@ -318,7 +344,7 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
318
344
  createElement(
319
345
  'div',
320
346
  { style: { display: 'flex', gap: 8, marginTop: 2 } },
321
- actionBtn('连接', true, () => void doConnect(), busy === 'add-agent'),
347
+ actionBtn('连接', true, () => void doConnect(), busy?.startsWith('add-agent:') ?? false),
322
348
  actionBtn('取消', false, () => { setDiscovered(null); setAddUrl(''); }, false),
323
349
  ),
324
350
  )
@@ -349,8 +375,8 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
349
375
  'span',
350
376
  { style: { marginLeft: 'auto' } },
351
377
  serve.enabled
352
- ? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable')
353
- : actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable'),
378
+ ? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable:')
379
+ : actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable:'),
354
380
  ),
355
381
  ),
356
382
  createElement(
@@ -384,13 +410,8 @@ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border:
384
410
  )
385
411
  : null,
386
412
  ),
387
- serve.enabled && !serve.customExecutor
388
- ? createElement(
389
- 'div',
390
- { style: { fontSize: 12, color: 'var(--dsw-warning, #b26a00)', background: 'rgba(178,106,0,.08)', borderRadius: 6, padding: '6px 10px' } },
391
- '⚠ 默认 executor 会执行 shell 命令。生产环境请通过配置指定受限的 execute 实现。',
392
- )
393
- : null,
413
+ serve.enabled ? executorNote(serve) : null,
414
+ serve.enabled ? authControl(serve, busy, act, authInput, setAuthInput) : null,
394
415
  )
395
416
  : null,
396
417
  // ── inbound tab ────────────────────────────────────────
@@ -594,6 +615,84 @@ function actionBtn(label: string, primary: boolean, onClick: () => void, disable
594
615
  );
595
616
  }
596
617
 
618
+ /**
619
+ * A semantically-correct note about how inbound tasks are executed. Dispatches
620
+ * on the `executor` field; falls back to the legacy `customExecutor` boolean
621
+ * for snapshots from an older host that didn't send `executor`.
622
+ */
623
+ function executorNote(serve: {
624
+ executor?: 'custom' | 'dsh-agent' | 'none';
625
+ customExecutor?: boolean;
626
+ }): ReactElement | null {
627
+ const kind = serve.executor ?? (serve.customExecutor ? 'custom' : undefined);
628
+ if (kind === 'custom') return null; // operator wired their own executor
629
+ if (kind === 'dsh-agent') {
630
+ return createElement(
631
+ 'div',
632
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', background: 'rgba(74,125,255,.08)', borderRadius: 6, padding: '6px 10px' } },
633
+ 'ℹ 入站任务由本 DSH 的 agent 会话执行,按 A2A contextId 一个对话一个会话。',
634
+ );
635
+ }
636
+ if (kind === 'none') {
637
+ return createElement(
638
+ 'div',
639
+ { style: { fontSize: 12, color: 'var(--dsw-warning, #b26a00)', background: 'rgba(178,106,0,.08)', borderRadius: 6, padding: '6px 10px' } },
640
+ '⚠ 未配置 executor,且未检测到可用的 agent 循环:入站任务会被拒绝。通过配置注入 execute,或加载 agent-loop 插件。',
641
+ );
642
+ }
643
+ return null;
644
+ }
645
+
646
+ /**
647
+ * Inbound bearer-token control for the serve tab: shows whether the endpoint is
648
+ * token-gated, and lets the operator set or clear the shared token. The token
649
+ * value is never read back from the host — only whether one is configured.
650
+ */
651
+ function authControl(
652
+ serve: { authConfigured?: boolean },
653
+ busy: string | null,
654
+ act: (a: string, t: string, p?: Record<string, unknown>) => Promise<void>,
655
+ authInput: string,
656
+ setAuthInput: (v: string) => void,
657
+ ): ReactElement {
658
+ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3))', background: 'var(--dsw-alias-fill-l1, rgba(127,127,127,.06))', color: 'var(--dsw-alias-label-primary, #222)' };
659
+ return createElement(
660
+ 'div',
661
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 } },
662
+ createElement(
663
+ 'div',
664
+ { style: { display: 'flex', alignItems: 'center', gap: 8 } },
665
+ createElement('span', { style: { fontSize: 12.5, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, '入站鉴权'),
666
+ stateDot(serve.authConfigured ? 'connected' : 'disabled'),
667
+ createElement(
668
+ 'span',
669
+ { style: { fontSize: 12, color: serve.authConfigured ? '#2e7d32' : '#9e9e9e' } },
670
+ serve.authConfigured ? '已启用 Bearer 鉴权' : '未鉴权(任何客户端可调用)',
671
+ ),
672
+ ),
673
+ createElement(
674
+ 'div',
675
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
676
+ createElement('input', {
677
+ type: 'password',
678
+ placeholder: serve.authConfigured ? '输入新 token 以替换' : '设置 Bearer Token 以开启鉴权',
679
+ value: authInput,
680
+ onChange: (e: { target: { value: string } }) => setAuthInput(e.target.value),
681
+ style: { ...inputStyle, flex: 1, minWidth: 220 },
682
+ }),
683
+ actionBtn('保存', true, () => { void act('set-server-auth', '', { authToken: authInput.trim() }); setAuthInput(''); }, busy === 'set-server-auth:' || !authInput.trim()),
684
+ serve.authConfigured
685
+ ? actionBtn('清除', false, () => { void act('set-server-auth', '', { authToken: '' }); setAuthInput(''); }, busy === 'set-server-auth:')
686
+ : null,
687
+ ),
688
+ createElement(
689
+ 'div',
690
+ { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)' } },
691
+ 'token 会明文存入 a2a.json,重启后仍生效。设置后,入站 JSON-RPC 请求需带 Authorization: Bearer <token>。',
692
+ ),
693
+ );
694
+ }
695
+
597
696
  function inboundRow(p: InboundPeerApi, busy: string | null, act: (a: string, t: string) => void): ReactElement {
598
697
  return createElement(
599
698
  'tr',
package/src/dashboard.ts CHANGED
@@ -91,11 +91,13 @@ export interface DashboardControlHooks {
91
91
  /** Reconnect is not meaningful for an inbound peer — rejects unless overridden. */
92
92
  reconnectPeer?: (peerId: string) => Promise<ControlResult>;
93
93
  /** Runtime-add an outbound agent (visual config). Returns the new connection id. */
94
- addAgent?: (name: string, agentCardUrl: string) => Promise<ControlResult>;
94
+ addAgent?: (name: string, agentCardUrl: string, opts?: { bearerToken?: string }) => Promise<ControlResult>;
95
95
  /** Discover an Agent Card without connecting (for the import preview). */
96
- discoverAgent?: (agentCardUrl: string) => Promise<ControlResult>;
96
+ discoverAgent?: (agentCardUrl: string, opts?: { bearerToken?: string }) => Promise<ControlResult>;
97
97
  /** Enable/disable the inbound A2A server (serve routes) at runtime. */
98
98
  setServerEnabled?: (enabled: boolean) => Promise<ControlResult>;
99
+ /** Set or clear the inbound server's shared bearer token at runtime (persisted). */
100
+ setServerAuthToken?: (token?: string) => Promise<ControlResult>;
99
101
  /** Read current inbound server status (for the serve panel). */
100
102
  serverStatus?: () => ControlResult;
101
103
  /** Runtime-remove an outbound agent by its connection id. */
@@ -271,16 +273,18 @@ export class DashboardRegistry {
271
273
  case 'discover-agent': {
272
274
  const url = (payload?.agentCardUrl as string) ?? target;
273
275
  if (!url) return { ok: false, message: 'discover-agent requires agentCardUrl' };
276
+ const bearerToken = (payload?.bearerToken as string) || undefined;
274
277
  return this.hooks.discoverAgent
275
- ? this.hooks.discoverAgent(url)
278
+ ? this.hooks.discoverAgent(url, { bearerToken })
276
279
  : { ok: false, message: 'discover-agent is not wired' };
277
280
  }
278
281
  case 'add-agent': {
279
282
  const name = (payload?.name as string) ?? target;
280
283
  const url = payload?.agentCardUrl as string | undefined;
281
284
  if (!url) return { ok: false, message: 'add-agent requires agentCardUrl' };
285
+ const bearerToken = (payload?.bearerToken as string) || undefined;
282
286
  return this.hooks.addAgent
283
- ? this.hooks.addAgent(name, url)
287
+ ? this.hooks.addAgent(name, url, { bearerToken })
284
288
  : { ok: false, message: 'add-agent is not wired (client mode not mounted)' };
285
289
  }
286
290
  case 'remove-agent':
@@ -295,6 +299,14 @@ export class DashboardRegistry {
295
299
  return this.hooks.setServerEnabled
296
300
  ? this.hooks.setServerEnabled(false)
297
301
  : { ok: false, message: 'server control is not wired (server mode not mounted)' };
302
+ case 'set-server-auth': {
303
+ // Empty/missing token clears the gate. The token value is never echoed
304
+ // back in the result message.
305
+ const token = (payload?.authToken as string) || undefined;
306
+ return this.hooks.setServerAuthToken
307
+ ? this.hooks.setServerAuthToken(token)
308
+ : { ok: false, message: 'set-server-auth is not wired (server mode not mounted)' };
309
+ }
298
310
  case 'server-status':
299
311
  return this.hooks.serverStatus
300
312
  ? Promise.resolve(this.hooks.serverStatus())
package/src/index.ts CHANGED
@@ -23,10 +23,14 @@
23
23
 
24
24
  import type { Context } from '@deepseek-ai/cordis';
25
25
  import type { IncomingMessage, ServerResponse } from 'node:http';
26
+ import { mkdirSync } from 'node:fs';
27
+ import { networkInterfaces } from 'node:os';
28
+ import { dirname, join } from 'node:path';
26
29
  import { registerAgentTools } from './outbound.js';
27
30
  import { fetchAgentCard, pickInterface } from './card.js';
28
- import { A2AServer, type ServerOptions } from './server.js';
31
+ import { A2AServer, type ServerOptions, createDshAgentExecutor, type AgentRegistryLike } from './server.js';
29
32
  import { TaskStore } from './server.js';
33
+ import type { AgentPresetsLike } from './server.js';
30
34
  import { getSharedRegistry } from './dashboard.js';
31
35
  import {
32
36
  loadConfig as loadPersistedA2A,
@@ -54,6 +58,8 @@ export interface A2APluginConfig {
54
58
  skills?: ServerOptions['skills'];
55
59
  endpointPath?: string;
56
60
  execute?: ServerOptions['execute'];
61
+ /** Optional shared bearer token protecting the inbound /a2a endpoint. */
62
+ authToken?: string;
57
63
  // dashboard
58
64
  /** Serve the dashboard API on the webserver (default true). */
59
65
  dashboard?: boolean;
@@ -63,7 +69,8 @@ export { A2AClient } from './client.js';
63
69
  export * from './protocol.js';
64
70
  export * from './errors.js';
65
71
  export { fetchAgentCard } from './card.js';
66
- export { A2AServer, TaskStore, defaultExecutor, defaultSkills } from './server.js';
72
+ export { A2AServer, TaskStore, defaultExecutor, notConfiguredExecutor, shellExecutor, defaultSkills, createDshAgentExecutor } from './server.js';
73
+ export type { AgentRegistryLike, AgentPresetsLike, DshAgentExecutorOptions } from './server.js';
67
74
  export { registerAgentTools } from './outbound.js';
68
75
  export { DashboardRegistry, getSharedRegistry } from './dashboard.js';
69
76
  export type {
@@ -91,12 +98,46 @@ export interface WebServerService {
91
98
  }): () => void;
92
99
  }
93
100
 
101
+ /**
102
+ * Structural slice of `ctx.agentDefaultModel` (owned by
103
+ * `@deepseek-ai/dsh-agent-default-model`). It holds the deployment's default
104
+ * `{ provider, model }` (layered with the user's Settings choice). Reading it
105
+ * to seed a newly-created agent's model is the creating entry point's job — a
106
+ * fresh session has no model selection, and DSH's persona system prompt fails
107
+ * assembly on an empty `{{model}}` variable otherwise.
108
+ */
109
+ export interface AgentDefaultModelService {
110
+ currentSelection(): { provider?: string; model?: string; reasoningEffort?: string };
111
+ }
112
+
113
+ /**
114
+ * Structural slice of `ctx.sessionTitle` (owned by `@deepseek-ai/dsh-session-title`).
115
+ * `rename` accepts an explicit user title and pins it. We name inbound sessions
116
+ * ourselves because their prompts carry a `plugin` source, which the title
117
+ * service's automatic first-prompt naming deliberately ignores (human messages
118
+ * only) — so without this they fall back to the cwd basename.
119
+ */
120
+ export interface SessionTitleService {
121
+ rename(session: unknown, title: string): unknown;
122
+ }
123
+
94
124
  declare module '@deepseek-ai/cordis' {
95
125
  interface Context {
96
126
  /** Provided by @deepseek-ai/dsh-host-webserver. */
97
127
  webServer: WebServerService;
128
+ /** Provided by @deepseek-ai/dsh-agent-default-model (optional). */
129
+ agentDefaultModel?: AgentDefaultModelService;
130
+ /** Provided by @deepseek-ai/dsh-session-title (optional). */
131
+ sessionTitle?: SessionTitleService;
98
132
  /** Provided by @deepseek-ai/dsh-tools. */
99
133
  tools: { register(def: unknown): () => void };
134
+ /**
135
+ * Provided by @deepseek-ai/dsh-agent (the agent registry). Present whenever
136
+ * that layer is composed, but `create()` only succeeds once an agent-loop
137
+ * plugin has registered its factory — so server mode probes readiness
138
+ * before enabling {@link createDshAgentExecutor}, and never hard-injects it.
139
+ */
140
+ agents?: AgentRegistryLike;
100
141
  }
101
142
  }
102
143
 
@@ -365,19 +406,20 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
365
406
  dashboard.removeAgent(connectionId);
366
407
  return { ok: true, message: `connection ${connectionId} reconnected as ${newId}` };
367
408
  },
368
- addAgent: async (name, agentCardUrl) => {
369
- const cid = await connect(name, agentCardUrl, true);
409
+ addAgent: async (name, agentCardUrl, opts) => {
410
+ const bearerToken = opts?.bearerToken;
411
+ const cid = await connect(name, agentCardUrl, true, { bearerToken });
370
412
  if (!cid) return { ok: false, message: `failed to connect to ${agentCardUrl}` };
371
- // Persist: add to a2a.json so it survives restart.
372
- initialAgents.push({ name, agentCardUrl, configured: true, enabled: true });
413
+ // Persist: add to a2a.json so it survives restart (token included).
414
+ initialAgents.push({ name, agentCardUrl, configured: true, enabled: true, bearerToken });
373
415
  persistAgents();
374
416
  return { ok: true, message: `agent "${name}" connected as ${cid} (saved to ${configPath})` };
375
417
  },
376
- discoverAgent: async (agentCardUrl) => {
418
+ discoverAgent: async (agentCardUrl, opts) => {
377
419
  try {
378
420
  const card = await fetchAgentCard(agentCardUrl, {
379
421
  timeoutMs: mergedConfig.timeoutMs ?? 8000,
380
- bearerToken: mergedConfig.bearerToken,
422
+ bearerToken: opts?.bearerToken ?? mergedConfig.bearerToken,
381
423
  });
382
424
  const iface = pickInterface(card);
383
425
  return {
@@ -467,12 +509,64 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
467
509
  ctx.effect(() => {
468
510
  const webServer = ctx.webServer;
469
511
  // Derive the advertised base URL from the real listen address when the
470
- // config doesn't pin one; avoids advertising a stale port.
471
- const baseUrl =
472
- mergedConfig.baseUrl ??
473
- `http://${webServer.host === '0.0.0.0' ? '127.0.0.1' : webServer.host}:${webServer.port}`;
512
+ // config doesn't pin one; avoids advertising a stale port. When bound
513
+ // to 0.0.0.0, advertise a reachable LAN address, not loopback.
514
+ const baseUrl = mergedConfig.baseUrl ?? inferBaseUrl(webServer.host, webServer.port);
474
515
  const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
475
516
 
517
+ // ── executor precedence ───────────────────────────────────────────────
518
+ // 1. explicit `execute` (config injection) → use it
519
+ // 2. no explicit executor & the agent loop is ready → dshAgentExecutor:
520
+ // inbound message → a fresh, workspace-bound DSH agent session →
521
+ // reply. "Ready" means the agent-loop factory is registered, not
522
+ // merely that `ctx.agents` exists — a bare dsh-base profile has the
523
+ // registry but `create()` would reject. We probe via the reflection
524
+ // layer (no inject requirement) so a composition without the agent
525
+ // layer simply falls through.
526
+ // 3. otherwise → A2AServer's
527
+ // default notConfiguredExecutor (refuses + points at `execute`).
528
+ let execute = mergedConfig.execute;
529
+ let executorKind: 'custom' | 'dsh-agent' | 'none' = execute ? 'custom' : 'none';
530
+ let disposeExecutor: (() => Promise<void>) | undefined;
531
+ if (!execute) {
532
+ const agents = probeAgentRegistry(ctx);
533
+ if (agents) {
534
+ // Dedicated cwd for inbound sessions: a subdir of the profile dir,
535
+ // NOT the profile root — so per-context sessions don't pollute the
536
+ // profile-root session group with a burst of `.a2a`-named rows. DSH
537
+ // requires an absolute cwd and freezes it into the session header at
538
+ // creation. Inbound sessions land in the "Ungrouped" bucket of the
539
+ // session list; they are named `A2A: <summary>` so they're still
540
+ // identifiable there.
541
+ const inboundCwd = inboundSessionCwd(configPath);
542
+ // Seed the model per task from the deployment default selection
543
+ // (ctx.agentDefaultModel) — a fresh session has no model selection,
544
+ // and the persona system prompt fails assembly on an empty
545
+ // `{{model}}` without it. Resolving per task means a Settings model
546
+ // switch is picked up by the next inbound task.
547
+ const dshExec = createDshAgentExecutor(agents, {
548
+ cwd: inboundCwd,
549
+ plugin: 'a2a',
550
+ agentPresets: probeAgentPresets(ctx),
551
+ resolveAgentOptions: () => {
552
+ const sel = probeAgentDefaultModel(ctx)?.currentSelection();
553
+ if (!sel?.model && !sel?.provider) return undefined;
554
+ return { provider: sel.provider, model: sel.model };
555
+ },
556
+ onSessionOpened: ({ session, firstPrompt }) => {
557
+ // Name the session "A2A: <first-prompt summary>" — its prompts
558
+ // carry a plugin source, which DSH's automatic titling skips, so
559
+ // without this it would fall back to the cwd basename.
560
+ probeSessionTitle(ctx)?.rename(session, `A2A: ${summarize(firstPrompt)}`);
561
+ },
562
+ });
563
+ execute = dshExec;
564
+ disposeExecutor = () => dshExec.disposeAll();
565
+ executorKind = 'dsh-agent';
566
+ logger?.info?.(`[a2a] server executor: DSH agent session per A2A context (cwd ${inboundCwd})`);
567
+ }
568
+ }
569
+
476
570
  const server = new A2AServer(
477
571
  {
478
572
  baseUrl,
@@ -481,7 +575,8 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
481
575
  agentVersion: mergedConfig.agentVersion ?? '0.1.0',
482
576
  skills: mergedConfig.skills,
483
577
  endpointPath,
484
- execute: mergedConfig.execute,
578
+ execute,
579
+ authToken: mergedConfig.authToken,
485
580
  onInbound: (facts) => {
486
581
  const peerId = dashboard.touchInbound(
487
582
  { headers: facts.headers, source: facts.source },
@@ -544,17 +639,17 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
544
639
  }
545
640
  const body = await readBody(req);
546
641
  if (isStream) {
547
- res.writeHead(200, {
548
- 'content-type': 'text/event-stream',
549
- 'cache-control': 'no-cache',
550
- connection: 'keep-alive',
551
- });
552
- await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
642
+ const stream = await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
643
+ if (stream.status !== 200) {
644
+ res.writeHead(stream.status, { 'content-type': 'text/plain', ...(stream.headers ?? {}) });
645
+ res.end('Unauthorized');
646
+ return;
647
+ }
553
648
  res.end();
554
649
  return;
555
650
  }
556
651
  const out = await server.handle(toServerReq(req), body);
557
- res.writeHead(out.status, { 'content-type': out.contentType });
652
+ res.writeHead(out.status, { 'content-type': out.contentType, ...(out.headers ?? {}) });
558
653
  res.end(out.body);
559
654
  },
560
655
  }),
@@ -580,6 +675,12 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
580
675
  agentCardUrl: `${baseUrl}/.well-known/agent-card.json`,
581
676
  skills: (server.card.skills ?? []).map((s) => ({ id: s.id, name: s.name, description: s.description })),
582
677
  customExecutor: Boolean(mergedConfig.execute),
678
+ // How inbound tasks are handled: a config-injected executor, this
679
+ // harness's own agent session, or none (refuses until configured).
680
+ executor: executorKind,
681
+ // Whether the inbound endpoint is token-gated. Never expose the token
682
+ // value itself — only whether one is set.
683
+ authConfigured: server.authConfigured,
583
684
  });
584
685
  dashboard.setHooks({
585
686
  setServerEnabled: async (enable) => {
@@ -595,6 +696,19 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
595
696
  logger?.info?.('[a2a] A2A server disabled');
596
697
  return { ok: true, message: 'A2A server disabled' };
597
698
  },
699
+ setServerAuthToken: async (token) => {
700
+ server.setAuthToken(token);
701
+ // Persist to a2a.json (server.authToken) so the gate survives restart.
702
+ const next: PersistedA2AConfig = loadPersistedA2A();
703
+ next.server = { ...(next.server ?? {}), authToken: token && token.length > 0 ? token : undefined };
704
+ const res = savePersistedA2A(next);
705
+ if (!res.ok) {
706
+ logger?.error?.(`[a2a] failed to persist authToken to ${res.path}: ${res.message}`);
707
+ return { ok: false, message: `applied in-memory but failed to save: ${res.message}` };
708
+ }
709
+ logger?.info?.(`[a2a] inbound authToken ${token ? 'set' : 'cleared'} (saved to ${res.path})`);
710
+ return { ok: true, message: token ? '已设置入站鉴权 token' : '已清除入站鉴权 token' };
711
+ },
598
712
  serverStatus: () => ({
599
713
  ok: true,
600
714
  message: JSON.stringify({ enabled, ...serverCard() }),
@@ -603,6 +717,8 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
603
717
 
604
718
  return () => {
605
719
  unregisterRoutes();
720
+ // Dispose every live per-context inbound session on unload/teardown.
721
+ void disposeExecutor?.().catch((err) => logger?.error?.(`[a2a] executor dispose failed: ${(err as Error).message}`));
606
722
  };
607
723
  }, 'a2a: server routes');
608
724
  });
@@ -612,6 +728,155 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
612
728
  // Wired per-instance inside the client-mode effect above.
613
729
  }
614
730
 
731
+ /**
732
+ * Probe for a usable agent registry WITHOUT taking an inject dependency on it.
733
+ *
734
+ * `ctx.agents` (the registry) can be present while `create()` still rejects —
735
+ * that happens on a bare dsh-base profile that has the agent layer but no
736
+ * agent-loop plugin to register the creation factory. We can't cheaply tell
737
+ * "factory registered" apart from "registry present" without attempting a
738
+ * create, so this returns the registry when it's structurally usable and lets
739
+ * {@link createDshAgentExecutor} degrade to a readable error if a later
740
+ * `create()` rejects.
741
+ */
742
+ function probeAgentRegistry(ctx: Context): AgentRegistryLike | undefined {
743
+ return probeService(ctx, 'agents', 'create') as AgentRegistryLike | undefined;
744
+ }
745
+
746
+ /**
747
+ * Probe for the agent-preset roster service (`ctx.agentPresets`, owned by
748
+ * `@deepseek-ai/dsh-agent-presets`) without an inject dependency. When the
749
+ * deployment composes presets (`dsh-web-app` mounts the roster with a
750
+ * `default: standard`), inbound sessions must be composed from the same
751
+ * preset or they publish against the empty global layer — tools, prompt
752
+ * sections, and skills all absent. Absent rosters keep legacy behavior.
753
+ */
754
+ function probeAgentPresets(ctx: Context): AgentPresetsLike | undefined {
755
+ return probeService(ctx, 'agentPresets', 'resolve') as AgentPresetsLike | undefined;
756
+ }
757
+
758
+ /**
759
+ * Probe for the deployment default-model service without an inject dependency.
760
+ * Returns undefined when the service is absent, so the executor simply creates
761
+ * the agent without a seeded selection — fine for compositions that seed the
762
+ * model another way.
763
+ */
764
+ function probeAgentDefaultModel(ctx: Context): AgentDefaultModelService | undefined {
765
+ const svc = probeService(ctx, 'agentDefaultModel', 'currentSelection');
766
+ return svc as AgentDefaultModelService | undefined;
767
+ }
768
+
769
+ /** Probe `ctx.sessionTitle` (optional). Used to name inbound sessions. */
770
+ function probeSessionTitle(ctx: Context): SessionTitleService | undefined {
771
+ return probeService(ctx, 'sessionTitle', 'rename') as SessionTitleService | undefined;
772
+ }
773
+
774
+ /**
775
+ * Read a strict service off the reflection layer and confirm it exposes the
776
+ * named method, without taking an inject dependency. Returns undefined when the
777
+ * service is absent, its fiber inactive, or the method missing.
778
+ */
779
+ function probeService(ctx: Context, name: string, method: string): unknown {
780
+ try {
781
+ const reflect = (ctx as unknown as { reflect?: { get(n: string, strict?: boolean): unknown } }).reflect;
782
+ const svc = reflect?.get(name, true);
783
+ if (svc && typeof (svc as Record<string, unknown>)[method] === 'function') return svc;
784
+ } catch {
785
+ // reflection unavailable or service resolution threw — treat as absent.
786
+ }
787
+ return undefined;
788
+ }
789
+
790
+ /**
791
+ * One-line title summary from a prompt: first line, whitespace-collapsed, cut
792
+ * to a display-friendly length on a word boundary when possible.
793
+ */
794
+ function summarize(prompt: string, maxChars = 40): string {
795
+ const text = prompt.replace(/\s+/g, ' ').trim();
796
+ if (text.length <= maxChars) return text || '(empty)';
797
+ const cut = text.slice(0, maxChars);
798
+ const sp = cut.lastIndexOf(' ');
799
+ return `${(sp > maxChars * 0.6 ? cut.slice(0, sp) : cut).trimEnd()}…`;
800
+ }
801
+
802
+ /**
803
+ * Absolute cwd for inbound per-context sessions: a `.a2a-sessions` subdir of
804
+ * the profile directory (the dir holding `a2a.json`), kept separate from the
805
+ * profile root so inbound sessions don't crowd the user's interactive
806
+ * conversations there. They surface in the session list's "Ungrouped" bucket,
807
+ * named `A2A: <summary>`. Created if missing (persistence writes under it);
808
+ * falls back to cwd on any failure.
809
+ */
810
+ function inboundSessionCwd(configPath: string): string {
811
+ try {
812
+ const dir = join(dirname(configPath), '.a2a-sessions');
813
+ mkdirSync(dir, { recursive: true });
814
+ return dir;
815
+ } catch {
816
+ return process.cwd();
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Derive the public base URL advertised in the AgentCard from the real wire
822
+ * listen address, when the operator did not pin `baseUrl` in config.
823
+ *
824
+ * The A2A AgentCard must carry a URL a REMOTE caller can reach. When the web
825
+ * server binds only loopback (`127.0.0.1`), that address is correct. But when
826
+ * it binds every interface (`0.0.0.0`), advertising `127.0.0.1` is wrong for
827
+ * every non-local caller — it points at the caller's own loopback. In that
828
+ * case pick the machine's best-known LAN address (the primary non-loopback
829
+ * IPv4 of the interface that routes to the default gateway), and only fall
830
+ * back to loopback when no such address exists. IPv6 link-local is skipped
831
+ * (not reachable without a scope id).
832
+ *
833
+ * @param host - the web server's configured bind host ('127.0.0.1' | '0.0.0.0').
834
+ * @param port - the OS-assigned (or configured) listen port.
835
+ * @returns a scheme+host+port URL for Agents to call back into this DSH.
836
+ */
837
+ export function inferBaseUrl(host: string, port: number): string {
838
+ const hostname =
839
+ host === '0.0.0.0'
840
+ ? primaryLanAddress() ?? '127.0.0.1'
841
+ : host || '127.0.0.1';
842
+ return `http://${hostname}:${port}`;
843
+ }
844
+
845
+ /**
846
+ * Best single LAN IPv4 for this machine: the address of the non-loopback
847
+ * interface whose route reaches the default gateway (the address a remote
848
+ * peer on the same subnet would use to reach us). Returns undefined when the
849
+ * machine has no non-loopback IPv4 (e.g. offline / containers).
850
+ *
851
+ * Prefers IPv4 (A2A URLs here are v4-style), skips link-local (169.254.0.0/16)
852
+ * and CGNAT (100.64.0.0/10) ranges, which are useless to advertise.
853
+ */
854
+ function primaryLanAddress(): string | undefined {
855
+ try {
856
+ const ifaces = networkInterfaces();
857
+ let candidate: string | undefined;
858
+ for (const name of Object.keys(ifaces)) {
859
+ const addrs = ifaces[name] ?? [];
860
+ for (const a of addrs) {
861
+ if (a.family !== 'IPv4' || a.internal) continue;
862
+ const ip = a.address;
863
+ if (ip.startsWith('169.254.')) continue;
864
+ const first = Number(ip.split('.')[0]);
865
+ const second = Number(ip.split('.')[1]);
866
+ // CGNAT 100.64.0.0/10: first octet 100, second in [64..127]. The rest
867
+ // of 100.x.y.z is ordinary public space — keep it.
868
+ if (first === 100 && second >= 64 && second <= 127) continue;
869
+ if (!candidate) candidate = ip;
870
+ // Prefer addresses that match a default-route interface if visible.
871
+ if (name === 'en0' || name === 'eth0' || name === 'ens3' || name === 'ens5') return ip;
872
+ }
873
+ }
874
+ return candidate;
875
+ } catch {
876
+ return undefined;
877
+ }
878
+ }
879
+
615
880
  /** Drain the request body from a Node IncomingMessage. */
616
881
  async function readBody(req: IncomingMessage): Promise<string> {
617
882
  return new Promise<string>((resolve, reject) => {