@zhin.js/host-http 1.2.1 → 1.3.0

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.
Files changed (44) hide show
  1. package/lib/access-control.d.ts +50 -5
  2. package/lib/access-control.js +167 -13
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +92 -15
  7. package/lib/console-rpc-extended/dispatcher.js +10 -3
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +2 -1
  10. package/lib/console-rpc-extended/index.js +1 -0
  11. package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
  12. package/lib/console-rpc-extended/login-rpc.js +276 -16
  13. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  14. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  15. package/lib/console-rpc-handlers.d.ts +11 -1
  16. package/lib/console-rpc-handlers.js +221 -12
  17. package/lib/console-rpc-types.d.ts +2 -0
  18. package/lib/console-rpc-types.js +43 -0
  19. package/lib/console-rpc.d.ts +29 -1
  20. package/lib/console-rpc.js +33 -7
  21. package/lib/http-host.js +5 -4
  22. package/lib/index.d.ts +5 -3
  23. package/lib/index.js +4 -2
  24. package/lib/project-files.d.ts +7 -0
  25. package/lib/project-files.js +88 -11
  26. package/lib/unknown-rpc.d.ts +12 -0
  27. package/lib/unknown-rpc.js +33 -0
  28. package/package.json +3 -3
  29. package/src/access-control.ts +209 -14
  30. package/src/console-rest-pages-helpers.ts +27 -3
  31. package/src/console-rest-pages.ts +52 -15
  32. package/src/console-rpc-extended/contracts.ts +83 -16
  33. package/src/console-rpc-extended/dispatcher.ts +10 -3
  34. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  35. package/src/console-rpc-extended/index.ts +4 -0
  36. package/src/console-rpc-extended/login-rpc.ts +250 -17
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +198 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +63 -7
  41. package/src/http-host.ts +6 -2
  42. package/src/index.ts +15 -0
  43. package/src/project-files.ts +95 -11
  44. package/src/unknown-rpc.ts +44 -0
