hermoso 0.1.262 → 0.1.263

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
@@ -174,7 +174,7 @@ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermos
174
174
  Codex, Gemini CLI and the rest are in [Install in one command](#install-in-one-command).
175
175
  3. **Sign in once.** `npx -y hermoso auth login` opens your browser; your agent also runs it by itself the first
176
176
  time it needs Hermoso. On a machine with no browser, use `npx -y hermoso auth login --token hmk_…` with a key
177
- from **Settings → Agents & API**.
177
+ from the **MCP & CLI** tab, under **Terminal & API keys**.
178
178
  4. **Ask for what you want**, in your normal prompts. Claude Code picks the Hermoso skill for the job and runs the
179
179
  commands. You type none of them.
180
180
 
package/bin/hermoso.mjs CHANGED
@@ -77,7 +77,7 @@ async function main() {
77
77
  if (isLocal) { await saveConfig({ apiBase, token: '', profile }); return console.log(`✓ Local dev — no auth required. API: ${apiBase}`); }
78
78
  if (sub === 'login') { // browser sign-in: spin a loopback server, open the app's /?cliauth page, receive a minted key
79
79
  const key = await browserLogin(apiBase);
80
- if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one in the app under Agents & API keys).`);
80
+ if (!key) return die(`Sign-in didn’t complete. Re-run "hermoso auth login", or paste a key: hermoso auth login --token <key> (create one in the app on the MCP & CLI tab, under Terminal & API keys).`);
81
81
  await saveConfig({ apiBase, token: key, profile });
82
82
  return console.log(`✓ Signed in — key stored (~/.hermoso/config.json). API: ${apiBase}`);
83
83
  }
package/mcp/client.mjs CHANGED
@@ -365,6 +365,16 @@ export async function submitJob(type, input, { label = '' } = {}) {
365
365
  export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
366
366
  export function jobResult(job) { const r = job?.result; return r && Object.prototype.hasOwnProperty.call(r, 'data') ? r.data : r; }
367
367
 
368
+ // HOW LONG A RENDER TOOL HOLDS ITS CALLER, in ms. `cap` is the transport's own maximum (45s hosted, 10min local) and
369
+ // stays the answer for anything that is not a usable number — absent, negative, NaN — so a garbled ask can never
370
+ // lengthen a wait or turn into "forever". 0 means "do not wait at all". PURE, so a check runs it.
371
+ export function jobWaitMs(requested, cap) {
372
+ if (requested === undefined || requested === null || requested === '') return cap;
373
+ const n = Number(requested);
374
+ if (!Number.isFinite(n) || n < 0) return cap;
375
+ return Math.min(cap, Math.floor(n));
376
+ }
377
+
368
378
  export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 1000, onTick } = {}) {
369
379
  const deadline = Date.now() + timeoutMs;
370
380
  for (;;) {
@@ -373,7 +383,10 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
373
383
  if (job.status === 'done') return { job, result: jobResult(job) };
374
384
  if (job.status === 'error') throw new Error(job.error || 'Render failed');
375
385
  if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
376
- await new Promise(r => setTimeout(r, intervalMs));
386
+ // NEVER SLEEP PAST THE DEADLINE. A 3s interval made every wait 3s-granular: a caller who asked for 1s was held 3s,
387
+ // and one who asked for 29s was held 30s, which is the whole 30-second step budget the ask exists to stay inside.
388
+ // The defaults (45s, 10min) are whole multiples of the interval, so they poll exactly as they always have.
389
+ await new Promise(r => setTimeout(r, Math.min(intervalMs, Math.max(50, deadline - Date.now()))));
377
390
  }
378
391
  }
379
392
 
package/mcp/http.mjs CHANGED
@@ -130,7 +130,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
130
130
  how_to_connect: {
131
131
  oauth: `Open ${BASE}/oauth/authorize through your client's own connector flow (a browser is required).`,
132
132
  no_browser_stdio: `Add Hermoso as a STDIO command instead of a URL: run \`npx -y hermoso mcp\` with the environment variable HERMOSO_TOKEN set to a key from ${BASE}/#developers. THE TOKEN IS NOT OPTIONAL HERE: a sandbox that could not open a browser for OAuth cannot open one for \`hermoso auth login\` either, so plain stdio with no token connects and then has no account to act on. (On your own machine, where a browser exists, \`npx -y hermoso auth login\` once replaces the token.)`,
133
- no_browser_header: `Keep this URL and send an \`Authorization: Bearer <key>\` header. Create the key at ${BASE}/#developers (Settings ▸ Agents & API). A key in the URL query does NOT work and is not supported.`,
133
+ no_browser_header: `Keep this URL and send an \`Authorization: Bearer <key>\` header. Create the key at ${BASE}/#developers (the MCP & CLI tab, under Terminal & API keys). A key in the URL query does NOT work and is not supported.`,
134
134
  },
135
135
  // Named so an agent can branch on it rather than parsing prose.
136
136
  retry_will_not_help: true,
@@ -0,0 +1,102 @@
1
+ // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════
2
+ // THE ONE ABSOLUTISER — a served path becomes a URL the CALLER can open (2026-09-20)
3
+ // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // WHAT WENT WRONG. A render that has not moved to the durable asset host is recorded as a served path
6
+ // (`/generated/…`), and every tool turned that into a link by prefixing `API_BASE`. `API_BASE` is the address the
7
+ // tool layer uses to CALL the app, and on the hosted transport and the `/v1` passthrough that is a loopback
8
+ // self-call (`http://127.0.0.1:<port>`) so a tool's own `/api` calls never leave the instance. Correct for
9
+ // calling; wrong for a link handed to somebody on the other side of the internet. `list_library` answered
10
+ // `http://127.0.0.1:8080/generated/…` to a REST caller on 2026-09-20, and it had its own hand-written copy of the
11
+ // prefixing, which is why fixing `abs()` alone would not have closed it.
12
+ //
13
+ // THE RULE. The base used to CALL the API and the origin used to LINK to an asset are two different facts:
14
+ // • a LOCAL caller (stdio, the CLI) shares a machine with whatever `API_BASE` names, so that base is the right
15
+ // link, loopback included (a self-hoster on localhost gets a localhost link, which is what they can open);
16
+ // • a REMOTE caller (hosted MCP, `/v1`) can never reach a loopback or private address, so one is NEVER emitted:
17
+ // a served path is joined to the PUBLIC origin, and an already-absolute URL that names an unroutable host has
18
+ // its origin replaced. That second half is what makes this a guarantee rather than a fix for one call site: a
19
+ // row stored with a loopback origin months ago is repaired on the way out too.
20
+ //
21
+ // PURE, NO IMPORTS. This file is a byte-twin of `cli/mcp/public-url.mjs` (every `.mjs` in both directories is),
22
+ // and `lib/public-api-v1-mount.mjs` imports it directly, so `/v1` and the tools cannot disagree about what a
23
+ // public link is. It deliberately does NOT live in `client.mjs`: that module reads the API base at import time,
24
+ // and importing it from the server before `HEIST_API_BASE` is set would pin every tool's self-call to the default.
25
+ // ══════════════════════════════════════════════════════════════════════════════════════════════════════════════
26
+
27
+ /** Where a public link points when nothing better is configured. The app's own origin serves `/generated/…`. */
28
+ export const PUBLIC_ORIGIN_DEFAULT = 'https://app.hermoso.ai';
29
+
30
+ /**
31
+ * TRUE for a hostname no caller outside this machine or network can reach: loopback, the unspecified address,
32
+ * RFC 1918 private ranges, link-local (which includes the cloud metadata address), CGNAT, IPv6 loopback /
33
+ * unique-local / link-local, and the conventional internal suffixes. An unparseable host counts as unroutable:
34
+ * a link we cannot read is not one to hand out. PURE.
35
+ */
36
+ export function isUnroutableHost(hostname) {
37
+ let h = String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
38
+ if (!h) return true;
39
+ if (h.startsWith('::ffff:')) h = h.slice(7); // an IPv4-mapped IPv6 literal is judged as the IPv4 it carries
40
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.lan')) return true;
41
+ if (h === '::' || h === '::1' || /^f[cd][0-9a-f]{2}:/.test(h) || /^fe[89ab][0-9a-f]:/.test(h)) return true;
42
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
43
+ if (m) {
44
+ const [a, b] = [Number(m[1]), Number(m[2])];
45
+ if (a === 0 || a === 10 || a === 127) return true;
46
+ if (a === 169 && b === 254) return true;
47
+ if (a === 172 && b >= 16 && b <= 31) return true;
48
+ if (a === 192 && b === 168) return true;
49
+ if (a === 100 && b >= 64 && b <= 127) return true;
50
+ return false;
51
+ }
52
+ // A bare single-label name ("app", "hermoso-internal") only resolves inside a private network.
53
+ if (!h.includes('.') && !h.includes(':')) return true;
54
+ return false;
55
+ }
56
+
57
+ /** A configured origin, or null when it is absent, unparseable, not http(s), or unroutable. PURE. */
58
+ function usableOrigin(candidate) {
59
+ const s = String(candidate ?? '').trim();
60
+ if (!s) return null;
61
+ let u; try { u = new URL(s); } catch { return null; }
62
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
63
+ if (isUnroutableHost(u.hostname)) return null;
64
+ return u.origin;
65
+ }
66
+
67
+ /**
68
+ * THE PUBLIC ORIGIN. Configured (`APP_PUBLIC_URL`, the name server.js reads, then the two older spellings),
69
+ * never asserted by a caller, and never a request's Host header. A configured value that is itself unroutable is
70
+ * IGNORED rather than honoured: this function's one job is that its answer can be opened from outside. PURE.
71
+ */
72
+ export function publicOrigin(env = {}) {
73
+ return usableOrigin(env?.APP_PUBLIC_URL) || usableOrigin(env?.APP_URL) || usableOrigin(env?.PUBLIC_BASE) || PUBLIC_ORIGIN_DEFAULT;
74
+ }
75
+
76
+ /**
77
+ * Absolutise an asset reference for the caller who will open it.
78
+ *
79
+ * @param {string} u a served path (`/generated/x.mp4`), or an already-absolute URL
80
+ * @param {object} o
81
+ * @param {string} o.base the base the tool layer CALLS the API on (may be loopback)
82
+ * @param {boolean} o.remote true when the caller is not on this machine (hosted MCP, `/v1`)
83
+ * @param {object} o.env where a configured public origin is read from
84
+ *
85
+ * Anything that is not a string, is empty, or is not http(s) / a served path (a `data:` URI, a bare id) is
86
+ * returned untouched: this decides where a link points, never whether a value is a link. PURE.
87
+ */
88
+ export function absolutizeAssetUrl(u, { base = '', remote = false, env = {} } = {}) {
89
+ if (typeof u !== 'string' || !u) return u;
90
+ const pub = publicOrigin(env);
91
+ if (u.startsWith('/') && !u.startsWith('//')) {
92
+ const b = String(base || '').replace(/\/+$/, '');
93
+ let baseHost = null; try { baseHost = new URL(b).hostname; } catch { baseHost = null; }
94
+ // No usable base at all is treated like an unroutable one: a bare path is not a link for anybody.
95
+ const useBase = b && baseHost !== null && !(remote && isUnroutableHost(baseHost));
96
+ return (useBase ? b : pub) + u;
97
+ }
98
+ if (!remote || !/^https?:\/\//i.test(u)) return u;
99
+ let parsed; try { parsed = new URL(u); } catch { return u; }
100
+ if (!isUnroutableHost(parsed.hostname)) return u;
101
+ return pub + parsed.pathname + parsed.search + parsed.hash;
102
+ }
package/mcp/tools.mjs CHANGED
@@ -4,7 +4,8 @@
4
4
  // Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
5
5
  // needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
6
6
  import { z } from 'zod';
7
- import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
7
+ import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
8
+ import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile } from './client.mjs';
8
9
  import { readFile } from 'node:fs/promises';
9
10
  import { createHash } from 'node:crypto';
10
11
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -23,7 +24,13 @@ import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './too
23
24
  import { withHints } from './tool-hints.mjs';
24
25
 
25
26
  const JOB_TIMEOUT = +(process.env.HERMOSO_JOB_TIMEOUT_MS || process.env.HEIST_JOB_TIMEOUT_MS || 10 * 60 * 1000);
26
- const abs = (u) => (u && u.startsWith('/') ? API_BASE + u : u); // /generated/x.mp4 → clickable absolute URL
27
+ // /generated/x.mp4 → a URL THE CALLER can open. `API_BASE` is the base this layer CALLS the app on, and on the hosted
28
+ // transport and the `/v1` passthrough that is a loopback self-call — so prefixing it handed a remote caller
29
+ // `http://127.0.0.1:8080/generated/…` (live on /v1 2026-09-20). The rule lives in public-url.mjs, the ONE absolutiser:
30
+ // a local caller keeps the base it shares a machine with, a remote one never receives a loopback or private host.
31
+ // EVERY link this file builds goes through here. Never prefix `API_BASE` onto a path by hand: `list_library` did, which
32
+ // is exactly how the one tool that lists served paths was the one that leaked.
33
+ const abs = (u) => absolutizeAssetUrl(u, { base: API_BASE, remote: isRemote(), env: process.env });
27
34
  // Null-valued keys are STRIPPED from structuredContent (2026-07-20): the SDK validates results against outputSchema
28
35
  // server-side, and zod .optional() rejects null — a single null field (e.g. editCredits:null on a key-less deploy)
29
36
  // bricked the whole tool result with a protocol-level validation error. Every field in our schemas is optional, so
@@ -307,7 +314,12 @@ async function imageBlock(url) {
307
314
  // render inherits the behaviour instead of having to remember it. Fails OPEN: an unknown client keeps the block.
308
315
  if (hostRendersWidgets()) return null;
309
316
  try {
310
- const r = await fetch(url); if (!r.ok) return null;
317
+ // The link the CALLER gets is public (abs()); the bytes WE read to inline it are on this instance. Reading our own
318
+ // served file back through the public edge would be a round trip out and in for a file on local disk, so a URL
319
+ // under the public origin is fetched on the base this layer already calls the app on. The link is never rewritten.
320
+ const own = publicOrigin(process.env) + '/';
321
+ const src = typeof url === 'string' && url.startsWith(own) && isRemote() ? API_BASE + '/' + url.slice(own.length) : url;
322
+ const r = await fetch(src); if (!r.ok) return null;
311
323
  const ct = (r.headers.get('content-type') || 'image/jpeg').split(';')[0];
312
324
  if (!/^image\//.test(ct)) return null;
313
325
  const buf = Buffer.from(await r.arrayBuffer());
@@ -478,9 +490,20 @@ const publishWrap = (fn) => {
478
490
  // RESUMABLE handle instead of dying (the agent polls get_job, which now attaches the poster on done).
479
491
  async function renderJob(type, input, label) {
480
492
  const job = await submitJob(type, input, { label });
481
- const remote = !!mcpCtx.getStore(); // AsyncLocalStorage ctx only exists on the remote transport
493
+ const ctx = mcpCtx.getStore(); // AsyncLocalStorage ctx only exists on the remote transport
494
+ const remote = !!ctx;
495
+ // THE ONE PLACE A RENDER TOOL WAITS, SO THE ONE PLACE A CALLER'S WAIT IS HONOURED (2026-09-20). `/v1/tools` puts a
496
+ // caller's `?wait=` on the context as `waitMs`; nothing else sets it, so with it absent this is the same 45s / 10min
497
+ // it always was. It can only SHORTEN the wait (jobWaitMs caps it at the transport's own maximum), and it is read
498
+ // AFTER submitJob on purpose: the job is queued through the identical POST /api/jobs — same spend gate, same
499
+ // pre-queue credit check, same reserve and settle in the worker — whether or not anybody waits for it. Waiting was
500
+ // never part of what a render costs, so not waiting cannot change it, and there is no second submit path to drift.
501
+ const waitMs = jobWaitMs(ctx?.waitMs, remote ? 45_000 : JOB_TIMEOUT);
502
+ // wait=0: answer the moment the job is queued. Not even one poll — pollJob sleeps between reads, and a caller with a
503
+ // 30s step budget (an automation platform) asked for the handle, not for a status.
504
+ if (waitMs <= 0) return { jobId: job.id, url: null, stillRendering: true, raw: null };
482
505
  try {
483
- const { result } = await pollJob(job.id, { timeoutMs: remote ? 45_000 : JOB_TIMEOUT });
506
+ const { result } = await pollJob(job.id, { timeoutMs: waitMs });
484
507
  const url = abs(result?.video || result?.image || result?.url);
485
508
  // creditsUsed AND the delivered geometry ride out here deliberately. The widget's own registered
486
509
  // description promises "the model that rendered it and credits spent", and the pill only ever appeared
@@ -19123,7 +19146,7 @@ function memoryNoteVerdict(text) {
19123
19146
  const kind = a.kind && a.kind !== 'all' ? a.kind : null;
19124
19147
  const lim = Math.min(60, Math.max(1, +a.limit || 20));
19125
19148
  const assets = list.filter(x => x && x.url && (!kind || x.kind === kind)).slice(0, lim)
19126
- .map(x => ({ url: /^https?:/.test(x.url) ? x.url : `${API_BASE}${x.url}`, kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
19149
+ .map(x => ({ url: abs(x.url), kind: x.kind || '', model: x.model || '', ageHours: x.at ? Math.round((Date.now() - x.at) / 36e5) : undefined }));
19127
19150
  if (!assets.length) return ok('The Library is empty for this workspace — render something first.', { assets: [] });
19128
19151
  return ok(`${assets.length} asset${assets.length === 1 ? '' : 's'} (newest first):\n` + assets.map((x, i) => ` ${i + 1}. [${x.kind || '?'}${x.model ? ' · ' + x.model : ''}${x.ageHours != null ? ' · ' + x.ageHours + 'h ago' : ''}] ${x.url}`).join('\n'), { assets });
19129
19152
  }));
@@ -19139,7 +19162,7 @@ function memoryNoteVerdict(text) {
19139
19162
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
19140
19163
  }, wrap(async ({ url, name }) => {
19141
19164
  const absolute = abs(url);
19142
- const dl = `${API_BASE}/api/download?url=${encodeURIComponent(url)}${name ? `&name=${encodeURIComponent(name)}` : ''}`;
19165
+ const dl = abs(`/api/download?url=${encodeURIComponent(url)}${name ? `&name=${encodeURIComponent(name)}` : ''}`);
19143
19166
  return ok(`Asset: ${absolute}\nDownload: ${dl}`, { url: absolute, downloadUrl: dl });
19144
19167
  }));
19145
19168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.262",
3
+ "version": "0.1.263",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 847 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
6
6
  "type": "module",