Context
The current GitMirror.normalizeCredentialRef (ai/services/knowledge-base/helpers/GitMirror.mjs:96-125) supports only three credentialRef shapes — env:VAR, ssh:keyPath, and bare-string (treated as env:). For single-tenant deployments this is sufficient. For multi-tenant deployments — even modest ones with 2–5 tenant repos — the env: indirection forces operators to:
- Declare one
NEO_TENANT_<SLUG>_TOKEN env var per tenant in the deployment's environment-source file.
- Either list each var explicitly in the orchestrator service's
environment: block (compose grows linearly with tenant count), OR adopt env_file: (which over-broadly pulls all env-source entries into the container, including unrelated secrets like OAuth client secrets and session signing keys).
Surfaced during a first-real-world cloud deployment exercise — friction-to-gold signal post-#12030 RawRepoSource.
The Problem
env: indirection bottoms out at the OS-level env-var surface, which has two limitations:
- Container-runtime granularity: env vars are passed to the entire container process, not per-secret. Docker's native
secrets: directive mounts secrets as files at /run/secrets/<name> with per-file UID/GID + mode control — a finer-grained primitive that env: cannot consume.
- Production secret stores: HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, and Kubernetes Secrets all expose the canonical "secret bytes available at a filesystem path" pattern. Today's GitMirror credentialRef cannot consume any of them without an intermediate "read file, set env var, restart process" shim.
The narrower friction — "operator wants to add 2nd tenant without bloating the orchestrator's environment: block" — is the entry point. The broader gap — "credentialRef cannot consume the dominant secret-distribution primitive (filesystem mount)" — is what justifies substrate work.
The Architectural Reality
The Fix
Extend normalizeCredentialRef to accept a third string scheme:
file:/absolute/path/to/secret → {type: 'file', filePath: '/absolute/path/to/secret'}
Add a matching branch in resolveCredentialEnvironment:
- Read
filePath via fs.readFile(..., 'utf-8'), trim whitespace.
- Error on empty resolved contents with the same
KB_GITMIRROR_CREDENTIAL_REF_INVALID code path as env-not-set.
- Reuse the existing askpass-temp-dir +
NEO_GITMIRROR_PASSWORD + NEO_GITMIRROR_USERNAME pattern identically.
- Default
NEO_GITMIRROR_USERNAME to 'x-access-token' (matching the env branch's GitLab/GitHub deploy-token convention).
- Object shape
{type: 'file', filePath} passes through normalizeCredentialRef unchanged for callers that need to set a non-default username.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
credentialRef: 'file:/path' (new string scheme) |
ai/services/knowledge-base/helpers/GitMirror.mjs normalizeCredentialRef |
Resolved to {type: 'file', filePath} shape, secret bytes read at git subprocess boundary, never persisted |
env: and ssh: schemes unchanged |
New scheme added to the cloud-deployment credential-boundary guide |
Unit test for file-resolution branch; integration test using /tmp/test-secret fixture |
credentialRef: {type: 'file', filePath} (object shape) |
Same as above |
Same resolution path |
Same |
Same |
Object-shape pass-through test |
Decision Record impact
aligned-with ADR 0014 — extends the credential-boundary surface within the existing tenant-ingestion subsystem without redrawing service boundaries. ADR 0014's tenant-repo amendment names #11787 as the credential-boundary prerequisite; this ticket extends #11787's substrate forward without superseding it.
Acceptance Criteria
Out of Scope
vault: / secrets-manager: schemes — separate tickets once a deployment actually needs them. Premature abstraction is a worse tax than the missing scheme.
- Pluggable resolver plugin architecture — string-based dispatch with a small fixed set of schemes is sufficient until we have 4+ schemes to discriminate.
- Deployment-side migration — downstream deployments that switch from
env: → file: are operator decisions; this ticket only widens the surface.
Avoided Traps
- Treating this as "just add another scheme": filesystem-mounted secrets is the canonical primitive for Docker secrets + every major cloud secret store. It's a substrate gap, not a feature request.
- Adding
vault: speculatively: no deployment is currently using Vault. Each new scheme adds an integration surface; one-scheme-at-a-time keeps the substrate lean.
Related
- Substrate foundation: #11787 (config + credentialed contract), #11788 (GitMirror primitive)
- Parent epic: #11731 (Server-side tenant-repo ingestion)
- Empirical anchor: #12030 (RawRepoSource opt-in, merged)
Context
The current
GitMirror.normalizeCredentialRef(ai/services/knowledge-base/helpers/GitMirror.mjs:96-125) supports only three credentialRef shapes —env:VAR,ssh:keyPath, and bare-string (treated asenv:). For single-tenant deployments this is sufficient. For multi-tenant deployments — even modest ones with 2–5 tenant repos — theenv:indirection forces operators to:NEO_TENANT_<SLUG>_TOKENenv var per tenant in the deployment's environment-source file.environment:block (compose grows linearly with tenant count), OR adoptenv_file:(which over-broadly pulls all env-source entries into the container, including unrelated secrets like OAuth client secrets and session signing keys).Surfaced during a first-real-world cloud deployment exercise — friction-to-gold signal post-#12030 RawRepoSource.
The Problem
env:indirection bottoms out at the OS-level env-var surface, which has two limitations:secrets:directive mounts secrets as files at/run/secrets/<name>with per-file UID/GID + mode control — a finer-grained primitive thatenv:cannot consume.The narrower friction — "operator wants to add 2nd tenant without bloating the orchestrator's
environment:block" — is the entry point. The broader gap — "credentialRef cannot consume the dominant secret-distribution primitive (filesystem mount)" — is what justifies substrate work.The Architectural Reality
ai/services/knowledge-base/helpers/GitMirror.mjs:96-125—normalizeCredentialRefaccepts the string forms and dispatches to env/ssh/object branches.ai/services/knowledge-base/helpers/GitMirror.mjs:144-207—resolveCredentialEnvironmentimplements env/ssh branches; newfilebranch slots in alongside.ai/services/knowledge-base/helpers/TenantRepoAccessContract.mjs:194-220—normalizeTenantRepoConfigvalidates entries; no scheme-specific gating needed (string form passes through to GitMirror).file:branch reads from disk into the same in-memory variable and inherits redaction unchanged.The Fix
Extend
normalizeCredentialRefto accept a third string scheme:file:/absolute/path/to/secret→{type: 'file', filePath: '/absolute/path/to/secret'}Add a matching branch in
resolveCredentialEnvironment:filePathviafs.readFile(..., 'utf-8'), trim whitespace.KB_GITMIRROR_CREDENTIAL_REF_INVALIDcode path as env-not-set.NEO_GITMIRROR_PASSWORD+NEO_GITMIRROR_USERNAMEpattern identically.NEO_GITMIRROR_USERNAMEto'x-access-token'(matching the env branch's GitLab/GitHub deploy-token convention).{type: 'file', filePath}passes throughnormalizeCredentialRefunchanged for callers that need to set a non-defaultusername.Contract Ledger
credentialRef: 'file:/path'(new string scheme)ai/services/knowledge-base/helpers/GitMirror.mjsnormalizeCredentialRef{type: 'file', filePath}shape, secret bytes read at git subprocess boundary, never persistedenv:andssh:schemes unchanged/tmp/test-secretfixturecredentialRef: {type: 'file', filePath}(object shape)Decision Record impact
aligned-with ADR 0014— extends the credential-boundary surface within the existing tenant-ingestion subsystem without redrawing service boundaries. ADR 0014's tenant-repo amendment names #11787 as the credential-boundary prerequisite; this ticket extends #11787's substrate forward without superseding it.Acceptance Criteria
normalizeCredentialRefrecognizesfile:/absolute/pathstring form →{type: 'file', filePath}shape.{type: 'file', filePath}passes throughnormalizeCredentialRefunchanged.resolveCredentialEnvironmentaddsfilebranch: readsfilePath, trims, errors on empty/missing withKB_GITMIRROR_CREDENTIAL_REF_INVALID, setsNEO_GITMIRROR_PASSWORD+GIT_ASKPASSidentical to env branch.file:/tmp/<fixture>, (b) object form, (c) empty-file rejection, (d) missing-file rejection.secretHintsincludes the file-resolved token; redaction sweeps git stdout/stderr the same way as env-resolved tokens.secrets:directive pattern + Kubernetes Secret mount pattern both unlock withfile:/run/secrets/<name>.Out of Scope
vault:/secrets-manager:schemes — separate tickets once a deployment actually needs them. Premature abstraction is a worse tax than the missing scheme.env:→file:are operator decisions; this ticket only widens the surface.Avoided Traps
vault:speculatively: no deployment is currently using Vault. Each new scheme adds an integration surface; one-scheme-at-a-time keeps the substrate lean.Related