omp-onlyne 0.2.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),
@@ -180,7 +213,7 @@ export class OnlyneBootstrap implements Component {
180
213
  threads: { page_size: Number(this.value("pageSize")) },
181
214
  ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
182
215
  ...(previous?.trust === undefined ? {} : { trust: previous.trust }),
183
- ...(previous?.acquisition === undefined ? {} : { acquisition: previous.acquisition }),
216
+ acquisition: { policy, allow },
184
217
  ...(previous?.language === undefined ? {} : { language: previous.language }),
185
218
  };
186
219
  this.saving = true;
@@ -188,6 +221,14 @@ export class OnlyneBootstrap implements Component {
188
221
  this.changed();
189
222
  let persisted = false;
190
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
+ }
191
232
  await saveConfig(config);
192
233
  persisted = true;
193
234
  const runtime = await startRuntime(this.pi, this.ctx);
@@ -254,6 +295,29 @@ export class OnlyneBootstrap implements Component {
254
295
  return node("row", { role: "omp.btn", actions: { click: act }, title: label }, [textNode(label)], act);
255
296
  }
256
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
+
257
321
  describe(_cx: DescribeContext): NativeNode {
258
322
  if (this.locale !== getLocale()) {
259
323
  this.locale = getLocale();
@@ -268,6 +332,7 @@ export class OnlyneBootstrap implements Component {
268
332
  body.push(draft.field);
269
333
  } else body.push(this.fieldRow(name));
270
334
  if (name === "region") body.push(node("text", { text: this.regionPreview(), wrap: "word" }));
335
+ if (name === "policy") body.push(this.policyRow());
271
336
  }
272
337
  if (this.error !== undefined) body.push(node("text", { spans: [span(this.error, "error")], wrap: "word" }));
273
338
  body.push(
@@ -292,6 +357,15 @@ export class OnlyneBootstrap implements Component {
292
357
  this.done();
293
358
  return;
294
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
+ }
295
369
  if (act.startsWith("onlyne.bootstrap.field.")) {
296
370
  const name = act.slice("onlyne.bootstrap.field.".length) as FieldName;
297
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: [
@@ -43,7 +43,6 @@ const en = {
43
43
  "approvals.head": "{node} asks to exchange keys with you",
44
44
  "field.peerFingerprint": "peer fingerprint",
45
45
  "field.ownFingerprint": "local fingerprint",
46
- "field.endpoint": "endpoint",
47
46
  "action.accept": "Accept",
48
47
  "approvals.accept.title": "Fingerprints match; approve this exchange",
49
48
  "action.reject": "Reject",
@@ -51,7 +50,15 @@ const en = {
51
50
  "action.details": "Details",
52
51
  "approvals.details.title": "Open this node in the member column",
53
52
 
54
- "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}",
55
62
 
56
63
  "composer.reply": "Replying to {node} · {id}",
57
64
  "composer.new": "New message to {node}",
@@ -104,11 +111,14 @@ const en = {
104
111
  "field.fingerprint": "fingerprint",
105
112
  "field.domain": "domain",
106
113
  "field.status": "status",
114
+ "field.declared": "declared address",
115
+ "field.observed": "observed address",
107
116
  "field.source": "source",
108
117
  "field.lastSeen": "last seen",
109
118
  "member.fingerprint.none": "not exchanged",
110
119
  "member.source.seed": "connected by address (saved as a seed)",
111
120
  "member.lastSeen.stale": " · record expired",
121
+ "member.observed.none": "not recorded",
112
122
  "action.useTarget": "Use as recipient",
113
123
  "member.target.title": "Send messages to this node",
114
124
  "action.acceptExchange": "Accept exchange",
@@ -118,6 +128,46 @@ const en = {
118
128
  "verb.revoke": "Revoke",
119
129
  "verb.reject": "Reject",
120
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
+
121
171
  "notice.refreshing": "Refreshing discovery…",
122
172
  "notice.refreshed": "Discovery refreshed.",
123
173
  "notice.refreshFailed": "Refresh failed: {error}",
@@ -133,6 +183,10 @@ const en = {
133
183
  "notice.requestFailed": "Request failed: {error}",
134
184
  "notice.exchangeSent": "Key exchange with {node} submitted; both sides' approval shows in the member column.",
135
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}",
136
190
 
137
191
  "outcome.refused": "Not delivered: {error}",
138
192
  "outcome.refused.peer": "the peer refused",
@@ -194,7 +248,6 @@ const zh: Record<ViewerTextKey, string> = {
194
248
  "approvals.head": "{node} 请求与你交换密钥",
195
249
  "field.peerFingerprint": "对方指纹",
196
250
  "field.ownFingerprint": "本机指纹",
197
- "field.endpoint": "端点",
198
251
  "action.accept": "同意",
199
252
  "approvals.accept.title": "双方指纹一致,批准这次交换",
200
253
  "action.reject": "拒绝",
@@ -202,7 +255,15 @@ const zh: Record<ViewerTextKey, string> = {
202
255
  "action.details": "查看详情",
203
256
  "approvals.details.title": "在成员栏打开这位节点",
204
257
 
205
- "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}",
206
267
 
207
268
  "composer.reply": "回复 {node} · {id}",
208
269
  "composer.new": "新消息发送到 {node}",
@@ -255,11 +316,14 @@ const zh: Record<ViewerTextKey, string> = {
255
316
  "field.fingerprint": "指纹",
256
317
  "field.domain": "域",
257
318
  "field.status": "状态",
319
+ "field.declared": "声明地址",
320
+ "field.observed": "观测地址",
258
321
  "field.source": "来源",
259
322
  "field.lastSeen": "最后可见",
260
323
  "member.fingerprint.none": "未交换",
261
324
  "member.source.seed": "按地址连接(已存为种子)",
262
325
  "member.lastSeen.stale": " · 记录已过期",
326
+ "member.observed.none": "未记录",
263
327
  "action.useTarget": "用作发送目标",
264
328
  "member.target.title": "把消息发给这位节点",
265
329
  "action.acceptExchange": "同意交换",
@@ -269,6 +333,46 @@ const zh: Record<ViewerTextKey, string> = {
269
333
  "verb.revoke": "撤销",
270
334
  "verb.reject": "拒绝",
271
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
+
272
376
  "notice.refreshing": "正在刷新发现…",
273
377
  "notice.refreshed": "发现已刷新。",
274
378
  "notice.refreshFailed": "刷新失败:{error}",
@@ -284,6 +388,10 @@ const zh: Record<ViewerTextKey, string> = {
284
388
  "notice.requestFailed": "请求失败:{error}",
285
389
  "notice.exchangeSent": "已提交与 {node} 的密钥交换,双方批准状态见成员栏。",
286
390
  "notice.sendFailed": "发送失败:{error}",
391
+ "notice.policySaved": "获取策略已改为 {policy},节点已重启。",
392
+ "notice.policyFailed": "修改策略失败:{error}",
393
+ "notice.allowSaved": "允许集合已保存,节点已重启。",
394
+ "notice.allowFailed": "修改允许集合失败:{error}",
287
395
 
288
396
  "outcome.refused": "未送达:{error}",
289
397
  "outcome.refused.peer": "对方拒绝",