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 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
- constructor(ctx: Context);
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
- /** The permission preset a job runs under: bounded to the directory the device declared. */
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
- constructor(ctx) {
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, JOB_PRESET);
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.1",
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",
@@ -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
- /** The permission preset a job runs under: bounded to the directory the device declared. */
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
- constructor(private readonly ctx: Context) {}
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, JOB_PRESET)
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)