@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.
Files changed (57) hide show
  1. package/README.md +15 -7
  2. package/dist/harness.bundle +11 -62
  3. package/dist/prebuilds-manifest.json +0 -8
  4. package/dist-lib/index.d.ts +16 -1
  5. package/dist-lib/index.js +31 -1
  6. package/dist-lib/lib/capabilities/completion.d.ts +1 -0
  7. package/dist-lib/lib/capabilities/completion.js +66 -9
  8. package/dist-lib/lib/capabilities/detect-language.d.ts +11 -0
  9. package/dist-lib/lib/capabilities/detect-language.js +13 -0
  10. package/dist-lib/lib/capabilities/invoke.d.ts +15 -0
  11. package/dist-lib/lib/capabilities/invoke.js +33 -0
  12. package/dist-lib/lib/connect.d.ts +4 -1
  13. package/dist-lib/lib/connect.js +51 -22
  14. package/dist-lib/lib/contract-types.d.ts +21 -0
  15. package/dist-lib/lib/docx/index.js +4 -27
  16. package/dist-lib/lib/hooks/built-in/index.d.ts +1 -0
  17. package/dist-lib/lib/hooks/built-in/index.js +1 -0
  18. package/dist-lib/lib/hooks/built-in/sandbox.d.ts +5 -0
  19. package/dist-lib/lib/hooks/built-in/sandbox.js +45 -0
  20. package/dist-lib/lib/hooks/built-in/skill-catalog.js +7 -2
  21. package/dist-lib/lib/hooks/registry.js +2 -1
  22. package/dist-lib/lib/hooks/types.d.ts +1 -0
  23. package/dist-lib/lib/office/index.d.ts +6 -0
  24. package/dist-lib/lib/office/index.js +38 -0
  25. package/dist-lib/lib/office/pptx.d.ts +1 -0
  26. package/dist-lib/lib/office/pptx.js +26 -0
  27. package/dist-lib/lib/office/python.d.ts +7 -0
  28. package/dist-lib/lib/office/python.js +32 -0
  29. package/dist-lib/lib/office/xlsx.d.ts +1 -0
  30. package/dist-lib/lib/office/xlsx.js +26 -0
  31. package/dist-lib/lib/serve.js +32 -4
  32. package/dist-lib/lib/tools/edit-image.js +1 -1
  33. package/dist-lib/lib/tools/generate-image.js +1 -1
  34. package/dist-lib/lib/tools/generate-music.js +1 -1
  35. package/dist-lib/lib/tools/http-request.js +4 -4
  36. package/dist-lib/lib/tools/mcp-call.js +3 -3
  37. package/dist-lib/lib/tools/skill.js +4 -2
  38. package/dist-lib/lib/tools/skills/auth-resolver.d.ts +1 -0
  39. package/dist-lib/lib/tools/skills/auth-resolver.js +59 -29
  40. package/dist-lib/lib/tools/skills/manifest-parse.d.ts +1 -0
  41. package/dist-lib/lib/tools/skills/manifest-parse.js +92 -1
  42. package/dist-lib/lib/tools/skills/manifest.d.ts +12 -0
  43. package/dist-lib/lib/tools/toolsets.d.ts +1 -0
  44. package/dist-lib/lib/tools/toolsets.js +1 -0
  45. package/dist-lib/lib/tools/weather-lookup.js +9 -3
  46. package/dist-lib/lib/tools/web-fetch.js +18 -5
  47. package/dist-lib/lib/tools/web-search.js +5 -2
  48. package/dist-lib/schema/harness.js +54 -4
  49. package/dist-lib/spec/bind.js +3 -0
  50. package/dist-lib/spec/calls.js +1 -0
  51. package/dist-lib/spec/hrpc/hrpc.json +13 -0
  52. package/dist-lib/spec/hrpc/index.js +15 -3
  53. package/dist-lib/spec/hrpc/messages.js +207 -13
  54. package/dist-lib/spec/hyperschema/index.js +207 -13
  55. package/dist-lib/spec/hyperschema/schema.json +163 -1
  56. package/dist-lib/spec/hyperschema/types.d.ts +43 -2
  57. 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 (!known.has(name))
15
- return { error: `skill not found: ${name}`, guidance: true };
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
+ `;
@@ -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' || started)
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.completion(context)) {
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: error.name === 'AbortError' || error instanceof AbortedError
168
- ? 'request timed out'
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: error.name === 'AbortError' || error instanceof AbortedError
146
- ? 'request timed out'
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 listing = available.length ? ` — files this skill has: ${available.join(', ')}` : '';
135
- return { error: `file not found in skill ${name}: ${file}${listing}` };
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
- export async function resolveAuth(auth, url, credentialStore, credentialAllowList, ctx) {
64
- const key = auth.tokenCredentialKey;
65
- if (!credentialStore)
66
- return { error: 'credentials not configured' };
67
- const entry = credentialAllowList?.[key];
68
- if (!entry) {
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} is not available here — load the skill that declares it first`
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 { error: `unknown credential: ${key}` };
80
- return { token: record.token };
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
- let selected = null;
86
- for (const [key, entry] of Object.entries(credentialAllowList)) {
87
- const patterns = Array.isArray(entry) ? entry : [entry];
88
- if (!urlAllowed(url, patterns))
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: auth.tokenCredentialKey };
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(resolveAuth(auth, url, credentialStore, credentialAllowList, ctx), signal);
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: auth.tokenCredentialKey };
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;