@qvac/harness 0.2.9 → 0.2.12
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 +15 -7
- package/dist/harness.bundle +11 -62
- package/dist/prebuilds-manifest.json +0 -8
- package/dist-lib/index.d.ts +16 -1
- package/dist-lib/index.js +31 -1
- package/dist-lib/lib/capabilities/completion.d.ts +1 -0
- package/dist-lib/lib/capabilities/completion.js +66 -9
- package/dist-lib/lib/capabilities/detect-language.d.ts +11 -0
- package/dist-lib/lib/capabilities/detect-language.js +13 -0
- package/dist-lib/lib/capabilities/invoke.d.ts +15 -0
- package/dist-lib/lib/capabilities/invoke.js +33 -0
- package/dist-lib/lib/connect.d.ts +4 -1
- package/dist-lib/lib/connect.js +51 -22
- package/dist-lib/lib/contract-types.d.ts +21 -0
- package/dist-lib/lib/docx/index.js +4 -27
- package/dist-lib/lib/hooks/built-in/index.d.ts +1 -0
- package/dist-lib/lib/hooks/built-in/index.js +1 -0
- package/dist-lib/lib/hooks/built-in/sandbox.d.ts +5 -0
- package/dist-lib/lib/hooks/built-in/sandbox.js +45 -0
- package/dist-lib/lib/hooks/built-in/skill-catalog.js +7 -2
- package/dist-lib/lib/hooks/registry.js +2 -1
- package/dist-lib/lib/hooks/types.d.ts +1 -0
- package/dist-lib/lib/office/index.d.ts +6 -0
- package/dist-lib/lib/office/index.js +38 -0
- package/dist-lib/lib/office/pptx.d.ts +1 -0
- package/dist-lib/lib/office/pptx.js +26 -0
- package/dist-lib/lib/office/python.d.ts +7 -0
- package/dist-lib/lib/office/python.js +32 -0
- package/dist-lib/lib/office/xlsx.d.ts +1 -0
- package/dist-lib/lib/office/xlsx.js +26 -0
- package/dist-lib/lib/serve.js +32 -4
- package/dist-lib/lib/tools/edit-image.js +1 -1
- package/dist-lib/lib/tools/generate-image.js +1 -1
- package/dist-lib/lib/tools/generate-music.js +1 -1
- package/dist-lib/lib/tools/http-request.js +4 -4
- package/dist-lib/lib/tools/mcp-call.js +3 -3
- package/dist-lib/lib/tools/skill.js +4 -2
- package/dist-lib/lib/tools/skills/auth-resolver.d.ts +1 -0
- package/dist-lib/lib/tools/skills/auth-resolver.js +59 -29
- package/dist-lib/lib/tools/skills/manifest-parse.d.ts +1 -0
- package/dist-lib/lib/tools/skills/manifest-parse.js +92 -1
- package/dist-lib/lib/tools/skills/manifest.d.ts +12 -0
- package/dist-lib/lib/tools/toolsets.d.ts +1 -0
- package/dist-lib/lib/tools/toolsets.js +1 -0
- package/dist-lib/lib/tools/weather-lookup.js +9 -3
- package/dist-lib/lib/tools/web-fetch.js +18 -5
- package/dist-lib/lib/tools/web-search.js +5 -2
- package/dist-lib/schema/harness.js +54 -4
- package/dist-lib/spec/bind.js +3 -0
- package/dist-lib/spec/calls.js +1 -0
- package/dist-lib/spec/hrpc/hrpc.json +13 -0
- package/dist-lib/spec/hrpc/index.js +15 -3
- package/dist-lib/spec/hrpc/messages.js +207 -13
- package/dist-lib/spec/hyperschema/index.js +207 -13
- package/dist-lib/spec/hyperschema/schema.json +163 -1
- package/dist-lib/spec/hyperschema/types.d.ts +43 -2
- package/package.json +7 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { planVendoredInstall } from '../../tools/exec/adapters/python/wheels.js';
|
|
2
|
+
import { bareBin, firstCommandToken } from '../../tools/exec/scope.js';
|
|
3
|
+
const NETWORK_BINS = new Set([
|
|
4
|
+
'curl',
|
|
5
|
+
'wget',
|
|
6
|
+
'nc',
|
|
7
|
+
'ncat',
|
|
8
|
+
'netcat',
|
|
9
|
+
'ssh',
|
|
10
|
+
'scp',
|
|
11
|
+
'sftp',
|
|
12
|
+
'rsync',
|
|
13
|
+
'ftp',
|
|
14
|
+
'telnet',
|
|
15
|
+
'git',
|
|
16
|
+
'npm',
|
|
17
|
+
'npx',
|
|
18
|
+
'pnpm',
|
|
19
|
+
'yarn',
|
|
20
|
+
'bun',
|
|
21
|
+
'pip',
|
|
22
|
+
'pip3',
|
|
23
|
+
'brew',
|
|
24
|
+
'dig',
|
|
25
|
+
'nslookup',
|
|
26
|
+
'ping',
|
|
27
|
+
'openssl'
|
|
28
|
+
]);
|
|
29
|
+
export class SandboxHook {
|
|
30
|
+
phase = 'preToolCall';
|
|
31
|
+
run(ctx) {
|
|
32
|
+
if (ctx.phase !== 'preToolCall')
|
|
33
|
+
return;
|
|
34
|
+
if (!ctx.sandboxMode || ctx.toolName !== 'exec')
|
|
35
|
+
return;
|
|
36
|
+
const packages = ctx.args.packages;
|
|
37
|
+
if (Array.isArray(packages) && packages.length && !planVendoredInstall(packages.map(String))) {
|
|
38
|
+
return { error: 'sandbox mode: installing packages reaches the network', guidance: true };
|
|
39
|
+
}
|
|
40
|
+
const bin = bareBin(firstCommandToken(String(ctx.args.command ?? '')).replace(/^.*[/\\]/, ''));
|
|
41
|
+
if (bin && NETWORK_BINS.has(bin)) {
|
|
42
|
+
return { error: `sandbox mode: ${bin} reaches the network`, guidance: true };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -11,7 +11,12 @@ export class SkillCatalogGuardHook {
|
|
|
11
11
|
const known = new Set(entries.map((entry) => (typeof entry === 'string' ? entry : entry.name)));
|
|
12
12
|
const raw = ctx.args.name ?? ctx.args.skill_name ?? ctx.args.skill;
|
|
13
13
|
const name = typeof raw === 'string' ? raw : '';
|
|
14
|
-
if (
|
|
15
|
-
return
|
|
14
|
+
if (known.has(name))
|
|
15
|
+
return;
|
|
16
|
+
const listed = [...known].join(', ');
|
|
17
|
+
const rest = listed
|
|
18
|
+
? `The only skills are: ${listed}. Nothing else needs a skill load — answer or act with the tools you already have.`
|
|
19
|
+
: 'No skill is available this turn — answer or act with the tools you already have.';
|
|
20
|
+
return { error: `skill not found: ${name}. ${rest}`, guidance: true };
|
|
16
21
|
}
|
|
17
22
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ArgRepairHook, EgressGuardHook, SkillCatalogGuardHook } from './built-in/index.js';
|
|
1
|
+
import { ArgRepairHook, EgressGuardHook, SandboxHook, SkillCatalogGuardHook } from './built-in/index.js';
|
|
2
2
|
export const harnessHooks = [
|
|
3
3
|
new ArgRepairHook(),
|
|
4
4
|
new EgressGuardHook(),
|
|
5
|
+
new SandboxHook(),
|
|
5
6
|
new SkillCatalogGuardHook()
|
|
6
7
|
];
|
|
@@ -14,6 +14,7 @@ export interface PreToolCallPayload {
|
|
|
14
14
|
readonly signal?: BareAbortSignal | undefined;
|
|
15
15
|
readonly skillNames?: readonly string[];
|
|
16
16
|
readonly parameters?: ToolDefinition['parameters'];
|
|
17
|
+
readonly sandboxMode?: boolean;
|
|
17
18
|
args: Record<string, unknown>;
|
|
18
19
|
}
|
|
19
20
|
export interface PostToolCallPayload {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AbortSignal } from 'bare-abort-controller';
|
|
2
|
+
export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/client.ts';
|
|
3
|
+
export declare function extractOfficeText(bytes: Uint8Array, fileName: string, options?: {
|
|
4
|
+
readonly signal?: AbortSignal | undefined;
|
|
5
|
+
readonly mimeType?: string | undefined;
|
|
6
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { extractDocxText } from '../docx/index.js';
|
|
3
|
+
import { extractWithPython } from './python.js';
|
|
4
|
+
import { XLSX_EXTRACT } from './xlsx.js';
|
|
5
|
+
import { PPTX_EXTRACT } from './pptx.js';
|
|
6
|
+
export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/client.js';
|
|
7
|
+
const OFFICE_MIME_EXTENSIONS = {
|
|
8
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
|
|
9
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
|
|
10
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx'
|
|
11
|
+
};
|
|
12
|
+
export async function extractOfficeText(bytes, fileName, options = {}) {
|
|
13
|
+
const mimeType = options.mimeType?.split(';')[0]?.trim().toLowerCase();
|
|
14
|
+
const suffix = path.extname(fileName).toLowerCase();
|
|
15
|
+
const extension = ['.docx', '.xlsx', '.pptx'].includes(suffix)
|
|
16
|
+
? suffix
|
|
17
|
+
: OFFICE_MIME_EXTENSIONS[mimeType ?? ''];
|
|
18
|
+
if (extension === '.docx') {
|
|
19
|
+
return extractDocxText(bytes, fileName, options);
|
|
20
|
+
}
|
|
21
|
+
if (extension === '.xlsx') {
|
|
22
|
+
return extractWithPython(bytes, fileName, {
|
|
23
|
+
format: 'XLSX',
|
|
24
|
+
packages: ['openpyxl==3.1.5'],
|
|
25
|
+
source: XLSX_EXTRACT,
|
|
26
|
+
signal: options.signal
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (extension === '.pptx') {
|
|
30
|
+
return extractWithPython(bytes, fileName, {
|
|
31
|
+
format: 'PPTX',
|
|
32
|
+
packages: ['python-pptx==1.0.2'],
|
|
33
|
+
source: PPTX_EXTRACT,
|
|
34
|
+
signal: options.signal
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Unsupported Office document: ${fileName}`);
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PPTX_EXTRACT: string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const PPTX_EXTRACT = `from pptx import Presentation
|
|
2
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def text(shapes):
|
|
6
|
+
for shape in shapes:
|
|
7
|
+
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
8
|
+
yield from text(shape.shapes)
|
|
9
|
+
elif shape.has_text_frame:
|
|
10
|
+
if shape.text.strip():
|
|
11
|
+
yield shape.text
|
|
12
|
+
elif shape.has_table:
|
|
13
|
+
for row in shape.table.rows:
|
|
14
|
+
cells = ["" if cell.is_spanned else cell.text.replace("\\n", " ") for cell in row.cells]
|
|
15
|
+
if any(cell.strip() for cell in cells):
|
|
16
|
+
yield " | ".join(cells)
|
|
17
|
+
|
|
18
|
+
with Path("extracted.txt").open("w", encoding="utf-8") as output:
|
|
19
|
+
for number, slide in enumerate(Presentation("input.pptx").slides, 1):
|
|
20
|
+
heading = False
|
|
21
|
+
for block in text(slide.shapes):
|
|
22
|
+
if not heading:
|
|
23
|
+
output.write("Slide " + str(number) + "\\n")
|
|
24
|
+
heading = True
|
|
25
|
+
output.write(block + "\\n")
|
|
26
|
+
`;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AbortSignal } from 'bare-abort-controller';
|
|
2
|
+
export declare function extractWithPython(bytes: Uint8Array, fileName: string, { format, packages, source, signal }: {
|
|
3
|
+
readonly format: string;
|
|
4
|
+
readonly packages: readonly string[];
|
|
5
|
+
readonly source: string;
|
|
6
|
+
readonly signal?: AbortSignal | undefined;
|
|
7
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import b4a from 'b4a';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { runPython } from '../tools/exec/adapters/python/pyodide.js';
|
|
4
|
+
import { planVendoredInstall, vendoredManifest, wheelsDir } from '../tools/exec/adapters/python/wheels.js';
|
|
5
|
+
const MAX_OFFICE_BYTES = 20 * 1024 * 1024;
|
|
6
|
+
export async function extractWithPython(bytes, fileName, { format, packages, source, signal }) {
|
|
7
|
+
if (bytes.byteLength > MAX_OFFICE_BYTES) {
|
|
8
|
+
throw new Error(`document is too large to extract: ${bytes.byteLength} bytes, limit ${MAX_OFFICE_BYTES}`);
|
|
9
|
+
}
|
|
10
|
+
const dir = wheelsDir();
|
|
11
|
+
const manifest = vendoredManifest(dir);
|
|
12
|
+
const install = planVendoredInstall(packages, { dir, manifest });
|
|
13
|
+
if (!dir ||
|
|
14
|
+
!manifest ||
|
|
15
|
+
!install ||
|
|
16
|
+
!['micropip', ...install.packages].every((name) => Object.keys(manifest.files).some((file) => file.replaceAll('_', '-').startsWith(`${name}-`) && fs.existsSync(`${dir}/${file}`)))) {
|
|
17
|
+
throw new Error(`bundled ${format} extraction packages are unavailable`);
|
|
18
|
+
}
|
|
19
|
+
const result = await runPython(source, {
|
|
20
|
+
packages,
|
|
21
|
+
inputs: [{ path: `input.${format.toLowerCase()}`, bytes }],
|
|
22
|
+
outputs: ['extracted.txt'],
|
|
23
|
+
signal
|
|
24
|
+
});
|
|
25
|
+
if (result.exitCode !== 0)
|
|
26
|
+
throw new Error(`${fileName}: ${result.stderr.trim()}`);
|
|
27
|
+
const output = result.files?.get('extracted.txt');
|
|
28
|
+
if (!output) {
|
|
29
|
+
throw new Error(result.rejected?.[0]?.reason ?? `${format} extraction produced no text file`);
|
|
30
|
+
}
|
|
31
|
+
return b4a.toString(output, 'utf8');
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const XLSX_EXTRACT: string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const XLSX_EXTRACT = `from openpyxl import load_workbook
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
formulas = load_workbook("input.xlsx", read_only=True, data_only=False, keep_links=False)
|
|
5
|
+
values = load_workbook("input.xlsx", read_only=True, data_only=True, keep_links=False)
|
|
6
|
+
try:
|
|
7
|
+
with Path("extracted.txt").open("w", encoding="utf-8") as output:
|
|
8
|
+
for sheet in formulas.worksheets:
|
|
9
|
+
heading = False
|
|
10
|
+
cached = values[sheet.title]
|
|
11
|
+
sheet.reset_dimensions()
|
|
12
|
+
cached.reset_dimensions()
|
|
13
|
+
for cells, results in zip(sheet.iter_rows(), cached.iter_rows()):
|
|
14
|
+
row = [result.value if cell.data_type == "f" and result.value is not None else cell.value
|
|
15
|
+
for cell, result in zip(cells, results)]
|
|
16
|
+
if not any(value is not None and str(value).strip() for value in row):
|
|
17
|
+
continue
|
|
18
|
+
if not heading:
|
|
19
|
+
output.write("Sheet: " + sheet.title + "\\n")
|
|
20
|
+
heading = True
|
|
21
|
+
output.write(" | ".join("" if value is None else str(value).replace("\\n", " ")
|
|
22
|
+
for value in row) + "\\n")
|
|
23
|
+
finally:
|
|
24
|
+
formulas.close()
|
|
25
|
+
values.close()
|
|
26
|
+
`;
|
package/dist-lib/lib/serve.js
CHANGED
|
@@ -4,6 +4,7 @@ import { settleOnClose } from './rpc-session.js';
|
|
|
4
4
|
import { pipeline, Readable } from 'streamx';
|
|
5
5
|
import { bindApi } from '../spec/bind.js';
|
|
6
6
|
import HRPC from '../spec/hrpc/index.js';
|
|
7
|
+
import { isCapability } from './capabilities/invoke.js';
|
|
7
8
|
import { unsupportedKvCacheDeletion } from './capabilities/kv-cache.js';
|
|
8
9
|
import { errorMessage } from './error-fields.js';
|
|
9
10
|
import { hlog } from './log.js';
|
|
@@ -11,7 +12,7 @@ import { pushQueue } from './queue.js';
|
|
|
11
12
|
import { TextCoalescer } from './helpers/text-coalescer.js';
|
|
12
13
|
import { prepareOpenclCache } from './models/opencl-cache.js';
|
|
13
14
|
import { stageFile, sweepStaging } from './staging.js';
|
|
14
|
-
import { readSetup } from './tools/skills/manifest-parse.js';
|
|
15
|
+
import { readSetup, toWireSetup } from './tools/skills/manifest-parse.js';
|
|
15
16
|
import { packVectors, toDownloadWire, toWire } from './wire.js';
|
|
16
17
|
const MODEL_STATUSES = new Set([
|
|
17
18
|
'loading',
|
|
@@ -96,6 +97,15 @@ export function serveHarness(stream, harness) {
|
|
|
96
97
|
text: await harness.transcribe({ model: model ?? '', audio: audio ?? Buffer.alloc(0) })
|
|
97
98
|
};
|
|
98
99
|
},
|
|
100
|
+
detectLanguage: async ({ text, topK }) => {
|
|
101
|
+
if (!harness.detectLanguage)
|
|
102
|
+
throw new Error('harness does not support detectLanguage');
|
|
103
|
+
const languages = await harness.detectLanguage({
|
|
104
|
+
text: text ?? '',
|
|
105
|
+
topK: topK ?? undefined
|
|
106
|
+
});
|
|
107
|
+
return { languages };
|
|
108
|
+
},
|
|
99
109
|
speak: async ({ model, text }) => {
|
|
100
110
|
if (!harness.speak)
|
|
101
111
|
throw new Error('harness does not support speak');
|
|
@@ -159,7 +169,7 @@ export function serveHarness(stream, harness) {
|
|
|
159
169
|
});
|
|
160
170
|
}
|
|
161
171
|
function toWireSkill(skill) {
|
|
162
|
-
const setupV2 = readSetup(skill.setup);
|
|
172
|
+
const setupV2 = toWireSetup(readSetup(skill.setup));
|
|
163
173
|
const binaries = (skill.requires?.bins ?? []).map((bin) => ({
|
|
164
174
|
bin,
|
|
165
175
|
minVersion: skill.requires?.binMinVersions?.[bin]?.min,
|
|
@@ -275,6 +285,24 @@ function speakStream(harness) {
|
|
|
275
285
|
}
|
|
276
286
|
};
|
|
277
287
|
}
|
|
288
|
+
function runFrame(harness, frame, context) {
|
|
289
|
+
if (frame.type !== 'invoke')
|
|
290
|
+
return harness.completion(context);
|
|
291
|
+
if (!harness.invoke)
|
|
292
|
+
throw new Error('harness does not support invoke');
|
|
293
|
+
const capability = frame.capability ?? '';
|
|
294
|
+
if (!isCapability(capability))
|
|
295
|
+
throw new Error(`unknown capability: ${capability}`);
|
|
296
|
+
return harness.invoke({
|
|
297
|
+
capability,
|
|
298
|
+
args: frame.arguments ?? {},
|
|
299
|
+
agent: frame.agent ?? undefined,
|
|
300
|
+
attach: context.attach,
|
|
301
|
+
fetchAttachment: context.fetchAttachment,
|
|
302
|
+
progress: context.progress,
|
|
303
|
+
signal: context.signal
|
|
304
|
+
});
|
|
305
|
+
}
|
|
278
306
|
function completion(harness) {
|
|
279
307
|
return async function* (duplex) {
|
|
280
308
|
const events = pushQueue({ highWaterMark: WIRE_HIGH_WATER_MARK });
|
|
@@ -404,7 +432,7 @@ function completion(harness) {
|
|
|
404
432
|
waiter.resolve((frame.payload ?? {}));
|
|
405
433
|
return;
|
|
406
434
|
}
|
|
407
|
-
if (frame.type !== 'start'
|
|
435
|
+
if (started || (frame.type !== 'start' && frame.type !== 'invoke'))
|
|
408
436
|
return;
|
|
409
437
|
started = true;
|
|
410
438
|
const context = {
|
|
@@ -523,7 +551,7 @@ function completion(harness) {
|
|
|
523
551
|
return events.push({ type, text: merged });
|
|
524
552
|
}, { cap: TEXT_COALESCE_CAP, holdMs: TEXT_COALESCE_MS });
|
|
525
553
|
try {
|
|
526
|
-
for await (const event of harness
|
|
554
|
+
for await (const event of runFrame(harness, frame, context)) {
|
|
527
555
|
produced++;
|
|
528
556
|
const wire = toWire(event);
|
|
529
557
|
if ((wire.type === 'content' || wire.type === 'thinking') && wire.text) {
|
|
@@ -170,7 +170,7 @@ export function createEditImageTool(runtime, { loadMedia = defaultLoadMedia, res
|
|
|
170
170
|
if (sources.length > 1 && !inContext)
|
|
171
171
|
return { error: MULTI_SOURCE_UNSUPPORTED };
|
|
172
172
|
if (!runtime.acquire())
|
|
173
|
-
return { error: BUSY_ERROR };
|
|
173
|
+
return { error: BUSY_ERROR, code: 'busy' };
|
|
174
174
|
const report = ctx.progress;
|
|
175
175
|
const onTick = report && generationPercentTick((percentage) => report({ percentage }), sources.length);
|
|
176
176
|
try {
|
|
@@ -323,7 +323,7 @@ export function createGenerateImageTool(runtime, clamps) {
|
|
|
323
323
|
if (!ctx?.attach)
|
|
324
324
|
return { error: 'this device cannot persist generated images' };
|
|
325
325
|
if (!runtime.acquire())
|
|
326
|
-
return { error: BUSY_ERROR };
|
|
326
|
+
return { error: BUSY_ERROR, code: 'busy' };
|
|
327
327
|
const report = ctx.progress;
|
|
328
328
|
const onTick = report && generationPercentTick((percentage) => report({ percentage }));
|
|
329
329
|
try {
|
|
@@ -443,7 +443,7 @@ export function createGenerateMusicTool(runtime, clamps) {
|
|
|
443
443
|
if (!ctx?.attach)
|
|
444
444
|
return { error: 'this device cannot persist generated audio' };
|
|
445
445
|
if (!runtime.acquire())
|
|
446
|
-
return { error: BUSY_ERROR };
|
|
446
|
+
return { error: BUSY_ERROR, code: 'busy' };
|
|
447
447
|
const report = ctx.progress;
|
|
448
448
|
const onTick = report && musicPercentTick((percentage) => report({ percentage }));
|
|
449
449
|
const capMax = stepsCap ?? MAX_STEPS;
|
|
@@ -14,7 +14,7 @@ export function createHttpRequestTool({ fetch: fetchImpl, credentialStore, crede
|
|
|
14
14
|
return {
|
|
15
15
|
name: 'http_request',
|
|
16
16
|
toolset: 'http-request',
|
|
17
|
-
description: 'Make an outbound HTTP or HTTPS request and return the response status and body (truncated). Authorization/Cookie headers are stripped before sending. Stored skill credentials are attached automatically for their allow-listed hosts; `auth` is only needed to force a specific credential key.',
|
|
17
|
+
description: 'Make an outbound HTTP or HTTPS request and return the response status and body (truncated) as text. Authorization/Cookie headers are stripped before sending. Stored skill credentials are attached automatically for their allow-listed hosts; `auth` is only needed to force a specific credential key. For APIs and text responses only: a URL that serves a file — PDF, .docx, .xlsx, an image, any binary — comes back as truncated garbage here. Use web_fetch for those; it downloads and attaches the file.',
|
|
18
18
|
parameters: {
|
|
19
19
|
type: 'object',
|
|
20
20
|
properties: {
|
|
@@ -163,10 +163,10 @@ export function createHttpRequestTool({ fetch: fetchImpl, credentialStore, crede
|
|
|
163
163
|
}
|
|
164
164
|
catch (err) {
|
|
165
165
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
166
|
+
const timedOut = error.name === 'AbortError' || error instanceof AbortedError;
|
|
166
167
|
return {
|
|
167
|
-
error:
|
|
168
|
-
|
|
169
|
-
: `request failed: ${error.message}`
|
|
168
|
+
error: timedOut ? 'request timed out' : `request failed: ${error.message}`,
|
|
169
|
+
code: timedOut ? 'timeout' : 'transport'
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
172
|
finally {
|
|
@@ -141,10 +141,10 @@ export function createMcpCallTool({ fetch: fetchImpl, credentialStore, credentia
|
|
|
141
141
|
}
|
|
142
142
|
catch (err) {
|
|
143
143
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
144
|
+
const timedOut = error.name === 'AbortError' || error instanceof AbortedError;
|
|
144
145
|
return {
|
|
145
|
-
error:
|
|
146
|
-
|
|
147
|
-
: `request failed: ${error.message}`
|
|
146
|
+
error: timedOut ? 'request timed out' : `request failed: ${error.message}`,
|
|
147
|
+
code: timedOut ? 'timeout' : 'transport'
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
150
|
finally {
|
|
@@ -131,8 +131,10 @@ export function createSkillTool({ skillsDir, overlayDir = resolveSkillsOverlayDi
|
|
|
131
131
|
if (file === MAIN_FILE)
|
|
132
132
|
return { error: `skill not found: ${name}` };
|
|
133
133
|
const available = await listSkillFiles([overlayDrive, baseDrive], name);
|
|
134
|
-
const
|
|
135
|
-
|
|
134
|
+
const rest = available.length
|
|
135
|
+
? ` — files this skill has: ${available.join(', ')}`
|
|
136
|
+
: '. This skill has no other files; you already have its full instructions — act now.';
|
|
137
|
+
return { error: `file not found in skill ${name}: ${file}${rest}` };
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
140
|
};
|
|
@@ -19,6 +19,7 @@ export declare function isCredentialUrlAllowed(target: string, patterns: readonl
|
|
|
19
19
|
export type CredentialAllowListFn = (ctx?: ToolExecuteContext) => CredentialAllowList;
|
|
20
20
|
export type AuthOutcome = {
|
|
21
21
|
readonly token: string;
|
|
22
|
+
readonly key: string;
|
|
22
23
|
} | {
|
|
23
24
|
readonly error: string;
|
|
24
25
|
};
|
|
@@ -60,49 +60,79 @@ export function parseAuth(v) {
|
|
|
60
60
|
}
|
|
61
61
|
return { ok: true, value: { type: 'bearer', tokenCredentialKey: key } };
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
function grantedKeys(url, credentialAllowList) {
|
|
64
|
+
return Object.entries(credentialAllowList)
|
|
65
|
+
.filter(([, entry]) => urlAllowed(url, Array.isArray(entry) ? entry : [entry]))
|
|
66
|
+
.map(([key]) => key);
|
|
67
|
+
}
|
|
68
|
+
async function connectedCredentials(keys, credentialStore, ctx) {
|
|
69
|
+
const connected = [];
|
|
70
|
+
for (const key of keys) {
|
|
71
|
+
const record = credentialRecord(await credentialStore(key, ctx));
|
|
72
|
+
if (record)
|
|
73
|
+
connected.push({ token: record.token, key });
|
|
74
|
+
}
|
|
75
|
+
return connected;
|
|
76
|
+
}
|
|
77
|
+
function ambiguous(keys) {
|
|
78
|
+
return {
|
|
79
|
+
error: `multiple credentials match this URL; set auth.tokenCredentialKey to one of ${keys.join(', ')}`
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function resolveGuessedKey(key, url, credentialStore, credentialAllowList, ctx) {
|
|
83
|
+
const granted = grantedKeys(url, credentialAllowList);
|
|
84
|
+
if (granted.length === 0) {
|
|
69
85
|
return {
|
|
70
|
-
error: `credential ${key}
|
|
86
|
+
error: `unknown credential key ${key}; no loaded skill grants a credential for ${url.host} — omit auth, or load the skill that declares it`
|
|
71
87
|
};
|
|
72
88
|
}
|
|
89
|
+
const [first, ...rest] = await connectedCredentials(granted, credentialStore, ctx);
|
|
90
|
+
if (!first) {
|
|
91
|
+
return {
|
|
92
|
+
error: `unknown credential key ${key}; no connected credential for ${url.host} (${granted.join(', ')}). Tell the user to connect it in Skills; do not retry`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (rest.length > 0)
|
|
96
|
+
return ambiguous([first, ...rest].map((c) => c.key));
|
|
97
|
+
return first;
|
|
98
|
+
}
|
|
99
|
+
async function resolveDeclaredKey(key, url, credentialStore, credentialAllowList, ctx) {
|
|
100
|
+
const entry = credentialAllowList?.[key];
|
|
101
|
+
if (!entry)
|
|
102
|
+
return null;
|
|
73
103
|
const patterns = Array.isArray(entry) ? entry : [entry];
|
|
74
104
|
if (!urlAllowed(url, patterns)) {
|
|
75
105
|
return { error: `credential ${key} is not allowed for URL ${url.href}` };
|
|
76
106
|
}
|
|
77
107
|
const record = credentialRecord(await credentialStore(key, ctx));
|
|
78
|
-
if (!record)
|
|
79
|
-
return {
|
|
80
|
-
|
|
108
|
+
if (!record) {
|
|
109
|
+
return {
|
|
110
|
+
error: `credential ${key} is not connected for ${url.host}. Tell the user to connect it in Skills; do not retry`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { token: record.token, key };
|
|
114
|
+
}
|
|
115
|
+
export async function resolveAuth(auth, url, credentialStore, credentialAllowList, ctx) {
|
|
116
|
+
const key = auth.tokenCredentialKey;
|
|
117
|
+
if (!credentialStore)
|
|
118
|
+
return { error: 'credentials not configured' };
|
|
119
|
+
return ((await resolveDeclaredKey(key, url, credentialStore, credentialAllowList, ctx)) ??
|
|
120
|
+
resolveGuessedKey(key, url, credentialStore, credentialAllowList ?? {}, ctx));
|
|
81
121
|
}
|
|
82
122
|
export async function resolveAutoAuth(url, credentialStore, credentialAllowList, ctx) {
|
|
83
123
|
if (!credentialStore || !credentialAllowList)
|
|
84
124
|
return null;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
continue;
|
|
90
|
-
const record = credentialRecord(await credentialStore(key, ctx));
|
|
91
|
-
if (!record)
|
|
92
|
-
continue;
|
|
93
|
-
if (selected) {
|
|
94
|
-
return { error: 'multiple credentials match this URL; set auth.tokenCredentialKey' };
|
|
95
|
-
}
|
|
96
|
-
selected = { token: record.token, key };
|
|
97
|
-
}
|
|
98
|
-
return selected;
|
|
125
|
+
const connected = await connectedCredentials(grantedKeys(url, credentialAllowList), credentialStore, ctx);
|
|
126
|
+
if (connected.length > 1)
|
|
127
|
+
return ambiguous(connected.map((c) => c.key));
|
|
128
|
+
return connected[0] ?? null;
|
|
99
129
|
}
|
|
100
130
|
export async function resolveBearerHeader(auth, url, credentialStore, credentialAllowList, ctx, signal) {
|
|
101
131
|
if (auth) {
|
|
102
132
|
const resolved = await raceAbort(resolveAuth(auth, url, credentialStore, credentialAllowList, ctx), signal);
|
|
103
133
|
if ('error' in resolved)
|
|
104
134
|
return { error: resolved.error };
|
|
105
|
-
return { header: `Bearer ${resolved.token}`, credentialKey:
|
|
135
|
+
return { header: `Bearer ${resolved.token}`, credentialKey: resolved.key };
|
|
106
136
|
}
|
|
107
137
|
const resolved = await raceAbort(resolveAutoAuth(url, credentialStore, credentialAllowList, ctx), signal);
|
|
108
138
|
if (resolved && 'error' in resolved)
|
|
@@ -112,10 +142,10 @@ export async function resolveBearerHeader(auth, url, credentialStore, credential
|
|
|
112
142
|
: { header: null };
|
|
113
143
|
}
|
|
114
144
|
export async function resolveRedirectBearerHeader(auth, url, credentialStore, credentialAllowList, ctx, signal) {
|
|
115
|
-
if (!auth)
|
|
145
|
+
if (!auth || !credentialStore)
|
|
116
146
|
return { header: null };
|
|
117
|
-
const resolved = await raceAbort(
|
|
118
|
-
return 'error' in resolved
|
|
147
|
+
const resolved = await raceAbort(resolveDeclaredKey(auth.tokenCredentialKey, url, credentialStore, credentialAllowList, ctx), signal);
|
|
148
|
+
return !resolved || 'error' in resolved
|
|
119
149
|
? { header: null }
|
|
120
|
-
: { header: `Bearer ${resolved.token}`, credentialKey:
|
|
150
|
+
: { header: `Bearer ${resolved.token}`, credentialKey: resolved.key };
|
|
121
151
|
}
|
|
@@ -10,3 +10,4 @@ export interface ParsedManifest {
|
|
|
10
10
|
export declare function parseManifestBlock(block: string, key?: string): ParsedManifest;
|
|
11
11
|
export declare function readInstall(v: unknown): InstallStep[] | undefined;
|
|
12
12
|
export declare function readSetup(v: unknown): SkillSetup | undefined;
|
|
13
|
+
export declare function toWireSetup(setup: SkillSetup | undefined): unknown;
|
|
@@ -165,6 +165,9 @@ function readRoutes(v) {
|
|
|
165
165
|
const steps = strArr(raw.steps);
|
|
166
166
|
const configSteps = strArr(raw.configSteps);
|
|
167
167
|
const fields = readFields(raw.fields);
|
|
168
|
+
const oauth = readOAuth(raw.oauth);
|
|
169
|
+
if (raw.kind === 'oauth' && !oauth)
|
|
170
|
+
continue;
|
|
168
171
|
routes.push({
|
|
169
172
|
kind: raw.kind,
|
|
170
173
|
label: raw.label,
|
|
@@ -175,11 +178,99 @@ function readRoutes(v) {
|
|
|
175
178
|
...(steps ? { steps } : {}),
|
|
176
179
|
...(configSteps ? { configSteps } : {}),
|
|
177
180
|
...(fields ? { fields } : {}),
|
|
178
|
-
...(typeof raw.satisfied === 'boolean' ? { satisfied: raw.satisfied } : {})
|
|
181
|
+
...(typeof raw.satisfied === 'boolean' ? { satisfied: raw.satisfied } : {}),
|
|
182
|
+
...(oauth ? { oauth } : {})
|
|
179
183
|
});
|
|
180
184
|
}
|
|
181
185
|
return routes.length > 0 ? routes : undefined;
|
|
182
186
|
}
|
|
187
|
+
const OAUTH_TOKEN_AUTH = new Set(['pkce-only', 'secret-in-body', 'secret-in-basic']);
|
|
188
|
+
const OAUTH_RESERVED_PARAMS = new Set([
|
|
189
|
+
'client_id',
|
|
190
|
+
'client_secret',
|
|
191
|
+
'redirect_uri',
|
|
192
|
+
'response_type',
|
|
193
|
+
'state',
|
|
194
|
+
'scope',
|
|
195
|
+
'code_challenge',
|
|
196
|
+
'code_challenge_method'
|
|
197
|
+
]);
|
|
198
|
+
function isOAuthTokenAuth(v) {
|
|
199
|
+
return typeof v === 'string' && OAUTH_TOKEN_AUTH.has(v);
|
|
200
|
+
}
|
|
201
|
+
function httpsUrl(v) {
|
|
202
|
+
if (typeof v !== 'string' || v.length === 0)
|
|
203
|
+
return undefined;
|
|
204
|
+
try {
|
|
205
|
+
return new URL(v).protocol === 'https:' ? v : undefined;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function isPort(v) {
|
|
212
|
+
return typeof v === 'number' && Number.isInteger(v) && v > 0 && v < 65536;
|
|
213
|
+
}
|
|
214
|
+
function readAuthParams(v) {
|
|
215
|
+
const raw = Array.isArray(v)
|
|
216
|
+
? Object.fromEntries(v.flatMap((pair) => isObj(pair) && typeof pair.key === 'string' && typeof pair.value === 'string'
|
|
217
|
+
? [[pair.key, pair.value]]
|
|
218
|
+
: []))
|
|
219
|
+
: v;
|
|
220
|
+
const record = readStringRecord(raw);
|
|
221
|
+
if (!record)
|
|
222
|
+
return undefined;
|
|
223
|
+
const out = Object.fromEntries(Object.entries(record).filter(([key]) => !OAUTH_RESERVED_PARAMS.has(key)));
|
|
224
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
225
|
+
}
|
|
226
|
+
function readOAuth(v) {
|
|
227
|
+
if (!isObj(v))
|
|
228
|
+
return undefined;
|
|
229
|
+
const authUrl = httpsUrl(v.authUrl);
|
|
230
|
+
const tokenUrl = httpsUrl(v.tokenUrl);
|
|
231
|
+
if (typeof v.stateKey !== 'string' ||
|
|
232
|
+
v.stateKey.length === 0 ||
|
|
233
|
+
!authUrl ||
|
|
234
|
+
!tokenUrl ||
|
|
235
|
+
!isOAuthTokenAuth(v.tokenAuth)) {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
const scopes = v.scopes === undefined || v.scopes === null ? [] : strArr(v.scopes);
|
|
239
|
+
if (!scopes)
|
|
240
|
+
return undefined;
|
|
241
|
+
const extraAuthParams = readAuthParams(v.extraAuthParams);
|
|
242
|
+
const registrationEndpoint = httpsUrl(v.registrationEndpoint);
|
|
243
|
+
return {
|
|
244
|
+
stateKey: v.stateKey,
|
|
245
|
+
authUrl,
|
|
246
|
+
tokenUrl,
|
|
247
|
+
scopes,
|
|
248
|
+
tokenAuth: v.tokenAuth,
|
|
249
|
+
...(extraAuthParams ? { extraAuthParams } : {}),
|
|
250
|
+
...(registrationEndpoint ? { registrationEndpoint } : {}),
|
|
251
|
+
...(isPort(v.port) ? { port: v.port } : {}),
|
|
252
|
+
...(typeof v.sharedIosSession === 'boolean' ? { sharedIosSession: v.sharedIosSession } : {})
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
export function toWireSetup(setup) {
|
|
256
|
+
if (!setup?.routes)
|
|
257
|
+
return setup;
|
|
258
|
+
return {
|
|
259
|
+
...setup,
|
|
260
|
+
routes: setup.routes.map((route) => route.oauth?.extraAuthParams
|
|
261
|
+
? {
|
|
262
|
+
...route,
|
|
263
|
+
oauth: {
|
|
264
|
+
...route.oauth,
|
|
265
|
+
extraAuthParams: Object.entries(route.oauth.extraAuthParams).map(([key, value]) => ({
|
|
266
|
+
key,
|
|
267
|
+
value
|
|
268
|
+
}))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
: route)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
183
274
|
function readFields(v) {
|
|
184
275
|
if (!Array.isArray(v))
|
|
185
276
|
return undefined;
|