@apifuse/provider-sdk 2.2.0-beta.5 → 2.2.0-beta.8

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 (66) hide show
  1. package/AUTHORING.md +53 -0
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +5 -1
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-check.ts +26 -1
  6. package/bin/apifuse-pack-check.ts +14 -0
  7. package/bin/apifuse-submit-check.ts +193 -2
  8. package/bin/apifuse-sync-assets.ts +117 -0
  9. package/dist/auth-turn/index.d.ts +2 -2
  10. package/dist/cli/commands.d.ts +1 -1
  11. package/dist/cli/commands.js +8 -0
  12. package/dist/cli/create.d.ts +3 -0
  13. package/dist/cli/create.js +34 -35
  14. package/dist/cli/prompt-assets.d.ts +80 -0
  15. package/dist/cli/prompt-assets.js +743 -0
  16. package/dist/cli/templates/provider/AGENTS.md.tpl +17 -8
  17. package/dist/config/loader.d.ts +79 -6
  18. package/dist/config/loader.js +272 -48
  19. package/dist/define.js +27 -3
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/runtime/executor.js +7 -0
  23. package/dist/runtime/http.js +3 -0
  24. package/dist/runtime/proxy-errors.js +6 -2
  25. package/dist/runtime/proxy-nodemaven.d.ts +34 -0
  26. package/dist/runtime/proxy-nodemaven.js +128 -0
  27. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  28. package/dist/runtime/proxy-telemetry.js +39 -4
  29. package/dist/runtime/secrets.d.ts +27 -0
  30. package/dist/runtime/secrets.js +51 -0
  31. package/dist/runtime/stealth.js +20 -9
  32. package/dist/server/serve.d.ts +5 -0
  33. package/dist/server/serve.js +39 -0
  34. package/dist/server/types.d.ts +9 -9
  35. package/dist/types.d.ts +30 -1
  36. package/package.json +4 -3
  37. package/src/cli/commands.ts +10 -0
  38. package/src/cli/create.ts +42 -35
  39. package/src/cli/prompt-assets.ts +865 -0
  40. package/src/cli/templates/provider/AGENTS.md.tpl +17 -8
  41. package/src/config/loader.ts +405 -61
  42. package/src/define.ts +35 -3
  43. package/src/index.ts +5 -0
  44. package/src/runtime/executor.ts +8 -0
  45. package/src/runtime/http.ts +3 -0
  46. package/src/runtime/proxy-errors.ts +12 -4
  47. package/src/runtime/proxy-nodemaven.ts +178 -0
  48. package/src/runtime/proxy-telemetry.ts +56 -5
  49. package/src/runtime/secrets.ts +64 -0
  50. package/src/runtime/stealth.ts +26 -10
  51. package/src/server/serve.ts +53 -0
  52. package/src/types.ts +30 -1
  53. package/dist/cli/templates/provider/CLAUDE.md.tpl +0 -1
  54. package/src/cli/templates/provider/CLAUDE.md.tpl +0 -1
  55. /package/dist/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  56. /package/dist/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  57. /package/dist/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  58. /package/dist/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  59. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  60. /package/dist/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
  61. /package/src/cli/templates/provider/{skills → .agents/skills}/fixtures-and-recording/SKILL.md.tpl +0 -0
  62. /package/src/cli/templates/provider/{skills → .agents/skills}/health-checks-and-fail-closed/SKILL.md.tpl +0 -0
  63. /package/src/cli/templates/provider/{skills → .agents/skills}/normalization-standards/SKILL.md.tpl +0 -0
  64. /package/src/cli/templates/provider/{skills → .agents/skills}/pagination-and-counts/SKILL.md.tpl +0 -0
  65. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-contract-verification/SKILL.md.tpl +0 -0
  66. /package/src/cli/templates/provider/{skills → .agents/skills}/upstream-notes/README.md.tpl +0 -0
package/AUTHORING.md CHANGED
@@ -309,6 +309,59 @@ External contributors are expected to submit standalone Provider source plus:
309
309
  Maintainers own monorepo import under `providers/<id>/`, registry generation,
310
310
  deployment projection checks, and release workflows.
311
311
 
