@apifuse/provider-sdk 2.2.0-beta.66 → 2.2.0-beta.68

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 (38) hide show
  1. package/AUTHORING.md +28 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +8 -4
  4. package/bin/apifuse-record.ts +207 -31
  5. package/bin/apifuse-submit-check.ts +273 -74
  6. package/dist/cli/local-connection.d.ts +111 -0
  7. package/dist/cli/local-connection.js +151 -0
  8. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  9. package/dist/cli/templates/provider/README.md.tpl +17 -9
  10. package/dist/config/loader.d.ts +16 -0
  11. package/dist/config/loader.js +25 -8
  12. package/dist/error-resolution.d.ts +13 -0
  13. package/dist/error-resolution.js +72 -35
  14. package/dist/lint.d.ts +2 -0
  15. package/dist/lint.js +493 -2
  16. package/dist/runtime/executor.js +3 -0
  17. package/dist/runtime/http.d.ts +2 -0
  18. package/dist/runtime/http.js +2 -1
  19. package/dist/runtime/provider.d.ts +7 -0
  20. package/dist/runtime/provider.js +14 -0
  21. package/dist/server/index.d.ts +1 -0
  22. package/dist/server/serve-implementation.d.ts +2 -1
  23. package/dist/server/serve-implementation.js +43 -29
  24. package/dist/server/state-backend-report.d.ts +70 -0
  25. package/dist/server/state-backend-report.js +113 -0
  26. package/package.json +1 -1
  27. package/src/cli/local-connection.ts +221 -0
  28. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +138 -23
  29. package/src/cli/templates/provider/README.md.tpl +17 -9
  30. package/src/config/loader.ts +49 -12
  31. package/src/error-resolution.ts +89 -35
  32. package/src/lint.ts +541 -1
  33. package/src/runtime/executor.ts +3 -0
  34. package/src/runtime/http.ts +2 -1
  35. package/src/runtime/provider.ts +15 -0
  36. package/src/server/index.ts +4 -0
  37. package/src/server/serve-implementation.ts +56 -30
  38. package/src/server/state-backend-report.ts +185 -0
@@ -12,6 +12,11 @@ import type TS from "typescript";
12
12
  import { z } from "zod";
13
13
 
14
14
  import packageJson from "../package.json";
15
+ import {
16
+ LOCAL_CONNECTION_SECRETS_ENV,
17
+ providerRequiresConnection,
18
+ resolveLocalConnection,
19
+ } from "../src/cli/local-connection.js";
15
20
  import { formatPromptAssetIssues, verifyPromptAssets } from "../src/cli/prompt-assets.js";
