dsh-foreman-node 0.2.1 → 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 +18 -0
- package/lib/client.js +1 -0
- package/lib/index.d.ts +15 -1
- package/lib/index.js +21 -5
- 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 +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
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
|
+
|
|
1
19
|
## 0.2.1 — 2026-11-08
|
|
2
20
|
|
|
3
21
|
### 修复:任务跑完了,却没有回报给 hub
|
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,13 +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`. */
|
|
348
357
|
private readonly reports;
|
|
349
|
-
|
|
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);
|
|
350
364
|
/**
|
|
351
365
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
352
366
|
* register it so a heartbeat counts it.
|
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,14 +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`. */
|
|
881
890
|
reports = /* @__PURE__ */ new Set();
|
|
882
|
-
|
|
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) {
|
|
883
897
|
this.ctx = ctx;
|
|
898
|
+
this.preset = preset;
|
|
884
899
|
}
|
|
885
900
|
/**
|
|
886
901
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
@@ -906,7 +921,7 @@ var JobSessions = class {
|
|
|
906
921
|
}
|
|
907
922
|
if ("error" in result) throw result.error;
|
|
908
923
|
await workspace.attachSession(id);
|
|
909
|
-
this.ctx.permissionPresets.set(result.agent.session,
|
|
924
|
+
this.ctx.permissionPresets.set(result.agent.session, this.preset);
|
|
910
925
|
await this.ctx.sessionController.rename({
|
|
911
926
|
sessionId: id,
|
|
912
927
|
title: input.title
|
|
@@ -1100,7 +1115,8 @@ const Typed = s.object({
|
|
|
1100
1115
|
keyPath: "",
|
|
1101
1116
|
remotePort: 8790,
|
|
1102
1117
|
localPort: 8790
|
|
1103
|
-
}).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.")
|
|
1104
1120
|
});
|
|
1105
1121
|
/**
|
|
1106
1122
|
* What this configuration still needs before the device can dial its hub, or undefined when it is complete.
|
|
@@ -1161,7 +1177,7 @@ async function apply(ctx, raw) {
|
|
|
1161
1177
|
ctx.logger.info("%s", missing);
|
|
1162
1178
|
return;
|
|
1163
1179
|
}
|
|
1164
|
-
const jobs = new JobSessions(ctx);
|
|
1180
|
+
const jobs = new JobSessions(ctx, config.jobPreset);
|
|
1165
1181
|
const client = new NodeClient({
|
|
1166
1182
|
url: hubUrlOf(config),
|
|
1167
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>`. */
|
|
@@ -63,7 +70,12 @@ export class JobSessions {
|
|
|
63
70
|
/** Assignments that have actually reported; a turn ending without one is closed out by `watchTurnEnd`. */
|
|
64
71
|
private readonly reports = new Set<string>()
|
|
65
72
|
|
|
66
|
-
|
|
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) {}
|
|
67
79
|
|
|
68
80
|
/**
|
|
69
81
|
* Resolve or create the `job-<assignmentId>` Session in the given directory, attach its `foreman_report` tool, and
|
|
@@ -87,7 +99,7 @@ export class JobSessions {
|
|
|
87
99
|
if ('error' in result) throw result.error
|
|
88
100
|
await workspace.attachSession(id as SessionId)
|
|
89
101
|
// The one place the device's policy differs from the hub's: bounded, because no person is here to approve more.
|
|
90
|
-
this.ctx.permissionPresets.set(result.agent.session,
|
|
102
|
+
this.ctx.permissionPresets.set(result.agent.session, this.preset)
|
|
91
103
|
await this.ctx.sessionController.rename({ sessionId: id as SessionId, title: input.title })
|
|
92
104
|
if (onReport !== undefined && !this.reported.has(input.assignmentId)) {
|
|
93
105
|
this.reported.add(input.assignmentId)
|