@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.
@@ -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,13 @@
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 { dirname, join } from 'node:path';
26
28
  import { registerAgentTools } from './outbound.js';
27
29
  import { fetchAgentCard, pickInterface } from './card.js';
28
- import { A2AServer, type ServerOptions } from './server.js';
30
+ import { A2AServer, type ServerOptions, createDshAgentExecutor, type AgentRegistryLike } from './server.js';
29
31
  import { TaskStore } from './server.js';
32
+ import type { AgentPresetsLike } from './server.js';
30
33
  import { getSharedRegistry } from './dashboard.js';
31
34
  import {
32
35
  loadConfig as loadPersistedA2A,
@@ -54,6 +57,8 @@ export interface A2APluginConfig {
54
57
  skills?: ServerOptions['skills'];
55
58
  endpointPath?: string;
56
59
  execute?: ServerOptions['execute'];
60
+ /** Optional shared bearer token protecting the inbound /a2a endpoint. */
61
+ authToken?: string;
57
62
  // dashboard
58
63
  /** Serve the dashboard API on the webserver (default true). */
59
64
  dashboard?: boolean;
@@ -63,7 +68,8 @@ export { A2AClient } from './client.js';
63
68
  export * from './protocol.js';
64
69
  export * from './errors.js';
65
70
  export { fetchAgentCard } from './card.js';
66
- export { A2AServer, TaskStore, defaultExecutor, defaultSkills } from './server.js';
71
+ export { A2AServer, TaskStore, defaultExecutor, notConfiguredExecutor, shellExecutor, defaultSkills, createDshAgentExecutor } from './server.js';
72
+ export type { AgentRegistryLike, AgentPresetsLike, DshAgentExecutorOptions } from './server.js';
67
73
  export { registerAgentTools } from './outbound.js';
68
74
  export { DashboardRegistry, getSharedRegistry } from './dashboard.js';
69
75
  export type {
@@ -91,12 +97,46 @@ export interface WebServerService {
91
97
  }): () => void;
92
98
  }
93
99
 
100
+ /**
101
+ * Structural slice of `ctx.agentDefaultModel` (owned by
102
+ * `@deepseek-ai/dsh-agent-default-model`). It holds the deployment's default
103
+ * `{ provider, model }` (layered with the user's Settings choice). Reading it
104
+ * to seed a newly-created agent's model is the creating entry point's job — a
105
+ * fresh session has no model selection, and DSH's persona system prompt fails
106
+ * assembly on an empty `{{model}}` variable otherwise.
107
+ */
108
+ export interface AgentDefaultModelService {
109
+ currentSelection(): { provider?: string; model?: string; reasoningEffort?: string };
110
+ }
111
+
112
+ /**
113
+ * Structural slice of `ctx.sessionTitle` (owned by `@deepseek-ai/dsh-session-title`).
114
+ * `rename` accepts an explicit user title and pins it. We name inbound sessions
115
+ * ourselves because their prompts carry a `plugin` source, which the title
116
+ * service's automatic first-prompt naming deliberately ignores (human messages
117
+ * only) — so without this they fall back to the cwd basename.
118
+ */
119
+ export interface SessionTitleService {
120
+ rename(session: unknown, title: string): unknown;
121
+ }
122
+
94
123
  declare module '@deepseek-ai/cordis' {
95
124
  interface Context {
96
125
  /** Provided by @deepseek-ai/dsh-host-webserver. */
97
126
  webServer: WebServerService;
127
+ /** Provided by @deepseek-ai/dsh-agent-default-model (optional). */
128
+ agentDefaultModel?: AgentDefaultModelService;
129
+ /** Provided by @deepseek-ai/dsh-session-title (optional). */
130
+ sessionTitle?: SessionTitleService;
98
131
  /** Provided by @deepseek-ai/dsh-tools. */
99
132
  tools: { register(def: unknown): () => void };
133
+ /**
134
+ * Provided by @deepseek-ai/dsh-agent (the agent registry). Present whenever
135
+ * that layer is composed, but `create()` only succeeds once an agent-loop
136
+ * plugin has registered its factory — so server mode probes readiness
137
+ * before enabling {@link createDshAgentExecutor}, and never hard-injects it.
138
+ */
139
+ agents?: AgentRegistryLike;
100
140
  }
101
141
  }
102
142
 
@@ -365,19 +405,20 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
365
405
  dashboard.removeAgent(connectionId);
366
406
  return { ok: true, message: `connection ${connectionId} reconnected as ${newId}` };
367
407
  },
368
- addAgent: async (name, agentCardUrl) => {
369
- const cid = await connect(name, agentCardUrl, true);
408
+ addAgent: async (name, agentCardUrl, opts) => {
409
+ const bearerToken = opts?.bearerToken;
410
+ const cid = await connect(name, agentCardUrl, true, { bearerToken });
370
411
  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 });
412
+ // Persist: add to a2a.json so it survives restart (token included).
413
+ initialAgents.push({ name, agentCardUrl, configured: true, enabled: true, bearerToken });
373
414
  persistAgents();
