omp-onlyne 0.1.0 → 0.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.
@@ -17,13 +17,21 @@ import {
17
17
  text as textNode,
18
18
  } from "@oh-my-pi/pi-tui";
19
19
  import { configPath, normalizeNodeId, normalizeRegion, normalizeServiceUrl, saveConfig, shortNodeId } from "../config.ts";
20
+ import { parseAllowBlock, type AcquirePolicy } from "@onlyne/node-host/acquire";
21
+ import { ensureKeyDir } from "@onlyne/node-host/keys";
20
22
  import { startRuntime } from "../runtime.ts";
21
- import type { OnlyneConfig } from "../types.ts";
23
+ import type { OnlyneConfig } from "@onlyne/node-host/types";
22
24
  import { t, type UiTextKey } from "./bootstrap-text.ts";
23
25
 
24
- const FIELDS = ["region", "nodeId", "bind", "keyScope", "pageSize", "contact"] as const;
26
+ const FIELDS = ["region", "nodeId", "bind", "keyScope", "pageSize", "contact", "policy", "allow"] as const;
25
27
  type FieldName = (typeof FIELDS)[number];
26
28
 
29
+ const POLICIES: ReadonlyArray<{ value: AcquirePolicy; key: UiTextKey }> = [
30
+ { value: "all", key: "bootstrap.policy.all" },
31
+ { value: "segment", key: "bootstrap.policy.segment" },
32
+ { value: "none", key: "bootstrap.policy.none" },
33
+ ];
34
+
27
35
  const LABELS: Record<FieldName, UiTextKey> = {
28
36
  region: "bootstrap.field.region",
29
37
  nodeId: "bootstrap.field.nodeId",
@@ -31,6 +39,8 @@ const LABELS: Record<FieldName, UiTextKey> = {
31
39
  keyScope: "bootstrap.field.keyScope",
32
40
  pageSize: "bootstrap.field.pageSize",
33
41
  contact: "bootstrap.field.contact",
42
+ policy: "bootstrap.field.policy",
43
+ allow: "bootstrap.field.allow",
34
44
  };
35
45
 
36
46
  const HINTS: Record<FieldName, UiTextKey> = {
@@ -40,6 +50,8 @@ const HINTS: Record<FieldName, UiTextKey> = {
40
50
  keyScope: "bootstrap.hint.keyScope",
41
51
  pageSize: "bootstrap.hint.pageSize",
42
52
  contact: "bootstrap.hint.contact",
53
+ policy: "bootstrap.hint.policy",
54
+ allow: "bootstrap.hint.allow",
43
55
  };
44
56
 
45
57
  /** The values the command handler derived from Tern and the saved config. */
@@ -51,6 +63,10 @@ export interface BootstrapDefaults {
51
63
  bind: string;
52
64
  keyScope: string;
53
65
  pageSize: number;
66
+ /** RFC 0018 §2: the policy this project currently enforces. */
67
+ policy: AcquirePolicy;
68
+ /** The allow set as stored, one entry per element. */
69
+ allow: readonly string[];
54
70
  /** Existing configuration; the draft carries what the form does not edit. */
55
71
  current?: OnlyneConfig;
56
72
  }
@@ -96,6 +112,8 @@ export class OnlyneBootstrap implements Component {
96
112
  (defaults.current?.discovery?.seeds ?? []).join(", "),
97
113
  t(HINTS.contact),
98
114
  ),
115
+ policy: this.makeField(t(LABELS.policy), defaults.policy, t(HINTS.policy)),
116
+ allow: this.makeField(t(LABELS.allow), defaults.allow.join(", "), t(HINTS.allow)),
99
117
  };
100
118
  }
101
119
 
@@ -162,6 +180,21 @@ export class OnlyneBootstrap implements Component {
162
180
  this.fail(error instanceof Error ? error.message : String(error));
163
181
  return;
164
182
  }
183
+ const policy = this.value("policy").toLowerCase();
184
+ if (policy !== "all" && policy !== "segment" && policy !== "none") {
185
+ this.fail(t("bootstrap.policyInvalid"));
186
+ return;
187
+ }
188
+ const allow = this.value("allow")
189
+ .split(/[,\s]+/)
190
+ .map(entry => entry.trim())
191
+ .filter(entry => entry.length > 0);
192
+ for (const entry of allow) {
193
+ if (parseAllowBlock(entry) === undefined) {
194
+ this.fail(t("bootstrap.allowInvalid", { value: entry }));
195
+ return;
196
+ }
197
+ }
165
198
  const previous = this.defaults.current;
