@tbrandenburg/node-red-agents 0.3.7 → 0.3.8

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/README.md CHANGED
@@ -19,7 +19,9 @@ and example flows (`nodes/gh/examples/`).
19
19
 
20
20
  ## Requirements
21
21
 
22
- - Node-RED >= 4.0.0, Node.js >= 22
22
+ - Node-RED >= 4.0.0, Node.js >= 20
23
+ (Node.js 20 compatibility is verified for these nodes; the monorepo's own
24
+ dev/CI tooling still targets Node.js 22, see the repo root `.nvmrc`)
23
25
  - The [`opencode`](https://opencode.ai) CLI on `PATH` (for `agent`/`agent-server`)
24
26
  - [`srt`](https://github.com/anthropics/sandbox-runtime) on `PATH`, only if using the SRT runtime option
25
27
  - The [`gh`](https://cli.github.com) CLI on `PATH`, authenticated (for `gh`)
@@ -120,7 +120,18 @@
120
120
  srtAllowedDomains: { value: [] },
121
121
  srtAllowedWriteDirs: { value: ['.', '/tmp', '~/.local/share/opencode'] },
122
122
  srtStrictAllowlist: { value: true },
123
- srtAdvancedJson: { value: '' }
123
+ srtAdvancedJson: {
124
+ value: '',
125
+ validate: function (v) {
126
+ if (!v || !v.trim()) return true;
127
+ try {
128
+ JSON.parse(v);
129
+ return true;
130
+ } catch (err) {
131
+ return false;
132
+ }
133
+ }
134
+ }
124
135
  },
125
136
  inputs: 1,
126
137
  outputs: 2,
@@ -312,8 +323,49 @@
312
323
  });
313
324
  return values;
314
325
  }
315
- this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
316
- this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
326
+
327
+ // Only persist the SRT inline-settings lists/fields while
328
+ // they're actually the active runtime+mode -- otherwise
329
+ // reset them to their defaults. Without this, switching
330
+ // Runtime back to Direct (or SRT settings back to File
331
+ // path) silently keeps whatever domains/directories/JSON
332
+ // were entered earlier, still saved into the flow even
333
+ // though the now-hidden fields are irrelevant to the
334
+ // deployed config -- confusing at best, and a footgun if
335
+ // the runtime is ever switched back to SRT without
336
+ // re-checking these stale values.
337
+ //
338
+ // Note: Node-RED's own core copies each defaults-bound
339
+ // plain input's *current DOM value* into `this[key]`
340
+ // right after oneditsave returns, so setting e.g.
341
+ // `this.srtSettingsMode` here alone is not enough -- a
342
+ // hidden <select>/<input>/<textarea> still holds its old
343
+ // value and would silently overwrite it straight back.
344
+ // The reset has to touch the DOM element itself for any
345
+ // field with a plain `#node-input-<name>` binding;
346
+ // srtAllowedDomains/srtAllowedWriteDirs are the exception
347
+ // (no such element exists -- only the "-list" editableList
348
+ // container), so those are fine set directly on `this`.
349
+ const runtime = $('#node-input-runtime').val();
350
+ const srtSettingsMode = $('#node-input-srtSettingsMode').val();
351
+
352
+ if (runtime !== 'srt') {
353
+ $('#node-input-srtSettingsMode').val('file');
354
+ $('#node-input-srtSettingsPath').val('');
355
+ $('#node-input-srtBinary').val('');
356
+ $('#node-input-srtStrictAllowlist').prop('checked', true);
357
+ $('#node-input-srtAdvancedJson').val('');
358
+ this.srtAllowedDomains = [];
359
+ this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
360
+ } else if (srtSettingsMode !== 'inline') {
361
+ $('#node-input-srtStrictAllowlist').prop('checked', true);
362
+ $('#node-input-srtAdvancedJson').val('');
363
+ this.srtAllowedDomains = [];
364
+ this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
365
+ } else {
366
+ this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
367
+ this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
368
+ }
317
369
  }