374
415
  return { ok: true, message: `agent "${name}" connected as ${cid} (saved to ${configPath})` };
375
416
  },
376
- discoverAgent: async (agentCardUrl) => {
417
+ discoverAgent: async (agentCardUrl, opts) => {
377
418
  try {
378
419
  const card = await fetchAgentCard(agentCardUrl, {
379
420
  timeoutMs: mergedConfig.timeoutMs ?? 8000,
380
- bearerToken: mergedConfig.bearerToken,
421
+ bearerToken: opts?.bearerToken ?? mergedConfig.bearerToken,
381
422
  });
382
423
  const iface = pickInterface(card);
383
424
  return {
@@ -473,6 +514,59 @@ export function apply(ctx: Context, config: A2APluginConfig = {}) {
473
514
  `http://${webServer.host === '0.0.0.0' ? '127.0.0.1' : 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,95 @@ 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
+
615
820
  /** Drain the request body from a Node IncomingMessage. */
616
821
  async function readBody(req: IncomingMessage): Promise<string> {
617
822
  return new Promise<string>((resolve, reject) => {
package/src/outbound.ts CHANGED
@@ -92,6 +92,25 @@ export async function registerAgentTools(
92
92
  const skills = card.skills ?? [];
93
93
  options.onReady?.({ connectionId, card });
94
94
 
95
+ // A2A scopes a conversation by `contextId`. To give the LOCAL caller a
96
+ // continuous conversation with this remote agent (cross-call memory), we map
97
+ // the calling DSH agent's session id → a stable contextId, shared across all
98
+ // of this connection's skill-tools (one remote conversation per local agent).
99
+ // Calls with no agent context (e.g. a non-agent caller) share one fallback
100
+ // contextId so they still form a single stable conversation rather than a new
101
+ // remote session per call. Reused for the connection's lifetime.
102
+ const contextByAgent = new Map<string, string>();
103
+ const fallbackContextKey = '\0fallback';
104
+ const contextFor = (agentId: string | undefined): string => {
105
+ const key = agentId ?? fallbackContextKey;
106
+ let ctxId = contextByAgent.get(key);
107
+ if (ctxId === undefined) {
108
+ ctxId = `a2a-out-${crypto.randomUUID()}`;
109
+ contextByAgent.set(key, ctxId);
110
+ }
111
+ return ctxId;
112
+ };
113
+
95
114
  let disposers: RegisteredTool[] = [];
96
115
  if (options.mapSkills === false || skills.length === 0) {
97
116
  // Fall back to a single generic tool for the whole agent.
@@ -100,13 +119,13 @@ export async function registerAgentTools(
100
119
  id: 'agent',
101
120
  name: options.name,
102
121
  description: `${card.name}: ${card.description} (no skills advertised)`,
103
- }));
122
+ }, contextFor));
104
123
  if (dispose) disposers = [{ name, dispose }];
105
124
  } else {
106
125
  const out: RegisteredTool[] = [];
107
126
  for (const skill of skills) {
108
127
  const name = skillToToolName(options.name, skill);
109
- const dispose = tools.register?.(makeTool(name, client, skill));
128
+ const dispose = tools.register?.(makeTool(name, client, skill, contextFor));
110
129
  if (dispose) out.push({ name, dispose });
111
130
  }
112
131
  disposers = out;
@@ -122,7 +141,12 @@ export async function registerAgentTools(
122
141
  }));
123
142
  }
124
143
 
125
- function makeTool(name: string, client: A2AClient, skill: Pick<AgentSkill, 'id' | 'name' | 'description' | 'examples'>) {
144
+ function makeTool(
145
+ name: string,
146
+ client: A2AClient,
147
+ skill: Pick<AgentSkill, 'id' | 'name' | 'description' | 'examples'>,
148
+ contextFor: (agentId: string | undefined) => string,
149
+ ) {
126
150
  const description = [
127
151
  skill.description,
128
152
  ...(skill.examples?.length ? [`Examples: ${skill.examples.join(' | ')}`] : []),
@@ -147,10 +171,13 @@ function makeTool(name: string, client: A2AClient, skill: Pick<AgentSkill, 'id'
147
171
  { type: 'text', text: String(value) },
148
172
  ],
149
173
  },
150
- async execute(args: { prompt: string }, exec: { signal?: AbortSignal }): Promise<string> {
174
+ async execute(args: { prompt: string }, exec: { signal?: AbortSignal; agent?: { id?: string } }): Promise<string> {
175
+ // Reuse one contextId per calling local agent so multi-turn tool use is a
176
+ // continuous conversation with the remote agent (cross-call memory).
151
177
  const message: Message = {
152
178
  messageId: crypto.randomUUID(),
153
179
  role: Role.USER,
180
+ contextId: contextFor(exec.agent?.id),
154
181
  parts: [{ text: args.prompt }],
155
182
  };
156
183
  try {