@@ -1,39 +1,71 @@
1
- import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
1
+ import { CONSOLE_LOGIN_UNSUPPORTED, LOGIN_RPC, type ConsoleLoginFlow } from '@zhin.js/console-protocol';
2
+ import type { ConsoleEndpointLoginAccess, ConsoleLoginAssistPort, ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
2
3
  import { strField } from './rpc-values.js';
3
4
 
4
- export function listLoginPending(ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
5
+ const NOT_WIRED = '登录辅助未接线(LoginAssist 未挂载)';
6
+ /** `login.submit` with `wait: true` waits at most this long for the login result. */
7
+ export const LOGIN_SUBMIT_WAIT_MS = 15_000;
8
+
9
+ export function listLoginPending(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
5
10
  const assist = ctx.loginAssist;
6
- if (!assist) return { error: '登录辅助未接线(LoginAssist 未挂载)' };
7
- const tasks = assist.listPending().map((task) => ({
11
+ if (!assist) return { error: NOT_WIRED };
12
+ const scope = loginScope(d, ctx);
13
+ if ('error' in scope) return scope;
14
+ const tasks = assist.listPending().filter((task) => inScope(scope, task)).map((task) => ({
8
15
  id: task.id,
9
16
  adapter: task.adapter,
10
17
  endpointKey: task.endpointKey,
11
18
  ...(task.endpointIdentity ? { endpointIdentity: task.endpointIdentity } : {}),
12
19
  type: task.type,
13
- payload: task.payload,
20
+ payload: canOperate(ctx, task) ? task.payload : eventSafeLoginPayload(task.payload),
14
21
  createdAt: task.createdAt,
15
22
  expiresAt: task.expiresAt,
23
+ ...(task.flowId ? { flowId: task.flowId } : {}),
24
+ ...(task.challengeId ? { challengeId: task.challengeId } : {}),
16
25
  }));
17
26
  return { data: { tasks, count: tasks.length } };
18
27
  }
19
28
 
20
- export function submitLogin(
29
+ /**
30
+ * `login.submit { taskId, value, endpointIdentity?, adapter?, endpointKey?, wait? }`.
31
+ * `value` is a string (legacy) or `{ action, values }`. With `wait: true` the reply carries the login
32
+ * result (`{ accepted, outcome, flow }`) or `{ accepted, pending: true }` after 15 s; without it the
33
+ * legacy `{ success: true }` reply is unchanged.
34
+ */
35
+ export async function submitLogin(
21
36
  d: Record<string, unknown>,
22
37
  ctx: ConsoleRpcExtendedCtx,
23
- ): ExtendedRpcResult {
38
+ ): Promise<ExtendedRpcResult> {
24
39
  const assist = ctx.loginAssist;
25
- if (!assist) return { error: '登录辅助未接线(LoginAssist 未挂载)' };
40
+ if (!assist) return { error: NOT_WIRED };
26
41
  const id = strField(d, 'taskId');
27
42
  if (!id) return { error: 'taskId is required' };
28
- const raw = d.value ?? '';
29
- const value = typeof raw === 'string' || (raw && typeof raw === 'object')
30
- ? raw as string | Record<string, unknown>
31
- : String(raw ?? '');
43
+ const value = readSubmitValue(d.value);
44
+ if (value === false) return { error: 'value must be a string or { action, values }' };
32
45
  const identity = readEndpointIdentity(d.endpointIdentity);
33
46
  if (identity === false) return { error: 'Invalid login Endpoint identity' };
34
- const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
35
- if (!ok) return { error: `login task not found: ${id}` };
36
- return { data: { success: true } };
47
+ const target = checkTaskTarget(d, ctx, id);
48
+ if (target) return target;
49
+ try {
50
+ if (d.wait === true && assist.submitAndWait) {
51
+ const result = await assist.submitAndWait(id, value, {
52
+ ...(identity ? { endpointIdentity: identity } : {}),
53
+ timeoutMs: LOGIN_SUBMIT_WAIT_MS,
54
+ });
55
+ if (!result.accepted) return { error: `login task not found: ${id}` };
56
+ if (result.outcome) return { data: { accepted: true, outcome: result.outcome, ...(result.flow ? { flow: result.flow } : {}) } };
57
+ return { data: { accepted: true, pending: true, ...(result.flow ? { flow: result.flow } : {}) } };
58
+ }
59
+ const ok = identity ? assist.submit(id, value, identity) : assist.submit(id, value);
60
+ if (!ok) return { error: `login task not found: ${id}` };
61
+ return { data: { success: true } };
62
+ } catch (error) {
63
+ // LoginSubmissionError: the challenge stays open; its message never contains the value.
64
+ if (error instanceof Error && error.name === 'LoginSubmissionError') {
65
+ return { error: error.message, data: { code: (error as Error & { code?: string }).code ?? 'input_invalid' } };
66
+ }
67
+ throw error;
68
+ }
37
69
  }
38
70
 
39
71
  export function cancelLogin(
@@ -41,18 +73,219 @@ export function cancelLogin(
41
73
  ctx: ConsoleRpcExtendedCtx,
42
74
  ): ExtendedRpcResult {
43
75
  const assist = ctx.loginAssist;
44
- if (!assist) return { error: '登录辅助未接线(LoginAssist 未挂载)' };
76
+ if (!assist) return { error: NOT_WIRED };
45
77
  const id = strField(d, 'taskId');
46
78
  if (!id) return { error: 'taskId is required' };
47
79
  const reason = strField(d, 'reason') || 'cancelled';
48
80
  const identity = readEndpointIdentity(d.endpointIdentity);
49
81
  if (identity === false) return { error: 'Invalid login Endpoint identity' };
82
+ const target = checkTaskTarget(d, ctx, id);
83
+ if (target) return target;
50
84
  const ok = identity ? assist.cancel(id, reason, identity) : assist.cancel(id, reason);
51
85
  if (!ok) return { error: `login task not found: ${id}` };
52
86
  return { data: { success: true } };
53
87
  }
54
88
 
55
- // ---------------------------------------------------------------- 社交读取
89
+ /**
90
+ * `login.flow.list { adapter?, endpointKey? }`: latest flow per Endpoint. Live challenge material
91
+ * only for Endpoints the caller may operate; the event-safe form otherwise (ADR 0058 §8).
92
+ */
93
+ export function listLoginFlows(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
94
+ const assist = ctx.loginAssist;
95
+ if (!assist) return { error: NOT_WIRED };
96
+ if (!assist.listFlows) return unsupported('login flows');
97
+ const scope = loginScope(d, ctx);
98
+ if ('error' in scope) return scope;
99
+ const flows = assist.listFlows().filter((flow) => inScope(scope, flow)).map((flow) => visibleFlow(assist, ctx, flow));
100
+ return { data: { flows, count: flows.length } };
101
+ }
102
+
103
+ /** `login.flow.get { flowId } | { adapter, endpointKey }` → `{ flow: … | null }`. */
104
+ export function getLoginFlow(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): ExtendedRpcResult {
105
+ const assist = ctx.loginAssist;
106
+ if (!assist) return { error: NOT_WIRED };
107
+ if (!assist.getFlow || !assist.getFlowById) return unsupported('login flows');
108
+ const flowId = strField(d, 'flowId');
109
+ const scope = loginScope(d, ctx);
110
+ if ('error' in scope) return scope;
111
+ if (flowId) {
112
+ const flow = assist.getFlowById(flowId);
113
+ // A flow id names one Endpoint; a scoped caller cannot read another Endpoint's flow by id.
114
+ return { data: { flow: flow && inScope(scope, flow) ? visibleFlow(assist, ctx, flow) : null } };
115
+ }
116
+ if (!scope.adapter || !scope.endpointKey) return { error: 'flowId or adapter and endpointKey are required' };
117
+ const flow = assist.getFlow(scope.adapter, scope.endpointKey);
118
+ return { data: { flow: flow ? visibleFlow(assist, ctx, flow) : null } };
119
+ }
120
+
121
+ /** `login.relogin` / `login.refresh { adapter, endpointKey }` through the Endpoint's login port. */
122
+ export async function controlLogin(
123
+ type: typeof LOGIN_RPC.RELOGIN | typeof LOGIN_RPC.REFRESH,
124
+ d: Record<string, unknown>,
125
+ ctx: ConsoleRpcExtendedCtx,
126
+ ): Promise<ExtendedRpcResult> {
127
+ const scope = loginScope(d, ctx);
128
+ if ('error' in scope) return scope;
129
+ const { adapter, endpointKey } = scope;
130
+ if (!adapter || !endpointKey) return { error: 'adapter and endpointKey are required' };
131
+ const relogin = type === LOGIN_RPC.RELOGIN;
132
+ const label = relogin ? 'relogin' : 'refresh challenge';
133
+ if (!ctx.withEndpointLogin) return unsupported(label);
134
+ const result = await ctx.withEndpointLogin(adapter, endpointKey, async (login: ConsoleEndpointLoginAccess) => {
135
+ const declared = relogin ? login.declaration?.relogin === true : login.declaration?.refreshQr === true;
136
+ const run = relogin ? login.control?.relogin : login.control?.refreshChallenge;
137
+ if (!declared || !run) return unsupported(label, adapter);
138
+ await run.call(login.control, {});
139
+ return { data: { accepted: true } } satisfies ExtendedRpcResult;
140
+ });
141
+ return result ?? { error: `Endpoint not found: ${adapter}/${endpointKey}` };
142
+ }
143
+
144
+ /**
145
+ * Audit detail of a login RPC reply: the result category only (never values, never messages).
146
+ * `undefined` for other RPCs.
147
+ */
148
+ export function loginAuditDetail(type: unknown, data: unknown): Readonly<Record<string, string>> | undefined {
149
+ if (type !== LOGIN_RPC.SUBMIT && type !== LOGIN_RPC.CANCEL && type !== LOGIN_RPC.RELOGIN && type !== LOGIN_RPC.REFRESH) return undefined;
150
+ if (!data || typeof data !== 'object') return undefined;
151
+ const record = data as { outcome?: { result?: unknown; code?: unknown }; pending?: unknown; accepted?: unknown; success?: unknown; code?: unknown };
152
+ if (typeof record.code === 'string') return Object.freeze({ result: record.code });
153
+ if (record.outcome && typeof record.outcome.result === 'string') {
154
+ return Object.freeze({ result: record.outcome.result, ...(typeof record.outcome.code === 'string' ? { code: record.outcome.code } : {}) });
155
+ }
156
+ if (record.pending === true) return Object.freeze({ result: 'pending' });
157
+ if (record.accepted === true || record.success === true) return Object.freeze({ result: 'accepted' });
158
+ return undefined;
159
+ }
160
+
161
+ // ---------------------------------------------------------------- helpers
162
+
163
+ function unsupported(what: string, adapter?: string): ExtendedRpcResult {
164
+ return {
165
+ error: `${CONSOLE_LOGIN_UNSUPPORTED}: ${adapter ? `${adapter} does not support ${what}` : `${what} not available on this host`}`,
166
+ data: { code: CONSOLE_LOGIN_UNSUPPORTED },
167
+ };
168
+ }
169
+
170
+ interface LoginScope { readonly adapter: string; readonly endpointKey: string }
171
+
172
+ /**
173
+ * The Endpoint a login RPC is about: `adapter` / `endpointKey` read from `data` and the top level,
174
+ * exactly the fields the authorization map reads (ADR 0049). Sources that disagree, or a value that
175
+ * is not a string, are rejected. When the dispatcher admitted the caller only through an adapter /
176
+ * endpoint-scoped grant (`ctx.authorizedEndpoint`), the scope is that Endpoint and any other named
177
+ * target is rejected; global callers may omit it (`''` = no filter).
178
+ */
179
+ function loginScope(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): LoginScope | { error: string } {
180
+ const sources = d.data && typeof d.data === 'object' && !Array.isArray(d.data)
181
+ ? [d.data as Record<string, unknown>, d]
182
+ : [d];
183
+ const read = (key: 'adapter' | 'endpointKey'): string | false => {
184
+ let found = '';
185
+ for (const source of sources) {
186
+ const value = source[key];
187
+ if (value === undefined || value === null || value === '') continue;
188
+ if (typeof value !== 'string') return false;
189
+ const text = value.trim();
190
+ if (!text) continue;
191
+ if (found && found !== text) return false;
192
+ found = text;
193
+ }
194
+ return found;
195
+ };
196
+ const adapter = read('adapter');
197
+ const endpointKey = read('endpointKey');
198
+ if (adapter === false || endpointKey === false) return { error: 'Conflicting login target (adapter / endpointKey)' };
199
+ const authorized = ctx.authorizedEndpoint;
200
+ if (!authorized) return { adapter, endpointKey };
201
+ if ((adapter && adapter !== authorized.adapter) || (endpointKey && endpointKey !== authorized.endpointKey)) {
202
+ return { error: 'Login target outside the authorized Endpoint' };
203
+ }
204
+ return { adapter: authorized.adapter, endpointKey: authorized.endpointKey };
205
+ }
206
+
207
+ /** The caller may handle this Endpoint's challenge (`endpoint.operate` there); no principal = trusted. */
208
+ function canOperate(ctx: ConsoleRpcExtendedCtx, item: { readonly adapter: string; readonly endpointKey: string }): boolean {
209
+ return ctx.canOperateEndpoint ? ctx.canOperateEndpoint(item.adapter, item.endpointKey) : true;
210
+ }
211
+
212
+ /**
213
+ * A flow as this caller may see it: live for operators, otherwise the event-safe form (no QR image,
214
+ * sensitive blocks redacted, verification links dropped). A port without `eventSafeFlow` falls back
215
+ * to removing every challenge's media and blocks.
216
+ */
217
+ function visibleFlow(assist: ConsoleLoginAssistPort, ctx: ConsoleRpcExtendedCtx, flow: ConsoleLoginFlow): ConsoleLoginFlow {
218
+ if (canOperate(ctx, flow)) return flow;
219
+ return assist.eventSafeFlow ? assist.eventSafeFlow(flow) : withoutChallengeMaterial(flow);
220
+ }
221
+
222
+ function withoutChallengeMaterial(flow: ConsoleLoginFlow): ConsoleLoginFlow {
223
+ if (!flow.steps.some((step) => step.challenge?.media || step.challenge?.blocks)) return flow;
224
+ return Object.freeze({
225
+ ...flow,
226
+ steps: Object.freeze(flow.steps.map((step) => {
227
+ if (!step.challenge || (!step.challenge.media && !step.challenge.blocks)) return step;
228
+ const { media, blocks: _blocks, ...challenge } = step.challenge;
229
+ return Object.freeze({
230
+ ...step,
231
+ challenge: Object.freeze({
232
+ ...challenge,
233
+ media: Object.freeze({
234
+ ...(media?.expiresAt !== undefined ? { expiresAt: media.expiresAt } : {}),
235
+ ...(media?.refreshable !== undefined ? { refreshable: media.refreshable } : {}),
236
+ ...(media?.qrImage ? { qrImageOmitted: true } : {}),
237
+ ...(media?.link || media?.captcha ? { linkOmitted: true } : {}),
238
+ }),
239
+ }),
240
+ });
241
+ })),
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Legacy task payload without the QR image (`image`) and verification link (`url`, may carry a
247
+ * session id), marked `imageOmitted` / `urlOmitted` like the `endpoint.login.*` events.
248
+ */
249
+ function eventSafeLoginPayload(payload: Record<string, unknown>): Record<string, unknown> {
250
+ // Allow-list: only the message survives; legacy adapters may put sid / ticket / device anywhere.
251
+ const safe: Record<string, unknown> = {};
252
+ if (typeof payload.message === 'string') safe.message = payload.message;
253
+ if (payload.image !== undefined) safe.imageOmitted = true;
254
+ if (payload.url !== undefined) safe.urlOmitted = true;
255
+ if (Object.keys(payload).some((key) => key !== 'message' && key !== 'image' && key !== 'url')) safe.fieldsOmitted = true;
256
+ return safe;
257
+ }
258
+
259
+ function inScope(scope: LoginScope, item: { readonly adapter: string; readonly endpointKey: string }): boolean {
260
+ return (!scope.adapter || item.adapter === scope.adapter) && (!scope.endpointKey || item.endpointKey === scope.endpointKey);
261
+ }
262
+
263
+ /** When the caller names an Endpoint, or was authorized for one, the task must belong to it. */
264
+ function checkTaskTarget(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx, id: string): ExtendedRpcResult | undefined {
265
+ const scope = loginScope(d, ctx);
266
+ if ('error' in scope) return scope;
267
+ if (!scope.adapter && !scope.endpointKey) return undefined;
268
+ const task = ctx.loginAssist?.listPending().find((item) => item.id === id);
269
+ if (!task || !inScope(scope, task)) return { error: `login task not found: ${id}` };
270
+ return undefined;
271
+ }
272
+
273
+ function readSubmitValue(raw: unknown): string | Record<string, unknown> | false {
274
+ if (raw === undefined || raw === null) return '';
275
+ if (typeof raw === 'string') return raw;
276
+ if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
277
+ if (typeof raw !== 'object' || Array.isArray(raw)) return false;
278
+ const record = raw as Record<string, unknown>;
279
+ if (record.action === undefined) return record;
280
+ if (typeof record.action !== 'string' || !record.action || record.action.length > 64) return false;
281
+ if (record.values !== undefined && (!record.values || typeof record.values !== 'object' || Array.isArray(record.values))) return false;
282
+ const values: Record<string, string> = {};
283
+ for (const [key, item] of Object.entries((record.values ?? {}) as Record<string, unknown>)) {
284
+ if (typeof item !== 'string' && typeof item !== 'number') return false;
285
+ values[key] = String(item);
286
+ }
287
+ return { action: record.action, values };
288
+ }
56
289
 
57
290
  function readEndpointIdentity(value: unknown): { readonly canonicalId: string; readonly instanceId: string; readonly epoch: number; readonly incarnationId: string } | false | undefined {
58
291
  if (value === undefined) return undefined;
@@ -6,7 +6,32 @@ const CRON_NOT_WIRED =
6
6
  '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
7
7
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
8
8
 
9
- export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult {
9
+ /** `titleI18n[locale]` → `titleI18n[language]` → `title`. */
10
+ function localizedTitle(job: ScheduleJobRow, locale: string | undefined): string | undefined {
11
+ const map = job.titleI18n && typeof job.titleI18n === 'object' ? job.titleI18n : undefined;
12
+ if (locale && map) {
13
+ const exact = map[locale];
14
+ if (typeof exact === 'string' && exact.trim()) return exact.trim();
15
+ const language = locale.split('-')[0]!;
16
+ const byLanguage = map[language];
17
+ if (typeof byLanguage === 'string' && byLanguage.trim()) return byLanguage.trim();
18
+ }
19
+ return typeof job.title === 'string' && job.title.trim() ? job.title.trim() : undefined;
20
+ }
21
+
22
+ function lastRunFields(job: ScheduleJobRow): Record<string, unknown> {
23
+ const run = job.lastRun;
24
+ if (!run || typeof run !== 'object' || typeof run.startedAt !== 'number') return {};
25
+ return {
26
+ lastExecutedAt: run.startedAt,
27
+ ...(typeof run.finishedAt === 'number' ? { lastFinishedAt: run.finishedAt, lastDurationMs: Math.max(0, run.finishedAt - run.startedAt) } : {}),
28
+ lastStatus: run.status === 'failed' ? 'failed' : 'succeeded',
29
+ ...(run.status === 'failed' && typeof run.error === 'string' ? { lastError: run.error } : {}),
30
+ };
31
+ }
32
+
33
+ export function listSchedule(ctx: ConsoleScheduleListCtx, d: Record<string, unknown> = {}): Promise<ExtendedRpcResult> | ExtendedRpcResult {
34
+ const locale = typeof d.locale === 'string' && d.locale.trim() ? d.locale.trim() : undefined;
10
35
  const host = ctx.scheduleHost as { list?: () => unknown } | undefined;
11
36
  if (!host || typeof host.list !== 'function') {
12
37
  return { error: '调度服务未配置(scheduleHost 未挂载)' };
@@ -18,17 +43,26 @@ export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcRe
18
43
  !!job && typeof job === 'object'
19
44
  && typeof (job as ScheduleJobRow).id === 'string'
20
45
  && typeof (job as ScheduleJobRow).cron === 'string')
21
- .map((job) => ({
46
+ .map((job) => {
47
+ const title = localizedTitle(job, locale);
48
+ return {
22
49
  id: job.id,
23
50
  cron: job.cron,
24
51
  ...(typeof job.description === 'string' ? { description: job.description } : {}),
52
+ // #1250: plugin polling is not a user reminder; title is localized, displayName never falls back to a bare id when a title exists.
53
+ kind: job.kind === 'user-task' ? 'user-task' : 'plugin-internal',
54
+ ...(title ? { title } : {}),
55
+ ...(job.titleI18n && typeof job.titleI18n === 'object' ? { titleI18n: job.titleI18n } : {}),
56
+ displayName: title ?? (typeof job.description === 'string' && job.description ? job.description : job.id),
57
+ ...lastRunFields(job),
25
58
  // console cron 页渲染字段(由 schedule host 提供时透传)
26
59
  expression: (job as { expression?: string }).expression ?? job.cron,
27
60
  running: (job as { running?: boolean }).running ?? true,
28
61
  ...((job as { plugin?: string }).plugin ? { plugin: (job as { plugin?: string }).plugin } : {}),
29
62
  ...((job as { nextExecution?: number }).nextExecution
30
63
  ? { nextExecution: (job as { nextExecution?: number }).nextExecution } : {}),
31
- }));
64
+ };
65
+ });
32
66
  return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
33
67
  } catch (error) {
34
68
  return { error: errorMessage(error) };
@@ -50,10 +84,14 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
50
84
  prompt: (job.action as { prompt?: string } | undefined)?.prompt ?? '',
51
85
  enabled: job.enabled !== false,
52
86
  source: job.source ?? 'manual',
87
+ // Persistent jobs are created by a person or on their behalf by the Agent.
88
+ kind: 'user-task',
53
89
  createdAt: (job as { createdAt?: number }).createdAt,
54
90
  nextExecution: (job.state as { nextRunAtMs?: number } | undefined)?.nextRunAtMs,
55
91
  ...((job.notify as { target?: unknown } | undefined)?.target
56
92
  ? { context: { target: (job.notify as { target?: unknown }).target } } : {}),
93
+ // #1250: the resolved delivery target; `resolved: false` = results cannot be delivered.
94
+ ...(ctx.describeScheduleTarget ? { target: ctx.describeScheduleTarget(job.notify) } : {}),
57
95
  lastExecutedAt: (job.state as { lastExecutedAt?: number } | undefined)?.lastExecutedAt,
58
96
  lastStatus: (job.state as { lastStatus?: string } | undefined)?.lastStatus,
59
97
  }));
@@ -62,6 +100,22 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
62
100
  }
63
101
  }
64
102
 
103
+ /** The account (`adapter` / `endpoint`) a schedule delivery target sends from, if it names one. */
104
+ function deliveryAccount(target: unknown): { adapter: string; endpoint: string } | undefined {
105
+ if (typeof target === 'string') {
106
+ // `<adapter>:<endpoint>:<kind>:<id>`
107
+ const [adapter, endpoint] = target.split(':');
108
+ return adapter && endpoint ? { adapter, endpoint } : undefined;
109
+ }
110
+ if (!target || typeof target !== 'object') return undefined;
111
+ const scene = (target as { scene?: unknown }).scene;
112
+ if (!scene || typeof scene !== 'object') return undefined;
113
+ const { platform, endpointKey } = scene as { platform?: unknown; endpointKey?: unknown };
114
+ return typeof platform === 'string' && platform && typeof endpointKey === 'string' && endpointKey
115
+ ? { adapter: platform, endpoint: endpointKey }
116
+ : undefined;
117
+ }
118
+
65
119
  export async function addCron(
66
120
  d: Record<string, unknown>,
67
121
  ctx: ConsoleRpcExtendedCtx,
@@ -80,6 +134,16 @@ export async function addCron(
80
134
  const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
81
135
  const context = (d.context ?? {}) as Record<string, unknown>;
82
136
  const target = context.target ?? context.channel;
137
+ // A delivery target sends to that account on every run: `endpoint.operate` there, like
138
+ // `/message/send`, on top of `ops.schedule.write` (absent check = trusted in-process caller).
139
+ if (target && ctx.canOperateEndpoint) {
140
+ const account = deliveryAccount(target);
141
+ if (!account || !ctx.canOperateEndpoint(account.adapter, account.endpoint)) {
142
+ return { error: account
143
+ ? `没有向该账号投递的权限:需要 ${account.adapter} / ${account.endpoint} 上的 endpoint.operate`
144
+ : '投递目标需要指明账号(scene.platform 与 scene.endpointKey)' };
145
+ }
146
+ }
83
147
  const job = {
84
148
  id: `console-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
85
149
  ...(label ? { label } : {}),