166
199
  const config: OnlyneConfig = {
167
200
  path: configPath(this.ctx.cwd),
@@ -172,10 +205,15 @@ export class OnlyneBootstrap implements Component {
172
205
  // The form does not edit these; saving must not erase them.
173
206
  ...(previous?.node.key_ref === undefined ? {} : { key_ref: previous.node.key_ref }),
174
207
  },
175
- server: { bind, ...(previous?.server.public_url === undefined ? {} : { public_url: previous.server.public_url }) },
208
+ server: {
209
+ bind,
210
+ ...(previous?.server.port === undefined ? {} : { port: previous.server.port }),
211
+ ...(previous?.server.public_url === undefined ? {} : { public_url: previous.server.public_url }),
212
+ },
176
213
  threads: { page_size: Number(this.value("pageSize")) },
177
214
  ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
178
215
  ...(previous?.trust === undefined ? {} : { trust: previous.trust }),
216
+ acquisition: { policy, allow },
179
217
  ...(previous?.language === undefined ? {} : { language: previous.language }),
180
218
  };
181
219
  this.saving = true;
@@ -183,6 +221,14 @@ export class OnlyneBootstrap implements Component {
183
221
  this.changed();
184
222
  let persisted = false;
185
223
  try {
224
+ if (scope !== "none") {
225
+ try {
226
+ await ensureKeyDir(this.ctx.cwd, config.node.id, scope);
227
+ } catch (error) {
228
+ this.fail(error instanceof Error ? error.message : String(error));
229
+ return;
230
+ }
231
+ }
186
232
  await saveConfig(config);
187
233
  persisted = true;
188
234
  const runtime = await startRuntime(this.pi, this.ctx);
@@ -249,6 +295,29 @@ export class OnlyneBootstrap implements Component {
249
295
  return node("row", { role: "omp.btn", actions: { click: act }, title: label }, [textNode(label)], act);
250
296
  }
251
297
 
298
+ /** The three values side by side: a policy is chosen, not typed into a free-form field. */
299
+ private policyRow(): NativeNode {
300
+ const current = this.value("policy").toLowerCase();
301
+ return node(
302
+ "row",
303
+ { role: "onlyne.policy", gap: "sm", align: "center" },
304
+ POLICIES.map(item =>
305
+ node(
306
+ "row",
307
+ {
308
+ role: "omp.btn",
309
+ actions: { click: `onlyne.bootstrap.policy.${item.value}` },
310
+ tone: current === item.value ? "accent" : undefined,
311
+ title: t("bootstrap.policy.title", { value: item.value }),
312
+ },
313
+ [textNode([span(t(item.key), current === item.value ? "accent strong" : "muted")])],
314
+ item.value,
315
+ ),
316
+ ),
317
+ "policy-switch",
318
+ );
319
+ }
320
+
252
321
  describe(_cx: DescribeContext): NativeNode {
253
322
  if (this.locale !== getLocale()) {
254
323
  this.locale = getLocale();
@@ -263,6 +332,7 @@ export class OnlyneBootstrap implements Component {
263
332
  body.push(draft.field);
264
333
  } else body.push(this.fieldRow(name));
265
334
  if (name === "region") body.push(node("text", { text: this.regionPreview(), wrap: "word" }));
335
+ if (name === "policy") body.push(this.policyRow());
266
336
  }
267
337
  if (this.error !== undefined) body.push(node("text", { spans: [span(this.error, "error")], wrap: "word" }));
268
338
  body.push(
@@ -287,6 +357,15 @@ export class OnlyneBootstrap implements Component {
287
357
  this.done();
288
358
  return;
289
359
  }
360
+ if (act.startsWith("onlyne.bootstrap.policy.")) {
361
+ const value = act.slice("onlyne.bootstrap.policy.".length);
362
+ const known = POLICIES.find(item => item.value === value);
363
+ if (known === undefined) return;
364
+ this.active = "policy";
365
+ this.fields.policy.editor.setText(known.value);
366
+ this.changed();
367
+ return;
368
+ }
290
369
  if (act.startsWith("onlyne.bootstrap.field.")) {
291
370
  const name = act.slice("onlyne.bootstrap.field.".length) as FieldName;
292
371
  if (FIELDS.includes(name)) {
package/src/ui/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
2
  import { ConfigError, loadConfig } from "../config.ts";
3
+ import { DEFAULT_ACQUISITION } from "@onlyne/node-host/acquire";
3
4
  import { getRuntime, startRuntime } from "../runtime.ts";
4
5
  import { detectTernSession } from "../tern.ts";
5
- import type { OnlyneConfig } from "../types.ts";
6
+ import type { OnlyneConfig } from "@onlyne/node-host/types";
6
7
  import { OnlyneBootstrap, type BootstrapDefaults } from "./bootstrap.ts";
7
8
  import { OnlyneViewer } from "./viewer.ts";
8
9
  import { LOCALES, setLocale, type Locale } from "@onlyne/node-host/i18n";
@@ -70,7 +71,8 @@ export function registerUi(pi: ExtensionAPI): void {
70
71
  try {
71
72
  let runtime = getRuntime();
72
73
  if (command === "init") {
73
- const existing = runtime?.config ?? (await loadConfig(ctx.cwd).catch(() => undefined));
74
+ // Only refuse init if the runtime is actually running; if unstarted or down, allow init to proceed or retry bootstrap.
75
+ const existing = runtime?.config;
74
76
  if (existing !== undefined) {
75
77
  ctx.ui.notify(t("configured"), "info");
76
78
  return;
@@ -103,6 +105,8 @@ export function registerUi(pi: ExtensionAPI): void {
103
105
  bind: current?.server.bind ?? "127.0.0.1",
104
106
  keyScope: current?.node.key_scope ?? "project",
105
107
  pageSize: current?.threads.page_size ?? 50,
108
+ policy: current?.acquisition?.policy ?? DEFAULT_ACQUISITION.policy,
109
+ allow: current?.acquisition?.allow ?? DEFAULT_ACQUISITION.allow,
106
110
  ...(current === undefined ? {} : { current }),
107
111
  };
108
112
  await ctx.ui.custom<void>(
@@ -1,10 +1,11 @@
1
1
  import type { MessageRenderer } from "@oh-my-pi/pi-coding-agent";
2
2
  import { getLocale } from "@onlyne/node-host/i18n";
3
3
  import { Markdown, Text, getMarkdownTheme, node, span, text, type Component, type NativeNode, type Theme, type TspSpan } from "@oh-my-pi/pi-tui";
4
- import type { HistoryMessage, MessageRef, ThreadRef } from "@onlyne/lib";
4
+ import type { HistoryMessage, MessageRef, ThreadRef, WorkRef } from "@onlyne/lib";
5
5
  import { shortNodeId } from "../config.ts";
6
6
  import { getRuntime, type PluginRuntime } from "../runtime.ts";
7
7
  import { conversationPath, messagePath } from "../urls.ts";
8
+ import { workValue } from "../work.ts";
8
9
  import { t } from "./bootstrap-text.ts";
9
10
 
10
11
  export interface OnlyneMessageDetails {
@@ -22,6 +23,7 @@ export interface OnlyneMessageDetails {
22
23
  body: string;
23
24
  relation?: "reply" | "followup";
24
25
  in_reply_to?: MessageRef;
26
+ work?: WorkRef;
25
27
  };
26
28
  }
27
29
 
@@ -169,6 +171,7 @@ class MailMessage implements Component {
169
171
  ...(mail.in_reply_to === undefined
170
172
  ? []
171
173
  : [`${this.theme.fg("muted", `${t("mail.inReplyTo")}:`)} ${mail.in_reply_to.sender}/${mail.in_reply_to.id}`]),
174
+ ...(mail.work === undefined ? [] : [`${this.theme.fg("muted", `${t("mail.work")}:`)} ${workValue(mail.work)}`]),
172
175
  `${this.theme.fg("muted", `${t("mail.delivery")}:`)} ${this.status.delivery}${
173
176
  this.status.consumption === undefined ? "" : t("mail.consumed", { state: this.status.consumption })
174
177
  }`,
@@ -215,6 +218,7 @@ class MailMessage implements Component {
215
218
  k: t("mail.inReplyTo"),
216
219
  v: [span(`${mail.in_reply_to.sender}/${mail.in_reply_to.id}`, "link mono", { href: replyHref })],
217
220
  }]),
221
+ ...(mail.work === undefined ? [] : [{ k: t("mail.work"), v: [span(workValue(mail.work), "mono")] }]),
218
222
  {
219
223
  k: t("mail.delivery"),
220
224
  v: [
@@ -24,6 +24,7 @@ const en = {
24
24
 
25
25
  "send.blocked.empty": "Type a message first.",
26
26
  "send.blocked.noTarget": "Open a thread first, or pick a recipient.",
27
+ "send.blocked.readOnly": "This thread is read-only here: its owner holds it, so send from a node that owns it.",
27
28
 
28
29
  "rail.domain.title": "{label} · nodes {count}",
29
30
  "rail.empty": "no domains",
@@ -36,12 +37,12 @@ const en = {
36
37
  "sidebar.inbox": "✉ Inbox",
37
38
  "sidebar.noKey": "no node key",
38
39
  "thread.closed": "closed",
40
+ "thread.readOnly": "read-only",
39
41
 
40
42
  "key.missing": "this node has no key",
41
43
  "approvals.head": "{node} asks to exchange keys with you",
42
44
  "field.peerFingerprint": "peer fingerprint",
43
45
  "field.ownFingerprint": "local fingerprint",
44
- "field.endpoint": "endpoint",
45
46
  "action.accept": "Accept",
46
47
  "approvals.accept.title": "Fingerprints match; approve this exchange",
47
48
  "action.reject": "Reject",
@@ -49,7 +50,15 @@ const en = {
49
50
  "action.details": "Details",
50
51
  "approvals.details.title": "Open this node in the member column",
51
52
 
52
- "message.reply.title": "#{seq} · click to reply to this one",
53
+ "message.open.title": "#{seq} · click to read the whole message",
54
+ "messageDetail.head": "#{seq} · {author} · {time}",
55
+ "messageDetail.close": "Back to messages",
56
+ "messageDetail.close.title": "Close the full message",
57
+ "messageDetail.reply": "Reply",
58
+ "messageDetail.reply.title": "Answer this message",
59
+ "messageDetail.target": "Use as recipient",
60
+ "messageDetail.target.title": "Start a new message to this author",
61
+ "messageDetail.work": "Work: {value}",
53
62
 
54
63
  "composer.reply": "Replying to {node} · {id}",
55
64
  "composer.new": "New message to {node}",
@@ -102,11 +111,14 @@ const en = {
102
111
  "field.fingerprint": "fingerprint",
103
112
  "field.domain": "domain",
104
113
  "field.status": "status",
114
+ "field.declared": "declared address",
115
+ "field.observed": "observed address",
105
116
  "field.source": "source",
106
117
  "field.lastSeen": "last seen",
107
118
  "member.fingerprint.none": "not exchanged",
108
119
  "member.source.seed": "connected by address (saved as a seed)",
109
120
  "member.lastSeen.stale": " · record expired",
121
+ "member.observed.none": "not recorded",
110
122
  "action.useTarget": "Use as recipient",
111
123
  "member.target.title": "Send messages to this node",
112
124
  "action.acceptExchange": "Accept exchange",
@@ -116,6 +128,46 @@ const en = {
116
128
  "verb.revoke": "Revoke",
117
129
  "verb.reject": "Reject",
118
130
 
131
+ "trustpage.open": "Trust rules",
132
+ "trustpage.open.title": "Acquisition policy, pending peers and the decision record",
133
+ "trustpage.title": "Trust & acquisition",
134
+ "trustpage.meta": "{pending} waiting for you · {audit} recorded decisions",
135
+ "trustpage.policy": "acquisition policy",
136
+ "trustpage.policy.all": "all: any peer is approved automatically",
137
+ "trustpage.policy.segment": "segment: only the allow set is approved automatically",
138
+ "trustpage.policy.none": "none: only a human approves a peer",
139
+ "trustpage.policy.unknown": "this node has not reported a policy",
140
+ "trustpage.policy.set": "Set the acquisition policy to {value}",
141
+ "trustpage.allow": "allowed networks",
142
+ "trustpage.allow.none": "none: no peer is approved automatically",
143
+ "trustpage.allowLoopback": "loopback discovery",
144
+ "trustpage.allowLoopback.on": "on",
145
+ "trustpage.allowLoopback.off": "off (the default)",
146
+ "trustpage.edit": "Changes restart this node; trust already established is not touched.",
147
+ "trustpage.allow.edit": "Edit allow set",
148
+ "trustpage.allow.edit.title": "Append or remove network/prefix blocks and host names",
149
+ "trustpage.allow.save": "Save allow set",
150
+ "trustpage.allow.cancel": "Cancel",
151
+ "trustpage.allowInvalid": "Not a network/prefix block or host name: {value}",
152
+ "trustpage.warn.lan":
153
+ "A private or wide range lets every host inside it exchange keys with this node without asking you first. The set is matched against the address this node observed, not the address a peer declares.",
154
+ "trustpage.warn.axes":
155
+ "Discovery and acquisition are separate: discovery excludes loopback by default while acquisition approves only the allow set. Changing one does not change the other.",
156
+ "trustpage.warn.limits":
157
+ "Not covered: the real client address behind a reverse proxy, and DNS names that resolve to an address other than the one actually connected.",
158
+ "trustpage.invited.head": "waiting for your decision",
159
+ "trustpage.invited.empty": "No peer is waiting for approval.",
160
+ "trustpage.audit.head": "acquisition record",
161
+ "trustpage.audit.empty": "No acquisition decision was recorded in this session.",
162
+ "trustpage.actor.human": "human",
163
+ "trustpage.actor.agent": "agent",
164
+ "trustpage.actor.seed": "seed",
165
+ "trustpage.actor.policy": "policy",
166
+ "trustpage.override": "human override",
167
+ "trustpage.seed": "seed pre-authorization",
168
+ "trustpage.audit.allowed": "allowed",
169
+ "trustpage.audit.denied": "denied",
170
+
119
171
  "notice.refreshing": "Refreshing discovery…",
120
172
  "notice.refreshed": "Discovery refreshed.",
121
173
  "notice.refreshFailed": "Refresh failed: {error}",
@@ -131,6 +183,10 @@ const en = {
131
183
  "notice.requestFailed": "Request failed: {error}",
132
184
  "notice.exchangeSent": "Key exchange with {node} submitted; both sides' approval shows in the member column.",
133
185
  "notice.sendFailed": "Send failed: {error}",
186
+ "notice.policySaved": "Acquisition policy is now {policy}; the node restarted.",
187
+ "notice.policyFailed": "Policy change failed: {error}",
188
+ "notice.allowSaved": "Allow set saved; the node restarted.",
189
+ "notice.allowFailed": "Allow set failed: {error}",
134
190
 
135
191
  "outcome.refused": "Not delivered: {error}",
136
192
  "outcome.refused.peer": "the peer refused",
@@ -173,6 +229,7 @@ const zh: Record<ViewerTextKey, string> = {
173
229
 
174
230
  "send.blocked.empty": "输入内容后发送",
175
231
  "send.blocked.noTarget": "先打开线程,或选择收件人",
232
+ "send.blocked.readOnly": "这个线程在此只读:它由所有者托管,请从拥有它的节点发送。",
176
233
 
177
234
  "rail.domain.title": "{label} · {count} 节点",
178
235
  "rail.empty": "无域",
@@ -185,12 +242,12 @@ const zh: Record<ViewerTextKey, string> = {
185
242
  "sidebar.inbox": "✉ 收件箱",
186
243
  "sidebar.noKey": "无节点密钥",
187
244
  "thread.closed": "已关闭",
245
+ "thread.readOnly": "只读",
188
246
 
189
247
  "key.missing": "本节点没有密钥",
190
248
  "approvals.head": "{node} 请求与你交换密钥",
191
249
  "field.peerFingerprint": "对方指纹",
192
250
  "field.ownFingerprint": "本机指纹",
193
- "field.endpoint": "端点",
194
251
  "action.accept": "同意",
195
252
  "approvals.accept.title": "双方指纹一致,批准这次交换",
196
253
  "action.reject": "拒绝",
@@ -198,7 +255,15 @@ const zh: Record<ViewerTextKey, string> = {
198
255
  "action.details": "查看详情",
199
256
  "approvals.details.title": "在成员栏打开这位节点",
200
257
 
201
- "message.reply.title": "#{seq} · 点击回复这一条",
258
+ "message.open.title": "#{seq} · 点击读全文",
259
+ "messageDetail.head": "#{seq} · {author} · {time}",
260
+ "messageDetail.close": "返回消息列表",
261
+ "messageDetail.close.title": "关闭全文",
262
+ "messageDetail.reply": "回复",
263
+ "messageDetail.reply.title": "回复这一条",
264
+ "messageDetail.target": "用作发送目标",
265
+ "messageDetail.target.title": "给这位作者发一条新消息",
266
+ "messageDetail.work": "工作单元:{value}",
202
267
 
203
268
  "composer.reply": "回复 {node} · {id}",
204
269
  "composer.new": "新消息发送到 {node}",
@@ -251,11 +316,14 @@ const zh: Record<ViewerTextKey, string> = {
251
316
  "field.fingerprint": "指纹",
252
317
  "field.domain": "域",
253
318
  "field.status": "状态",
319
+ "field.declared": "声明地址",
320
+ "field.observed": "观测地址",
254
321
  "field.source": "来源",
255
322
  "field.lastSeen": "最后可见",
256
323
  "member.fingerprint.none": "未交换",
257
324
  "member.source.seed": "按地址连接(已存为种子)",
258
325
  "member.lastSeen.stale": " · 记录已过期",
326
+ "member.observed.none": "未记录",
259
327
  "action.useTarget": "用作发送目标",
260
328
  "member.target.title": "把消息发给这位节点",
261
329
  "action.acceptExchange": "同意交换",
@@ -265,6 +333,46 @@ const zh: Record<ViewerTextKey, string> = {
265
333
  "verb.revoke": "撤销",
266
334
  "verb.reject": "拒绝",
267
335
 
336
+ "trustpage.open": "信任规则",
337
+ "trustpage.open.title": "获取策略、待批节点与判定记录",
338
+ "trustpage.title": "信任与获取",
339
+ "trustpage.meta": "{pending} 个待你确认 · {audit} 条判定记录",
340
+ "trustpage.policy": "获取策略",
341
+ "trustpage.policy.all": "all:任意对端都自动批准",
342
+ "trustpage.policy.segment": "segment:只自动批准允许集合",
343
+ "trustpage.policy.none": "none:只有人类能批准",
344
+ "trustpage.policy.unknown": "运行中的节点没有报告策略",
345
+ "trustpage.policy.set": "把获取策略设为 {value}",
346
+ "trustpage.allow": "允许集合",
347
+ "trustpage.allow.none": "空:不对任何对端自动批准",
348
+ "trustpage.allowLoopback": "回环发现",
349
+ "trustpage.allowLoopback.on": "开",
350
+ "trustpage.allowLoopback.off": "关(默认)",
351
+ "trustpage.edit": "修改会重启本节点;已经建立的信任不受影响。",
352
+ "trustpage.allow.edit": "修改允许集合",
353
+ "trustpage.allow.edit.title": "增删 network/prefix 网段与主机名",
354
+ "trustpage.allow.save": "保存允许集合",
355
+ "trustpage.allow.cancel": "取消",
356
+ "trustpage.allowInvalid": "不是 network/prefix 网段或主机名:{value}",
357
+ "trustpage.warn.lan":
358
+ "私有网段或过宽的网段会让其中每台主机都能不经你确认与本节点交换密钥。允许集合按本节点观测到的地址判定,不按对端声明地址。",
359
+ "trustpage.warn.axes":
360
+ "发现与获取是两条轴:发现默认排除回环,获取只批准允许集合。改一项不会自动改另一项。",
361
+ "trustpage.warn.limits":
362
+ "不覆盖:反向代理后的真实客户端地址;DNS 解析结果与实际连接目标不一致的情形。",
363
+ "trustpage.invited.head": "待你决定",
364
+ "trustpage.invited.empty": "没有待批准的节点。",
365
+ "trustpage.audit.head": "获取记录",
366
+ "trustpage.audit.empty": "本次会话还没有获取判定记录。",
367
+ "trustpage.actor.human": "人类",
368
+ "trustpage.actor.agent": "agent",
369
+ "trustpage.actor.seed": "种子",
370
+ "trustpage.actor.policy": "策略",
371
+ "trustpage.override": "人类超控",
372
+ "trustpage.seed": "种子预授权",
373
+ "trustpage.audit.allowed": "允许",
374
+ "trustpage.audit.denied": "拒绝",
375
+
268
376
  "notice.refreshing": "正在刷新发现…",
269
377
  "notice.refreshed": "发现已刷新。",
270
378
  "notice.refreshFailed": "刷新失败:{error}",
@@ -280,6 +388,10 @@ const zh: Record<ViewerTextKey, string> = {
280
388
  "notice.requestFailed": "请求失败:{error}",
281
389
  "notice.exchangeSent": "已提交与 {node} 的密钥交换,双方批准状态见成员栏。",
282
390
  "notice.sendFailed": "发送失败:{error}",
391
+ "notice.policySaved": "获取策略已改为 {policy},节点已重启。",
392
+ "notice.policyFailed": "修改策略失败:{error}",
393
+ "notice.allowSaved": "允许集合已保存,节点已重启。",
394
+ "notice.allowFailed": "修改允许集合失败:{error}",
283
395
 
284
396
  "outcome.refused": "未送达:{error}",
285
397
  "outcome.refused.peer": "对方拒绝",