312
+ ### Declared secrets are SDK-enforced
313
+
314
+ Environment/secret presence validation is single-sourced in the SDK. Declare
315
+ every env secret the provider needs in `defineProvider`:
316
+
317
+ ```ts
318
+ secrets: [
319
+ {
320
+ name: "APIFUSE__PROVIDER__MY_PROVIDER__API_KEY",
321
+ required: true,
322
+ description: "Upstream API key from the vendor portal",
323
+ },
324
+ ],
325
+ ```
326
+
327
+ The runtime validates every `required: true` declaration before any operation
328
+ handler or auth-flow handler (except `abort`) runs. When a required secret is
329
+ unset or whitespace-only, the invocation fails with the canonical structured
330
+ error — code `MISSING_SECRET`, HTTP 400, `details.category:
331
+ "credential_unavailable"`, `retryable: false`, and a `fix` naming every missing
332
+ secret — across `/v1/{operation}`, self-test probes, `apifuse perf`, and
333
+ `apifuse record`. The server also emits a `provider_secrets_missing` warn log
334
+ at boot so unprovisioned deployments are visible immediately without crashing
335
+ the pod.
336
+
337
+ Provider-local presence re-validation is **deprecated**: do not write
338
+ `requireServiceKey`/`requireApiKey`-style guards that re-check `ctx.env.get()`
339
+ and throw a hand-rolled `CONFIGURATION_ERROR`/`MISSING_SECRET`. Those guards
340
+ are dead weight (the SDK gate runs first) and historically diverged into
341
+ inconsistent error shapes. The `sdk-owned-secret-presence` submit-check rule
342
+ flags them at warn level; acknowledge a deliberate exception with
343
+ `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
344
+
345
+ ```ts
346
+ // Before (deprecated): provider-local double validation
347
+ function requireServiceKey(ctx: ProviderContext): string {
348
+ const value = ctx.env.get(SERVICE_KEY_ENV);
349
+ if (!value?.trim()) {
350
+ throw new ProviderError(`Missing required provider secret: ${SERVICE_KEY_ENV}`, {
351
+ code: "CONFIGURATION_ERROR",
352
+ });
353
+ }
354
+ return value;
355
+ }
356
+
357
+ // After: declare { name: SERVICE_KEY_ENV, required: true } and read directly.
358
+ const serviceKey = ctx.env.get(SERVICE_KEY_ENV);
359
+ ```
360
+
361
+ Note the asymmetry: the gate treats whitespace-only values as missing, but
362
+ `ctx.env.get()` still returns the raw value to handlers — trim at the point of
363
+ use if the upstream is whitespace-sensitive.
364
+
312
365
  ### Public local debugging checklist
313
366
 
314
367
  - Operation smoke requests use the provider server envelope:
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.8
4
+
5
+ - Release candidate for main commit 9e8a3f028ee78b9cab29d4aa3f5494ac9cffa65f.
6
+
7
+ ## 2.2.0-beta.7
8
+
9
+ - Release candidate for main commit 2ce4ea4bd36ce333eba8b3b474bf6e82b5e9216c.
10
+
11
+ ## 2.2.0-beta.6
12
+
13
+ - Release candidate for main commit 17f4e41d44efe7c148ef875b950be4f2c7df1294.
14
+
3
15
  ## 2.2.0-beta.5
4
16
 
5
17
  - Release candidate for main commit 82fa14e99a9af7edd44e3196aa3f4e87b4699edf.
package/README.md CHANGED
@@ -119,7 +119,11 @@ the bad request path; provider/runtime failures include `code`, `message`, and
119
119
  local-only values through `connection.secrets`. Read them in handlers with
120
120
  `ctx.credential.get("key")` or `ctx.credential.getAccessToken()`.
121
121
  - **Provider env secrets**: declare `secrets[]`, set values in your shell or
122
- `.env`, and read only those names through `ctx.env.get("NAME")`.
122
+ `.env`, and read only those names through `ctx.env.get("NAME")`. The SDK
123
+ enforces presence of `required: true` declarations before handlers and auth
124
+ flows run, failing the invocation with a structured `MISSING_SECRET` error
125
+ (HTTP 400, category `credential_unavailable`) — do not re-check presence in
126
+ handlers.
123
127
  - **Credentials auth flows**: prefer `defineCredentialsAuth()` over hand-written
124
128
  `auth.flow`. Declare the form fields and credential keys once, then put the
125
129
  upstream login/session creation in `login(ctx, input)`. Return
package/SUBMISSION.md CHANGED
@@ -50,7 +50,7 @@ Fix all blockers before submitting:
50
50
  - High-confidence secret or token material in source, README, package metadata, or fixtures.
51
51
  - SDK-native source blockers: prefixed Provider ids, `vendor/` SDK shims or imports, raw `.describe()` prose instead of `describeKey`, raw global `fetch()` calls, and excessive `as Type` assertions.
52
52
 
53
- Warnings do not fail the command, but they should be addressed when practical. For example, the generated starter `ping` operation warns because it is not a real upstream-backed bounty Operation. SDK-native warnings also flag moderate `as Type` assertion counts and credentialed Providers that never reference `ctx.credential`.
53
+ Warnings do not fail the command, but they should be addressed when practical. For example, the generated starter `ping` operation warns because it is not a real upstream-backed bounty Operation. SDK-native warnings also flag moderate `as Type` assertion counts, credentialed Providers that never reference `ctx.credential`, and provider-local re-validation of declared env secrets (`sdk-owned-secret-presence`, 0 points): the SDK already rejects invocations with a structured `MISSING_SECRET` error when a declared `required: true` secret is unset, so `requireServiceKey`-style presence guards are deprecated dead weight — delete the guard and read the value directly with `ctx.env.get()`, or acknowledge a deliberate exception with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
54
54
 
55
55
  ## Measured local smoke
56
56
 
@@ -6,6 +6,11 @@ import { pathToFileURL } from "node:url";
6
6
 
7
7
  import { z } from "zod";
8
8
 
9
+ import {
10
+ formatPromptAssetIssues,
11
+ PROMPT_ASSET_SYNC_REMEDIATION,
12
+ verifyPromptAssets,
13
+ } from "../src/cli/prompt-assets.js";
9
14
  import type { ProviderDefinition } from "../src/index.js";
10
15
  import { lintProvider, type ProviderLintMode } from "../src/lint.js";
11
16
  import { safeParseSchemaSync } from "../src/schema.js";
@@ -61,7 +66,7 @@ function normalizeArgs(argv: string[]): string[] {
61
66
  return argv[0] === "check" ? argv.slice(1) : argv;
62
67
  }
63
68
 
64
- function resolveProviderRoot(inputPath: string): string {
69
+ export function resolveProviderRoot(inputPath: string): string {
65
70
  const resolvedInput = resolveFromParents(inputPath);
66
71
 
67
72
  if (!existsSync(resolvedInput)) {
@@ -129,9 +134,26 @@ export async function runChecks(
129
134
  checkProviderMetadata(provider),
130
135
  checkDockerfile(dockerfilePath),
131
136
  checkPackageJson(packageJsonPath),
137
+ checkPromptAssets(providerRoot),
132
138
  ];
133
139
  }
134
140
 
141
+ export const PROMPT_ASSETS_CHECK_MESSAGE =
142
+ "Agent prompt assets match the installed SDK version";
143
+
144
+ function checkPromptAssets(providerRoot: string): CheckResult {
145
+ const verification = verifyPromptAssets(providerRoot);
146
+ if (verification.ok) {
147
+ return { message: PROMPT_ASSETS_CHECK_MESSAGE, passed: true };
148
+ }
149
+
150
+ return {
151
+ message: PROMPT_ASSETS_CHECK_MESSAGE,
152
+ passed: false,
153
+ details: [...formatPromptAssetIssues(verification), PROMPT_ASSET_SYNC_REMEDIATION],
154
+ };
155
+ }
156
+
135
157
  function isScannableProviderSourceFile(relativePath: string): boolean {
136
158
  return (
137
159
  /\.(?:ts|tsx|js|jsx|mjs|cjs|sh|bash)$/.test(relativePath) ||
@@ -142,6 +164,9 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
142
164
 
143
165
  function collectProviderSourceFiles(providerRoot: string): Record<string, string> {
144
166
  const sources: Record<string, string> = {};
167
+ // `.agents`/`.apifuse` are deliberately not skipped: managed content there
168
+ // is markdown/JSON (never matched by isScannableProviderSourceFile), and a
169
+ // planted `.ts`/`.sh` under those directories must stay in scanner scope.
145
170
  const skipDirectories = new Set([".git", "node_modules", "dist", "build", ".next"]);
146
171
  const visit = (directory: string) => {
147
172
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
@@ -48,6 +48,13 @@ const requiredPaths = [
48
48
  "src/cli/templates/provider/operations/ping.ts.tpl",
49
49
  "src/cli/templates/provider/schemas/ping.ts.tpl",
50
50
  "src/cli/templates/provider/upstream/README.md.tpl",
51
+ "src/cli/templates/provider/AGENTS.md.tpl",
52
+ "src/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl",
53
+ "src/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl",
54
+ "src/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl",
55
+ "src/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl",
56
+ "src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl",
57
+ "src/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl",
51
58
  "dist/cli/templates/provider/.dockerignore.tpl",
52
59
  "dist/cli/templates/provider/.gitignore.tpl",
53
60
  "dist/cli/templates/provider/Dockerfile.tpl",
@@ -60,6 +67,13 @@ const requiredPaths = [
60
67
  "dist/cli/templates/provider/operations/ping.ts.tpl",
61
68
  "dist/cli/templates/provider/schemas/ping.ts.tpl",
62
69
  "dist/cli/templates/provider/upstream/README.md.tpl",
70
+ "dist/cli/templates/provider/AGENTS.md.tpl",
71
+ "dist/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl",
72
+ "dist/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl",
73
+ "dist/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl",
74
+ "dist/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl",
75
+ "dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl",
76
+ "dist/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl",
63
77
  "dist/auth-turn/index.js",
64
78
  "dist/auth-turn/index.d.ts",
65
79
  "dist/auth-turn/auth-turn.v1.schema.json",
@@ -11,6 +11,10 @@ import * as acorn from "acorn";
11
11
  import { z } from "zod";
12
12
 
13
13
  import packageJson from "../package.json";
14
+ import {
15
+ formatPromptAssetIssues,
16
+ verifyPromptAssets,
17
+ } from "../src/cli/prompt-assets.js";
14
18
  import type { ProviderDefinition } from "../src/index.js";
15
19
  import {
16
20
  loadProviderLocaleCatalogs,
@@ -19,7 +23,7 @@ import {
19
23
  } from "../src/i18n/index.js";
20
24
  import { APIFUSE_DESCRIPTION_KEY_META_KEY } from "../src/schema.js";
21
25
  import { safeParseSchemaSync } from "../src/schema.js";
22
- import { type CheckResult, runChecks } from "./apifuse-check.js";
26
+ import { type CheckResult, PROMPT_ASSETS_CHECK_MESSAGE, runChecks } from "./apifuse-check.js";
23
27
  import { hasSubstantiveXmlStructure } from "./submit-check-xml.js";
24
28
 
25
29
  const TIERS = ["bronze", "silver", "gold", "diamond"] as const;
@@ -267,7 +271,15 @@ export async function buildSubmitCheckReport(
267
271
  const baseChecks = await safeRunChecks(providerRoot);
268
272
  const provider = await safeLoadProvider(providerRoot);
269
273
 
270
- checks.push(...scoreBaseChecks(baseChecks));
274
+ // Prompt-asset freshness is reported by its own dedicated zero-point
275
+ // blocker below; filter the base-check duplicate so it is not double
276
+ // penalized under the definition category.
277
+ checks.push(
278
+ ...scoreBaseChecks(
279
+ baseChecks.filter((result) => result.message !== PROMPT_ASSETS_CHECK_MESSAGE),
280
+ ),
281
+ );
282
+ checks.push(scorePromptAssetFreshness(providerRoot));
271
283
  checks.push(scoreProviderIdSlug(providerRoot, provider));
272
284
  checks.push(scoreNoVendorShim(providerRoot));
273
285
  checks.push(scoreNoVendorImport(providerRoot));
@@ -283,6 +295,7 @@ export async function buildSubmitCheckReport(
283
295
  if (provider) {
284
296
  const smokeResult = args.smoke ? await runSubmitCheckSmoke(providerRoot, provider) : undefined;
285
297
  checks.push(scoreCredentialUsage(providerRoot, provider));
298
+ checks.push(scoreSdkOwnedSecretPresence(providerRoot, provider));
286
299
  checks.push(scoreLocaleCatalog(providerRoot, provider));
287
300
  checks.push(scoreOperationMetadata(provider));
288
301
  checks.push(scoreFixtureCoverage(provider));
@@ -1424,6 +1437,159 @@ function scoreCredentialUsage(providerRoot: string, provider: ProviderDefinition
1424
1437
  );
1425
1438
  }
1426
1439
 
1440
+ // ---------------------------------------------------------------------------
1441
+ // sdk-owned-secret-presence (warn): provider-local double validation of
1442
+ // declared env secrets.
1443
+ //
1444
+ // The SDK runtime is the single source of truth for secret presence: declared
1445
+ // `required: true` secrets are validated before every handler/auth-flow
1446
+ // invocation and fail with the canonical structured MISSING_SECRET error
1447
+ // (HTTP 400, category credential_unavailable). Provider-local presence guards
1448
+ // (requireServiceKey/requireApiKey style) are dead weight that historically
1449
+ // diverged into inconsistent shapes (CONFIGURATION_ERROR vs MISSING_SECRET,
1450
+ // with/without category), which broke uniform incident attribution when nine
1451
+ // providers shipped with unprovisioned secrets (2026-07-22).
1452
+ //
1453
+ // Heuristic, warn-only: a line reading a declared `required: true` secret via
1454
+ // `.env.get(...)` (string literal or a const alias of a declared name)
1455
+ // followed within a small window by a falsy presence check plus a `throw`.
1456
+ // The rule flags duplication of the SDK gate ONLY: env names that are not
1457
+ // declared in defineProvider secrets[], and optional declarations
1458
+ // (`required: false`/omitted) that the runtime deliberately does not enforce,
1459
+ // are out of scope. Escape hatch:
1460
+ // `// @apifuse-allow sdk-owned-secret-presence: <reason>`.
1461
+ // ---------------------------------------------------------------------------
1462
+
1463
+ const SDK_OWNED_SECRET_PRESENCE_RULE_ID = "sdk-owned-secret-presence";
1464
+ const SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES = 10;
1465
+
1466
+ const ENV_GET_CALL_PATTERN =
1467
+ /\.env\.get\(\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`|([A-Za-z_$][\w$]*))\s*\)/;
1468
+
1469
+ const SECRET_ALIAS_CONST_PATTERN =
1470
+ /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g;
1471
+
1472
+ const ENV_GET_ASSIGNMENT_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=[^;]*\.env\.get\(/;
1473
+
1474
+ // Const aliases of declared secret names (e.g. `const SERVICE_KEY_ENV =
1475
+ // "APIFUSE__PROVIDER__X__SERVICE_KEY"`) so aliased `.env.get(SERVICE_KEY_ENV)`
1476
+ // guards are detected, not just direct string literals.
1477
+ function buildDeclaredSecretAliasMap(
1478
+ providerRoot: string,
1479
+ declaredNames: ReadonlySet<string>,
1480
+ ): Map<string, string> {
1481
+ const aliases = new Map<string, string>();
1482
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1483
+ const content = readFileSync(filePath, "utf8");
1484
+ for (const match of content.matchAll(SECRET_ALIAS_CONST_PATTERN)) {
1485
+ const alias = match[1];
1486
+ const name = match[2] ?? match[3] ?? match[4];
1487
+ if (alias && name && declaredNames.has(name)) {
1488
+ aliases.set(alias, name);
1489
+ }
1490
+ }
1491
+ }
1492
+ return aliases;
1493
+ }
1494
+
1495
+ function hasLocalSecretPresenceGuard(
1496
+ line: string,
1497
+ remainingLines: readonly string[],
1498
+ declaredNames: ReadonlySet<string>,
1499
+ aliases: ReadonlyMap<string, string>,
1500
+ ): boolean {
1501
+ const match = ENV_GET_CALL_PATTERN.exec(line);
1502
+ if (!match) {
1503
+ return false;
1504
+ }
1505
+ const literal = match[1] ?? match[2] ?? match[3];
1506
+ const identifier = match[4];
1507
+ const readsDeclaredSecret =
1508
+ literal !== undefined
1509
+ ? declaredNames.has(literal)
1510
+ : identifier !== undefined && aliases.has(identifier);
1511
+ if (!readsDeclaredSecret) {
1512
+ return false;
1513
+ }
1514
+
1515
+ const window = [line, ...remainingLines.slice(0, SECRET_PRESENCE_GUARD_LOOKAHEAD_LINES)];
1516
+ if (!window.some((candidate) => /\bthrow\b/.test(candidate))) {
1517
+ return false;
1518
+ }
1519
+
1520
+ // Assigned read (`const key = ctx.env.get(...)`): only a falsy/undefined
1521
+ // check on THAT variable counts as a presence guard. Anchoring on the
1522
+ // assigned identifier avoids false positives from unrelated guards/throws
1523
+ // that merely sit near the env read (mirrors the aliased runtime-guard rule).
1524
+ const assigned = ENV_GET_ASSIGNMENT_PATTERN.exec(line)?.[1];
1525
+ if (assigned) {
1526
+ const escaped = assigned.replace(/\$/g, "\\$");
1527
+ const guardPattern = new RegExp(
1528
+ `(?:!\\s*${escaped}\\b|\\b${escaped}\\s*===?\\s*(?:undefined|null)\\b|\\b${escaped}\\s*==\\s*null\\b|\\b${escaped}(?:\\?\\.|\\.)length\\s*===?\\s*0\\b)`,
1529
+ );
1530
+ return window.some((candidate) => guardPattern.test(candidate));
1531
+ }
1532
+
1533
+ // Un-assigned read: only an inline presence check on the same line counts,
1534
+ // e.g. `if (!ctx.env.get(KEY)) throw ...`.
1535
+ return /(?:if\s*\(\s*!|===?\s*undefined\b|==\s*null\b)/.test(line);
1536
+ }
1537
+
1538
+ function scoreSdkOwnedSecretPresence(
1539
+ providerRoot: string,
1540
+ provider: ProviderDefinition,
1541
+ ): SubmitCheck {
1542
+ const passMessage = "Provider relies on SDK-owned secret presence validation.";
1543
+ // Only `required: true` declarations: those are exactly what the runtime
1544
+ // gate enforces. A presence guard over an optional secret is conditional
1545
+ // business logic the SDK will not replace, not double validation.
1546
+ const declaredNames: ReadonlySet<string> = new Set(
1547
+ (provider.secrets ?? [])
1548
+ .filter((secret) => secret.required === true)
1549
+ .map((secret) => secret.name),
1550
+ );
1551
+ if (declaredNames.size === 0) {
1552
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1553
+ }
1554
+
1555
+ const aliases = buildDeclaredSecretAliasMap(providerRoot, declaredNames);
1556
+ const findings = findSourceFindings(providerRoot, (line, remainingLines) =>
1557
+ hasLocalSecretPresenceGuard(line, remainingLines, declaredNames, aliases),
1558
+ );
1559
+ if (findings.length === 0) {
1560
+ return pass(SDK_OWNED_SECRET_PRESENCE_RULE_ID, SDK_NATIVE_CATEGORY, passMessage, 0);
1561
+ }
1562
+
1563
+ const { violations, overridden } = partitionAllowOverrides(
1564
+ providerRoot,
1565
+ findings,
1566
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1567
+ );
1568
+ if (violations.length === 0) {
1569
+ return pass(
1570
+ SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1571
+ SDK_NATIVE_CATEGORY,
1572
+ `${passMessage} ${overridden.length} acknowledged @apifuse-allow override(s).`,
1573
+ 0,
1574
+ formatSourceFindings(overridden),
1575
+ );
1576
+ }
1577
+
1578
+ return {
1579
+ id: SDK_OWNED_SECRET_PRESENCE_RULE_ID,
1580
+ category: SDK_NATIVE_CATEGORY,
1581
+ level: "warn",
1582
+ status: "warn",
1583
+ points: 0,
1584
+ maxPoints: 0,
1585
+ message:
1586
+ "Provider source re-validates declared env secret presence locally; the SDK owns this check.",
1587
+ remediation:
1588
+ "The provider SDK validates declared required secrets before handlers and auth flows run and returns the canonical MISSING_SECRET error (HTTP 400, category credential_unavailable). Declare the secret with required: true in defineProvider({ secrets: [...] }), delete the provider-local presence guard (requireServiceKey/requireApiKey style), and read the value directly with ctx.env.get(); the guard is dead weight and its divergent CONFIGURATION_ERROR-style shape is deprecated. Acknowledge intentional exceptions with `// @apifuse-allow sdk-owned-secret-presence: <reason>`.",
1589
+ evidence: formatSourceFindings(violations),
1590
+ };
1591
+ }
1592
+
1427
1593
  function findSourceLineMatches(
1428
1594
  providerRoot: string,
1429
1595
  pattern: RegExp | ((line: string) => boolean),
@@ -1523,6 +1689,10 @@ function isScannableProviderSourceFile(relativePath: string): boolean {
1523
1689
  );
1524
1690
  }
1525
1691
 
1692
+ // `.agents`/`.apifuse` stay IN scope on purpose: managed content there is
1693
+ // markdown/JSON (never scannable), while a planted `.ts`/`.sh` under those
1694
+ // directories must not become a scan-exempt hiding place for secrets, raw
1695
+ // fetch, or vendor imports.
1526
1696
  function shouldScanSourceDirectory(relativePath: string): boolean {
1527
1697
  return ![".git", "node_modules", "dist", "build", "coverage"].includes(relativePath);
1528
1698
  }
@@ -1545,6 +1715,27 @@ function formatSourceFindings(findings: readonly SourceFinding[]): string[] {
1545
1715
  return findings.map((finding) => `${finding.file}:${finding.line}`);
1546
1716
  }
1547
1717
 
1718
+ function scorePromptAssetFreshness(providerRoot: string): SubmitCheck {
1719
+ const verification = verifyPromptAssets(providerRoot);
1720
+ if (verification.ok) {
1721
+ return pass(
1722
+ "prompt-assets-fresh",
1723
+ "docs",
1724
+ "SDK-managed agent prompt assets match the installed SDK version.",
1725
+ 0,
1726
+ );
1727
+ }
1728
+
1729
+ return blocker(
1730
+ "prompt-assets-fresh",
1731
+ "docs",
1732
+ "SDK-managed agent prompt assets are missing, stale, or modified.",
1733
+ "Run `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and .apifuse/prompt-assets.json for the installed SDK version.",
1734
+ 0,
1735
+ formatPromptAssetIssues(verification),
1736
+ );
1737
+ }
1738
+
1548
1739
  function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1549
1740
  const missing: string[] = [];
1550
1741
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {
4
+ formatPromptAssetIssues,
5
+ installedSdkVersion,
6
+ syncPromptAssets,
7
+ verifyPromptAssets,
8
+ } from "../src/cli/prompt-assets.js";
9
+ import { resolveProviderRoot } from "./apifuse-check.js";
10
+
11
+ const HELP_TEXT = `Usage: apifuse sync-assets [path] [--check]
12
+ Example: apifuse sync-assets .
13
+ Default: apifuse sync-assets .
14
+
15
+ Regenerates the SDK-managed agent prompt assets for the installed SDK version:
16
+ AGENTS.md, .agents/skills/**, the CLAUDE.md/.claude/.codex symlinks, and the
17
+ .apifuse/prompt-assets.json manifest. Legacy top-level skills/ layouts are
18
+ migrated. Idempotent.
19
+
20
+ Options:
21
+ --check Verify only; exit 1 with a diff list when assets are stale/missing/modified
22
+ --help, -h Show this help`;
23
+
24
+ export async function main() {
25
+ const args = normalizeArgs(process.argv.slice(2));
26
+
27
+ if (args.includes("--help") || args.includes("-h")) {
28
+ console.log(HELP_TEXT);
29
+ return;
30
+ }
31
+
32
+ let checkOnly = false;
33
+ let inputPath: string | undefined;
34
+ for (const arg of args) {
35
+ if (arg === "--check") {
36
+ checkOnly = true;
37
+ continue;
38
+ }
39
+ if (arg.startsWith("-")) {
40
+ throw new Error(`Unknown option: ${arg}`);
41
+ }
42
+ if (inputPath !== undefined) {
43
+ throw new Error(`Unexpected argument: ${arg}`);
44
+ }
45
+ inputPath = arg;
46
+ }
47
+
48
+ const providerRoot = resolveProviderRoot(inputPath ?? ".");
49
+
50
+ if (checkOnly) {
51
+ const verification = verifyPromptAssets(providerRoot);
52
+ if (verification.ok) {
53
+ console.log(
54
+ `Prompt assets are in sync with the installed SDK (${installedSdkVersion()}): ${providerRoot}`,
55
+ );
56
+ return;
57
+ }
58
+ console.error(`Prompt assets are out of sync in ${providerRoot}:`);
59
+ for (const issue of formatPromptAssetIssues(verification)) {
60
+ console.error(` - ${issue}`);
61
+ }
62
+ console.error("\nRun `bun run sync-assets` (or `bunx apifuse sync-assets .`) to regenerate.");
63
+ process.exit(1);
64
+ }
65
+
66
+ const result = syncPromptAssets(providerRoot);
67
+
68
+ // Honesty gate: writes alone never imply success. sync-assets intentionally
69
+ // PRESERVES (does not delete) unauthorized skills and symlinks under
70
+ // .agents/skills, so it can return changed:false while verify still fails.
71
+ // Re-verify AFTER writing and let the true post-sync state drive the exit
72
+ // code — never claim success while the freshness gate would reject the tree.
73
+ const verification = verifyPromptAssets(providerRoot);
74
+
75
+ if (result.changed) {
76
+ for (const removed of result.removed) {
77
+ console.log(`removed ${removed}`);
78
+ }
79
+ for (const wrote of result.wroteFiles) {
80
+ console.log(`wrote ${wrote}`);
81
+ }
82
+ for (const link of result.createdSymlinks) {
83
+ console.log(`symlink ${link}`);
84
+ }
85
+ console.log(`manifest ${result.manifestPath} (sdkVersion ${installedSdkVersion()})`);
86
+ }
87
+
88
+ if (!verification.ok) {
89
+ console.error(`\nPrompt assets are still out of sync in ${providerRoot}:`);
90
+ for (const issue of formatPromptAssetIssues(verification)) {
91
+ console.error(` - ${issue}`);
92
+ }
93
+ console.error(
94
+ "\nsync-assets never deletes unrecognized content: resolve these by hand — remove any unauthorized skill directory or symlink under .agents/skills/, migrate a real .claude/.codex directory into .agents/ — then re-run `apifuse sync-assets .`.",
95
+ );
96
+ process.exit(1);
97
+ }
98
+
99
+ if (!result.changed) {
100
+ console.log(
101
+ `Prompt assets already in sync with the installed SDK (${installedSdkVersion()}): ${providerRoot}`,
102
+ );
103
+ return;
104
+ }
105
+ console.log(`\nPrompt assets synced: ${providerRoot}`);
106
+ }
107
+
108
+ function normalizeArgs(argv: string[]): string[] {
109
+ return argv[0] === "sync-assets" ? argv.slice(1) : argv;
110
+ }
111
+
112
+ if (import.meta.main) {
113
+ await main().catch((error: unknown) => {
114
+ console.error(error instanceof Error ? error.message : String(error));
115
+ process.exit(1);
116
+ });
117
+ }
@@ -87,7 +87,7 @@ export declare const AUTH_TURN_SCHEMA: {
87
87
  };
88
88
  readonly $defs: {
89
89
  readonly completeTurnData: {
90
- readonly title: 'Terminal payload for kind "complete"';
90
+ readonly title: "Terminal payload for kind \"complete\"";
91
91
  readonly description: "data payload of a complete turn. The gateway extracts data.credential for persistence; complete turns are never echoed to browsers.";
92
92
  readonly type: "object";
93
93
  readonly additionalProperties: true;
@@ -104,7 +104,7 @@ export declare const AUTH_TURN_SCHEMA: {
104
104
  };
105
105
  };
106
106
  readonly abortTurnData: {
107
- readonly title: 'Terminal payload for kind "abort"';
107
+ readonly title: "Terminal payload for kind \"abort\"";
108
108
  readonly description: "data payload of an abort turn. code, when present, is the machine-readable abort reason.";
109
109
  readonly type: "object";
110
110
  readonly additionalProperties: true;
@@ -1,4 +1,4 @@
1
- export type ApifuseCommandName = "create" | "dev" | "check" | "submit-check" | "bounty-check" | "record" | "test" | "perf";
1
+ export type ApifuseCommandName = "create" | "dev" | "check" | "sync-assets" | "submit-check" | "bounty-check" | "record" | "test" | "perf";
2
2
  export type ApifuseCommandManifest = {
3
3
  name: ApifuseCommandName;
4
4
  summary: string;
@@ -23,6 +23,13 @@ export const COMMAND_MANIFEST = {
23
23
  examples: ["apifuse check .", "apifuse check providers/korea-air-quality"],
24
24
  modulePath: "./apifuse-check",
25
25
  },
26
+ "sync-assets": {
27
+ name: "sync-assets",
28
+ summary: "Regenerate SDK-managed agent prompt assets (AGENTS.md, .agents/skills, symlinks, manifest) for the installed SDK version.",
29
+ usage: "apifuse sync-assets [path] [--check]",
30
+ examples: ["apifuse sync-assets .", "apifuse sync-assets . --check"],
31
+ modulePath: "./apifuse-sync-assets",
32
+ },
26
33
  "submit-check": {
27
34
  name: "submit-check",
28
35
  summary: "Score provider bounty submission readiness and emit checklist evidence.",
@@ -73,6 +80,7 @@ export const COMMAND_ORDER = [
73
80
  "create",
74
81
  "dev",
75
82
  "check",
83
+ "sync-assets",
76
84
  "submit-check",
77
85
  "record",
78
86
  "test",
@@ -26,7 +26,10 @@ export type CreateResolvedOptions = {
26
26
  };
27
27
  export type ProviderPlanFile = {
28
28
  path: string;
29
+ /** File content, or the symlink target (relative path) for kind "symlink". */
29
30
  content: string;
31
+ /** Absent kind means "file" (backward compatible with older consumers). */
32
+ kind?: "file" | "symlink";
30
33
  };
31
34
  export type ProviderCreatePlan = {
32
35
  displayName: string;