dsh-foreman-node 0.2.0 → 0.2.2
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.
- package/CHANGELOG.md +40 -0
- package/lib/client.js +1 -0
- package/lib/index.d.ts +28 -1
- package/lib/index.js +85 -6
- package/package.json +1 -1
- package/src/client/index.tsx +211 -211
- package/src/config.ts +9 -0
- package/src/index.ts +3 -1
- package/src/session.ts +72 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
## 0.2.2 — 2026-11-08
|
|
2
|
+
|
|
3
|
+
### 新增:任务运行的权限预设可以配置了
|
|
4
|
+
|
|
5
|
+
以前设备上的 job 会话固定用 `workspace-write`。它把改动限制在声明的工作区里,但**越界时会弹审批**——
|
|
6
|
+
而那台机器前面没有人,弹审批等于让任务卡住。
|
|
7
|
+
|
|
8
|
+
现在可以在设备配置里指定 `jobPreset`:
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
jobPreset: workspace-write-never-ask
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
推荐做法:在 DSH 里加一个 `workspace-write + never` 的预设,把它填在这里。**边界照旧限制在工作区内**,
|
|
15
|
+
只是不再停下来问人。
|
|
16
|
+
|
|
17
|
+
不填仍是 `workspace-write`(保持原有行为)。这个选择属于那台机器的操作者,因为风险落在他们的机器上。
|
|
18
|
+
|
|
19
|
+
## 0.2.1 — 2026-11-08
|
|
20
|
+
|
|
21
|
+
### 修复:任务跑完了,却没有回报给 hub
|
|
22
|
+
|
|
23
|
+
真机上一次派单:设备做了 30 步、37 次工具调用、写了 2808 字的完整答案,**一次都没调用 `foreman_report`**。
|
|
24
|
+
hub 因此一直停在 `running`,面板上那张工单永远不结束,人也收不到结果。
|
|
25
|
+
|
|
26
|
+
两个原因,都修了:
|
|
27
|
+
|
|
28
|
+
1. **任务文本里没有说要回报。** 设备收到的只有标题、请求原文和附件说明,模型自然当成普通任务,
|
|
29
|
+
做完就结束。现在每张派单的末尾都有一段说明:`foreman_report` 是这个任务到达人的唯一途径,
|
|
30
|
+
答在对话里不算完成。
|
|
31
|
+
2. **只靠模型自觉不可靠。** 加了一层兜底:一轮结束而还没有回报时,插件自动补一条 `ready`,
|
|
32
|
+
摘要取会话里最后一段模型输出。工具被正确调用过就不会重复补。
|
|
33
|
+
|
|
34
|
+
兜底是真的兜底——任何一轮正常结束都会触发它,所以即使模型完全无视那段说明,任务也不会挂死。
|
|
35
|
+
|
|
36
|
+
### 加固
|
|
37
|
+
|
|
38
|
+
- 会话事件监听注册失败时(宿主不提供 `ctx.effect`),仍然会挂上回报工具:
|
|
39
|
+
没有回报通道比“看不见轮次结束”严重得多。
|
|
40
|
+
|
|
1
41
|
## 0.2.0 — 2026-11-07
|
|
2
42
|
|
|
3
43
|
### 新增:配置页,设备绑定不用再碰命令行
|
package/lib/client.js
CHANGED
|
@@ -58,6 +58,7 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
roots: valueOf(source["roots"], []),
|
|
59
59
|
heartbeatMs: valueOf(source["heartbeatMs"], 15e3),
|
|
60
60
|
reconnectMs: valueOf(source["reconnectMs"], 1e3),
|
|
61
|
+
jobPreset: valueOf(source["jobPreset"], "workspace-write"),
|
|
61
62
|
ssh: {
|
|
62
63
|
...SSH_DEFAULTS,
|
|
63
64
|
...unwrapSsh(source["ssh"])
|
package/lib/index.d.ts
CHANGED
|
@@ -53,6 +53,14 @@ interface NodeConfig {
|
|
|
53
53
|
reconnectMs: number;
|
|
54
54
|
/** How to reach a hub that is not directly reachable. */
|
|
55
55
|
ssh: SshConfig;
|
|
56
|
+
/**
|
|
57
|
+
* The permission preset a job runs under.
|
|
58
|
+
*
|
|
59
|
+
* Defaults to `workspace-write`, which is bounded to the declared directory but asks before leaving it. A device has
|
|
60
|
+
* nobody sitting in front of it, so a question that stops the job is worse than it looks: the work simply hangs.
|
|
61
|
+
* Configure a preset that keeps the boundary and never asks — that is the operator's call, on their own machine.
|
|
62
|
+
*/
|
|
63
|
+
jobPreset: string;
|
|
56
64
|
}
|
|
57
65
|
/** The SSH defaults, spread over whatever the host hands in, so a partial block is still complete. */
|
|
58
66
|
declare const SSH_DEFAULTS: SshConfig;
|
|
@@ -340,11 +348,19 @@ interface EnsureInput {
|
|
|
340
348
|
*/
|
|
341
349
|
declare class JobSessions {
|
|
342
350
|
private readonly ctx;
|
|
351
|
+
private readonly preset;
|
|
343
352
|
/** Jobs by assignment id, so `running()` reports what this device actually holds rather than what it was told. */
|
|
344
353
|
private readonly jobs;
|
|
345
354
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
346
355
|
private readonly reported;
|
|
347
|
-
|
|
356
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
357
|
+
private readonly reports;
|
|
358
|
+
/**
|
|
359
|
+
* @param preset the permission preset a job runs under. The device operator chooses it in their own configuration:
|
|
360
|
+
* a machine with nobody in front of it cannot answer an approval prompt, so the useful choice is usually a preset
|
|
361
|
+
* that keeps the workspace boundary and never asks.
|
|
362
|
+
*/
|
|
363
|
+
constructor(ctx: Context, preset?: string);
|
|
348
364
|
/**
|
|
349
365
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
350
366
|
* register it so a heartbeat counts it.
|
|
@@ -372,6 +388,17 @@ declare class JobSessions {
|
|
|
372
388
|
forget(assignmentId: string): void;
|
|
373
389
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
374
390
|
reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void;
|
|
391
|
+
/**
|
|
392
|
+
* Report for a job that ended without calling the tool.
|
|
393
|
+
*
|
|
394
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
395
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
396
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
397
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
398
|
+
*/
|
|
399
|
+
private watchTurnEnd;
|
|
400
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
401
|
+
private lastText;
|
|
375
402
|
}
|
|
376
403
|
/** Version reported in the hello, so the hub can tell which device build is talking. */
|
|
377
404
|
declare const VERSION = "0.2.0";
|
package/lib/index.js
CHANGED
|
@@ -244,6 +244,7 @@ function configured(config) {
|
|
|
244
244
|
roots: valueOf(source["roots"], []),
|
|
245
245
|
heartbeatMs: valueOf(source["heartbeatMs"], 15e3),
|
|
246
246
|
reconnectMs: valueOf(source["reconnectMs"], 1e3),
|
|
247
|
+
jobPreset: valueOf(source["jobPreset"], "workspace-write"),
|
|
247
248
|
ssh: {
|
|
248
249
|
...SSH_DEFAULTS,
|
|
249
250
|
...unwrapSsh(source["ssh"])
|
|
@@ -858,7 +859,14 @@ function _usingCtx() {
|
|
|
858
859
|
};
|
|
859
860
|
}
|
|
860
861
|
// src/session.ts
|
|
861
|
-
/**
|
|
862
|
+
/**
|
|
863
|
+
* The permission preset a job runs under when the device's configuration names none.
|
|
864
|
+
*
|
|
865
|
+
* Bounded to the directory the device declared. A job on a machine with nobody in front of it cannot answer an
|
|
866
|
+
* approval prompt, so the useful alternative is not a wider sandbox but a quiet one: a preset that keeps this
|
|
867
|
+
* boundary and never asks. That is the device operator's decision, taken in their own configuration, because it is
|
|
868
|
+
* their machine and their risk.
|
|
869
|
+
*/
|
|
862
870
|
const JOB_PRESET = "workspace-write";
|
|
863
871
|
/** The Session a job runs in, named the way a hub names its own worker: `job-<assignmentId>`. */
|
|
864
872
|
const jobSessionId = (assignmentId) => `job-${assignmentId}`;
|
|
@@ -873,12 +881,21 @@ const requestMessage = (requestId) => MessageId(`dsh-foreman-node:${requestId}`)
|
|
|
873
881
|
*/
|
|
874
882
|
var JobSessions = class {
|
|
875
883
|
ctx;
|
|
884
|
+
preset;
|
|
876
885
|
/** Jobs by assignment id, so `running()` reports what this device actually holds rather than what it was told. */
|
|
877
886
|
jobs = /* @__PURE__ */ new Map();
|
|
878
887
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
879
888
|
reported = /* @__PURE__ */ new Set();
|
|
880
|
-
|
|
889
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
890
|
+
reports = /* @__PURE__ */ new Set();
|
|
891
|
+
/**
|
|
892
|
+
* @param preset the permission preset a job runs under. The device operator chooses it in their own configuration:
|
|
893
|
+
* a machine with nobody in front of it cannot answer an approval prompt, so the useful choice is usually a preset
|
|
894
|
+
* that keeps the workspace boundary and never asks.
|
|
895
|
+
*/
|
|
896
|
+
constructor(ctx, preset = JOB_PRESET) {
|
|
881
897
|
this.ctx = ctx;
|
|
898
|
+
this.preset = preset;
|
|
882
899
|
}
|
|
883
900
|
/**
|
|
884
901
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
@@ -904,7 +921,7 @@ var JobSessions = class {
|
|
|
904
921
|
}
|
|
905
922
|
if ("error" in result) throw result.error;
|
|
906
923
|
await workspace.attachSession(id);
|
|
907
|
-
this.ctx.permissionPresets.set(result.agent.session,
|
|
924
|
+
this.ctx.permissionPresets.set(result.agent.session, this.preset);
|
|
908
925
|
await this.ctx.sessionController.rename({
|
|
909
926
|
sessionId: id,
|
|
910
927
|
title: input.title
|
|
@@ -982,7 +999,12 @@ var JobSessions = class {
|
|
|
982
999
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
983
1000
|
reportTools(agent, assignmentId, onReport) {
|
|
984
1001
|
const registrar = agent.ctx.tools;
|
|
985
|
-
const
|
|
1002
|
+
const reported = this.reports;
|
|
1003
|
+
const report = (id, deviceReport) => {
|
|
1004
|
+
reported.add(id);
|
|
1005
|
+
onReport(id, deviceReport);
|
|
1006
|
+
};
|
|
1007
|
+
const tool = foremanReportTool({ report }, assignmentId);
|
|
986
1008
|
registrar.register({
|
|
987
1009
|
name: tool.name,
|
|
988
1010
|
description: tool.description,
|
|
@@ -999,6 +1021,62 @@ var JobSessions = class {
|
|
|
999
1021
|
},
|
|
1000
1022
|
execute: (args, execution) => tool.execute(args, execution)
|
|
1001
1023
|
});
|
|
1024
|
+
this.watchTurnEnd(agent, assignmentId, report);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Report for a job that ended without calling the tool.
|
|
1028
|
+
*
|
|
1029
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
1030
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
1031
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
1032
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
1033
|
+
*/
|
|
1034
|
+
watchTurnEnd(agent, assignmentId, report) {
|
|
1035
|
+
if (typeof agent.ctx.effect !== "function") return;
|
|
1036
|
+
agent.ctx.effect(() => {
|
|
1037
|
+
agent.ctx.on("session/event", (session, event) => {
|
|
1038
|
+
if (session.id !== agent.session.id || event.type !== "turn/end") return;
|
|
1039
|
+
if (this.reports.has(assignmentId)) return;
|
|
1040
|
+
this.lastText(agent).then((text) => {
|
|
1041
|
+
if (this.reports.has(assignmentId)) return;
|
|
1042
|
+
report(assignmentId, {
|
|
1043
|
+
state: "ready",
|
|
1044
|
+
summary: text === "" ? "the job finished without a report" : text.slice(0, 600)
|
|
1045
|
+
});
|
|
1046
|
+
}, () => {
|
|
1047
|
+
if (this.reports.has(assignmentId)) return;
|
|
1048
|
+
report(assignmentId, {
|
|
1049
|
+
state: "ready",
|
|
1050
|
+
summary: "the job finished without a report"
|
|
1051
|
+
});
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
return () => {};
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
1058
|
+
async lastText(agent) {
|
|
1059
|
+
try {
|
|
1060
|
+
var _usingCtx3 = _usingCtx();
|
|
1061
|
+
await this.ctx.sessionPersistence.flush();
|
|
1062
|
+
const handle = _usingCtx3.a(await this.ctx.sessionPersistence.open(agent.id, "read"));
|
|
1063
|
+
let found = "";
|
|
1064
|
+
for (let offset = 0;;) {
|
|
1065
|
+
const { events } = await handle.read(offset, 512);
|
|
1066
|
+
if (events.length === 0) break;
|
|
1067
|
+
for (const event of events) {
|
|
1068
|
+
if (event.type !== "assistant/message") continue;
|
|
1069
|
+
const text = (event.data?.message?.content ?? []).filter((part) => part?.type === "text").map((part) => part.text ?? "").join(" ").trim();
|
|
1070
|
+
if (text !== "") found = text;
|
|
1071
|
+
}
|
|
1072
|
+
offset += events.length;
|
|
1073
|
+
}
|
|
1074
|
+
return found;
|
|
1075
|
+
} catch (_) {
|
|
1076
|
+
_usingCtx3.e = _;
|
|
1077
|
+
} finally {
|
|
1078
|
+
await _usingCtx3.d();
|
|
1079
|
+
}
|
|
1002
1080
|
}
|
|
1003
1081
|
};
|
|
1004
1082
|
// src/index.ts
|
|
@@ -1037,7 +1115,8 @@ const Typed = s.object({
|
|
|
1037
1115
|
keyPath: "",
|
|
1038
1116
|
remotePort: 8790,
|
|
1039
1117
|
localPort: 8790
|
|
1040
|
-
}).description("How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open.")
|
|
1118
|
+
}).description("How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open."),
|
|
1119
|
+
jobPreset: s.string().default("workspace-write").volatile().description("The permission preset a job runs under. Use one that keeps the workspace boundary and never asks, because nobody is at this machine to answer.")
|
|
1041
1120
|
});
|
|
1042
1121
|
/**
|
|
1043
1122
|
* What this configuration still needs before the device can dial its hub, or undefined when it is complete.
|
|
@@ -1098,7 +1177,7 @@ async function apply(ctx, raw) {
|
|
|
1098
1177
|
ctx.logger.info("%s", missing);
|
|
1099
1178
|
return;
|
|
1100
1179
|
}
|
|
1101
|
-
const jobs = new JobSessions(ctx);
|
|
1180
|
+
const jobs = new JobSessions(ctx, config.jobPreset);
|
|
1102
1181
|
const client = new NodeClient({
|
|
1103
1182
|
url: hubUrlOf(config),
|
|
1104
1183
|
deviceId: config.deviceId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-foreman-node",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Device side of dsh-foreman: connects out to a foreman hub, takes a job into a local DSH session, reports back. No ledger, no policy, no database.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/client/index.tsx
CHANGED
|
@@ -1,211 +1,211 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The device's configuration page, mounted in the DSH Plugin Manager.
|
|
3
|
-
*
|
|
4
|
-
* It does two things the page alone cannot: it lists the keys this machine has (through the device's own route, because
|
|
5
|
-
* a browser cannot read `~/.ssh`) and it tests one authentication (also through the device, because a browser cannot
|
|
6
|
-
* start ssh). Everything it *writes* goes through DSH's own configuration form, so the saved values land in the
|
|
7
|
-
* profile's patch exactly as if they had been typed there by hand.
|
|
8
|
-
*/
|
|
9
|
-
import { useEffect, useState } from 'react'
|
|
10
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
-
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
12
|
-
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
13
|
-
import type { ConfigPageForm, PluginConfigViewProps } from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
14
|
-
import { configured, type NodeConfig } from '../config.ts'
|
|
15
|
-
import type { SshKey } from '../keys.ts'
|
|
16
|
-
import type { TestResult } from '../routes.ts'
|
|
17
|
-
import { addressPreview, DEFAULT_SSH, editsOf, emptyFields, keyLabel, testMessage } from './page-state.ts'
|
|
18
|
-
import { en, zh } from './locales.ts'
|
|
19
|
-
|
|
20
|
-
/** The prefix the device's own routes answer under. */
|
|
21
|
-
const API = '/foreman-node/api'
|
|
22
|
-
|
|
23
|
-
/** A key list answer, as `GET /foreman-node/api/keys` sends it. */
|
|
24
|
-
interface KeysAnswer { keys: SshKey[]; directory: string }
|
|
25
|
-
|
|
26
|
-
/** Fetches a key list or runs one test; both need the marker header the device's same-origin check looks for. */
|
|
27
|
-
async function ask<T>(path: string, init?: RequestInit): Promise<T> {
|
|
28
|
-
const response = await fetch(new URL(`${API}/${path}`, window.location.href), {
|
|
29
|
-
...init, headers: { 'x-orchestrator-request': '1', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }), ...init?.headers } })
|
|
30
|
-
if (!response.ok) throw new Error(`the device answered ${response.status}`)
|
|
31
|
-
return await response.json() as T
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export const inject = ['locale', 'slots']
|
|
35
|
-
export const name = 'dsh-foreman-node'
|
|
36
|
-
|
|
37
|
-
/** The locale namespace this page registers its dictionaries under. */
|
|
38
|
-
const NS = 'dshNode'
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The page itself. `t` comes from the host's locale service, like every other plugin's page.
|
|
42
|
-
*
|
|
43
|
-
* `initialKeys` and `keysFailed` exist so a test can render a page without a network or a web server. Production never
|
|
44
|
-
* passes them, so the page fetches as it otherwise would — they change nothing about how it behaves.
|
|
45
|
-
*/
|
|
46
|
-
function DevicePage({ form, t, initialKeys, keysFailed: prefailed }: {
|
|
47
|
-
t: (key: keyof typeof zh) => string
|
|
48
|
-
form: ConfigPageForm | undefined
|
|
49
|
-
initialKeys?: readonly SshKey[]
|
|
50
|
-
keysFailed?: boolean
|
|
51
|
-
}) {
|
|
52
|
-
// The draft starts from the saved value when there is one, so the first render already shows the configuration
|
|
53
|
-
// rather than a loading line that only an effect would replace.
|
|
54
|
-
// The host hands over the section's raw value; every field is volatile, so it is unwrapped once here.
|
|
55
|
-
const saved = form?.state.value === undefined ? undefined : configured(form.state.value as Partial<NodeConfig>)
|
|
56
|
-
const editable = form?.state.status === 'ready' && form.state.writable
|
|
57
|
-
// The draft starts from the saved value when there is one, so the first render already shows the configuration
|
|
58
|
-
// rather than a loading line that only an effect would replace.
|
|
59
|
-
const [draft, setDraft] = useState<NodeConfig | undefined>(() =>
|
|
60
|
-
saved === undefined ? undefined : { ...saved, token: '', ssh: { ...DEFAULT_SSH, ...saved.ssh } })
|
|
61
|
-
// The stored token never reaches the page (it is a secret), so the box starts empty and is only written when
|
|
62
|
-
// someone types one. `keepsToken` is what tells the page it does not have to ask.
|
|
63
|
-
// A stored token cannot be detected: it is stripped before it reaches the page, so `saved.token` is always
|
|
64
|
-
// undefined. What can be known is that the section already exists, which is what the hint is based on.
|
|
65
|
-
const keepsToken = saved !== undefined
|
|
66
|
-
const [keys, setKeys] = useState<SshKey[] | undefined>(initialKeys === undefined ? undefined : [...initialKeys])
|
|
67
|
-
const [keysFailed, setKeysFailed] = useState(prefailed === true)
|
|
68
|
-
const [tested, setTested] = useState<TestResult>()
|
|
69
|
-
const [testing, setTesting] = useState(false)
|
|
70
|
-
const [notice, setNotice] = useState<string>()
|
|
71
|
-
|
|
72
|
-
// The form's value is the saved truth; the draft is what a person is editing on top of it, so it is only adopted
|
|
73
|
-
// when nothing has been typed yet — a form that reloads must not overwrite a half-finished edit.
|
|
74
|
-
useEffect(() => { if (saved !== undefined) setDraft(current => current ?? { ...saved, ssh: { ...DEFAULT_SSH, ...saved.ssh } }) }, [saved])
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
if (initialKeys !== undefined || prefailed === true) return
|
|
77
|
-
ask<KeysAnswer>('keys').then(answer => setKeys(answer.keys), () => setKeysFailed(true))
|
|
78
|
-
}, [initialKeys, prefailed])
|
|
79
|
-
|
|
80
|
-
const patch = (change: Partial<NodeConfig>) => setDraft(current => current === undefined ? current : { ...current, ...change })
|
|
81
|
-
const patchSsh = (change: Partial<NodeConfig['ssh']>) =>
|
|
82
|
-
setDraft(current => current === undefined ? current : { ...current, ssh: { ...current.ssh, ...change } })
|
|
83
|
-
|
|
84
|
-
const test = async () => {
|
|
85
|
-
if (draft === undefined) return
|
|
86
|
-
setTesting(true); setTested(undefined)
|
|
87
|
-
try {
|
|
88
|
-
setTested(await ask<TestResult>('test', { method: 'POST', body: JSON.stringify({ host: draft.ssh.host.trim(),
|
|
89
|
-
port: draft.ssh.port, user: draft.ssh.user.trim(), keyPath: draft.ssh.keyPath.trim() }) }))
|
|
90
|
-
} catch (error) { setTested({ ok: false, output: String(error) }) } finally { setTesting(false) }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const save = async () => {
|
|
94
|
-
if (draft === undefined || form === undefined) return
|
|
95
|
-
setNotice(undefined)
|
|
96
|
-
try {
|
|
97
|
-
const ok = await form.mutate(editsOf(draft), form.state.revision)
|
|
98
|
-
setNotice(ok ? t('saved') : t('saveFailed'))
|
|
99
|
-
} catch { setNotice(t('saveFailed')) }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (draft === undefined) return <div data-foreman-node-page="loading">{t('loading')}</div>
|
|
103
|
-
const address = addressPreview(draft)
|
|
104
|
-
const missing = emptyFields(draft, keepsToken)
|
|
105
|
-
return <div data-foreman-node-page="ready">
|
|
106
|
-
<h2>{t('title')}</h2>
|
|
107
|
-
<p className="dt-hint">{t('intro')}</p>
|
|
108
|
-
|
|
109
|
-
<section data-section="keys">
|
|
110
|
-
<h3>{t('keysSection')}</h3>
|
|
111
|
-
<p className="dt-hint">{t('keysHint')}</p>
|
|
112
|
-
{keysFailed
|
|
113
|
-
? <p data-keys="unavailable">{t('keysUnavailable')}</p>
|
|
114
|
-
: keys === undefined ? <p>{t('loading')}</p>
|
|
115
|
-
: keys.length === 0 ? <p data-keys="empty">{t('keysEmpty')}</p>
|
|
116
|
-
: <select aria-label={t('keysSection')} data-node-key value={draft.ssh.keyPath}
|
|
117
|
-
onChange={event => patchSsh({ keyPath: event.target.value })}>
|
|
118
|
-
<option value="">—</option>
|
|
119
|
-
{keys.map(key => <option key={key.path} value={key.path}>{keyLabel(key)}</option>)}
|
|
120
|
-
</select>}
|
|
121
|
-
<label>{t('keyPath')}
|
|
122
|
-
<input data-node-key-path value={draft.ssh.keyPath} disabled={!editable}
|
|
123
|
-
onChange={event => patchSsh({ keyPath: event.target.value })} />
|
|
124
|
-
</label>
|
|
125
|
-
</section>
|
|
126
|
-
|
|
127
|
-
<section data-section="ssh">
|
|
128
|
-
<h3>{t('sshSection')}</h3>
|
|
129
|
-
<label><input type="checkbox" data-node-ssh-enabled checked={draft.ssh.enabled} disabled={!editable}
|
|
130
|
-
onChange={event => patchSsh({ enabled: event.target.checked })} />{t('sshEnabled')}</label>
|
|
131
|
-
<p className="dt-hint">{t('sshEnabledHint')}</p>
|
|
132
|
-
{draft.ssh.enabled && <>
|
|
133
|
-
<label>{t('host')}<input data-node-host value={draft.ssh.host} disabled={!editable}
|
|
134
|
-
onChange={event => patchSsh({ host: event.target.value })} /></label>
|
|
135
|
-
<label>{t('port')}<input type="number" data-node-port value={draft.ssh.port} disabled={!editable}
|
|
136
|
-
onChange={event => patchSsh({ port: Number(event.target.value) || DEFAULT_SSH.port })} /></label>
|
|
137
|
-
<label>{t('user')}<input data-node-user value={draft.ssh.user} disabled={!editable}
|
|
138
|
-
onChange={event => patchSsh({ user: event.target.value })} /></label>
|
|
139
|
-
<label>{t('remotePort')}<input type="number" data-node-remote-port value={draft.ssh.remotePort} disabled={!editable}
|
|
140
|
-
onChange={event => patchSsh({ remotePort: Number(event.target.value) || DEFAULT_SSH.remotePort })} /></label>
|
|
141
|
-
<label>{t('localPort')}<input type="number" data-node-local-port value={draft.ssh.localPort} disabled={!editable}
|
|
142
|
-
onChange={event => patchSsh({ localPort: Number(event.target.value) || DEFAULT_SSH.localPort })} /></label>
|
|
143
|
-
<button type="button" data-node-test data-testing={testing ? 'true' : undefined}
|
|
144
|
-
disabled={!draft.ssh.host.trim() || !draft.ssh.user.trim() || !draft.ssh.keyPath.trim()}
|
|
145
|
-
onClick={() => { void test() }}>{testing ? t('testing') : t('test')}</button>
|
|
146
|
-
{tested !== undefined && <p data-test-result={testMessage(tested)}>
|
|
147
|
-
{tested.ok ? t('testOk')
|
|
148
|
-
: testMessage(tested) === 'passphrase' ? t('testPassphrase')
|
|
149
|
-
: testMessage(tested) === 'unresolved' ? t('testUnresolved')
|
|
150
|
-
: testMessage(tested) === 'timeout' ? t('testTimeout')
|
|
151
|
-
: `${t('testRefused')} ${tested.output}`}
|
|
152
|
-
</p>}
|
|
153
|
-
</>}
|
|
154
|
-
</section>
|
|
155
|
-
|
|
156
|
-
<section data-section="hub">
|
|
157
|
-
<h3>{t('hubSection')}</h3>
|
|
158
|
-
<label>{t('address')}
|
|
159
|
-
<input data-node-url value={address.address} readOnly={address.derived} disabled={!editable || address.derived}
|
|
160
|
-
onChange={event => patch({ url: event.target.value })} />
|
|
161
|
-
</label>
|
|
162
|
-
{address.derived && <p className="dt-hint">{t('addressDerived')}</p>}
|
|
163
|
-
<label>{t('deviceId')}<input data-node-device value={draft.deviceId} disabled={!editable}
|
|
164
|
-
onChange={event => patch({ deviceId: event.target.value })} /></label>
|
|
165
|
-
<label>{t('token')}<input type="password" data-node-token value={draft.token} disabled={!editable}
|
|
166
|
-
autoComplete="new-password" placeholder={keepsToken ? t('tokenStored') : ''}
|
|
167
|
-
onChange={event => patch({ token: event.target.value })} /></label>
|
|
168
|
-
<p className="dt-hint">{keepsToken ? t('tokenKeep') : t('tokenHint')}</p>
|
|
169
|
-
</section>
|
|
170
|
-
|
|
171
|
-
<section data-section="roots">
|
|
172
|
-
<h3>{t('rootsSection')}</h3>
|
|
173
|
-
<p className="dt-hint">{t('rootsHint')}</p>
|
|
174
|
-
{draft.roots.map((root, index) => <div key={index} data-root={index}>
|
|
175
|
-
<label>{t('project')}<input value={root.project} disabled={!editable}
|
|
176
|
-
onChange={event => patch({ roots: draft.roots.map((item, at) => at === index ? { ...item, project: event.target.value } : item) })} /></label>
|
|
177
|
-
<label>{t('path')}<input value={root.path} disabled={!editable}
|
|
178
|
-
onChange={event => patch({ roots: draft.roots.map((item, at) => at === index ? { ...item, path: event.target.value } : item) })} /></label>
|
|
179
|
-
</div>)}
|
|
180
|
-
<button type="button" disabled={!editable}
|
|
181
|
-
onClick={() => patch({ roots: [...draft.roots, { project: '', path: '' }] })}>{t('addRoot')}</button>
|
|
182
|
-
</section>
|
|
183
|
-
|
|
184
|
-
{missing.length > 0 && <p data-missing="true">{t('missing')}{missing.join(', ')}</p>}
|
|
185
|
-
{editable
|
|
186
|
-
? <button type="button" data-node-save onClick={() => { void save() }}>{t('save')}</button>
|
|
187
|
-
: <p data-read-only="true">{t('readOnly')}</p>}
|
|
188
|
-
{notice !== undefined && <p data-notice>{notice}</p>}
|
|
189
|
-
</div>
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The page as the Plugin Manager mounts it: locale and form come from the host. */
|
|
193
|
-
interface PageProps extends PluginConfigViewProps {
|
|
194
|
-
t: (key: keyof typeof zh) => string
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function Mounted({ t, view, form = undefined }: PageProps) {
|
|
198
|
-
// A summary row gets nothing: the full page belongs to the page view, and DSH's own config form shows the summary.
|
|
199
|
-
return view === 'summary' ? null : <DevicePage t={t} form={form} />
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Registers the page for the `dsh-foreman-node` row. */
|
|
203
|
-
export function apply(ctx: Context): void {
|
|
204
|
-
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-foreman-node: dictionaries')
|
|
205
|
-
const t = ctx.locale.bind(NS) as unknown as (key: keyof typeof zh) => string
|
|
206
|
-
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
207
|
-
name: 'plugins.row.config', key: 'dsh-foreman-node#dsh-foreman-node', locale: NS },
|
|
208
|
-
(props: PluginConfigViewProps) => <Mounted {...props} t={(props as { t?: typeof t }).t ?? t} />))
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export { DevicePage }
|
|
1
|
+
/**
|
|
2
|
+
* The device's configuration page, mounted in the DSH Plugin Manager.
|
|
3
|
+
*
|
|
4
|
+
* It does two things the page alone cannot: it lists the keys this machine has (through the device's own route, because
|
|
5
|
+
* a browser cannot read `~/.ssh`) and it tests one authentication (also through the device, because a browser cannot
|
|
6
|
+
* start ssh). Everything it *writes* goes through DSH's own configuration form, so the saved values land in the
|
|
7
|
+
* profile's patch exactly as if they had been typed there by hand.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
13
|
+
import type { ConfigPageForm, PluginConfigViewProps } from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
14
|
+
import { configured, type NodeConfig } from '../config.ts'
|
|
15
|
+
import type { SshKey } from '../keys.ts'
|
|
16
|
+
import type { TestResult } from '../routes.ts'
|
|
17
|
+
import { addressPreview, DEFAULT_SSH, editsOf, emptyFields, keyLabel, testMessage } from './page-state.ts'
|
|
18
|
+
import { en, zh } from './locales.ts'
|
|
19
|
+
|
|
20
|
+
/** The prefix the device's own routes answer under. */
|
|
21
|
+
const API = '/foreman-node/api'
|
|
22
|
+
|
|
23
|
+
/** A key list answer, as `GET /foreman-node/api/keys` sends it. */
|
|
24
|
+
interface KeysAnswer { keys: SshKey[]; directory: string }
|
|
25
|
+
|
|
26
|
+
/** Fetches a key list or runs one test; both need the marker header the device's same-origin check looks for. */
|
|
27
|
+
async function ask<T>(path: string, init?: RequestInit): Promise<T> {
|
|
28
|
+
const response = await fetch(new URL(`${API}/${path}`, window.location.href), {
|
|
29
|
+
...init, headers: { 'x-orchestrator-request': '1', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }), ...init?.headers } })
|
|
30
|
+
if (!response.ok) throw new Error(`the device answered ${response.status}`)
|
|
31
|
+
return await response.json() as T
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const inject = ['locale', 'slots']
|
|
35
|
+
export const name = 'dsh-foreman-node'
|
|
36
|
+
|
|
37
|
+
/** The locale namespace this page registers its dictionaries under. */
|
|
38
|
+
const NS = 'dshNode'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The page itself. `t` comes from the host's locale service, like every other plugin's page.
|
|
42
|
+
*
|
|
43
|
+
* `initialKeys` and `keysFailed` exist so a test can render a page without a network or a web server. Production never
|
|
44
|
+
* passes them, so the page fetches as it otherwise would — they change nothing about how it behaves.
|
|
45
|
+
*/
|
|
46
|
+
function DevicePage({ form, t, initialKeys, keysFailed: prefailed }: {
|
|
47
|
+
t: (key: keyof typeof zh) => string
|
|
48
|
+
form: ConfigPageForm | undefined
|
|
49
|
+
initialKeys?: readonly SshKey[]
|
|
50
|
+
keysFailed?: boolean
|
|
51
|
+
}) {
|
|
52
|
+
// The draft starts from the saved value when there is one, so the first render already shows the configuration
|
|
53
|
+
// rather than a loading line that only an effect would replace.
|
|
54
|
+
// The host hands over the section's raw value; every field is volatile, so it is unwrapped once here.
|
|
55
|
+
const saved = form?.state.value === undefined ? undefined : configured(form.state.value as Partial<NodeConfig>)
|
|
56
|
+
const editable = form?.state.status === 'ready' && form.state.writable
|
|
57
|
+
// The draft starts from the saved value when there is one, so the first render already shows the configuration
|
|
58
|
+
// rather than a loading line that only an effect would replace.
|
|
59
|
+
const [draft, setDraft] = useState<NodeConfig | undefined>(() =>
|
|
60
|
+
saved === undefined ? undefined : { ...saved, token: '', ssh: { ...DEFAULT_SSH, ...saved.ssh } })
|
|
61
|
+
// The stored token never reaches the page (it is a secret), so the box starts empty and is only written when
|
|
62
|
+
// someone types one. `keepsToken` is what tells the page it does not have to ask.
|
|
63
|
+
// A stored token cannot be detected: it is stripped before it reaches the page, so `saved.token` is always
|
|
64
|
+
// undefined. What can be known is that the section already exists, which is what the hint is based on.
|
|
65
|
+
const keepsToken = saved !== undefined
|
|
66
|
+
const [keys, setKeys] = useState<SshKey[] | undefined>(initialKeys === undefined ? undefined : [...initialKeys])
|
|
67
|
+
const [keysFailed, setKeysFailed] = useState(prefailed === true)
|
|
68
|
+
const [tested, setTested] = useState<TestResult>()
|
|
69
|
+
const [testing, setTesting] = useState(false)
|
|
70
|
+
const [notice, setNotice] = useState<string>()
|
|
71
|
+
|
|
72
|
+
// The form's value is the saved truth; the draft is what a person is editing on top of it, so it is only adopted
|
|
73
|
+
// when nothing has been typed yet — a form that reloads must not overwrite a half-finished edit.
|
|
74
|
+
useEffect(() => { if (saved !== undefined) setDraft(current => current ?? { ...saved, ssh: { ...DEFAULT_SSH, ...saved.ssh } }) }, [saved])
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (initialKeys !== undefined || prefailed === true) return
|
|
77
|
+
ask<KeysAnswer>('keys').then(answer => setKeys(answer.keys), () => setKeysFailed(true))
|
|
78
|
+
}, [initialKeys, prefailed])
|
|
79
|
+
|
|
80
|
+
const patch = (change: Partial<NodeConfig>) => setDraft(current => current === undefined ? current : { ...current, ...change })
|
|
81
|
+
const patchSsh = (change: Partial<NodeConfig['ssh']>) =>
|
|
82
|
+
setDraft(current => current === undefined ? current : { ...current, ssh: { ...current.ssh, ...change } })
|
|
83
|
+
|
|
84
|
+
const test = async () => {
|
|
85
|
+
if (draft === undefined) return
|
|
86
|
+
setTesting(true); setTested(undefined)
|
|
87
|
+
try {
|
|
88
|
+
setTested(await ask<TestResult>('test', { method: 'POST', body: JSON.stringify({ host: draft.ssh.host.trim(),
|
|
89
|
+
port: draft.ssh.port, user: draft.ssh.user.trim(), keyPath: draft.ssh.keyPath.trim() }) }))
|
|
90
|
+
} catch (error) { setTested({ ok: false, output: String(error) }) } finally { setTesting(false) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const save = async () => {
|
|
94
|
+
if (draft === undefined || form === undefined) return
|
|
95
|
+
setNotice(undefined)
|
|
96
|
+
try {
|
|
97
|
+
const ok = await form.mutate(editsOf(draft), form.state.revision)
|
|
98
|
+
setNotice(ok ? t('saved') : t('saveFailed'))
|
|
99
|
+
} catch { setNotice(t('saveFailed')) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (draft === undefined) return <div data-foreman-node-page="loading">{t('loading')}</div>
|
|
103
|
+
const address = addressPreview(draft)
|
|
104
|
+
const missing = emptyFields(draft, keepsToken)
|
|
105
|
+
return <div data-foreman-node-page="ready">
|
|
106
|
+
<h2>{t('title')}</h2>
|
|
107
|
+
<p className="dt-hint">{t('intro')}</p>
|
|
108
|
+
|
|
109
|
+
<section data-section="keys">
|
|
110
|
+
<h3>{t('keysSection')}</h3>
|
|
111
|
+
<p className="dt-hint">{t('keysHint')}</p>
|
|
112
|
+
{keysFailed
|
|
113
|
+
? <p data-keys="unavailable">{t('keysUnavailable')}</p>
|
|
114
|
+
: keys === undefined ? <p>{t('loading')}</p>
|
|
115
|
+
: keys.length === 0 ? <p data-keys="empty">{t('keysEmpty')}</p>
|
|
116
|
+
: <select aria-label={t('keysSection')} data-node-key value={draft.ssh.keyPath}
|
|
117
|
+
onChange={event => patchSsh({ keyPath: event.target.value })}>
|
|
118
|
+
<option value="">—</option>
|
|
119
|
+
{keys.map(key => <option key={key.path} value={key.path}>{keyLabel(key)}</option>)}
|
|
120
|
+
</select>}
|
|
121
|
+
<label>{t('keyPath')}
|
|
122
|
+
<input data-node-key-path value={draft.ssh.keyPath} disabled={!editable}
|
|
123
|
+
onChange={event => patchSsh({ keyPath: event.target.value })} />
|
|
124
|
+
</label>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section data-section="ssh">
|
|
128
|
+
<h3>{t('sshSection')}</h3>
|
|
129
|
+
<label><input type="checkbox" data-node-ssh-enabled checked={draft.ssh.enabled} disabled={!editable}
|
|
130
|
+
onChange={event => patchSsh({ enabled: event.target.checked })} />{t('sshEnabled')}</label>
|
|
131
|
+
<p className="dt-hint">{t('sshEnabledHint')}</p>
|
|
132
|
+
{draft.ssh.enabled && <>
|
|
133
|
+
<label>{t('host')}<input data-node-host value={draft.ssh.host} disabled={!editable}
|
|
134
|
+
onChange={event => patchSsh({ host: event.target.value })} /></label>
|
|
135
|
+
<label>{t('port')}<input type="number" data-node-port value={draft.ssh.port} disabled={!editable}
|
|
136
|
+
onChange={event => patchSsh({ port: Number(event.target.value) || DEFAULT_SSH.port })} /></label>
|
|
137
|
+
<label>{t('user')}<input data-node-user value={draft.ssh.user} disabled={!editable}
|
|
138
|
+
onChange={event => patchSsh({ user: event.target.value })} /></label>
|
|
139
|
+
<label>{t('remotePort')}<input type="number" data-node-remote-port value={draft.ssh.remotePort} disabled={!editable}
|
|
140
|
+
onChange={event => patchSsh({ remotePort: Number(event.target.value) || DEFAULT_SSH.remotePort })} /></label>
|
|
141
|
+
<label>{t('localPort')}<input type="number" data-node-local-port value={draft.ssh.localPort} disabled={!editable}
|
|
142
|
+
onChange={event => patchSsh({ localPort: Number(event.target.value) || DEFAULT_SSH.localPort })} /></label>
|
|
143
|
+
<button type="button" data-node-test data-testing={testing ? 'true' : undefined}
|
|
144
|
+
disabled={!draft.ssh.host.trim() || !draft.ssh.user.trim() || !draft.ssh.keyPath.trim()}
|
|
145
|
+
onClick={() => { void test() }}>{testing ? t('testing') : t('test')}</button>
|
|
146
|
+
{tested !== undefined && <p data-test-result={testMessage(tested)}>
|
|
147
|
+
{tested.ok ? t('testOk')
|
|
148
|
+
: testMessage(tested) === 'passphrase' ? t('testPassphrase')
|
|
149
|
+
: testMessage(tested) === 'unresolved' ? t('testUnresolved')
|
|
150
|
+
: testMessage(tested) === 'timeout' ? t('testTimeout')
|
|
151
|
+
: `${t('testRefused')} ${tested.output}`}
|
|
152
|
+
</p>}
|
|
153
|
+
</>}
|
|
154
|
+
</section>
|
|
155
|
+
|
|
156
|
+
<section data-section="hub">
|
|
157
|
+
<h3>{t('hubSection')}</h3>
|
|
158
|
+
<label>{t('address')}
|
|
159
|
+
<input data-node-url value={address.address} readOnly={address.derived} disabled={!editable || address.derived}
|
|
160
|
+
onChange={event => patch({ url: event.target.value })} />
|
|
161
|
+
</label>
|
|
162
|
+
{address.derived && <p className="dt-hint">{t('addressDerived')}</p>}
|
|
163
|
+
<label>{t('deviceId')}<input data-node-device value={draft.deviceId} disabled={!editable}
|
|
164
|
+
onChange={event => patch({ deviceId: event.target.value })} /></label>
|
|
165
|
+
<label>{t('token')}<input type="password" data-node-token value={draft.token} disabled={!editable}
|
|
166
|
+
autoComplete="new-password" placeholder={keepsToken ? t('tokenStored') : ''}
|
|
167
|
+
onChange={event => patch({ token: event.target.value })} /></label>
|
|
168
|
+
<p className="dt-hint">{keepsToken ? t('tokenKeep') : t('tokenHint')}</p>
|
|
169
|
+
</section>
|
|
170
|
+
|
|
171
|
+
<section data-section="roots">
|
|
172
|
+
<h3>{t('rootsSection')}</h3>
|
|
173
|
+
<p className="dt-hint">{t('rootsHint')}</p>
|
|
174
|
+
{draft.roots.map((root, index) => <div key={index} data-root={index}>
|
|
175
|
+
<label>{t('project')}<input value={root.project} disabled={!editable}
|
|
176
|
+
onChange={event => patch({ roots: draft.roots.map((item, at) => at === index ? { ...item, project: event.target.value } : item) })} /></label>
|
|
177
|
+
<label>{t('path')}<input value={root.path} disabled={!editable}
|
|
178
|
+
onChange={event => patch({ roots: draft.roots.map((item, at) => at === index ? { ...item, path: event.target.value } : item) })} /></label>
|
|
179
|
+
</div>)}
|
|
180
|
+
<button type="button" disabled={!editable}
|
|
181
|
+
onClick={() => patch({ roots: [...draft.roots, { project: '', path: '' }] })}>{t('addRoot')}</button>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
{missing.length > 0 && <p data-missing="true">{t('missing')}{missing.join(', ')}</p>}
|
|
185
|
+
{editable
|
|
186
|
+
? <button type="button" data-node-save onClick={() => { void save() }}>{t('save')}</button>
|
|
187
|
+
: <p data-read-only="true">{t('readOnly')}</p>}
|
|
188
|
+
{notice !== undefined && <p data-notice>{notice}</p>}
|
|
189
|
+
</div>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The page as the Plugin Manager mounts it: locale and form come from the host. */
|
|
193
|
+
interface PageProps extends PluginConfigViewProps {
|
|
194
|
+
t: (key: keyof typeof zh) => string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Mounted({ t, view, form = undefined }: PageProps) {
|
|
198
|
+
// A summary row gets nothing: the full page belongs to the page view, and DSH's own config form shows the summary.
|
|
199
|
+
return view === 'summary' ? null : <DevicePage t={t} form={form} />
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Registers the page for the `dsh-foreman-node` row. */
|
|
203
|
+
export function apply(ctx: Context): void {
|
|
204
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-foreman-node: dictionaries')
|
|
205
|
+
const t = ctx.locale.bind(NS) as unknown as (key: keyof typeof zh) => string
|
|
206
|
+
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
|
|
207
|
+
name: 'plugins.row.config', key: 'dsh-foreman-node#dsh-foreman-node', locale: NS },
|
|
208
|
+
(props: PluginConfigViewProps) => <Mounted {...props} t={(props as { t?: typeof t }).t ?? t} />))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { DevicePage }
|
package/src/config.ts
CHANGED
|
@@ -49,6 +49,14 @@ export interface NodeConfig {
|
|
|
49
49
|
reconnectMs: number
|
|
50
50
|
/** How to reach a hub that is not directly reachable. */
|
|
51
51
|
ssh: SshConfig
|
|
52
|
+
/**
|
|
53
|
+
* The permission preset a job runs under.
|
|
54
|
+
*
|
|
55
|
+
* Defaults to `workspace-write`, which is bounded to the declared directory but asks before leaving it. A device has
|
|
56
|
+
* nobody sitting in front of it, so a question that stops the job is worse than it looks: the work simply hangs.
|
|
57
|
+
* Configure a preset that keeps the boundary and never asks — that is the operator's call, on their own machine.
|
|
58
|
+
*/
|
|
59
|
+
jobPreset: string
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/** The SSH defaults, spread over whatever the host hands in, so a partial block is still complete. */
|
|
@@ -95,6 +103,7 @@ export function configured(config: object | undefined): NodeConfig {
|
|
|
95
103
|
return { url: valueOf(source['url'] as string, ''), deviceId: valueOf(source['deviceId'] as string, ''),
|
|
96
104
|
token: valueOf(source['token'] as string, ''), roots: valueOf(source['roots'] as NodeRoot[], []),
|
|
97
105
|
heartbeatMs: valueOf(source['heartbeatMs'] as number, 15_000), reconnectMs: valueOf(source['reconnectMs'] as number, 1_000),
|
|
106
|
+
jobPreset: valueOf(source['jobPreset'] as string, 'workspace-write'),
|
|
98
107
|
// `ssh` holds volatile fields too, so each one is unwrapped like the top level.
|
|
99
108
|
ssh: { ...SSH_DEFAULTS, ...unwrapSsh(source['ssh']) } }
|
|
100
109
|
}
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,8 @@ const ConfigSchema = s.object({
|
|
|
59
59
|
localPort: s.natural().min(1).max(65535).default(8790).volatile().description('The local port the tunnel listens on; the device dials this.'),
|
|
60
60
|
}).default({ enabled: false, host: '', port: 22, user: '', keyPath: '', remotePort: 8790, localPort: 8790 })
|
|
61
61
|
.description('How to reach the hub when it is not directly reachable: a tunnel this device opens and keeps open.'),
|
|
62
|
+
jobPreset: s.string().default('workspace-write').volatile()
|
|
63
|
+
.description('The permission preset a job runs under. Use one that keeps the workspace boundary and never asks, because nobody is at this machine to answer.'),
|
|
62
64
|
})
|
|
63
65
|
/**
|
|
64
66
|
* Why every field is volatile.
|
|
@@ -154,7 +156,7 @@ export async function apply(ctx: Context, raw: ConfigInput): Promise<void> {
|
|
|
154
156
|
ctx.logger.info('%s', missing)
|
|
155
157
|
return
|
|
156
158
|
}
|
|
157
|
-
const jobs = new JobSessions(ctx)
|
|
159
|
+
const jobs = new JobSessions(ctx, config.jobPreset)
|
|
158
160
|
const client = new NodeClient({
|
|
159
161
|
url: hubUrlOf(config),
|
|
160
162
|
deviceId: config.deviceId,
|
package/src/session.ts
CHANGED
|
@@ -25,7 +25,14 @@ declare module '@deepseek-ai/dsh-llm' {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The permission preset a job runs under when the device's configuration names none.
|
|
30
|
+
*
|
|
31
|
+
* Bounded to the directory the device declared. A job on a machine with nobody in front of it cannot answer an
|
|
32
|
+
* approval prompt, so the useful alternative is not a wider sandbox but a quiet one: a preset that keeps this
|
|
33
|
+
* boundary and never asks. That is the device operator's decision, taken in their own configuration, because it is
|
|
34
|
+
* their machine and their risk.
|
|
35
|
+
*/
|
|
29
36
|
export const JOB_PRESET = 'workspace-write'
|
|
30
37
|
|
|
31
38
|
/** The Session a job runs in, named the way a hub names its own worker: `job-<assignmentId>`. */
|
|
@@ -60,8 +67,15 @@ export class JobSessions {
|
|
|
60
67
|
private readonly jobs = new Map<string, string>()
|
|
61
68
|
/** Assignments whose Session already carries the report tool, so a redelivered assign registers it only once. */
|
|
62
69
|
private readonly reported = new Set<string>()
|
|
70
|
+
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
71
|
+
private readonly reports = new Set<string>()
|
|
63
72
|
|
|
64
|
-
|
|
73
|
+
/**
|
|
74
|
+
* @param preset the permission preset a job runs under. The device operator chooses it in their own configuration:
|
|
75
|
+
* a machine with nobody in front of it cannot answer an approval prompt, so the useful choice is usually a preset
|
|
76
|
+
* that keeps the workspace boundary and never asks.
|
|
77
|
+
*/
|
|
78
|
+
constructor(private readonly ctx: Context, private readonly preset: string = JOB_PRESET) {}
|
|
65
79
|
|
|
66
80
|
/**
|
|
67
81
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
@@ -85,7 +99,7 @@ export class JobSessions {
|
|
|
85
99
|
if ('error' in result) throw result.error
|
|
86
100
|
await workspace.attachSession(id as SessionId)
|
|
87
101
|
// The one place the device's policy differs from the hub's: bounded, because no person is here to approve more.
|
|
88
|
-
this.ctx.permissionPresets.set(result.agent.session,
|
|
102
|
+
this.ctx.permissionPresets.set(result.agent.session, this.preset)
|
|
89
103
|
await this.ctx.sessionController.rename({ sessionId: id as SessionId, title: input.title })
|
|
90
104
|
if (onReport !== undefined && !this.reported.has(input.assignmentId)) {
|
|
91
105
|
this.reported.add(input.assignmentId)
|
|
@@ -147,10 +161,64 @@ export class JobSessions {
|
|
|
147
161
|
/** Attach the `foreman_report` tool to a job Session, disposed with it. */
|
|
148
162
|
reportTools(agent: Agent, assignmentId: string, onReport: (assignmentId: string, report: DeviceReport) => void): void {
|
|
149
163
|
const registrar = agent.ctx.tools
|
|
150
|
-
|
|
164
|
+
// Wrap the reporter so this Session's own turn ending can tell whether a report ever went out.
|
|
165
|
+
const reported = this.reports
|
|
166
|
+
const report = (id: string, deviceReport: DeviceReport): void => { reported.add(id); onReport(id, deviceReport) }
|
|
167
|
+
const tool = foremanReportTool({ report }, assignmentId)
|
|
151
168
|
registrar.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
|
|
152
169
|
output: { schema: { type: 'object', additionalProperties: true },
|
|
153
170
|
render: (_args: unknown, value: unknown) => [{ type: 'text' as const, text: JSON.stringify(value) }] },
|
|
154
171
|
execute: (args: unknown, execution: { signal: AbortSignal }) => tool.execute(args as Record<string, unknown>, execution) })
|
|
172
|
+
this.watchTurnEnd(agent, assignmentId, report)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Report for a job that ended without calling the tool.
|
|
177
|
+
*
|
|
178
|
+
* Telling the model to report is not a guarantee: on a real deployment a device did the work, wrote a complete
|
|
179
|
+
* answer, and never called `foreman_report`, so the hub waited at `running` forever. The tool alone leaves that
|
|
180
|
+
* failure open, so a turn that ends with no report is closed out here — `ready`, with the last thing the model said
|
|
181
|
+
* as the summary. A person still gets the result; the ledger still learns the job ended.
|
|
182
|
+
*/
|
|
183
|
+
private watchTurnEnd(agent: Agent, assignmentId: string, report: (id: string, r: DeviceReport) => void): void {
|
|
184
|
+
// The watcher is a safety net, not part of the tool: a host that cannot register it must still get the tool,
|
|
185
|
+
// because a job with no reporting channel at all is worse than one whose turn end is not observed.
|
|
186
|
+
if (typeof agent.ctx.effect !== 'function') return
|
|
187
|
+
agent.ctx.effect(() => {
|
|
188
|
+
const dispose = agent.ctx.on('session/event', (session, event) => {
|
|
189
|
+
if (session.id !== agent.session.id || event.type !== 'turn/end') return
|
|
190
|
+
if (this.reports.has(assignmentId)) return
|
|
191
|
+
// Reading the history is best effort: an unreadable history still closes the job out, because leaving the
|
|
192
|
+
// hub at `running` forever is the worse failure.
|
|
193
|
+
void this.lastText(agent).then(text => {
|
|
194
|
+
if (this.reports.has(assignmentId)) return
|
|
195
|
+
report(assignmentId, { state: 'ready', summary: text === '' ? 'the job finished without a report' : text.slice(0, 600) })
|
|
196
|
+
}, () => {
|
|
197
|
+
if (this.reports.has(assignmentId)) return
|
|
198
|
+
report(assignmentId, { state: 'ready', summary: 'the job finished without a report' })
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
return () => { void dispose }
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The last text the assistant said in a Session, read from its stored history. */
|
|
206
|
+
private async lastText(agent: Agent): Promise<string> {
|
|
207
|
+
await this.ctx.sessionPersistence.flush()
|
|
208
|
+
await using handle = await this.ctx.sessionPersistence.open(agent.id, 'read')
|
|
209
|
+
let found = ''
|
|
210
|
+
for (let offset = 0; ;) {
|
|
211
|
+
const { events } = await handle.read(offset, 512)
|
|
212
|
+
if (events.length === 0) break
|
|
213
|
+
for (const event of events) {
|
|
214
|
+
if (event.type !== 'assistant/message') continue
|
|
215
|
+
const parts = (event.data as unknown as { message?: { content?: readonly { type?: string; text?: string }[] } })
|
|
216
|
+
?.message?.content ?? []
|
|
217
|
+
const text = parts.filter(part => part?.type === 'text').map(part => part.text ?? '').join(' ').trim()
|
|
218
|
+
if (text !== '') found = text
|
|
219
|
+
}
|
|
220
|
+
offset += events.length
|
|
221
|
+
}
|
|
222
|
+
return found
|
|
155
223
|
}
|
|
156
224
|
}
|