Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/autonomy/social/ToolAccessProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const TOOL_CATEGORY_MAP: Readonly<Record<string, ToolCategory>> = Object.
'browser_attach_goto': 'search',
'browser_attach_read': 'search',
'browser_attach_release': 'search',
'browser_attach_control': 'search',
'feed_search': 'search',

// Content Extraction
Expand Down
1 change: 1 addition & 0 deletions src/autonomy/social/__tests__/ToolAccessProfiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ describe('Profile configurations', () => {
'browser_attach_goto',
'browser_attach_read',
'browser_attach_release',
'browser_attach_control',
]) {
expect(TOOL_CATEGORY_MAP[id]).toBe('search');
}
Expand Down
42 changes: 41 additions & 1 deletion src/cli/export/__tests__/report-export.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { describe, it, expect } from 'vitest';
import { buildPdfArgs, csvField, htmlEscape, toCsv, toHtml, type ReportDataset } from '../report-export.js';
import {
buildPdfArgs,
csvField,
htmlEscape,
toCsv,
toHtml,
toInteractiveHtml,
type ReportDataset,
} from '../report-export.js';

const ds: ReportDataset = {
title: 'LA Hotels',
Expand Down Expand Up @@ -52,6 +60,38 @@ describe('htmlEscape + toHtml', () => {
});
});

