feat: relicense AGPL-3.0 → MIT (v2.7.0)
Clear the 3 Unsloth-Studio-derived AGPL files and flip LICENSE + 5 package.json from AGPL-3.0-only to MIT. - html-to-md.ts → MIT node-html-markdown (parse5 dropped) - llama-args-validator.ts → clean-room (flag denylist = facts) - tool-call-parser.ts → delete dead Unsloth-ported code; keep extractToolCallBlocks/stripToolMarkup byte-identical (no behavior change) - LICENSE → MIT (Copyright (c) 2026 indifferentketchup); 5 package.json → MIT; AGPL SPDX headers removed; README License section; license-mit guard test - roadmap License-debt batch marked shipped; openspec/changes/license-debt-mit Decouples the relicense from the native-parsing retirement (the ported parser was dead code). Server suite 519 passing; build + coder typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,10 +70,16 @@ describe('htmlToMarkdown', () => {
|
||||
</tbody>
|
||||
</table>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('| Name | Age | City |');
|
||||
expect(md).toContain('| --- | --- | --- |');
|
||||
expect(md).toContain('| Alice | 30 | NYC |');
|
||||
expect(md).toContain('| Bob | 25 | LA |');
|
||||
// node-html-markdown pads columns to align them; assert structure rather
|
||||
// than exact spacing. Each cell value and a GFM separator row are present.
|
||||
expect(md).toContain('| Name ');
|
||||
expect(md).toContain('| Age ');
|
||||
expect(md).toContain('| City |');
|
||||
expect(md).toMatch(/\| -+ \| -+ \| -+ \|/); // separator row
|
||||
expect(md).toContain('| Alice ');
|
||||
expect(md).toContain('| NYC |');
|
||||
expect(md).toContain('| Bob ');
|
||||
expect(md).toContain('| LA |');
|
||||
});
|
||||
|
||||
it('escapes pipe characters in table cells', () => {
|
||||
@@ -162,14 +168,17 @@ describe('htmlToMarkdown', () => {
|
||||
|
||||
it('converts br to newline', () => {
|
||||
const md = htmlToMarkdown('line one<br>line two');
|
||||
expect(md).toContain('line one\nline two');
|
||||
// node-html-markdown emits a GFM hard line break (trailing two spaces).
|
||||
expect(md).toContain('line one \nline two');
|
||||
});
|
||||
|
||||
it('handles ol with start attribute', () => {
|
||||
const html = '<ol start="5"><li>five</li><li>six</li></ol>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('5. five');
|
||||
expect(md).toContain('6. six');
|
||||
// node-html-markdown does not honor the `start` attribute; it always
|
||||
// renumbers ordered lists from 1. (Old parse5 renderer honored start=.)
|
||||
expect(md).toContain('1. five');
|
||||
expect(md).toContain('2. six');
|
||||
});
|
||||
|
||||
it('collapses excessive blank lines', () => {
|
||||
@@ -212,9 +221,12 @@ describe('htmlToMarkdown', () => {
|
||||
expect(md).toContain('[a link](https://example.com)');
|
||||
expect(md).toContain('## Features');
|
||||
expect(md).toContain('* Fast');
|
||||
expect(md).toContain('| Metric | Value |');
|
||||
expect(md).toContain('| --- | --- |');
|
||||
expect(md).toContain('| Uptime | 99.9% |');
|
||||
// Table columns are padded to align (node-html-markdown behavior).
|
||||
expect(md).toContain('| Metric ');
|
||||
expect(md).toContain('| Value |');
|
||||
expect(md).toMatch(/\| -+ \| -+ \|/); // separator row
|
||||
expect(md).toContain('| Uptime ');
|
||||
expect(md).toContain('| 99.9% |');
|
||||
expect(md).toContain('> This tool is amazing.');
|
||||
expect(md).toContain('```js\nconsole.log("hello");\n```');
|
||||
expect(md).not.toContain('evil');
|
||||
|
||||
46
apps/server/src/services/__tests__/license-mit.test.ts
Normal file
46
apps/server/src/services/__tests__/license-mit.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
// Guards the AGPL-3.0 -> MIT relicense (openspec license-debt-mit). If any of
|
||||
// these fail, AGPL-derived provenance has crept back in.
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..');
|
||||
|
||||
describe('license: MIT relicense guard', () => {
|
||||
it('LICENSE is MIT (no Affero/AGPL text)', () => {
|
||||
const license = readFileSync(resolve(ROOT, 'LICENSE'), 'utf8');
|
||||
expect(license).toMatch(/^MIT License/);
|
||||
expect(license).not.toMatch(/AFFERO|AGPL/i);
|
||||
});
|
||||
|
||||
const PACKAGE_JSONS = [
|
||||
'package.json',
|
||||
'apps/server/package.json',
|
||||
'apps/web/package.json',
|
||||
'apps/coder/package.json',
|
||||
'apps/booterm/package.json',
|
||||
];
|
||||
for (const rel of PACKAGE_JSONS) {
|
||||
it(`${rel} declares "license": "MIT"`, () => {
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, rel), 'utf8')) as { license?: string };
|
||||
expect(pkg.license).toBe('MIT');
|
||||
});
|
||||
}
|
||||
|
||||
// The three files that were ported from Unsloth Studio (AGPL-3.0-only) and
|
||||
// cleared in this batch — they must carry no AGPL/Unsloth provenance.
|
||||
const FORMERLY_AGPL = [
|
||||
'apps/server/src/services/inference/tool-call-parser.ts',
|
||||
'apps/server/src/services/web/html-to-md.ts',
|
||||
'apps/server/src/services/inference/llama-args-validator.ts',
|
||||
];
|
||||
for (const rel of FORMERLY_AGPL) {
|
||||
it(`${rel} carries no AGPL / Unsloth provenance`, () => {
|
||||
const src = readFileSync(resolve(ROOT, rel), 'utf8');
|
||||
expect(src).not.toMatch(/AGPL/);
|
||||
expect(src).not.toMatch(/SPDX-License-Identifier:\s*AGPL/);
|
||||
expect(src).not.toMatch(/Unsloth/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,18 +4,11 @@ import {
|
||||
parseInvokeToolCall,
|
||||
partialXmlOpenerStart,
|
||||
extractToolCallBlocks,
|
||||
parseToolCallsFromText,
|
||||
stripToolMarkup,
|
||||
hasToolSignal,
|
||||
XML_TOOL_OPEN,
|
||||
XML_TOOL_CLOSE,
|
||||
INVOKE_TOOL_OPEN,
|
||||
INVOKE_TOOL_CLOSE,
|
||||
TOOL_XML_SIGNALS,
|
||||
BUDGET_EXHAUSTED_NUDGE,
|
||||
DUPLICATE_CALL_NUDGE,
|
||||
TOOL_ERROR_NUDGE,
|
||||
TOOL_ERROR_PREFIXES,
|
||||
} from '../inference/tool-call-parser.js';
|
||||
|
||||
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||
@@ -301,38 +294,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── New tests: Unsloth-ported functions ──────────────────────────────────
|
||||
|
||||
describe('hasToolSignal', () => {
|
||||
it('returns true for <tool_call>', () => {
|
||||
expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <function=', () => {
|
||||
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for <invoke', () => {
|
||||
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for near-miss <tool>', () => {
|
||||
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for near-miss <function>', () => {
|
||||
expect(hasToolSignal('prefix <function> suffix')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for near-miss <tool_call_thing>', () => {
|
||||
expect(hasToolSignal('<tool_call_thing>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for plain text', () => {
|
||||
expect(hasToolSignal('just some text')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripToolMarkup', () => {
|
||||
it('strips closed <tool_call> blocks', () => {
|
||||
const input = 'before <tool_call>{"name":"x"}</tool_call> after';
|
||||
@@ -380,166 +341,11 @@ describe('stripToolMarkup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolCallsFromText', () => {
|
||||
describe('pattern 1: <tool_call>{json}</tool_call>', () => {
|
||||
it('parses a well-formed JSON tool call', () => {
|
||||
const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.id).toBe('call_0');
|
||||
expect(calls[0]!.type).toBe('function');
|
||||
expect(calls[0]!.function.name).toBe('web_search');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
|
||||
});
|
||||
|
||||
it('handles string arguments field', () => {
|
||||
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls[0]!.function.arguments).toBe('already a string');
|
||||
});
|
||||
|
||||
it('handles balanced braces inside JSON strings', () => {
|
||||
const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
const parsed = JSON.parse(calls[0]!.function.arguments);
|
||||
expect(parsed.q).toBe('} { extra ');
|
||||
});
|
||||
|
||||
it('respects idOffset', () => {
|
||||
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input, { idOffset: 5 });
|
||||
expect(calls[0]!.id).toBe('call_5');
|
||||
});
|
||||
|
||||
it('parses multiple JSON tool calls', () => {
|
||||
const input =
|
||||
'<tool_call>{"name":"a","arguments":{}}</tool_call>' +
|
||||
'<tool_call>{"name":"b","arguments":{}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]!.id).toBe('call_0');
|
||||
expect(calls[1]!.id).toBe('call_1');
|
||||
});
|
||||
|
||||
it('skips malformed JSON', () => {
|
||||
const input = '<tool_call>{not json}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles missing closing tag', () => {
|
||||
const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern 2: <function=name><parameter=key>value', () => {
|
||||
it('parses a single-parameter function call', () => {
|
||||
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('single-param fast path preserves embedded </parameter>', () => {
|
||||
const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"');
|
||||
});
|
||||
|
||||
it('multi-param: value of first stops at start of second', () => {
|
||||
const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
const args = JSON.parse(calls[0]!.function.arguments);
|
||||
expect(args.pattern).toBe('foo');
|
||||
expect(args.path).toBe('src/');
|
||||
});
|
||||
|
||||
it('tolerates missing closing tags', () => {
|
||||
const input = '<function=view_file><parameter=path>/tmp/foo';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('does not fire when pattern 1 found results', () => {
|
||||
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
|
||||
it('parses a single-parameter invoke call', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('parses multi-parameter invoke call', () => {
|
||||
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
const args = JSON.parse(calls[0]!.function.arguments);
|
||||
expect(args.pattern).toBe('foo');
|
||||
expect(args.path).toBe('src/');
|
||||
});
|
||||
|
||||
it('does not fire when pattern 1 found results', () => {
|
||||
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('a');
|
||||
});
|
||||
|
||||
it('does not fire when pattern 2 found results', () => {
|
||||
const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('a');
|
||||
});
|
||||
|
||||
it('tolerates missing closing tags', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('supports single-quoted attributes', () => {
|
||||
const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('TOOL_XML_SIGNALS includes all three signal prefixes', () => {
|
||||
expect(TOOL_XML_SIGNALS).toContain('<tool_call>');
|
||||
expect(TOOL_XML_SIGNALS).toContain('<function=');
|
||||
expect(TOOL_XML_SIGNALS).toContain('<invoke');
|
||||
});
|
||||
|
||||
it('nudge constants are non-empty strings', () => {
|
||||
expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0);
|
||||
expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0);
|
||||
expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => {
|
||||
expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0);
|
||||
expect(TOOL_ERROR_PREFIXES).toContain('Error');
|
||||
describe('delimiter constants', () => {
|
||||
it('exports the expected delimiters', () => {
|
||||
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
||||
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
||||
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
||||
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user