16
21
  import {
17
22
  loadProviderLocaleCatalogs,
@@ -125,6 +130,25 @@ export type SmokeResult = {
125
130
  healthOk: boolean;
126
131
  bootError?: string;
127
132
  operations: SmokeOperationOutcome[];
133
+ /**
134
+ * Whether the provider's declared auth mode needs a gateway-injected
135
+ * credential, and whether this run had one. Recorded so a provider that
136
+ * could not be exercised at all says so, instead of scoring as "ran, but
137
+ * saw no live success" — the two are not the same evidence.
138
+ */
139
+ credential?: {
140
+ required: boolean;
141
+ supplied: boolean;
142
+ mode: string;
143
+ /**
144
+ * Why the local credential could not be resolved — a malformed
145
+ * `APIFUSE__LOCAL_CONNECTION__SECRETS`, a non-string value, a bad
146
+ * `--credential`. Distinct from "not configured": a misconfiguration
147
+ * reported as an absence sends the author looking for a key they already
148
+ * set.
149
+ */
150
+ error?: string;
151
+ };
128
152
  };
129
153
 
130
154
  type SourceFinding = {
@@ -156,7 +180,9 @@ const HELP_TEXT = `Usage: apifuse submit-check [path] [--tier bronze|silver|gold
156
180
  Alias: apifuse bounty-check [path]
157
181
  Default: apifuse submit-check .
158
182
 
159
- Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.`;
183
+ Smoke: --smoke boots the provider dev server, checks /health, and POSTs every operation fixture. APIFUSE__PROVIDER__* env vars enable live upstream calls; without them, structured provider errors can still verify runtime routing. --smoke-note is deprecated and ignored for scoring.
184
+
185
+ A provider whose auth.mode is not "none" reads a credential the gateway injects at invocation time; smoke attaches the same connection from ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', keyed as the provider reads them through ctx.credential.get(...). Without it no operation reaches the upstream, and the smoke check says so rather than scoring it as a missing live success.`;
160
186
 
161
187
  export async function main() {
162
188
  try {
@@ -1007,11 +1033,9 @@ function countAsAssertions(providerRoot: string): {
1007
1033
 
1008
1034
  for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
1009
1035
  const content = readFileSync(filePath, "utf8");
1010
- const lines = maskCommentsAndStrings(
1011
- content,
1012
- toRelativeProviderPath(providerRoot, filePath),
1013
- { blankPropertyKeys: true },
1014
- ).split(/\r?\n/);
1036
+ const lines = maskCommentsAndStrings(content, toRelativeProviderPath(providerRoot, filePath), {
1037
+ blankPropertyKeys: true,
1038
+ }).split(/\r?\n/);
1015
1039
  for (let index = 0; index < lines.length; index += 1) {
1016
1040
  const line = lines[index];
1017
1041
  if (
@@ -2017,18 +2041,13 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
2017
2041
  );
2018
2042
  if (opaqueSpread !== undefined) {
2019
2043
  const spreadLine = offsetToLine(source, opaqueSpread.getStart(sourceFile));
2020
- return escapeHatchResult(
2021
- providerRoot,
2022
- ruleId,
2023
- [{ file: indexRelPath, line: spreadLine }],
2024
- {
2025
- blockerMessage:
2026
- "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2027
- remediation:
2028
- "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2029
- passMessage: "defineProvider declares operations as a static object literal.",
2030
- },
2031
- );
2044
+ return escapeHatchResult(providerRoot, ruleId, [{ file: indexRelPath, line: spreadLine }], {
2045
+ blockerMessage:
2046
+ "The defineProvider implementation uses a non-enumerable spread that could override operations.",
2047
+ remediation:
2048
+ "Declare operations directly with a static object literal. Implementation spreads must be inline static object literals so the provider-registry AST gate can enumerate every property name.",
2049
+ passMessage: "defineProvider declares operations as a static object literal.",
2050
+ });
2032
2051
  }
2033
2052
 
2034
2053
  // Determine the effective initializer expression to classify. The alias may
@@ -2079,11 +2098,7 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
2079
2098
  });
2080
2099
 
2081
2100
  const declRe = new RegExp(aliasDecl.source, "g");
2082
- for (
2083
- let m = declRe.exec(maskedFileSource);
2084
- m !== null;
2085
- m = declRe.exec(maskedFileSource)
2086
- ) {
2101
+ for (let m = declRe.exec(maskedFileSource); m !== null; m = declRe.exec(maskedFileSource)) {
2087
2102
  const valueStart = m.index + m[0].length;
2088
2103
  const expr = unwrapParens(balancedExpressionSlice(fileSource, valueStart, relPath));
2089
2104
  const isFactory =
@@ -2133,9 +2148,9 @@ function scoreFlatOperationComposition(providerRoot: string): SubmitCheck {
2133
2148
  const maskedIndexSource = maskCommentsAndStrings(source, indexRelPath, {
2134
2149
  blankPropertyKeys: true,
2135
2150
  });
2136
- const importMatch = new RegExp(
2137
- `\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`,
2138
- ).exec(maskedIndexSource);
2151
+ const importMatch = new RegExp(`\\bimport\\b[^;]*\\b${aliasName}\\b[^;]*\\bfrom\\b`).exec(
2152
+ maskedIndexSource,
2153
+ );
2139
2154
  if (importMatch) {
2140
2155
  const raw = `${aliasName}(`;
2141
2156
  effective = { raw, masked: raw };
@@ -3303,12 +3318,7 @@ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
3303
3318
  if (!zObjectAppearsPublicOutput(sourceFile, zObject, relPath)) {
3304
3319
  continue;
3305
3320
  }
3306
- for (const keyFinding of vendorKeyFindingsForObject(
3307
- source,
3308
- sourceFile,
3309
- zObject,
3310
- bindings,
3311
- )) {
3321
+ for (const keyFinding of vendorKeyFindingsForObject(source, sourceFile, zObject, bindings)) {
3312
3322
  const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
3313
3323
  if (!seen.has(key)) {
3314
3324
  seen.add(key);
@@ -4066,18 +4076,37 @@ type MaskCommentsAndStringsOptions = {
4066
4076
  blankPropertyKeys?: boolean;
4067
4077
  };
4068
4078
 
4079
+ // Which literal bodies are blanked alongside comment bodies. "all" blanks
4080
+ // every string/template/regex body; "values" does the same but keeps quoted
4081
+ // property-key text (only its structural characters are blanked); "none"
4082
+ // leaves every literal and all code byte-identical, so only comments go.
4083
+ type LiteralMaskMode = "all" | "values" | "none";
4084
+
4069
4085
  export function maskCommentsAndStrings(
4070
4086
  source: string,
4071
4087
  fileName = "provider.ts",
4072
4088
  options: MaskCommentsAndStringsOptions = {},
4073
4089
  ): string {
4074
- const blankPropertyKeys = options.blankPropertyKeys === true;
4075
- const cacheKey = `${blankPropertyKeys ? "blank-keys" : "preserve-keys"}\0${source}`;
4090
+ return maskSource(source, fileName, options.blankPropertyKeys === true ? "all" : "values");
4091
+ }
4092
+
4093
+ // Blanks comment bodies only, from the same TypeScript-lexer comment ranges
4094
+ // maskCommentsAndStrings uses: a `//` inside a string, template, or regex is
4095
+ // not a comment, and a block comment spanning lines is blanked whole. Line
4096
+ // terminators survive, so line N of the result is line N of the source.
4097
+ // Throws like maskCommentsAndStrings when the source cannot be parsed.
4098
+ export function maskComments(source: string, fileName = "provider.ts"): string {
4099
+ return maskSource(source, fileName, "none");
4100
+ }
4101
+
4102
+ function maskSource(source: string, fileName: string, literals: LiteralMaskMode): string {
4103
+ const { scriptKind } = maskScriptKind(fileName);
4104
+ const cacheKey = `${literals}\0${scriptKind}\0${source}`;
4076
4105
  const cached = maskCache.get(cacheKey);
4077
4106
  if (cached !== undefined) {
4078
4107
  return cached;
4079
4108
  }
4080
- const masked = computeMaskedSource(source, fileName, blankPropertyKeys);
4109
+ const masked = computeMaskedSource(source, fileName, literals);
4081
4110
  if (maskCache.size >= MASK_CACHE_LIMIT) {
4082
4111
  const oldest = maskCache.keys().next();
4083
4112
  if (!oldest.done) {
@@ -4088,17 +4117,34 @@ export function maskCommentsAndStrings(
4088
4117
  return masked;
4089
4118
  }
4090
4119
 
4091
- function computeMaskedSource(
4092
- source: string,
4093
- fileName: string,
4094
- blankPropertyKeys: boolean,
4095
- ): string {
4120
+ // The script kind a source is lexed as, from its file name, and the synthetic
4121
+ // implementation file name transpileModule parses it under. Declaration files
4122
+ // trigger an internal TypeScript Debug Failure when passed to transpileModule,
4123
+ // so `.d.ts` (like every other non-JS/JSX/TSX name) parses as `provider.ts`.
4124
+ function maskScriptKind(fileName: string): {
4125
+ scriptKind: TS.ScriptKind;
4126
+ syntheticFileName: string;
4127
+ } {
4128
+ if (fileName.endsWith(".tsx")) {
4129
+ return { scriptKind: ts.ScriptKind.TSX, syntheticFileName: "provider.tsx" };
4130
+ }
4131
+ if (fileName.endsWith(".jsx")) {
4132
+ return { scriptKind: ts.ScriptKind.JSX, syntheticFileName: "provider.jsx" };
4133
+ }
4134
+ if (/\.[cm]?js$/.test(fileName)) {
4135
+ return { scriptKind: ts.ScriptKind.JS, syntheticFileName: "provider.js" };
4136
+ }
4137
+ return { scriptKind: ts.ScriptKind.TS, syntheticFileName: "provider.ts" };
4138
+ }
4139
+
4140
+ function computeMaskedSource(source: string, fileName: string, literals: LiteralMaskMode): string {
4141
+ const blankPropertyKeys = literals === "all";
4142
+ const { scriptKind, syntheticFileName } = maskScriptKind(fileName);
4096
4143
  const transpiled = ts.transpileModule(source, {
4097
- // Declaration files trigger an internal TypeScript Debug Failure when
4098
- // passed to transpileModule. Parsing is all we need here, so always use a
4099
- // synthetic implementation filename while retaining the real filename
4100
- // for source mapping and sanitized diagnostics below.
4101
- fileName: "provider.ts",
4144
+ // Parsing is all we need here: parse under the synthetic implementation
4145
+ // filename while retaining the real filename for source mapping and
4146
+ // sanitized diagnostics below.
4147
+ fileName: syntheticFileName,
4102
4148
  reportDiagnostics: true,
4103
4149
  compilerOptions: { target: ts.ScriptTarget.Latest },
4104
4150
  });
@@ -4107,7 +4153,7 @@ function computeMaskedSource(
4107
4153
  source,
4108
4154
  ts.ScriptTarget.Latest,
4109
4155
  true,
4110
- ts.ScriptKind.TS,
4156
+ scriptKind,
4111
4157
  );
4112
4158
  const parseDiagnostic = transpiled.diagnostics?.[0];
4113
4159
  if (parseDiagnostic) {
@@ -4125,10 +4171,7 @@ function computeMaskedSource(
4125
4171
  // by LF, CRLF, or lone CR) and every line terminator, so masked
4126
4172
  // string bodies remain syntactically valid and the mask output can
4127
4173
  // be re-parsed (mask(mask(x)) === mask(x)).
4128
- if (
4129
- source[index] === "\\" &&
4130
- (source[index + 1] === "\n" || source[index + 1] === "\r")
4131
- ) {
4174
+ if (source[index] === "\\" && (source[index + 1] === "\n" || source[index + 1] === "\r")) {
4132
4175
  continue;
4133
4176
  }
4134
4177
  if (chars[index] !== "\n" && chars[index] !== "\r") chars[index] = " ";
@@ -4141,17 +4184,13 @@ function computeMaskedSource(
4141
4184
  }
4142
4185
  };
4143
4186
 
4144
- const visit = (node: TS.Node): void => {
4145
- addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
4146
- addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
4147
-
4187
+ const maskLiteralBody = (node: TS.Node): void => {
4148
4188
  const start = node.getStart(sourceFile);
4149
4189
  if (ts.isStringLiteral(node)) {
4150
4190
  // Preserve quoted property keys ("response": ...) by default so range/key
4151
4191
  // scanners can still match them. Line scanners opt into blanking key bodies
4152
4192
  // as well, preventing key text from looking like executable source.
4153
- const isQuotedPropertyKey =
4154
- ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4193
+ const isQuotedPropertyKey = ts.isPropertyAssignment(node.parent) && node.parent.name === node;
4155
4194
  if (blankPropertyKeys || !isQuotedPropertyKey) {
4156
4195
  maskRange(start + 1, node.end - 1);
4157
4196
  } else {
@@ -4182,7 +4221,12 @@ function computeMaskedSource(
4182
4221
  } else if (node.kind === ts.SyntaxKind.TemplateTail) {
4183
4222
  maskRange(start + 1, node.end - 1);
4184
4223
  }
4224
+ };
4185
4225
 
4226
+ const visit = (node: TS.Node): void => {
4227
+ addCommentRanges(ts.getLeadingCommentRanges(source, node.getFullStart()));
4228
+ addCommentRanges(ts.getTrailingCommentRanges(source, node.getEnd()));
4229
+ if (literals !== "none") maskLiteralBody(node);
4186
4230
  for (const child of node.getChildren(sourceFile)) visit(child);
4187
4231
  };
4188
4232
 
@@ -5026,7 +5070,7 @@ function isAstNode(value: unknown): value is acorn.AnyNode {
5026
5070
  );
5027
5071
  }
5028
5072
 
5029
- function scoreSmoke(
5073
+ export function scoreSmoke(
5030
5074
  smokeResult: SmokeResult | undefined,
5031
5075
  smokeNote: string | undefined,
5032
5076
  ): SubmitCheck {
@@ -5048,8 +5092,22 @@ function scoreSmoke(
5048
5092
  };
5049
5093
  }
5050
5094
 
5095
+ const credential = smokeResult.credential;
5096
+ // An invalid configuration is reported even for a provider that needs no
5097
+ // credential: the operator set something, and silence would read as accepted.
5098
+ const credentialEvidence =
5099
+ credential?.required === true || credential?.error !== undefined
5100
+ ? [
5101
+ credential.supplied
5102
+ ? `credential: supplied for auth.mode "${credential.mode}"`
5103
+ : credential.error
5104
+ ? `credential: CONFIGURED BUT INVALID — ${credential.error}`
5105
+ : `credential: NOT supplied; auth.mode "${credential.mode}" needs one the gateway injects at invocation time, so every credentialed operation failed before reaching the upstream`,
5106
+ ]
5107
+ : [];
5051
5108
  const evidence = [
5052
5109
  `/health: ${smokeResult.healthOk ? "ok" : "failed"}`,
5110
+ ...credentialEvidence,
5053
5111
  ...smokeResult.operations.map(
5054
5112
  (outcome) =>
5055
5113
  `${outcome.operationId}: ${outcome.status}${outcome.httpStatus ? ` HTTP ${outcome.httpStatus}` : ""} - ${outcome.message}`,
@@ -5073,6 +5131,31 @@ function scoreSmoke(
5073
5131
  };
5074
5132
  }
5075
5133
 
5134
+ // Checked BEFORE the success branch. A provider that needs a
5135
+ // gateway-injected credential and did not get one never exercised its
5136
+ // credentialed path, so a schema-valid success from some other operation --
5137
+ // an unauthenticated one, or a fixture-shaped response -- must not buy full
5138
+ // smoke points. Saying "verified" would credit the run with evidence it does
5139
+ // not have.
5140
+ if (credential?.required === true && !credential.supplied) {
5141
+ return {
5142
+ id: "local-smoke",
5143
+ category: "smoke",
5144
+ level: "warn",
5145
+ status: "warn",
5146
+ points: 7,
5147
+ maxPoints: CATEGORY_MAX_POINTS.smoke,
5148
+ message: credential.error
5149
+ ? `Runtime path was verified, but the configured local credential could not be used, so the provider's credentialed operations never reached the upstream.`
5150
+ : `Runtime path was verified, but auth.mode "${credential.mode}" needs a credential this run did not have, so the provider's credentialed operations never reached the upstream.`,
5151
+ remediation: credential.error
5152
+ ? `Fix the local credential configuration and rerun \`bun run submit-check -- --smoke\`: ${credential.error}`
5153
+ : `Supply the credential the gateway injects and rerun \`bun run submit-check -- --smoke\`: ${LOCAL_CONNECTION_SECRETS_ENV}='{"<key>":"<value>"}', where the keys are the ones the provider passes to ctx.credential.get(...). For platform-managed providers that value is the platform-owned key an operator holds; it is not in the checkout.`,
5154
+ evidence,
5155
+ details: smokeResult,
5156
+ };
5157
+ }
5158
+
5076
5159
  const successes = smokeResult.operations.filter((outcome) => outcome.status === "success");
5077
5160
  if (successes.length > 0) {
5078
5161
  return {
@@ -5143,6 +5226,12 @@ export async function runSubmitCheckSmoke(
5143
5226
  operations: [],
5144
5227
  };
5145
5228
  }
5229
+ // The gateway attaches a resolved credential to every invocation of a
5230
+ // provider whose auth.mode is not "none". Smoke POSTed an envelope with
5231
+ // no `connection` at all, so every platform-managed provider answered
5232
+ // MISSING_SECRET and scored as "runtime verified, no live success" —
5233
+ // a capability gap wearing the costume of an ordinary upstream miss.
5234
+ const connection = resolveLocalConnection(loadedProvider);
5146
5235
  const operations: SmokeOperationOutcome[] = [];
5147
5236
  for (const [operationId, operation] of Object.entries(loadedProvider.operations)) {
5148
5237
  operations.push(
@@ -5150,10 +5239,21 @@ export async function runSubmitCheckSmoke(
5150
5239
  requestId: `req_submit_check_smoke_${operationId}`,
5151
5240
  input: operation.fixtures?.request ?? {},
5152
5241
  headers: {},
5242
+ ...(connection.ok && connection.connection ? { connection: connection.connection } : {}),
5153
5243
  }),
5154
5244
  );
5155
5245
  }
5156
- return { measured: true, healthOk: true, operations };
5246
+ return {
5247
+ measured: true,
5248
+ healthOk: true,
5249
+ operations,
5250
+ credential: {
5251
+ required: providerRequiresConnection(loadedProvider),
5252
+ supplied: connection.ok && connection.connection !== undefined,
5253
+ mode: loadedProvider.auth?.mode ?? "none",
5254
+ ...(connection.ok ? {} : { error: connection.reason }),
5255
+ },
5256
+ };
5157
5257
  } finally {
5158
5258
  await stopSmokeServer(server);
5159
5259
  }
@@ -5541,18 +5641,18 @@ function findEntropySecretFindings(providerRoot: string, providerId: string): Se
5541
5641
  if (isEntropySecretExcludedPath(relativePath)) continue;
5542
5642
  const content = readFileSync(filePath, "utf8");
5543
5643
  const lines = content.split(/\r?\n/);
5544
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
5545
- const line = lines[lineIndex] ?? "";
5546
- for (const candidate of extractStringLiteralCandidates(line)) {
5547
- const finding = classifyEntropyCandidate({
5548
- value: candidate,
5549
- line,
5550
- file: relativePath,
5551
- lineNumber: lineIndex + 1,
5552
- providerId,
5553
- });
5554
- if (finding) findings.push(finding);
5555
- }
5644
+ for (const candidate of extractStringLiteralCandidates(content, relativePath)) {
5645
+ // Context is the whole source line, comment tail included: a
5646
+ // comment that names the value is evidence about it, even though
5647
+ // comment text itself is never a candidate.
5648
+ const finding = classifyEntropyCandidate({
5649
+ value: candidate.value,
5650
+ line: lines[candidate.lineIndex] ?? "",
5651
+ file: relativePath,
5652
+ lineNumber: candidate.lineIndex + 1,
5653
+ providerId,
5654
+ });
5655
+ if (finding) findings.push(finding);
5556
5656
  }
5557
5657
  }
5558
5658
  return findings;
@@ -5568,7 +5668,62 @@ function isEntropySecretExcludedPath(relativePath: string): boolean {
5568
5668
  );
5569
5669
  }
5570
5670
 
5571
- export function extractStringLiteralCandidates(line: string): string[] {
5671
+ export type StringLiteralCandidate = {
5672
+ value: string;
5673
+ // 0-based index of the source line the literal sits on.
5674
+ lineIndex: number;
5675
+ };
5676
+
5677
+ // Every string-literal candidate (a quoted body of at least
5678
+ // ENTROPY_CANDIDATE_MIN_LENGTH characters) in one provider source file.
5679
+ // Candidates come from code only. Comments are prose, not values, so each
5680
+ // file's own comment syntax is removed before a quote is read:
5681
+ // - JS/TS sources: comment trivia is blanked with the TypeScript lexer
5682
+ // (maskComments, the comment ranges the other source lints skip), so `//`
5683
+ // inside a string or regex is not a comment and a `/* … */` block spanning
5684
+ // lines is skipped whole. A backtick in code opens a template literal — a
5685
+ // string value — so it stays a quote form.
5686
+ // - Shell sources (.sh/.bash, Dockerfile, entrypoint): `#` starts a comment
5687
+ // when it begins a word outside quotes, and a backtick is command
5688
+ // substitution (code), not a string delimiter.
5689
+ // Identifiers are therefore never candidates by construction: a bare name is
5690
+ // not a string literal, and a backticked name in a comment is comment text.
5691
+ // A JS/TS source the lexer cannot parse keeps the unmasked scan (every quote
5692
+ // pair is a candidate): when comments cannot be located the scan may
5693
+ // over-report, never under-report.
5694
+ export function extractStringLiteralCandidates(
5695
+ content: string,
5696
+ relativePath: string,
5697
+ ): StringLiteralCandidate[] {
5698
+ const shell = !isJavaScriptFamilySource(relativePath);
5699
+ const codeLines = (shell ? content : maskCommentsForSecretScan(content, relativePath)).split(
5700
+ /\r?\n/,
5701
+ );
5702
+ const candidates: StringLiteralCandidate[] = [];
5703
+ for (let lineIndex = 0; lineIndex < codeLines.length; lineIndex += 1) {
5704
+ const line = codeLines[lineIndex] ?? "";
5705
+ const values = shell ? extractShellLineStrings(line) : extractQuotedLineStrings(line);
5706
+ for (const value of values) candidates.push({ value, lineIndex });
5707
+ }
5708
+ return candidates;
5709
+ }
5710
+
5711
+ function isJavaScriptFamilySource(relativePath: string): boolean {
5712
+ return /\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(relativePath);
5713
+ }
5714
+
5715
+ function maskCommentsForSecretScan(content: string, relativePath: string): string {
5716
+ try {
5717
+ return maskComments(content, relativePath);
5718
+ } catch {
5719
+ // Unparseable: scan the source unmasked rather than skip it.
5720
+ return content;
5721
+ }
5722
+ }
5723
+
5724
+ // Quoted bodies on one comment-free JS/TS line (`"`, `'`, and template
5725
+ // backticks), with backslash escapes.
5726
+ function extractQuotedLineStrings(line: string): string[] {
5572
5727
  const candidates: string[] = [];
5573
5728
  for (let index = 0; index < line.length; index += 1) {
5574
5729
  const quote = line[index];
@@ -5595,6 +5750,51 @@ export function extractStringLiteralCandidates(line: string): string[] {
5595
5750
  return candidates;
5596
5751
  }
5597
5752
 
5753
+ // Characters after which a `#` begins a new shell word (blanks and the
5754
+ // metacharacters `;&|()<>`).
5755
+ const SHELL_WORD_BOUNDARY = /[\s;&|()<>]/;
5756
+
5757
+ // Quoted bodies on one shell line. Single quotes take no escapes; double
5758
+ // quotes and unquoted text honor backslash escapes. A `#` that begins a word
5759
+ // outside quotes ends the line's code (the rest is a comment). A word begins
5760
+ // at the line start or after an UNESCAPED boundary character, so `${#var}`,
5761
+ // `$#`, `"a"#b`, and `word\ #` (escaped blank) keep `#` inside the word.
5762
+ // Backticks are command substitution, so their text is lexed as code. A
5763
+ // quote left open on the line is read as a literal character, as the
5764
+ // line-based scan always has.
5765
+ function extractShellLineStrings(line: string): string[] {
5766
+ const candidates: string[] = [];
5767
+ let index = 0;
5768
+ let atWordStart = true;
5769
+ while (index < line.length) {
5770
+ const char = line[index];
5771
+ if (char === "\\") {
5772
+ index += 2;
5773
+ atWordStart = false;
5774
+ continue;
5775
+ }
5776
+ if (char === "#" && atWordStart) break;
5777
+ if (char === '"' || char === "'") {
5778
+ const contentStart = index + 1;
5779
+ let cursor = contentStart;
5780
+ while (cursor < line.length && line[cursor] !== char) {
5781
+ cursor += char === '"' && line[cursor] === "\\" ? 2 : 1;
5782
+ }
5783
+ if (cursor < line.length) {
5784
+ if (cursor - contentStart >= ENTROPY_CANDIDATE_MIN_LENGTH) {
5785
+ candidates.push(line.slice(contentStart, cursor));
5786
+ }
5787
+ index = cursor + 1;
5788
+ atWordStart = false;
5789
+ continue;
5790
+ }
5791
+ }
5792
+ atWordStart = SHELL_WORD_BOUNDARY.test(char ?? "");
5793
+ index += 1;
5794
+ }
5795
+ return candidates;
5796
+ }
5797
+
5598
5798
  function classifyEntropyCandidate(input: {
5599
5799
  value: string;
5600
5800
  line: string;
@@ -5699,7 +5899,7 @@ function literalContainerKey(literal: LineStringLiteral): string {
5699
5899
  // other literal is a VALUE: ternary arms (preceded by "?" or ":"), array
5700
5900
  // elements, call arguments, and assignment right-hand sides, even when a
5701
5901
  // ternary's ":" happens to follow them. Uses the same quote/escape walking as
5702
- // extractStringLiteralCandidates.
5902
+ // extractQuotedLineStrings.
5703
5903
  function tokenizeLineStringLiterals(line: string): LineStringLiteral[] {
5704
5904
  const literals: LineStringLiteral[] = [];
5705
5905
  const bracketStack: Array<{ bracket: "[" | "(" | "{"; index: number }> = [];
@@ -5994,8 +6194,7 @@ function sanitizeLoadErrorText(value: string, providerRoot: string): string {
5994
6194
  const pathCandidate = rawPath.replace(/[),.;:!?]+$/u, "");
5995
6195
  const relativePath = relative(providerRoot, resolve(pathCandidate));
5996
6196
  const insideProvider =
5997
- relativePath === "" ||
5998
- (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
6197
+ relativePath === "" || (relativePath !== ".." && !relativePath.startsWith(`..${sep}`));
5999
6198
  return `${prefix}${insideProvider ? relativePath || "." : "[REDACTED_PATH]"}`;
6000
6199
  },
6001
6200
  );
@@ -0,0 +1,111 @@
1
+ import type { AuthMode, ProviderDefinition } from "../types.js";
2
+ /**
3
+ * The connection the local CLIs stand in for the gateway with.
4
+ *
5
+ * At invocation time the gateway resolves the credential a provider needs and
6
+ * attaches it to the request envelope as
7
+ * `connection: { id, mode, secrets, metadata }`; `serve` then builds
8
+ * `ctx.credential` from `connection.secrets` and nothing else. For
9
+ * `auth.mode: "platform-managed"` the secrets are the platform-owned key the
10
+ * caller never sees — which is why a probe reaches one only by going back
11
+ * through the gateway (`ctx.gateway.execute(...)` in a health case's
12
+ * `prepareInput`), and why nothing that runs outside the gateway had any way
13
+ * to supply it.
14
+ *
15
+ * `apifuse record` hard-coded `credential: { mode: "none" }`, so
16
+ * `ctx.credential.get(...)` returned `undefined` for every platform-managed
17
+ * provider and the run died on that provider's own `MISSING_SECRET`. The same
18
+ * hole made `submit-check --smoke` POST an envelope with no `connection` at
19
+ * all. A large share of the fleet was unrecordable and unsmokeable, and it
20
+ * read as "no live upstream success" rather than as a missing capability.
21
+ *
22
+ * This module is the local resolver for that connection. It does not invent a
23
+ * second credential path: it produces the exact shape the gateway injects, so
24
+ * the provider code under test takes the production branch.
25
+ */
26
+ export interface LocalConnection {
27
+ readonly id: string;
28
+ readonly mode: AuthMode;
29
+ readonly secrets: Record<string, string>;
30
+ readonly metadata: Record<string, never>;
31
+ /**
32
+ * Empty, exactly as the gateway leaves it when it injects a platform
33
+ * credential. `OperationConnectionSchema` requires the field, so omitting
34
+ * it would make every smoke envelope fail request validation before the
35
+ * provider ran.
36
+ */
37
+ readonly externalRef: string;
38
+ }
39
+ /**
40
+ * JSON object of `connection.secrets`, keyed exactly as the provider reads
41
+ * them through `ctx.credential.get(...)`.
42
+ *
43
+ * A single JSON value rather than one variable per key on purpose:
44
+ * platform-managed providers must not declare `credential.keys` (the SDK lint
45
+ * rejects it), so the key names are known only to the provider's own source.
46
+ * There is no derivation from an env name that would be right for every
47
+ * provider, and guessing one would be a second, lossy contract next to the
48
+ * gateway's.
49
+ */
50
+ export declare const LOCAL_CONNECTION_SECRETS_ENV = "APIFUSE__LOCAL_CONNECTION__SECRETS";
51
+ /** Stable local connection id, mirroring the CLIs' other `local-*` scopes. */
52
+ export declare const LOCAL_CONNECTION_ID = "local-connection";
53
+ /** Auth modes whose providers read a credential the gateway supplies. */
54
+ export declare function providerRequiresConnection(provider: {
55
+ readonly auth?: {
56
+ readonly mode?: AuthMode;
57
+ } | undefined;
58
+ }): boolean;
59
+ export type CredentialOverrideParse = {
60
+ readonly ok: true;
61
+ readonly secrets: Record<string, string>;
62
+ } | {
63
+ readonly ok: false;
64
+ readonly reason: string;
65
+ };
66
+ /**
67
+ * Parses repeated `--credential key=value` arguments.
68
+ *
69
+ * Splits on the first `=` only: a base64 or URL-shaped secret routinely
70
+ * contains more.
71
+ */
72
+ export declare function parseCredentialOverrides(values: readonly string[]): CredentialOverrideParse;
73
+ /** Reads {@link LOCAL_CONNECTION_SECRETS_ENV} into a secrets map. */
74
+ export declare function parseEnvironmentSecrets(raw: string | undefined): CredentialOverrideParse;
75
+ export interface ResolveLocalConnectionOptions {
76
+ /** Repeated `--credential key=value` arguments, in order. */
77
+ readonly overrides?: readonly string[];
78
+ /** Environment to read {@link LOCAL_CONNECTION_SECRETS_ENV} from. */
79
+ readonly env?: Readonly<Record<string, string | undefined>>;
80
+ }
81
+ export type LocalConnectionResolution = {
82
+ readonly ok: true;
83
+ readonly connection: LocalConnection | undefined;
84
+ } | {
85
+ readonly ok: false;
86
+ readonly reason: string;
87
+ };
88
+ /**
89
+ * Resolves the connection to attach for one local invocation.
90
+ *
91
+ * Three outcomes, and the middle one is the point of the change:
92
+ *
93
+ * - the provider needs no credential (`auth.mode` absent or `"none"`) →
94
+ * no connection, exactly as before;
95
+ * - the provider needs one and none is configured → `ok: false` with a
96
+ * message naming both ways to supply it. The caller surfaces that instead
97
+ * of letting the provider fail deep inside a handler on a `MISSING_SECRET`
98
+ * that reads like an upstream problem;
99
+ * - the provider needs one and it is configured → the gateway's envelope
100
+ * shape.
101
+ *
102
+ * CLI overrides win over the environment so a one-off run does not need the
103
+ * shell variable unset.
104
+ */
105
+ export declare function resolveLocalConnection(provider: Pick<ProviderDefinition, "id"> & {
106
+ auth?: {
107
+ mode?: AuthMode;
108
+ };
109
+ }, options?: ResolveLocalConnectionOptions): LocalConnectionResolution;
110
+ /** The message a caller prints when a needed credential is not configured. */
111
+ export declare function missingLocalConnectionMessage(providerId: string, mode: AuthMode): string;