Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
216 lines
8.8 KiB
JavaScript
216 lines
8.8 KiB
JavaScript
// Guards the dispatch model-pin VALUE policy in msd-core/bin/msd-tools.cjs
|
|
// (`resolveDispatchModelPin`, `MODEL_ID_CHARSET_RE`, `MODEL_ID_CHARSET_BODY`,
|
|
// `MODEL_ID_SANITIZE_STRIP_RE`, `MODEL_ID_MAX_LENGTH`):
|
|
//
|
|
// - Item 1: the accept-class (matcher) and the render-class (sanitizer)
|
|
// are single-sourced from one character-class body and can never drift
|
|
// apart again the way they already did once (accept regex gained '@'
|
|
// for Vertex pins; sanitizer keep-class did not, so a rejected
|
|
// Vertex-shaped pin rendered "text-bison?002" instead of
|
|
// "text-bison@002").
|
|
// - Item 2: a pin longer than MODEL_ID_MAX_LENGTH (200) is dropped with a
|
|
// warning rather than reaching argv truncated. Boundary rows at
|
|
// limit-1/limit/limit+1 (199/200/201).
|
|
// - Item 3: the leading character must be alphanumeric, closing off
|
|
// '@'/'/' -shaped values (`@evil`, `/c`) from reaching argv, while five
|
|
// legitimate real-world model ids continue to pass unchanged.
|
|
//
|
|
// Every rejection path must degrade to "no model" (drop-and-warn) — never
|
|
// `undefined` being skipped in favor of an exception, and never a value
|
|
// that still contains an unsafe character escaping to argv.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('fast-check');
|
|
|
|
const msdTools = require('../msd-core/bin/msd-tools.cjs');
|
|
const {
|
|
resolveDispatchModelPin,
|
|
MODEL_ID_CHARSET_RE,
|
|
MODEL_ID_CHARSET_BODY,
|
|
MODEL_ID_SANITIZE_STRIP_RE,
|
|
MODEL_ID_MAX_LENGTH,
|
|
} = msdTools;
|
|
|
|
/** Capture process.stderr.write() calls made during `fn()`. */
|
|
function captureStderr(fn) {
|
|
const original = process.stderr.write;
|
|
const chunks = [];
|
|
process.stderr.write = (chunk) => {
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
};
|
|
try {
|
|
fn();
|
|
} finally {
|
|
process.stderr.write = original;
|
|
}
|
|
return chunks.join('');
|
|
}
|
|
|
|
describe('#3714 follow-up: dispatch model-pin VALUE policy', () => {
|
|
test('MODEL_ID_MAX_LENGTH is the documented 200', () => {
|
|
assert.strictEqual(MODEL_ID_MAX_LENGTH, 200);
|
|
});
|
|
|
|
test('five legitimate real-world model ids all pass through unchanged', () => {
|
|
const ids = [
|
|
'gpt-5.6-terra',
|
|
'synthetic/hf:zai-org/GLM-5.2',
|
|
'text-bison@002',
|
|
'gpt-4o_mini',
|
|
'azure/deployment',
|
|
];
|
|
for (const id of ids) {
|
|
const stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin(`agent-${id}`, id), id);
|
|
});
|
|
assert.strictEqual(stderr, '', `expected no warning for legitimate id "${id}"`);
|
|
}
|
|
});
|
|
|
|
test('item 3: leading "@" and leading "/" are dropped with a warning, never reach argv', () => {
|
|
for (const bad of ['@evil', '/c']) {
|
|
const stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin(`agent-${bad}`, bad), undefined);
|
|
});
|
|
assert.match(stderr, /msd: warning/);
|
|
assert.match(stderr, /unsafe characters/);
|
|
}
|
|
});
|
|
|
|
test('leading-dash values report the flag/option message, not the generic unsafe-characters message (LEADING_DASH_RE must run before MODEL_ID_CHARSET_RE)', () => {
|
|
for (const bad of ['-c', '--config', '-', '--', '-p']) {
|
|
const stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin(`agent-${bad}`, bad), undefined);
|
|
});
|
|
assert.match(stderr, /msd: warning/);
|
|
assert.match(stderr, /looks like a flag\/option, not a model id \(leading "-"\)/);
|
|
assert.doesNotMatch(stderr, /unsafe characters/);
|
|
}
|
|
});
|
|
|
|
test('non-dash out-of-charset values still report the generic unsafe-characters message', () => {
|
|
for (const bad of ['@evil', 'has a space']) {
|
|
const stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin(`agent-x`, bad), undefined);
|
|
});
|
|
assert.match(stderr, /msd: warning/);
|
|
assert.match(stderr, /unsafe characters/);
|
|
assert.doesNotMatch(stderr, /flag\/option/);
|
|
}
|
|
});
|
|
|
|
test('item 2: boundary rows at limit-1/limit/limit+1 (199/200/201)', () => {
|
|
const at199 = 'a'.repeat(199);
|
|
const at200 = 'a'.repeat(200);
|
|
const at201 = 'a'.repeat(201);
|
|
|
|
let stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin('agent-199', at199), at199);
|
|
});
|
|
assert.strictEqual(stderr, '', '199 chars must emit with no warning');
|
|
|
|
stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin('agent-200', at200), at200);
|
|
});
|
|
assert.strictEqual(stderr, '', '200 chars must emit with no warning');
|
|
|
|
stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin('agent-201', at201), undefined);
|
|
});
|
|
assert.match(stderr, /msd: warning/);
|
|
assert.match(stderr, /exceeds the maximum model id length \(200 characters\)/);
|
|
});
|
|
|
|
test('item 2: an over-length pin is dropped, never truncated into a shorter value', () => {
|
|
// A truncated model id is a different model id — the resolver must
|
|
// never return a 200-char prefix of a 5000-char input.
|
|
const huge = 'a'.repeat(5000);
|
|
const result = captureStderr(() => resolveDispatchModelPin('agent-huge', huge));
|
|
assert.match(result, /exceeds the maximum model id length/);
|
|
});
|
|
|
|
test('item 1 (parity): a rejected Vertex-shaped pin renders "@" correctly, not "?"', () => {
|
|
// Append a control byte so the value fails the charset test (and is
|
|
// therefore routed through the sanitizer) while still containing '@'.
|
|
const rawValue = 'text-bison@002' + String.fromCharCode(27);
|
|
const stderr = captureStderr(() => {
|
|
assert.strictEqual(resolveDispatchModelPin('agent-vertex', rawValue), undefined);
|
|
});
|
|
assert.match(stderr, /"text-bison@002\?"/, 'the "@" must survive the sanitizer unchanged');
|
|
assert.doesNotMatch(stderr, /text-bison\?002/, 'the "@" must never be sanitized to "?"');
|
|
});
|
|
|
|
test('item 1 (parity, exhaustive): every character in the accept-class body survives the sanitizer unchanged', () => {
|
|
// Every printable character that MODEL_ID_CHARSET_RE accepts as a
|
|
// non-leading character must also survive MODEL_ID_SANITIZE_STRIP_RE
|
|
// unchanged — the two are derived from one shared body, so this can
|
|
// never regress silently again.
|
|
const acceptedNonLeadingChars = 'A-Za-z0-9._:/@-';
|
|
// Expand the class body into a concrete character list (letters, digits,
|
|
// and the literal punctuation), independent of the regex-escaping used
|
|
// to define it, so the assertion doesn't just re-check the definition
|
|
// against itself.
|
|
const chars = [];
|
|
for (let c = 65; c <= 90; c++) chars.push(String.fromCharCode(c)); // A-Z
|
|
for (let c = 97; c <= 122; c++) chars.push(String.fromCharCode(c)); // a-z
|
|
for (let c = 48; c <= 57; c++) chars.push(String.fromCharCode(c)); // 0-9
|
|
chars.push('.', '_', ':', '/', '@', '-');
|
|
assert.ok(chars.length > 0);
|
|
|
|
for (const ch of chars) {
|
|
const value = 'x' + ch; // 'x' keeps the leading-char anchor satisfied
|
|
assert.match(value, MODEL_ID_CHARSET_RE, `"${value}" should be accepted by MODEL_ID_CHARSET_RE`);
|
|
const sanitized = value.replace(MODEL_ID_SANITIZE_STRIP_RE, '?');
|
|
assert.strictEqual(sanitized, value, `"${ch}" is accepted but was sanitized away`);
|
|
}
|
|
// Assert the literal class body above EQUALS the exported production
|
|
// value, so this test fails if msd-tools.cjs's MODEL_ID_CHARSET_BODY is
|
|
// edited (e.g. widened) without updating this test's expectations.
|
|
assert.strictEqual(
|
|
acceptedNonLeadingChars,
|
|
MODEL_ID_CHARSET_BODY,
|
|
'this test\'s expected charset has drifted from msd-tools.cjs\'s MODEL_ID_CHARSET_BODY',
|
|
);
|
|
});
|
|
|
|
test('item 1 (property): fast-check — any string accepted by MODEL_ID_CHARSET_RE is unchanged by the sanitizer', () => {
|
|
const bodyChar = fc.constantFrom(
|
|
...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:/@-'.split(''),
|
|
);
|
|
const leadChar = fc.constantFrom(
|
|
...'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split(''),
|
|
);
|
|
fc.assert(
|
|
fc.property(leadChar, fc.array(bodyChar, { maxLength: 40 }), (lead, rest) => {
|
|
const value = lead + rest.join('');
|
|
assert.match(value, MODEL_ID_CHARSET_RE);
|
|
const sanitized = value.replace(MODEL_ID_SANITIZE_STRIP_RE, '?');
|
|
assert.strictEqual(sanitized, value);
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('every rejection path degrades to undefined (drop-and-warn), never throws', () => {
|
|
const inputs = [
|
|
'@evil',
|
|
'/c',
|
|
'-c',
|
|
'--config',
|
|
'a'.repeat(201),
|
|
'sonnet',
|
|
String.fromCharCode(27) + 'malicious',
|
|
'',
|
|
' ',
|
|
'inherit',
|
|
'INHERIT',
|
|
];
|
|
for (const input of inputs) {
|
|
assert.doesNotThrow(() => {
|
|
captureStderr(() => resolveDispatchModelPin('agent-x', input));
|
|
}, `resolveDispatchModelPin must never throw for input ${JSON.stringify(input)}`);
|
|
}
|
|
});
|
|
});
|