describe('toInteractiveHtml', () => {
it('is a self-contained document with no external resources', () => {
const html = toInteractiveHtml(ds, { stats: [{ label: 'rows', value: '2' }] });
expect(html).toContain('<!DOCTYPE html>');
expect(html).not.toMatch(/https?:\/\/[^"']*\.(css|js|woff2?)/);
expect(html).not.toContain('<link rel="stylesheet" href="http');
});
it('embeds the row data and column metadata for client-side rendering', () => {
const html = toInteractiveHtml(ds, { columns: [{ key: 'rate', numeric: true, heat: 'lower-better' }] });
expect(html).toContain('const DATA =');
expect(html).toContain('"numeric":true');
expect(html).toContain('"heat":"lower-better"');
});
it('ships sort, search, theme-toggle, and CSV-export controls', () => {
const html = toInteractiveHtml(ds);
expect(html).toContain('id="q"'); // search box
expect(html).toContain('id="themeBtn"'); // theme toggle
expect(html).toContain('id="csvBtn"'); // client CSV export
expect(html).toContain('.onclick'); // sortable headers wired
});
it('escapes </script> in embedded data to prevent breakout', () => {
const evil: ReportDataset = { title: 't', columns: ['x'], rows: [{ x: '</script><img>' }] };
const html = toInteractiveHtml(evil);
expect(html).not.toContain('</script><img>');
expect(html).toContain('\\u003c/script>');
});
it('honors an explicit theme', () => {
expect(toInteractiveHtml(ds, { theme: 'dark' })).toContain("setAttribute('data-theme', \"dark\")");
expect(toInteractiveHtml(ds, { theme: 'auto' })).not.toContain("setAttribute('data-theme', \"auto\")");
});
});

describe('buildPdfArgs', () => {
it('targets an isolated throwaway profile dir, never the live profile', () => {
const args = buildPdfArgs('/tmp/w/report.html', '/tmp/w/out.pdf', '/tmp/w/chrome-profile');
Expand Down
123 changes: 120 additions & 3 deletions src/cli/export/report-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,117 @@ ${rows}
</body></html>`;
}

/**
* Column render hints for the interactive report — let a caller mark which
* columns are numeric (right-aligned, numeric sort), which carries the primary
* value metric (highlighted), and human labels.
*/
export interface ColumnSpec {
key: string;
label?: string;
numeric?: boolean;
/** When set, rows are heat-shaded by this column's numeric value. */
heat?: 'higher-better' | 'lower-better';
}

/** Options for {@link toInteractiveHtml}. */
export interface InteractiveOptions {
columns?: ColumnSpec[];
/** Stat cards shown above the table: label + value. */
stats?: Array<{ label: string; value: string }>;
/** Default theme; the viewer can toggle. */
theme?: 'light' | 'dark' | 'auto';
}

/**
* Render a dataset to a SELF-CONTAINED interactive HTML report: client-side
* column sort, a live search box, per-column filters, stat cards, optional
* value heat-shading, and a light/dark toggle. No external resources — all CSS
* and JS are inlined, so it opens offline and is safe to share as one file.
*/
export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the interactive report’s CSS/JS and interactive file-writing logic into small helper functions so toInteractiveHtml and exportReport stay focused on data wiring.

You can keep all functionality while reducing complexity by factoring out CSS/JS and a bit of orchestration logic. This keeps toInteractiveHtml readable without changing behavior.

1. Split CSS and JS into named constants

Move the inline CSS and JS into separate template helpers, so toInteractiveHtml is mostly about data wiring.

function buildInteractiveCss(): string {
  return `
  :root{--bg:#fff;--fg:#16181d;--muted:#5b616e;--line:#d8dbe2;--head:#f0f2f6;--accent:#2f6df6;--heat:#2f6df6}
  :root[data-theme=dark]{--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}
  @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}}
  *{box-sizing:border-box}
  body{font-family:Inter,-apple-system,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--fg);margin:0;padding:24px 28px;font-size:13px;line-height:1.45}
  /* ... keep the rest unchanged, just formatted more readably ... */
  `;
}

function buildInteractiveScript(dataJson: string, theme: InteractiveOptions['theme']): string {
  const themeInit =
    theme && theme !== 'auto'
      ? `document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)});`
      : '';

  return `
  const DATA = ${dataJson};
  const state = { sortKey: null, dir: 1, q: '' };

  const num = v => {
    const n = parseFloat(String(v).replace(/[^0-9.\\-]/g,''));
    return isNaN(n) ? null : n;
  };

  function heatRange(key) {
    let lo = Infinity, hi = -Infinity;
    for (const r of DATA.rows) {
      const n = num(r[key]);
      if (n == null) continue;
      lo = Math.min(lo, n);
      hi = Math.max(hi, n);
    }
    return { lo, hi };
  }

  const heats = {};
  for (const c of DATA.cols) if (c.heat) heats[c.key] = heatRange(c.key);

  function shade(c, v) {
    const h = heats[c.key];
    if (!h) return '';
    const n = num(v);
    if (n == null || h.hi === h.lo) return '';
    let t = (n - h.lo) / (h.hi - h.lo);
    if (c.heat === 'lower-better') t = 1 - t;
    const a = (0.06 + 0.20 * t).toFixed(3);
    return 'background:color-mix(in srgb, var(--heat) ' + (a * 100).toFixed(1) + '%, transparent)';
  }

  function getRows() {
    let rs = DATA.rows;
    if (state.q) {
      const q = state.q.toLowerCase();
      rs = rs.filter(r =>
        DATA.cols.some(c => String(r[c.key] ?? '').toLowerCase().includes(q)),
      );
    }
    if (state.sortKey) {
      const c = DATA.cols.find(x => x.key === state.sortKey);
      rs = rs.slice().sort((a, b) => {
        let av = a[state.sortKey], bv = b[state.sortKey];
        if (c && c.numeric) {
          av = num(av) ?? -Infinity;
          bv = num(bv) ?? -Infinity;
          return (av - bv) * state.dir;
        }
        return String(av ?? '').localeCompare(String(bv ?? '')) * state.dir;
      });
    }
    return rs;
  }

  function render() {
    const th = document.getElementById('thead');
    const tb = document.getElementById('tbody');

    th.innerHTML = '<tr>' + DATA.cols.map(c =>
      '<th class="' + (c.numeric ? 'num ' : '') +
      (state.sortKey === c.key ? 'sorted' : '') +
      '" data-k="' + c.key + '">' +
      c.label +
      '<span class="ar">' +
      (state.sortKey === c.key ? (state.dir > 0 ? '▲' : '▼') : '⇅') +
      '</span></th>',
    ).join('') + '</tr>';

    const rs = getRows();
    tb.innerHTML = rs.length
      ? rs.map(r =>
          '<tr>' + DATA.cols.map(c => {
            const v = r[c.key] ?? '';
            return '<td class="' + (c.numeric ? 'num' : 'wrapcell') +
              '" style="' + shade(c, v) + '">' +
              String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;') +
              '</td>';
          }).join('') + '</tr>',
        ).join('')
      : '<tr><td class="empty" colspan="' + DATA.cols.length +
        '">No matching rows</td></tr>';

    document.getElementById('count').textContent =
      rs.length + ' / ' + DATA.rows.length + ' rows';

    th.querySelectorAll('th').forEach(h => {
      h.onclick = () => {
        const k = h.dataset.k;
        if (state.sortKey === k) state.dir *= -1;
        else { state.sortKey = k; state.dir = 1; }
        render();
      };
    });
  }

  document.getElementById('q').addEventListener('input', e => {
    state.q = e.target.value;
    render();
  });

  document.getElementById('themeBtn').onclick = () => {
    const r = document.documentElement;
    const cur = r.getAttribute('data-theme') ||
      (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
    r.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
  };

  document.getElementById('csvBtn').onclick = () => {
    const esc = v => {
      const s = String(v ?? '');
      return /[",\\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
    };
    const cols = DATA.cols.map(c => c.key);
    const csv = [cols.map(esc).join(',')]
      .concat(getRows().map(r => cols.map(c => esc(r[c])).join(',')))
      .join('\\n');
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
    a.download = 'report.csv';
    a.click();
  };

  ${themeInit}
  render();
  `;
}

Then toInteractiveHtml becomes shorter and easier to scan:

export function toInteractiveHtml(
  ds: ReportDataset,
  opts: InteractiveOptions = {},
): string {
  const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
  const colMeta = specs.map((s) => ({
    key: s.key,
    label: s.label ?? s.key,
    numeric: !!s.numeric,
    heat: s.heat ?? null,
  }));

  const stats = opts.stats ?? [];
  const dataJson = JSON.stringify({ rows: ds.rows, cols: colMeta }).replace(/</g, '\\u003c');
  const statsHtml = stats
    .map((s) =>
      `<div class="stat"><b>${htmlEscape(s.value)}</b><span>${htmlEscape(s.label)}</span></div>`,
    )
    .join('');
  const theme = opts.theme ?? 'auto';

  return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(ds.title)}</title>
<style>${buildInteractiveCss()}</style></head><body>
<header>
  <div><h1>${htmlEscape(ds.title)}</h1>${ds.summary ? `<p class="summary">${htmlEscape(ds.summary)}</p>` : ''}</div>
  <button class="btn" id="themeBtn" type="button">◐ theme</button>
</header>
<div class="stats">${statsHtml}</div>
<div class="toolbar">
  <input type="search" id="q" placeholder="Search all columns…" autocomplete="off">
  <button class="btn" id="csvBtn" type="button">⬇ CSV</button>
  <span class="count" id="count"></span>
</div>
<div class="wrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<script>${buildInteractiveScript(dataJson, theme)}</script>
</body></html>`;
}

This keeps the “single-file, self-contained” property but isolates behavior and styling from the data mapping.

2. Reduce responsibility in exportReport with a helper

The interactive export branch is simple but you can make exportReport more declarative:

function writeInteractiveReport(
  ds: ReportDataset,
  outDir: string,
  base: string,
  interactive: boolean | InteractiveOptions | undefined,
  writeFileSync: typeof import('node:fs')['writeFileSync'],
): string | undefined {
  if (!interactive) return;
  const iv = join(outDir, `${base}.interactive.html`);
  const opts = typeof interactive === 'object' ? interactive : {};
  writeFileSync(iv, toInteractiveHtml(ds, opts));
  return iv;
}

Then inside exportReport:

const result: { csv: string; html: string; pdf?: string; interactive?: string } = { csv, html };

result.interactive = writeInteractiveReport(ds, outDir, base, opts.interactive, writeFileSync);

These small extra helpers significantly lower cognitive load while preserving all current features and behavior.

const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
const colMeta = specs.map((s) => ({
key: s.key,
label: s.label ?? s.key,
Comment on lines +109 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Column labels are injected into HTML without escaping, which can lead to broken markup or XSS in the interactive report.

In the interactive table header, c.label is written directly into innerHTML and can come from InteractiveOptions.columns, so unescaped characters like <, &, or quotes may corrupt the HTML or enable XSS in the exported report. Please ensure labels are HTML-escaped before insertion (e.g., by storing both raw and escaped values in colMeta, or escaping at header rendering time), consistent with how ds.title and stats are handled.

numeric: !!s.numeric,
heat: s.heat ?? null,
}));
const stats = opts.stats ?? [];
// Data goes to the client as JSON (htmlEscape not needed — JSON.stringify in a
// script context; we defend against </script> injection by escaping '<').
const dataJson = JSON.stringify({ rows: ds.rows, cols: colMeta }).replace(/</g, '\\u003c');
const statsHtml = stats
.map((s) => `<div class="stat"><b>${htmlEscape(s.value)}</b><span>${htmlEscape(s.label)}</span></div>`)
.join('');
const theme = opts.theme ?? 'auto';

return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(ds.title)}</title>
<style>
:root{--bg:#fff;--fg:#16181d;--muted:#5b616e;--line:#d8dbe2;--head:#f0f2f6;--accent:#2f6df6;--heat:#2f6df6}
:root[data-theme=dark]{--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}}
*{box-sizing:border-box}
body{font-family:Inter,-apple-system,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--fg);margin:0;padding:24px 28px;font-size:13px;line-height:1.45}
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
h1{font-size:21px;margin:0}
.summary{color:var(--muted);margin:4px 0 0}
.toolbar{display:flex;gap:10px;align-items:center;margin:16px 0 10px;flex-wrap:wrap}
input[type=search]{flex:1;min-width:180px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font-size:13px}
.btn{padding:6px 11px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);cursor:pointer;font-size:12px}
.stats{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0}
.stat{border:1px solid var(--line);border-radius:10px;padding:8px 13px;min-width:96px}
.stat b{display:block;font-size:17px}
.stat span{color:var(--muted);font-size:11px}
.wrap{overflow-x:auto;border:1px solid var(--line);border-radius:10px}
table{border-collapse:collapse;width:100%;min-width:520px}
th,td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;max-width:340px;overflow:hidden;text-overflow:ellipsis}
td.wrapcell{white-space:normal}
th{background:var(--head);position:sticky;top:0;cursor:pointer;user-select:none;font-size:11px;text-transform:uppercase;letter-spacing:.03em}
th .ar{opacity:.4;font-size:10px;margin-left:4px}
th.sorted .ar{opacity:1;color:var(--accent)}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
tbody tr:hover{background:var(--head)}
.count{color:var(--muted);font-size:12px}
.empty{padding:20px;text-align:center;color:var(--muted)}
</style></head><body>
<header>
<div><h1>${htmlEscape(ds.title)}</h1>${ds.summary ? `<p class="summary">${htmlEscape(ds.summary)}</p>` : ''}</div>
<button class="btn" id="themeBtn" type="button">◐ theme</button>
</header>
<div class="stats">${statsHtml}</div>
<div class="toolbar">
<input type="search" id="q" placeholder="Search all columns…" autocomplete="off">
<button class="btn" id="csvBtn" type="button">⬇ CSV</button>
<span class="count" id="count"></span>
</div>
<div class="wrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<script>
const DATA = ${dataJson};
const state = { sortKey: null, dir: 1, q: '' };
const num = v => { const n = parseFloat(String(v).replace(/[^0-9.\\-]/g,'')); return isNaN(n) ? null : n; };
function heatRange(key){ let lo=Infinity, hi=-Infinity; for(const r of DATA.rows){ const n=num(r[key]); if(n==null)continue; lo=Math.min(lo,n); hi=Math.max(hi,n);} return {lo,hi}; }
const heats = {}; for(const c of DATA.cols) if(c.heat) heats[c.key]=heatRange(c.key);
function shade(c, v){ const h=heats[c.key]; if(!h)return ''; const n=num(v); if(n==null||h.hi===h.lo)return ''; let t=(n-h.lo)/(h.hi-h.lo); if(c.heat==='lower-better')t=1-t; const a=(0.06+0.20*t).toFixed(3); return 'background:color-mix(in srgb, var(--heat) '+(a*100).toFixed(1)+'%, transparent)'; }
function rows(){ let rs=DATA.rows; if(state.q){ const q=state.q.toLowerCase(); rs=rs.filter(r=>DATA.cols.some(c=>String(r[c.key]??'').toLowerCase().includes(q))); }
if(state.sortKey){ const c=DATA.cols.find(x=>x.key===state.sortKey); rs=rs.slice().sort((a,b)=>{ let av=a[state.sortKey], bv=b[state.sortKey]; if(c&&c.numeric){ av=num(av)??-Infinity; bv=num(bv)??-Infinity; return (av-bv)*state.dir;} return String(av??'').localeCompare(String(bv??''))*state.dir; }); }
return rs; }
function render(){ const th=document.getElementById('thead'), tb=document.getElementById('tbody');
th.innerHTML='<tr>'+DATA.cols.map(c=>'<th class="'+(c.numeric?'num ':'')+(state.sortKey===c.key?'sorted':'')+'" data-k="'+c.key+'">'+c.label+'<span class="ar">'+(state.sortKey===c.key?(state.dir>0?'▲':'▼'):'⇅')+'</span></th>').join('')+'</tr>';
const rs=rows(); tb.innerHTML = rs.length? rs.map(r=>'<tr>'+DATA.cols.map(c=>{const v=r[c.key]??''; return '<td class="'+(c.numeric?'num':'wrapcell')+'" style="'+shade(c,v)+'">'+String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;')+'</td>';}).join('')+'</tr>').join('') : '<tr><td class="empty" colspan="'+DATA.cols.length+'">No matching rows</td></tr>';
document.getElementById('count').textContent=rs.length+' / '+DATA.rows.length+' rows';
th.querySelectorAll('th').forEach(h=>h.onclick=()=>{ const k=h.dataset.k; if(state.sortKey===k)state.dir*=-1; else {state.sortKey=k;state.dir=1;} render(); }); }
document.getElementById('q').addEventListener('input', e=>{ state.q=e.target.value; render(); });
document.getElementById('themeBtn').onclick=()=>{ const r=document.documentElement; const cur=r.getAttribute('data-theme')|| (matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'); r.setAttribute('data-theme', cur==='dark'?'light':'dark'); };
document.getElementById('csvBtn').onclick=()=>{ const esc=v=>{const s=String(v??'');return /[",\\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s;}; const cols=DATA.cols.map(c=>c.key); const csv=[cols.map(esc).join(',')].concat(rows().map(r=>cols.map(c=>esc(r[c])).join(','))).join('\\n'); const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='report.csv'; a.click(); };
${theme !== 'auto' ? `document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)});` : ''}
render();
</script>
</body></html>`;
}

/** Options controlling PDF rendering. */
export interface PdfOptions {
/** Path to the Chrome/Chromium binary. */
Expand Down Expand Up @@ -126,17 +237,23 @@ export async function renderPdf(html: string, pdfPath: string, opts: PdfOptions
export async function exportReport(
ds: ReportDataset,
outDir: string,
opts: { basename?: string; pdf?: boolean } & PdfOptions = {},
): Promise<{ csv: string; html: string; pdf?: string }> {
opts: { basename?: string; pdf?: boolean; interactive?: boolean | InteractiveOptions } & PdfOptions = {},
): Promise<{ csv: string; html: string; pdf?: string; interactive?: string }> {
const { mkdirSync, writeFileSync } = await import('node:fs');
mkdirSync(outDir, { recursive: true });
const base = opts.basename ?? 'report';
const csv = join(outDir, `${base}.csv`);
const html = join(outDir, `${base}.html`);
writeFileSync(csv, toCsv(ds));
// The print-safe static HTML is what the PDF is rendered from.
const htmlDoc = toHtml(ds);
writeFileSync(html, htmlDoc);
const result: { csv: string; html: string; pdf?: string } = { csv, html };
const result: { csv: string; html: string; pdf?: string; interactive?: string } = { csv, html };
if (opts.interactive) {
const iv = join(outDir, `${base}.interactive.html`);
writeFileSync(iv, toInteractiveHtml(ds, typeof opts.interactive === 'object' ? opts.interactive : {}));
result.interactive = iv;
}
if (opts.pdf !== false) {
try {
result.pdf = await renderPdf(htmlDoc, join(outDir, `${base}.pdf`), opts);
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/tools/__tests__/attach-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,20 @@ describe('attachOptionsFromEnv', () => {
expect(attachOptionsFromEnv({ WUNDERLAND_ATTACH_IDENTITY: 'x', WUNDERLAND_ATTACH_TRANSPORT: 'bogus' }).attach)
.toEqual({ expectedIdentity: 'x' });
});

it('parses dry-run and deadline knobs', () => {
const a = attachOptionsFromEnv({
WUNDERLAND_ATTACH_IDENTITY: 'x',
WUNDERLAND_ATTACH_DRYRUN: 'true',
WUNDERLAND_ATTACH_DEADLINE_MS: '20000',
}).attach;
expect(a).toMatchObject({ dryRun: true, deadlineMs: 20000 });
});

it('ignores a non-positive or non-numeric deadline', () => {
expect(attachOptionsFromEnv({ WUNDERLAND_ATTACH_IDENTITY: 'x', WUNDERLAND_ATTACH_DEADLINE_MS: 'nope' }).attach)
.toEqual({ expectedIdentity: 'x' });
expect(attachOptionsFromEnv({ WUNDERLAND_ATTACH_IDENTITY: 'x', WUNDERLAND_ATTACH_DEADLINE_MS: '0' }).attach)
.toEqual({ expectedIdentity: 'x' });
});
});
5 changes: 5 additions & 0 deletions src/runtime/tools/attach-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface AttachOptions {
leaseFile?: string;
profileRoot?: string;
identityProbeUrl?: string;
dryRun?: boolean;
deadlineMs?: number;
}

/**
Expand All @@ -40,5 +42,8 @@ export function attachOptionsFromEnv(env: Record<string, string | undefined>): {
if (env.WUNDERLAND_ATTACH_LEASE?.trim()) attach.leaseFile = env.WUNDERLAND_ATTACH_LEASE.trim();
if (env.WUNDERLAND_ATTACH_PROFILE_ROOT?.trim()) attach.profileRoot = env.WUNDERLAND_ATTACH_PROFILE_ROOT.trim();
if (env.WUNDERLAND_ATTACH_PROBE_URL?.trim()) attach.identityProbeUrl = env.WUNDERLAND_ATTACH_PROBE_URL.trim();
if (env.WUNDERLAND_ATTACH_DRYRUN === '1' || env.WUNDERLAND_ATTACH_DRYRUN === 'true') attach.dryRun = true;
const deadline = Number(env.WUNDERLAND_ATTACH_DEADLINE_MS);
if (Number.isFinite(deadline) && deadline > 0) attach.deadlineMs = deadline;
return { attach };
}
Loading