318
370
  });
319
371
  }());
@@ -208,7 +208,10 @@ module.exports = function (RED) {
208
208
  resolved,
209
209
  executionId,
210
210
  onEvent: (event) => {
211
- send([null, lifecycleEnvelope(msg, executionId, event, resolved.agentName, resolved.cwd)]);
211
+ send([
212
+ null,
213
+ lifecycleEnvelope(msg, executionId, event, resolved.agentName, resolved.cwd),
214
+ ]);
212
215
  },
213
216
  onStatus: (status) => {
214
217
  if (status === "running") {
@@ -3,6 +3,7 @@
3
3
  const fs = require("fs");
4
4
  const { AgentAdapter } = require("./base");
5
5
  const { toOpenCodeMcp } = require("../mcp/normalize");
6
+ const { assertModelFormat } = require("../../../../shared/model-format");
6
7
 
7
8
  // Maps opencode's real `--format json` event stream types (verified against
8
9
  // packages/opencode/src/cli/cmd/run.ts) onto the Agent node's generic event
@@ -18,6 +19,8 @@ const TYPE_MAP = {
18
19
 
19
20
  class OpenCodeAdapter extends AgentAdapter {
20
21
  validate(resolved) {
22
+ assertModelFormat(resolved.model);
23
+
21
24
  if (resolved.cwd) {
22
25
  let stat;
23
26
  try {
@@ -107,7 +110,7 @@ class OpenCodeAdapter extends AgentAdapter {
107
110
  return { type, sessionID: raw.sessionID, data: raw };
108
111
  }
109
112
 
110
- parseResult(events, exitCode, signal, stderr) {
113
+ parseResult(events, exitCode, signal, stderr, resolved) {
111
114
  const raw = events.map((e) => e.data);
112
115
  const errorEvent = raw.find((e) => e.type === "error");
113
116
  const sessionID = raw.length ? raw[raw.length - 1].sessionID : undefined;
@@ -121,14 +124,30 @@ class OpenCodeAdapter extends AgentAdapter {
121
124
  if (errorEvent) {
122
125
  const errDetail = errorEvent.error || {};
123
126
  const message =
124
- (errDetail.data && errDetail.data.message) || errDetail.name || "opencode reported an error";
127
+ (errDetail.data && errDetail.data.message) ||
128
+ errDetail.name ||
129
+ "opencode reported an error";
125
130
  // The JSON error event only carries opencode's own top-level
126
- // message/name -- append name (if distinct) and any stderr output
127
- // opencode wrote alongside it, since both would otherwise be
128
- // silently dropped here (unlike the exitCode!==0 branch below,
129
- // which already surfaces stderr).
131
+ // message/name -- append name (if distinct), its diagnostic ref (if
132
+ // any -- note this does NOT reliably show up in opencode's own log
133
+ // file, verified empirically, so it's a weak clue at best), and any
134
+ // stderr output opencode wrote alongside it, since all three would
135
+ // otherwise be silently dropped here (unlike the exitCode!==0
136
+ // branch below, which already surfaces stderr).
130
137
  const extras = [];
131
138
  if (errDetail.name && errDetail.name !== message) extras.push(errDetail.name);
139
+ if (errDetail.data && errDetail.data.ref) extras.push(`ref=${errDetail.data.ref}`);
140
+ // "UnknownError" is opencode's catch-all for a request the provider/
141
+ // server rejected before generating any content -- in practice the
142
+ // single most common trigger we've seen is a `--model` value that
143
+ // doesn't exist (wrong provider, typo, or a model that isn't
144
+ // actually available to this account). It's not the only possible
145
+ // cause, so this is phrased as a hint, not a diagnosis.
146
+ if (errDetail.name === "UnknownError" && resolved && resolved.model) {
147
+ extras.push(
148
+ `possible cause: model "${resolved.model}" may not exist or isn't available -- run "opencode models" to check`,
149
+ );
150
+ }
132
151
  if (stderr && String(stderr).trim()) extras.push(String(stderr).trim());
133
152
  const errorMessage = extras.length ? `${message} (${extras.join("; ")})` : message;
134
153
  return {
@@ -51,7 +51,13 @@ async function runAgent({ adapter, runtime, resolved, executionId, onEvent, onSt
51
51
  });
52
52
 
53
53
  const durationMs = Date.now() - startedAt;
54
- const result = adapter.parseResult(events, outcome.exitCode, outcome.signal, outcome.stderr);
54
+ const result = adapter.parseResult(
55
+ events,
56
+ outcome.exitCode,
57
+ outcome.signal,
58
+ outcome.stderr,
59
+ resolved,
60
+ );
55
61
  const status = outcome.timedOut ? "timeout" : result.status;
56
62
 
57
63
  if (onStatus) onStatus(status);
@@ -49,7 +49,18 @@
49
49
  srtAllowedDomains: { value: [] },
50
50
  srtAllowedWriteDirs: { value: ['.', '/tmp', '~/.local/share/opencode'] },
51
51
  srtStrictAllowlist: { value: true },
52
- srtAdvancedJson: { value: '' }
52
+ srtAdvancedJson: {
53
+ value: '',
54
+ validate: function (v) {
55
+ if (!v || !v.trim()) return true;
56
+ try {
57
+ JSON.parse(v);
58
+ return true;
59
+ } catch (err) {
60
+ return false;
61
+ }
62
+ }
63
+ }
53
64
  },
54
65
  inputs: 1,
55
66
  outputs: 2,
@@ -135,8 +146,46 @@
135
146
  });
136
147
  return values;
137
148
  }
138
- this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
139
- this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
149
+
150
+ // Same rationale as the agent node's oneditsave: only
151
+ // persist the SRT inline-settings lists/fields while
152
+ // they're actually the active runtime+mode, otherwise
153
+ // reset them to their defaults so switching back to
154
+ // Direct (or File path mode) doesn't silently leave a
155
+ // stale SRT config sitting in the deployed flow.
156
+ //
157
+ // Note: Node-RED's own core copies each defaults-bound
158
+ // plain input's *current DOM value* into `this[key]`
159
+ // right after oneditsave returns, so the reset has to
160
+ // touch the DOM element itself for any field with a
161
+ // plain `#node-input-<name>` binding (srtSettingsMode,
162
+ // srtSettingsPath, srtBinary, srtStrictAllowlist,
163
+ // srtAdvancedJson) -- setting `this.<name>` alone would
164
+ // get silently overwritten straight back by that hidden
165
+ // element's stale value. srtAllowedDomains/
166
+ // srtAllowedWriteDirs are the exception (no such element
167
+ // exists -- only the "-list" editableList container), so
168
+ // those are fine set directly on `this`.
169
+ const runtime = $('#node-input-runtime').val();
170
+ const srtSettingsMode = $('#node-input-srtSettingsMode').val();
171
+
172
+ if (runtime !== 'srt') {
173
+ $('#node-input-srtSettingsMode').val('file');
174
+ $('#node-input-srtSettingsPath').val('');
175
+ $('#node-input-srtBinary').val('');
176
+ $('#node-input-srtStrictAllowlist').prop('checked', true);
177
+ $('#node-input-srtAdvancedJson').val('');
178
+ this.srtAllowedDomains = [];
179
+ this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
180
+ } else if (srtSettingsMode !== 'inline') {
181
+ $('#node-input-srtStrictAllowlist').prop('checked', true);
182
+ $('#node-input-srtAdvancedJson').val('');
183
+ this.srtAllowedDomains = [];
184
+ this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
185
+ } else {
186
+ this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
187
+ this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
188
+ }
140
189
  }
141
190
  });
142
191
  }());
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ const { assertModelFormat } = require("../../../shared/model-format");
4
+
3
5
  // The `agent` node's --model flag accepts a plain "provider/model" string
4
6
  // (opencode's CLI parses that itself). The HTTP API has no such shorthand --
5
7
  // POST /session/:id/message's `model` field must be a
@@ -10,12 +12,8 @@
10
12
  function parseModel(value) {
11
13
  if (value === undefined || value === null || value === "") return undefined;
12
14
  const str = String(value);
15
+ assertModelFormat(str); // throws with a shared, consistent message (see shared/model-format.js)
13
16
  const slash = str.indexOf("/");
14
- if (slash <= 0 || slash === str.length - 1) {
15
- throw new Error(
16
- `invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-4.6")`,
17
- );
18
- }
19
17
  return { providerID: str.slice(0, slash), modelID: str.slice(slash + 1) };
20
18
  }
21
19
 
package/nodes/gh/gh.js CHANGED
@@ -43,7 +43,10 @@ function validateCommand(command) {
43
43
  // the (locale/version dependent) human-readable message themselves. Order
44
44
  // matters: more specific patterns are checked before generic ones.
45
45
  const ERROR_TYPE_PATTERNS = [
46
- [/not logged into|to authenticate|gh auth login|authentication required|bad credentials/i, "auth"],
46
+ [
47
+ /not logged into|to authenticate|gh auth login|authentication required|bad credentials/i,
48
+ "auth",
49
+ ],
47
50
  [/api rate limit exceeded|secondary rate limit/i, "rate-limit"],
48
51
  [/has disabled issues|has disabled pull requests|has disabled projects/i, "feature-disabled"],
49
52
  [/could not resolve to a repository|repository not found|404/i, "not-found"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-agents",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Node-RED nodes for running coding agents (opencode, pi) and GitHub CLI operations from flows.",
5
5
  "keywords": [
6
6
  "node-red",
@@ -41,7 +41,7 @@
41
41
  "!**/fixtures/**"
42
42
  ],
43
43
  "engines": {
44
- "node": ">=22"
44
+ "node": ">=20"
45
45
  },
46
46
  "scripts": {
47
47
  "test": "node --test --experimental-test-coverage=false 'nodes/**/test/**/*.spec.js' 'shared/**/test/**/*.spec.js'"
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ // opencode's model identifiers are always "provider/model" (e.g.
4
+ // "github-copilot/claude-sonnet-5"). Both the `agent` node (which passes
5
+ // the string straight through to `opencode run --model`) and the
6
+ // `agent-server` node (which splits it into { providerID, modelID } for the
7
+ // HTTP API, see agent-server/lib/model.js) need the same shape check.
8
+ //
9
+ // This exists because a malformed or non-existent model string currently
10
+ // only surfaces as opencode's own generic, unhelpful failure -- e.g.
11
+ // `opencode run --model bogus "hi"` prints
12
+ // {"type":"error","error":{"name":"UnknownError","data":{"message":
13
+ // "Unexpected server error. Check server logs for details.","ref":"err_..."}}}
14
+ // with exit code 0, and that "err_..." ref does *not* actually appear in
15
+ // opencode's own log file (verified empirically) -- so "check server logs"
16
+ // is a dead end. Catching an obviously-malformed string (no slash, or an
17
+ // empty provider/model half) before ever spawning opencode turns that into
18
+ // an immediate, actionable Node-RED error instead. It cannot catch a
19
+ // syntactically valid but non-existent "provider/model" pair -- that still
20
+ // requires either `opencode models` (see opencode's own --help) or opencode
21
+ // fixing its own error reporting.
22
+ function assertModelFormat(value) {
23
+ if (value === undefined || value === null || value === "") return;
24
+ const str = String(value);
25
+ const slash = str.indexOf("/");
26
+ if (slash <= 0 || slash === str.length - 1) {
27
+ throw new Error(
28
+ `invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-5"); ` +
29
+ `run "opencode models" to list valid values`,
30
+ );
31
+ }
32
+ }
33
+
34
+ module.exports = { assertModelFormat };