dsh-push-host 0.0.0-stage → 0.3.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pairing payload helpers for the DSH push host plugin (pure functions; no I/O).
3
+ *
4
+ * `deploy/relay-user-add.sh <user>` already emits the pairing payload the phone needs; this module
5
+ * emits the *same schema with the same field names* from the host side, so the project has exactly
6
+ * one payload vocabulary:
7
+ *
8
+ * { "v": 1, "relay": "<relay base>", "host": "<hostId>", "topic": "<ntfy topic>",
9
+ * "writeToken": "tk_…", // host → ntfy (this plugin publishes with it)
10
+ * "readToken": "tk_…", // phone → ntfy (the App subscribes with it)
11
+ * "hostToken": "hk_…" } // host → relay queue
12
+ *
13
+ * `buildPairingPayload` never invents values: every field comes from the credentials file (or, for
14
+ * `relay`, from the relay base the host will actually use), and a payload with a missing field is
15
+ * reported through `missing` instead of being emitted half-empty.
16
+ */
17
+
18
+ /** Field order, identical to the payload `relay-user-add.sh` prints. */
19
+ export const PAYLOAD_FIELDS = ['v', 'relay', 'host', 'topic', 'writeToken', 'readToken', 'hostToken']
20
+
21
+ /**
22
+ * Optional extras (v0.1.1): written only when known. `relay-user-add.sh` cannot know the host's
23
+ * web base (only the operator knows which address the phone should dial), so these come from
24
+ * `dsh-push-pair payload --web-base/--web-name` or from the credentials file. Absent = the phone
25
+ * and the plugin behave exactly as before.
26
+ */
27
+ export const PAYLOAD_OPTIONAL_FIELDS = ['webBase', 'webName']
28
+
29
+ /** Payload schema version — the `v` that `relay-user-add.sh` writes. */
30
+ export const PAYLOAD_VERSION = 1
31
+
32
+ /**
33
+ * @param sources `{ credentials, relayBase }` (credentials use credentials-file vocabulary).
34
+ * @returns `{ payload, missing }`; `missing` lists the payload fields that could not be filled.
35
+ */
36
+ export function buildPairingPayload(sources = {}) {
37
+ const credentials = sources.credentials ?? {}
38
+ const candidates = {
39
+ relay: String(sources.relayBase ?? credentials.relayBase ?? '').replace(/\/+$/, ''),
40
+ host: String(credentials.host ?? '').trim(),
41
+ topic: String(credentials.topic ?? '').trim(),
42
+ writeToken: String(credentials.ntfyToken ?? '').trim(),
43
+ readToken: String(credentials.phoneToken ?? '').trim(),
44
+ hostToken: String(credentials.hostToken ?? '').trim(),
45
+ }
46
+ const missing = Object.keys(candidates).filter((field) => candidates[field].length === 0)
47
+ const payload = { v: PAYLOAD_VERSION, ...candidates }
48
+ for (const field of PAYLOAD_OPTIONAL_FIELDS) {
49
+ const value = String(credentials[field] ?? '').trim()
50
+ if (value.length > 0) payload[field] = value
51
+ }
52
+ return { payload, missing }
53
+ }
54
+
55
+ /** Human-readable one-liner for the operator's terminal (tokens shortened). */
56
+ export function summarizePayload(payload) {
57
+ const short = (value, keep = 10) => {
58
+ const text = String(value ?? '')
59
+ return text.length > keep ? text.slice(0, keep) + '…' : text
60
+ }
61
+ return (
62
+ 'host=' + String(payload?.host ?? '') +
63
+ ' topic=' + String(payload?.topic ?? '') +
64
+ ' relay=' + String(payload?.relay ?? '') +
65
+ ' writeToken=' + short(payload?.writeToken) +
66
+ ' readToken=' + short(payload?.readToken) +
67
+ ' hostToken=' + short(payload?.hostToken)
68
+ )
69
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pairing tool for the DSH push host plugin — the operator's single command for onboarding one user.
4
+ *
5
+ * dsh-push-pair [payload] [options] print the pairing payload for one user's phone
6
+ * dsh-push-pair verify [options] check a credentials file against the live relay + ntfy
7
+ *
8
+ * Options (both commands):
9
+ * --credentials-file <path> credentials JSON (default: $DSH_PUSH_CREDENTIALS or
10
+ * ~/.config/dsh-push/credentials.json)
11
+ * --relay <url> relay base to put in the payload (default: credentials.relayBase)
12
+ * --host/--topic/--write-token/--read-token/--host-token <value> one-off overrides
13
+ * --web-base/--web-name <value> optional payload extras (host web link base / instance name)
14
+ * --pretty indent the JSON output
15
+ * --json machine-readable result for `verify`
16
+ * --help usage
17
+ *
18
+ * Schema: identical to what `deploy/relay-user-add.sh` prints (`v`, `relay`, `host`, `topic`,
19
+ * `writeToken`, `readToken`, `hostToken`) — one payload vocabulary in the project, not two. The file
20
+ * it reads is the same credentials file the plugin reads, so one saved payload configures the host
21
+ * side and is handed to the phone unchanged.
22
+ *
23
+ * `verify` performs read-only checks plus one harmless ntfy test message; it never reads another
24
+ * host's queue and never changes relay state.
25
+ */
26
+ import { credentialsPath, loadCredentials } from './credentials.mjs'
27
+ import { PAYLOAD_FIELDS, PAYLOAD_VERSION, buildPairingPayload, summarizePayload } from './pairing-lib.mjs'
28
+
29
+ const USAGE = `usage:
30
+ dsh-push-pair [payload] [options] print the pairing payload for one user's phone
31
+ dsh-push-pair verify [options] check a credentials file against the live relay + ntfy
32
+
33
+ options:
34
+ --credentials-file <path> credentials JSON file (default: $DSH_PUSH_CREDENTIALS or
35
+ ~/.config/dsh-push/credentials.json)
36
+ --relay <url> relay base written into the payload
37
+ --host <id> --topic <name> overrides for a one-off payload
38
+ --write-token <tk_...> --read-token <tk_...> --host-token <hk_...>
39
+ --web-base <url> host-side web base for the #web link (optional; written into the
40
+ payload so the credentials file carries it too)
41
+ --web-name <name> friendly instance name for #name (optional)
42
+ --pretty indented JSON (payload)
43
+ --json machine-readable JSON (verify)
44
+ --help this text
45
+
46
+ payload schema (v${PAYLOAD_VERSION}, same field names as deploy/relay-user-add.sh):
47
+ ${PAYLOAD_FIELDS.join(', ')}
48
+
49
+ writeToken = host -> ntfy (this plugin publishes with it)
50
+ readToken = phone -> ntfy (the App subscribes with it)
51
+ hostToken = host -> relay queue token
52
+ `
53
+
54
+ /** Tiny `--flag value` / `--flag=value` parser (no dependency, no surprises). */
55
+ export function parseArgs(argv = []) {
56
+ const options = { command: 'payload', flags: {}, overrides: {} }
57
+ const aliases = {
58
+ '--credentials-file': 'credentialsFile',
59
+ '--relay': 'relayBase',
60
+ '--host': 'host',
61
+ '--topic': 'topic',
62
+ '--write-token': 'ntfyToken',
63
+ '--read-token': 'phoneToken',
64
+ '--host-token': 'hostToken',
65
+ '--web-base': 'webBase',
66
+ '--web-name': 'webName',
67
+ }
68
+ const booleans = { '--pretty': 'pretty', '--json': 'json', '--help': 'help' }
69
+ const queue = [...argv]
70
+ while (queue.length > 0) {
71
+ const raw = queue.shift()
72
+ if (raw === 'payload' || raw === 'verify') {
73
+ options.command = raw
74
+ continue
75
+ }
76
+ const equals = raw.indexOf('=')
77
+ const flag = equals > 0 ? raw.slice(0, equals) : raw
78
+ if (Object.hasOwn(booleans, flag)) {
79
+ options.flags[booleans[flag]] = equals > 0 ? raw.slice(equals + 1) !== 'false' : true
80
+ continue
81
+ }
82
+ if (Object.hasOwn(aliases, flag)) {
83
+ const value = equals > 0 ? raw.slice(equals + 1) : queue.shift()
84
+ if (value === undefined) throw new Error('missing value for ' + flag)
85
+ options.overrides[aliases[flag]] = value
86
+ continue
87
+ }
88
+ if (raw === '-h') {
89
+ options.flags.help = true
90
+ continue
91
+ }
92
+ throw new Error('unknown argument ' + JSON.stringify(raw))
93
+ }
94
+ return options
95
+ }
96
+
97
+ /** Credentials file + per-invocation overrides. */
98
+ export function load(options = {}, env = process.env) {
99
+ const path = credentialsPath({ credentialsFile: options.overrides?.credentialsFile ?? '' }, env)
100
+ const file = loadCredentials(path)
101
+ const credentials = { ...file.value }
102
+ for (const field of ['host', 'topic', 'ntfyToken', 'phoneToken', 'hostToken', 'relayBase', 'webBase', 'webName']) {
103
+ const value = options.overrides?.[field]
104
+ if (typeof value === 'string' && value.trim().length > 0) credentials[field] = value.trim()
105
+ }
106
+ return { path, file, credentials }
107
+ }
108
+
109
+ async function fetchStatus(url, headers = {}) {
110
+ const response = await fetch(url, { headers })
111
+ return response.status
112
+ }
113
+
114
+ /** Read-only relay/ntfy checks; the ntfy test message proves the write token actually publishes. */
115
+ export async function verify(options = {}, env = process.env) {
116
+ const { path, file, credentials } = load(options, env)
117
+ const relay = String(credentials.relayBase ?? '').replace(/\/+$/, '')
118
+ const results = []
119
+
120
+ if (!file.present) results.push({ check: 'credentials-file', ok: false, detail: 'missing: ' + path })
121
+ else if (file.error !== undefined) results.push({ check: 'credentials-file', ok: false, detail: file.error })
122
+ else results.push({ check: 'credentials-file', ok: true, detail: path })
123
+
124
+ const { payload, missing } = buildPairingPayload({ credentials })
125
+ results.push({
126
+ check: 'payload-fields',
127
+ ok: missing.length === 0,
128
+ detail: missing.length === 0 ? summarizePayload(payload) : 'missing: ' + missing.join(', '),
129
+ })
130
+
131
+ if (relay.length > 0 && payload.host.length > 0 && payload.hostToken.length > 0) {
132
+ const url = relay + '/answer/pending?host=' + encodeURIComponent(payload.host) + '&_=' + String(Date.now())
133
+ try {
134
+ const status = await fetchStatus(url, { 'X-Host-Token': payload.hostToken })
135
+ results.push({ check: 'relay-host-token', ok: status === 200, detail: 'HTTP ' + String(status) + ' <- ' + url })
136
+ } catch (error) {
137
+ results.push({ check: 'relay-host-token', ok: false, detail: String(error?.message ?? error) })
138
+ }
139
+ } else {
140
+ results.push({ check: 'relay-host-token', ok: false, detail: 'no relay base / host / hostToken to probe' })
141
+ }
142
+
143
+ if (relay.length > 0 && payload.topic.length > 0 && payload.readToken.length > 0) {
144
+ const url = relay + '/' + encodeURIComponent(payload.topic) + '/json?poll=1&since=1h&limit=1'
145
+ try {
146
+ const status = await fetchStatus(url, { Authorization: 'Bearer ' + payload.readToken })
147
+ results.push({ check: 'ntfy-read-token', ok: status === 200, detail: 'HTTP ' + String(status) + ' <- ' + url })
148
+ } catch (error) {
149
+ results.push({ check: 'ntfy-read-token', ok: false, detail: String(error?.message ?? error) })
150
+ }
151
+ } else {
152
+ results.push({ check: 'ntfy-read-token', ok: false, detail: 'no relay base / topic / readToken to probe' })
153
+ }
154
+
155
+ if (relay.length > 0 && payload.topic.length > 0 && payload.writeToken.length > 0) {
156
+ try {
157
+ const response = await fetch(relay + '/', {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + payload.writeToken },
160
+ body: JSON.stringify({
161
+ topic: payload.topic,
162
+ title: 'DSH 配对自检',
163
+ message: '配对自检成功(字段名与 relay-user-add.sh 一致:' + PAYLOAD_FIELDS.join(', ') + ')',
164
+ priority: 3,
165
+ tags: ['white_check_mark'],
166
+ }),
167
+ })
168
+ results.push({
169
+ check: 'ntfy-write-token',
170
+ ok: response.status === 200,
171
+ detail: 'HTTP ' + String(response.status) + ' <- POST / topic=' + payload.topic,
172
+ })
173
+ } catch (error) {
174
+ results.push({ check: 'ntfy-write-token', ok: false, detail: String(error?.message ?? error) })
175
+ }
176
+ } else {
177
+ results.push({ check: 'ntfy-write-token', ok: false, detail: 'no relay base / topic / writeToken to probe' })
178
+ }
179
+
180
+ const failed = results.filter((entry) => entry.ok !== true)
181
+ return { path, results, failed, payload }
182
+ }
183
+
184
+ export async function main(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
185
+ const options = parseArgs(argv)
186
+ if (options.flags.help === true || argv.length === 0) {
187
+ stdout.write(USAGE)
188
+ return 0
189
+ }
190
+
191
+ if (options.command === 'verify') {
192
+ const report = await verify(options)
193
+ if (options.flags.json === true) {
194
+ stdout.write(JSON.stringify({ path: report.path, results: report.results }, undefined, 2) + '\n')
195
+ } else {
196
+ stdout.write('credentials: ' + report.path + '\n')
197
+ for (const entry of report.results) {
198
+ stdout.write((entry.ok === true ? 'PASS ' : 'FAIL ') + entry.check + ' -> ' + entry.detail + '\n')
199
+ }
200
+ stdout.write('\n' + String(report.results.length - report.failed.length) + '/' + String(report.results.length) + ' checks passed\n')
201
+ }
202
+ if (report.failed.length > 0) {
203
+ stderr.write(
204
+ '\nnext steps: on the relay host run `bash relay-user-add.sh <user>`, save the payload it prints as ' +
205
+ report.path + ', then run `dsh-push-pair verify` again (see host-plugin/README.md).\n',
206
+ )
207
+ return 1
208
+ }
209
+ return 0
210
+ }
211
+
212
+ const { path, file, credentials } = load(options)
213
+ const { payload, missing } = buildPairingPayload({ credentials })
214
+ if (missing.length > 0) {
215
+ stderr.write(
216
+ 'cannot build a complete pairing payload: missing ' + missing.join(', ') + '\n' +
217
+ (file.present ? '' : 'credentials file not found: ' + path + '\n') +
218
+ (file.error === undefined ? '' : file.error + '\n') +
219
+ 'provision the user first (on the relay host: `bash relay-user-add.sh <user>`) and save its output as\n' +
220
+ path +
221
+ ', or pass --host/--topic/--write-token/--read-token/--host-token explicitly.\n',
222
+ )
223
+ return 1
224
+ }
225
+ stdout.write(JSON.stringify(payload, undefined, options.flags.pretty === true ? 2 : undefined) + '\n')
226
+ return 0
227
+ }
228
+
229
+ /** True when this module is the process entry point (npm bin shim, `node lib/pairing.mjs`, …). */
230
+ function isEntryPoint() {
231
+ const invoked = process.argv[1]
232
+ if (typeof invoked !== 'string' || invoked.length === 0) return false
233
+ const file = invoked.replaceAll('\\', '/').split('/').pop()
234
+ return file === 'pairing.mjs' && import.meta.url.endsWith('/' + file)
235
+ }
236
+
237
+ if (isEntryPoint()) {
238
+ try {
239
+ process.exitCode = await main()
240
+ } catch (error) {
241
+ process.stderr.write('dsh-push-pair: ' + String(error?.message ?? error) + '\n')
242
+ process.exitCode = 1
243
+ }
244
+ }
package/lib/relay.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Relay client for the DSH push host plugin.
3
+ *
4
+ * Two directions:
5
+ * - push: POST <relay>/ (ntfy JSON publish, write token)
6
+ * POST <relay>/answer/register (tell the relay a question is pending, with hostId)
7
+ * - answer: GET <relay>/answer/pending?host=<id> (poll, X-Host-Token)
8
+ * POST <relay>/answer/ack (remove delivered answers)
9
+ *
10
+ * The poll direction is outbound-only on purpose: a host behind NAT needs no inbound port and the
11
+ * relay never has to reach it (this replaced the old DSH-cookie bridge).
12
+ */
13
+
14
+ export class RelayClient {
15
+ constructor(config) {
16
+ this.base = config.relayBase.replace(/\/+$/, '');
17
+ this.host = config.host;
18
+ this.hostToken = config.hostToken;
19
+ this.regToken = config.regToken;
20
+ this.ntfyToken = config.ntfyToken;
21
+ this.topic = config.topic;
22
+ this.fetchImpl = config.fetch ?? globalThis.fetch;
23
+ }
24
+
25
+ async #json(res) {
26
+ const text = await res.text();
27
+ try { return JSON.parse(text); } catch { return { ok: false, raw: text.slice(0, 200) }; }
28
+ }
29
+
30
+ /**
31
+ * Publish a notification to the phone through ntfy (same JSON body shape the app expects).
32
+ *
33
+ * `priority` MUST be an integer 1-5. ntfy's JSON body only accepts numbers here: passing the
34
+ * string 'default' (which is what ntfy's *header* form uses) makes the server answer
35
+ * 400 `invalid request: request body must be valid JSON` — a message that points nowhere near
36
+ * the real cause. Callers inside this plugin always pass 1/3/4; the default used to be the
37
+ * string 'default', which broke every caller that omitted it.
38
+ */
39
+ async push({ title, body, click, priority = 3, tags = [] }) {
40
+ const r = await this.fetchImpl(this.base + '/', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + this.ntfyToken },
43
+ body: JSON.stringify({ topic: this.topic, title, message: body, priority, click, tags }),
44
+ });
45
+ return { status: r.status, ...(await this.#json(r)) };
46
+ }
47
+
48
+ /** Register a pending question so the phone can answer it; the relay queues the reply for us. */
49
+ async register({ callId, agentId, questions, ttl = 86400 }) {
50
+ const r = await this.fetchImpl(this.base + '/answer/register', {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json', 'X-Reg-Token': this.regToken },
53
+ body: JSON.stringify({ callId, agentId, questions, hostId: this.host, ttl }),
54
+ });
55
+ return { status: r.status };
56
+ }
57
+
58
+ /** Poll for answers addressed to this host. Returns [] when idle. */
59
+ async pending() {
60
+ // The `_` cache-buster is not cosmetic: DSH's fetch runs through its own proxy layer, and
61
+ // an identical GET URL that had been polled before kept coming back as the FIRST response
62
+ // (observed 2026-10-12: the relay queue grew while the plugin kept seeing one stale item).
63
+ // A unique query string keeps every poll a distinct request.
64
+ const url = this.base + '/answer/pending?host=' + encodeURIComponent(this.host) + '&_=' + String(Date.now());
65
+ const r = await this.fetchImpl(url, { headers: { 'X-Host-Token': this.hostToken } });
66
+ if (r.status !== 200) return [];
67
+ const j = await this.#json(r);
68
+ return Array.isArray(j.items) ? j.items : [];
69
+ }
70
+
71
+ /** Confirm delivery so the relay drops them (at-least-once; the caller dedupes by callId). */
72
+ async ack(callIds) {
73
+ const r = await this.fetchImpl(this.base + '/answer/ack', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json', 'X-Host-Token': this.hostToken },
76
+ body: JSON.stringify({ host: this.host, callIds }),
77
+ });
78
+ return { status: r.status, ...(await this.#json(r)) };
79
+ }
80
+
81
+ /** Long-running poll loop; `onAnswer(item)` may throw — the item is acked only when it returns. */
82
+ async runAnswerLoop(onAnswer, { intervalMs = 3000, log = console.log } = {}) {
83
+ for (;;) {
84
+ try {
85
+ for (const item of await this.pending()) {
86
+ try {
87
+ await onAnswer(item);
88
+ await this.ack([item.callId]);
89
+ log('[relay] answered ' + item.callId);
90
+ } catch (e) {
91
+ log('[relay] answer failed for ' + item.callId + ': ' + (e?.message ?? e));
92
+ }
93
+ }
94
+ } catch (e) {
95
+ log('[relay] poll failed: ' + (e?.message ?? e));
96
+ }
97
+ await new Promise((r) => setTimeout(r, intervalMs));
98
+ }
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "dsh-push-host",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "description": "DSH host-side phone push: question events to ntfy + in-process answering via relay polling (replaces the external notifier + answer bridge)",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "cordis",
10
+ "push",
11
+ "ntfy",
12
+ "notification"
13
+ ],
14
+ "license": "MIT",
15
+ "author": {
16
+ "name": "cpyh",
17
+ "url": "https://github.com/thiscp"
18
+ },
19
+ "contributors": [
20
+ {
21
+ "name": "DeepSeek Harness agents"
22
+ }
23
+ ],
24
+ "type": "module",
25
+ "main": "lib/index.js",
26
+ "exports": {
27
+ ".": "./lib/index.js",
28
+ "./client": "./lib/client.js",
29
+ "./credentials": "./lib/credentials.mjs",
30
+ "./pairing": "./lib/pairing.mjs",
31
+ "./pairing-lib": "./lib/pairing-lib.mjs",
32
+ "./relay": "./lib/relay.js",
33
+ "./cordis.patch.yml": "./cordis.patch.yml",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "bin": {
37
+ "dsh-push-pair": "./lib/pairing.mjs"
38
+ },
39
+ "files": [
40
+ "lib",
41
+ "cordis.patch.yml",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": ">=20.11"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org/"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/thiscp/dsh-push.git",
55
+ "directory": "host-plugin"
56
+ },
57
+ "homepage": "https://github.com/thiscp/dsh-push/tree/main/host-plugin#readme",
58
+ "bugs": {
59
+ "url": "https://github.com/thiscp/dsh-push/issues"
60
+ },
61
+ "scripts": {
62
+ "test": "node plugin-selftest.mjs",
63
+ "pair": "node lib/pairing.mjs"
64
+ },
65
+ "dsh": {
66
+ "bundle": {
67
+ "version": "1",
68
+ "patch": "./cordis.patch.yml"
69
+ },
70
+ "client": {
71
+ "platform": "web"
72
+ }
73
+ },
74
+ "peerDependencies": {
75
+ "@deepseek-ai/cordis": "^4.0.2",
76
+ "@deepseek-ai/schemastery": "^3.18.2"
77
+ }
78
+ }